@hot-updater/react-native 0.27.0 → 0.28.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.
Files changed (62) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +3 -0
  3. package/android/src/main/AndroidManifestNew.xml +3 -0
  4. package/android/src/main/cpp/CMakeLists.txt +9 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +143 -0
  6. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +170 -204
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +39 -13
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  10. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  11. package/android/src/newarch/HotUpdaterModule.kt +2 -8
  12. package/android/src/oldarch/HotUpdaterModule.kt +2 -8
  13. package/android/src/oldarch/HotUpdaterSpec.kt +1 -1
  14. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +189 -203
  15. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  16. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +265 -11
  18. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  19. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  20. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +293 -9
  21. package/lib/commonjs/native.js +18 -21
  22. package/lib/commonjs/native.js.map +1 -1
  23. package/lib/commonjs/native.spec.js +86 -0
  24. package/lib/commonjs/native.spec.js.map +1 -0
  25. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  26. package/lib/commonjs/types.js.map +1 -1
  27. package/lib/commonjs/wrap.js +4 -5
  28. package/lib/commonjs/wrap.js.map +1 -1
  29. package/lib/module/native.js +17 -20
  30. package/lib/module/native.js.map +1 -1
  31. package/lib/module/native.spec.js +85 -0
  32. package/lib/module/native.spec.js.map +1 -0
  33. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  34. package/lib/module/types.js.map +1 -1
  35. package/lib/module/wrap.js +5 -6
  36. package/lib/module/wrap.js.map +1 -1
  37. package/lib/typescript/commonjs/native.d.ts +4 -15
  38. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/native.spec.d.ts +2 -0
  40. package/lib/typescript/commonjs/native.spec.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +4 -8
  42. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/types.d.ts +2 -3
  44. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/wrap.d.ts +2 -5
  46. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  47. package/lib/typescript/module/native.d.ts +4 -15
  48. package/lib/typescript/module/native.d.ts.map +1 -1
  49. package/lib/typescript/module/native.spec.d.ts +2 -0
  50. package/lib/typescript/module/native.spec.d.ts.map +1 -0
  51. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +4 -8
  52. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  53. package/lib/typescript/module/types.d.ts +2 -3
  54. package/lib/typescript/module/types.d.ts.map +1 -1
  55. package/lib/typescript/module/wrap.d.ts +2 -5
  56. package/lib/typescript/module/wrap.d.ts.map +1 -1
  57. package/package.json +6 -6
  58. package/src/native.spec.ts +84 -0
  59. package/src/native.ts +20 -19
  60. package/src/specs/NativeHotUpdater.ts +4 -6
  61. package/src/types.ts +2 -3
  62. package/src/wrap.tsx +7 -11
@@ -15,6 +15,8 @@ class HotUpdaterImpl {
15
15
  private val context: Context
16
16
  private val bundleStorage: BundleStorageService
17
17
  private val preferences: PreferencesService
18
+ private val recoveryManager: HotUpdaterRecoveryManager
19
+ private var currentLaunchSelection: LaunchSelection? = null
18
20
 
19
21
  /**
20
22
  * Primary constructor with dependency injection (for testing)
@@ -27,6 +29,7 @@ class HotUpdaterImpl {
27
29
  this.context = context.applicationContext
28
30
  this.bundleStorage = bundleStorage
29
31
  this.preferences = preferences
32
+ this.recoveryManager = HotUpdaterRecoveryManager(this.context)
30
33
  }
31
34
 
32
35
  /**
@@ -265,7 +268,7 @@ class HotUpdaterImpl {
265
268
  * Gets the path to the bundle file
266
269
  * @return The path to the bundle file
267
270
  */
268
- fun getJSBundleFile(): String = bundleStorage.getBundleURL()
271
+ fun getJSBundleFile(): String = prepareLaunchIfNeeded().bundleUrl
269
272
 
270
273
  /**
271
274
  * Updates the bundle from the specified URL
@@ -299,6 +302,7 @@ class HotUpdaterImpl {
299
302
  */
300
303
  suspend fun reload(reactContext: Context) {
301
304
  try {
305
+ currentLaunchSelection = null
302
306
  withContext(Dispatchers.Main) {
303
307
  performReactReload(reactContext)
304
308
  }
@@ -309,6 +313,7 @@ class HotUpdaterImpl {
309
313
 
310
314
  suspend fun reloadProcess(reactContext: Context) {
311
315
  try {
316
+ currentLaunchSelection = null
312
317
  withContext(Dispatchers.Main) {
313
318
  if (!restartApplication(reactContext)) {
314
319
  Log.w(TAG, "Falling back to in-process reload because process restart could not be started")
@@ -330,22 +335,24 @@ class HotUpdaterImpl {
330
335
 
331
336
  // Use a cold restart in release builds so bundle application does not depend on RN reload timing.
332
337
  private fun restartApplication(reactContext: Context): Boolean {
338
+ val applicationContext = reactContext.applicationContext
333
339
  val currentActivity =
334
340
  (reactContext as? com.facebook.react.bridge.ReactApplicationContext)?.currentActivity
335
- if (currentActivity == null) {
336
- Log.w(TAG, "Cannot restart app: current activity unavailable")
337
- return false
338
- }
339
341
 
340
342
  return try {
341
343
  val restartIntent =
342
- Intent(currentActivity, HotUpdaterRestartActivity::class.java).apply {
344
+ Intent(applicationContext, HotUpdaterRestartActivity::class.java).apply {
345
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
343
346
  addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
344
- putExtra(HotUpdaterRestartActivity.EXTRA_PACKAGE_NAME, currentActivity.packageName)
347
+ putExtra(HotUpdaterRestartActivity.EXTRA_PACKAGE_NAME, applicationContext.packageName)
345
348
  putExtra(HotUpdaterRestartActivity.EXTRA_TARGET_PID, Process.myPid())
346
349
  }
347
- val options = ActivityOptions.makeCustomAnimation(currentActivity, 0, 0)
348
- currentActivity.startActivity(restartIntent, options.toBundle())
350
+ if (currentActivity != null) {
351
+ val options = ActivityOptions.makeCustomAnimation(currentActivity, 0, 0)
352
+ currentActivity.startActivity(restartIntent, options.toBundle())
353
+ } else {
354
+ applicationContext.startActivity(restartIntent)
355
+ }
349
356
 
350
357
  Log.i(TAG, "Started restart trampoline to apply update bundle")
351
358
  return true
@@ -356,12 +363,11 @@ class HotUpdaterImpl {
356
363
  }
357
364
 
358
365
  /**
359
- * Notifies the system that the app has successfully started with the given bundle.
360
- * If the bundle matches the staging bundle, it promotes to stable.
361
- * @param bundleId The ID of the currently running bundle
366
+ * Returns the launch report for the current process.
367
+ * Startup success and rollback are finalized before JS reads it.
362
368
  * @return Map containing status and optional crashedBundleId
363
369
  */
364
- fun notifyAppReady(bundleId: String): Map<String, Any?> = bundleStorage.notifyAppReady(bundleId)
370
+ fun notifyAppReady(): Map<String, Any?> = bundleStorage.notifyAppReady()
365
371
 
366
372
  /**
367
373
  * Gets the crashed bundle history.
@@ -387,7 +393,27 @@ class HotUpdaterImpl {
387
393
  val success = bundleStorage.resetChannel()
388
394
  if (success) {
389
395
  preferences.setItem(CHANNEL_STORAGE_KEY, null)
396
+ currentLaunchSelection = null
390
397
  }
391
398
  return success
392
399
  }
400
+
401
+ private fun prepareLaunchIfNeeded(): LaunchSelection {
402
+ currentLaunchSelection?.let { return it }
403
+
404
+ val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
405
+ val selection = bundleStorage.prepareLaunch(pendingRecovery)
406
+ recoveryManager.startMonitoring(
407
+ bundleId = selection.launchedBundleId,
408
+ shouldRollback = selection.shouldRollbackOnCrash,
409
+ onContentAppeared = { launchedBundleId ->
410
+ bundleStorage.markLaunchCompleted(launchedBundleId)
411
+ },
412
+ onRecoveryRestartRequested = {
413
+ restartApplication(context)
414
+ },
415
+ )
416
+ currentLaunchSelection = selection
417
+ return selection
418
+ }
393
419
  }
@@ -0,0 +1,533 @@
1
+ package com.hotupdater
2
+
3
+ import android.app.AlarmManager
4
+ import android.app.PendingIntent
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.os.Process
10
+ import android.os.SystemClock
11
+ import android.util.Log
12
+ import com.facebook.react.ReactApplication
13
+ import com.facebook.react.bridge.JSExceptionHandler
14
+ import com.facebook.react.bridge.ReactContext
15
+ import com.facebook.react.bridge.ReactMarker
16
+ import com.facebook.react.bridge.ReactMarkerConstants
17
+ import org.json.JSONObject
18
+ import java.io.File
19
+ import java.lang.reflect.Field
20
+ import kotlin.system.exitProcess
21
+
22
+ internal class HotUpdaterRecoveryManager(
23
+ context: Context,
24
+ ) {
25
+ private val appContext = context.applicationContext
26
+ private val mainHandler = Handler(Looper.getMainLooper())
27
+ private val bundleStoreDir = getBundleStoreDir(appContext)
28
+ private val crashMarkerFile = File(bundleStoreDir, CRASH_MARKER_FILENAME)
29
+ private val watchdogStateFile = File(bundleStoreDir, WATCHDOG_STATE_FILENAME)
30
+
31
+ private var currentBundleId: String? = null
32
+ private var shouldRollbackOnCrash = false
33
+ private var isMonitoring = false
34
+ private var recoveryRequested = false
35
+ private var contentAppearedCallback: ((String?) -> Unit)? = null
36
+ private var recoveryRestartCallback: (() -> Boolean)? = null
37
+
38
+ private val stopMonitoringRunnable =
39
+ Runnable {
40
+ cancelRecoveryWatchdog()
41
+ Log.d(TAG, "Stopping crash monitoring for current launch")
42
+ isMonitoring = false
43
+ recoveryRequested = false
44
+ shouldRollbackOnCrash = false
45
+ currentBundleId = null
46
+ contentAppearedCallback = null
47
+ recoveryRestartCallback = null
48
+ activeManager = null
49
+ updateNativeLaunchState(null, false)
50
+ }
51
+
52
+ private val installJsExceptionHooksRunnable =
53
+ object : Runnable {
54
+ override fun run() {
55
+ if (!isMonitoring) {
56
+ return
57
+ }
58
+
59
+ installJavaScriptExceptionHooks()
60
+ mainHandler.postDelayed(this, JS_EXCEPTION_HOOK_RETRY_DELAY_MS)
61
+ }
62
+ }
63
+
64
+ private val contentAppearedListener =
65
+ ReactMarker.MarkerListener { name, _, _ ->
66
+ if (name == ReactMarkerConstants.CONTENT_APPEARED) {
67
+ handleContentAppeared()
68
+ }
69
+ }
70
+
71
+ fun consumePendingCrashRecovery(): PendingCrashRecovery? {
72
+ if (!crashMarkerFile.exists()) {
73
+ return null
74
+ }
75
+
76
+ return try {
77
+ val recovery = PendingCrashRecovery.fromJson(JSONObject(crashMarkerFile.readText()))
78
+ Log.d(
79
+ TAG,
80
+ "Consumed pending crash marker bundleId=${recovery.launchedBundleId} shouldRollback=${recovery.shouldRollback}",
81
+ )
82
+ recovery
83
+ } catch (e: Exception) {
84
+ Log.e(TAG, "Failed to read crash marker", e)
85
+ null
86
+ } finally {
87
+ crashMarkerFile.delete()
88
+ }
89
+ }
90
+
91
+ fun startMonitoring(
92
+ bundleId: String?,
93
+ shouldRollback: Boolean,
94
+ onContentAppeared: (String?) -> Unit,
95
+ onRecoveryRestartRequested: () -> Boolean,
96
+ ) {
97
+ crashMarkerFile.parentFile?.mkdirs()
98
+ contentAppearedCallback = onContentAppeared
99
+ currentBundleId = bundleId
100
+ shouldRollbackOnCrash = shouldRollback
101
+ isMonitoring = true
102
+ recoveryRequested = false
103
+ recoveryRestartCallback = onRecoveryRestartRequested
104
+ activeManager = this
105
+
106
+ ensureExceptionHandlerInstalled()
107
+ ensureNativeSignalHandlerInstalled()
108
+
109
+ mainHandler.removeCallbacks(installJsExceptionHooksRunnable)
110
+ mainHandler.removeCallbacks(stopMonitoringRunnable)
111
+ ReactMarker.removeListener(contentAppearedListener)
112
+ ReactMarker.addListener(contentAppearedListener)
113
+ updateNativeLaunchState(bundleId, shouldRollback)
114
+ if (shouldRollback) {
115
+ startRecoveryWatchdog()
116
+ } else {
117
+ cancelRecoveryWatchdog()
118
+ }
119
+ mainHandler.post(installJsExceptionHooksRunnable)
120
+
121
+ Log.d(TAG, "Started crash monitoring bundleId=$bundleId shouldRollback=$shouldRollback")
122
+ }
123
+
124
+ private fun handleContentAppeared() {
125
+ if (!isMonitoring) {
126
+ return
127
+ }
128
+
129
+ Log.d(TAG, "First content appeared for bundleId=$currentBundleId")
130
+ ReactMarker.removeListener(contentAppearedListener)
131
+ mainHandler.removeCallbacks(installJsExceptionHooksRunnable)
132
+ mainHandler.removeCallbacks(stopMonitoringRunnable)
133
+ contentAppearedCallback?.invoke(currentBundleId)
134
+ shouldRollbackOnCrash = false
135
+ updateNativeLaunchState(currentBundleId, false)
136
+ cancelRecoveryWatchdog()
137
+ mainHandler.postDelayed(stopMonitoringRunnable, MONITORING_GRACE_PERIOD_MS)
138
+ }
139
+
140
+ private fun ensureExceptionHandlerInstalled() {
141
+ if (exceptionHandlerInstalled) {
142
+ return
143
+ }
144
+
145
+ previousExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
146
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
147
+ val manager = activeManager
148
+ manager?.writeCrashMarker()
149
+
150
+ if (manager?.requestAutomaticRecovery() == true) {
151
+ Process.killProcess(Process.myPid())
152
+ exitProcess(10)
153
+ }
154
+
155
+ previousExceptionHandler?.uncaughtException(thread, throwable)
156
+ ?: run {
157
+ Process.killProcess(Process.myPid())
158
+ exitProcess(10)
159
+ }
160
+ }
161
+ exceptionHandlerInstalled = true
162
+ }
163
+
164
+ private fun writeCrashMarker() {
165
+ if (!isMonitoring) {
166
+ return
167
+ }
168
+
169
+ try {
170
+ val payload =
171
+ JSONObject().apply {
172
+ put("bundleId", currentBundleId ?: JSONObject.NULL)
173
+ put("shouldRollback", shouldRollbackOnCrash)
174
+ }
175
+ crashMarkerFile.parentFile?.mkdirs()
176
+ crashMarkerFile.writeText(payload.toString())
177
+ } catch (e: Exception) {
178
+ Log.e(TAG, "Failed to write crash marker", e)
179
+ }
180
+ }
181
+
182
+ private fun startRecoveryWatchdog() {
183
+ try {
184
+ watchdogStateFile.parentFile?.mkdirs()
185
+ watchdogStateFile.writeText((System.currentTimeMillis() + MONITORING_GRACE_PERIOD_MS).toString())
186
+ scheduleRecoveryWatchdogTick(appContext, WATCHDOG_TICK_INTERVAL_MS)
187
+ } catch (e: Exception) {
188
+ Log.e(TAG, "Failed to schedule recovery watchdog", e)
189
+ }
190
+ }
191
+
192
+ private fun cancelRecoveryWatchdog() {
193
+ watchdogStateFile.delete()
194
+ cancelRecoveryWatchdogAlarm(appContext)
195
+ }
196
+
197
+ private fun requestAutomaticRecovery(): Boolean {
198
+ if (!isMonitoring || !shouldRollbackOnCrash) {
199
+ return false
200
+ }
201
+
202
+ synchronized(this) {
203
+ if (recoveryRequested) {
204
+ return true
205
+ }
206
+ recoveryRequested = true
207
+ }
208
+
209
+ val started = recoveryRestartCallback?.invoke() == true
210
+ if (!started) {
211
+ synchronized(this) {
212
+ recoveryRequested = false
213
+ }
214
+ Log.w(TAG, "Failed to schedule automatic recovery restart")
215
+ } else {
216
+ Log.i(TAG, "Scheduled automatic recovery restart for bundleId=$currentBundleId")
217
+ }
218
+ return started
219
+ }
220
+
221
+ private fun handleJavaScriptException(exception: Exception): Boolean {
222
+ Log.e(TAG, "Caught React startup exception for bundleId=$currentBundleId", exception)
223
+ writeCrashMarker()
224
+ return requestAutomaticRecovery()
225
+ }
226
+
227
+ private fun ensureNativeSignalHandlerInstalled() {
228
+ if (signalHandlerInstalled || !loadNativeLibrary()) {
229
+ return
230
+ }
231
+
232
+ try {
233
+ nativeInstallSignalHandler(crashMarkerFile.absolutePath)
234
+ signalHandlerInstalled = true
235
+ } catch (e: UnsatisfiedLinkError) {
236
+ Log.w(TAG, "Signal handler not available", e)
237
+ }
238
+ }
239
+
240
+ private fun updateNativeLaunchState(
241
+ bundleId: String?,
242
+ shouldRollback: Boolean,
243
+ ) {
244
+ if (!signalHandlerInstalled || !nativeLibraryLoaded) {
245
+ return
246
+ }
247
+
248
+ try {
249
+ nativeUpdateLaunchState(bundleId, shouldRollback)
250
+ } catch (e: UnsatisfiedLinkError) {
251
+ Log.w(TAG, "Failed to update native launch state", e)
252
+ }
253
+ }
254
+
255
+ private fun installJavaScriptExceptionHooks() {
256
+ val application = appContext as? ReactApplication ?: return
257
+
258
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
259
+ installReactHostExceptionHook(application)
260
+ } else {
261
+ installLegacyExceptionHook(application)
262
+ }
263
+ }
264
+
265
+ private fun installReactHostExceptionHook(application: ReactApplication) {
266
+ val reactHost = getReactHost(application) ?: return
267
+ val reactHostDelegate =
268
+ findField(reactHost.javaClass, "mReactHostDelegate")?.let { field ->
269
+ field.isAccessible = true
270
+ field.get(reactHost)
271
+ }
272
+ ?: findField(reactHost.javaClass, "reactHostDelegate")?.let { field ->
273
+ field.isAccessible = true
274
+ field.get(reactHost)
275
+ }
276
+ ?: return
277
+
278
+ val delegateIdentity = System.identityHashCode(reactHostDelegate)
279
+ if (patchedReactHostDelegateIds.add(delegateIdentity)) {
280
+ val exceptionHandlerField = findField(reactHostDelegate.javaClass, "exceptionHandler")
281
+ if (exceptionHandlerField == null) {
282
+ patchedReactHostDelegateIds.remove(delegateIdentity)
283
+ return
284
+ }
285
+
286
+ exceptionHandlerField.isAccessible = true
287
+ @Suppress("UNCHECKED_CAST")
288
+ val previousHandler =
289
+ exceptionHandlerField.get(reactHostDelegate) as? (Exception) -> Unit
290
+
291
+ exceptionHandlerField.set(reactHostDelegate) { exception: Exception ->
292
+ if (activeManager?.handleJavaScriptException(exception) != true) {
293
+ previousHandler?.invoke(exception) ?: throw exception
294
+ }
295
+ }
296
+ }
297
+
298
+ val reactContext =
299
+ findMethod(reactHost.javaClass, "getCurrentReactContext")?.invoke(reactHost) as? ReactContext
300
+ reactContext?.let { patchReactContextExceptionHandler(it) }
301
+ }
302
+
303
+ private fun installLegacyExceptionHook(application: ReactApplication) {
304
+ val instanceManager = application.reactNativeHost.reactInstanceManager
305
+ val managerIdentity = System.identityHashCode(instanceManager)
306
+ if (patchedInstanceManagerIds.add(managerIdentity)) {
307
+ val exceptionHandlerField = findField(instanceManager.javaClass, "mJSExceptionHandler")
308
+ if (exceptionHandlerField == null) {
309
+ patchedInstanceManagerIds.remove(managerIdentity)
310
+ return
311
+ }
312
+
313
+ exceptionHandlerField.isAccessible = true
314
+ val previousHandler = exceptionHandlerField.get(instanceManager) as? JSExceptionHandler
315
+ exceptionHandlerField.set(instanceManager, RecoveryJSExceptionHandler(previousHandler))
316
+ }
317
+
318
+ instanceManager.currentReactContext?.let { reactContext ->
319
+ patchReactContextExceptionHandler(reactContext)
320
+ patchCatalystInstanceExceptionHandler(reactContext)
321
+ }
322
+ }
323
+
324
+ private fun patchReactContextExceptionHandler(reactContext: ReactContext) {
325
+ val contextIdentity = System.identityHashCode(reactContext)
326
+ if (!patchedReactContextIds.add(contextIdentity)) {
327
+ return
328
+ }
329
+
330
+ val previousHandler = reactContext.jsExceptionHandler
331
+ if (previousHandler is RecoveryJSExceptionHandler) {
332
+ return
333
+ }
334
+
335
+ reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler))
336
+ }
337
+
338
+ private fun patchCatalystInstanceExceptionHandler(reactContext: ReactContext) {
339
+ val catalystInstance =
340
+ try {
341
+ reactContext.catalystInstance
342
+ } catch (_: Exception) {
343
+ null
344
+ } ?: return
345
+
346
+ val catalystIdentity = System.identityHashCode(catalystInstance)
347
+ if (!patchedCatalystInstanceIds.add(catalystIdentity)) {
348
+ return
349
+ }
350
+
351
+ val exceptionHandlerField = findField(catalystInstance.javaClass, "mJSExceptionHandler")
352
+ if (exceptionHandlerField == null) {
353
+ patchedCatalystInstanceIds.remove(catalystIdentity)
354
+ return
355
+ }
356
+
357
+ exceptionHandlerField.isAccessible = true
358
+ val previousHandler = exceptionHandlerField.get(catalystInstance) as? JSExceptionHandler
359
+ if (previousHandler is RecoveryJSExceptionHandler) {
360
+ return
361
+ }
362
+ exceptionHandlerField.set(catalystInstance, RecoveryJSExceptionHandler(previousHandler))
363
+ }
364
+
365
+ private fun getReactHost(application: ReactApplication): Any? =
366
+ try {
367
+ findMethod(application.javaClass, "getReactHost")?.invoke(application)
368
+ } catch (_: Exception) {
369
+ null
370
+ }
371
+
372
+ private fun findMethod(
373
+ clazz: Class<*>,
374
+ name: String,
375
+ ) = runCatching { clazz.getMethod(name) }.getOrNull()
376
+
377
+ private fun findField(
378
+ clazz: Class<*>,
379
+ name: String,
380
+ ): Field? {
381
+ var current: Class<*>? = clazz
382
+ while (true) {
383
+ val currentClass = current ?: break
384
+ runCatching { currentClass.getDeclaredField(name) }
385
+ .getOrNull()
386
+ ?.let { return it }
387
+ current = currentClass.superclass
388
+ }
389
+ return null
390
+ }
391
+
392
+ private fun loadNativeLibrary(): Boolean {
393
+ if (nativeLibraryLoadAttempted) {
394
+ return nativeLibraryLoaded
395
+ }
396
+
397
+ nativeLibraryLoadAttempted = true
398
+ nativeLibraryLoaded =
399
+ try {
400
+ System.loadLibrary("hotupdater_recovery")
401
+ true
402
+ } catch (e: UnsatisfiedLinkError) {
403
+ Log.w(TAG, "Failed to load recovery native library", e)
404
+ false
405
+ }
406
+ return nativeLibraryLoaded
407
+ }
408
+
409
+ private external fun nativeInstallSignalHandler(crashMarkerPath: String)
410
+
411
+ private external fun nativeUpdateLaunchState(
412
+ bundleId: String?,
413
+ shouldRollback: Boolean,
414
+ )
415
+
416
+ private class RecoveryJSExceptionHandler(
417
+ private val previousHandler: JSExceptionHandler?,
418
+ ) : JSExceptionHandler {
419
+ override fun handleException(e: Exception) {
420
+ if (activeManager?.handleJavaScriptException(e) != true) {
421
+ previousHandler?.handleException(e) ?: throw e
422
+ }
423
+ }
424
+ }
425
+
426
+ companion object {
427
+ private const val TAG = "HotUpdaterRecovery"
428
+ private const val CRASH_MARKER_FILENAME = "recovery-crash-marker.json"
429
+ private const val WATCHDOG_STATE_FILENAME = "recovery-watchdog-state.txt"
430
+ private const val WATCHDOG_ACTION = "com.hotupdater.RECOVERY_WATCHDOG"
431
+ private const val MONITORING_GRACE_PERIOD_MS = 10_000L
432
+ private const val WATCHDOG_TICK_INTERVAL_MS = 1_500L
433
+ private const val JS_EXCEPTION_HOOK_RETRY_DELAY_MS = 50L
434
+
435
+ @Volatile
436
+ private var nativeLibraryLoadAttempted = false
437
+
438
+ @Volatile
439
+ private var nativeLibraryLoaded = false
440
+
441
+ @Volatile
442
+ private var signalHandlerInstalled = false
443
+
444
+ @Volatile
445
+ private var exceptionHandlerInstalled = false
446
+
447
+ @Volatile
448
+ private var previousExceptionHandler: Thread.UncaughtExceptionHandler? = null
449
+
450
+ @Volatile
451
+ private var activeManager: HotUpdaterRecoveryManager? = null
452
+
453
+ private val patchedInstanceManagerIds = mutableSetOf<Int>()
454
+ private val patchedCatalystInstanceIds = mutableSetOf<Int>()
455
+ private val patchedReactContextIds = mutableSetOf<Int>()
456
+ private val patchedReactHostDelegateIds = mutableSetOf<Int>()
457
+
458
+ @JvmStatic
459
+ fun handleRecoveryWatchdog(context: Context) {
460
+ val appContext = context.applicationContext
461
+ val watchdogStateFile = File(getBundleStoreDir(appContext), WATCHDOG_STATE_FILENAME)
462
+ val deadlineAt = watchdogStateFile.takeIf(File::exists)?.readText()?.toLongOrNull()
463
+ if (deadlineAt == null) {
464
+ cancelRecoveryWatchdogAlarm(appContext)
465
+ watchdogStateFile.delete()
466
+ return
467
+ }
468
+
469
+ val crashMarkerFile = File(getBundleStoreDir(appContext), CRASH_MARKER_FILENAME)
470
+ if (crashMarkerFile.exists()) {
471
+ Log.i(TAG, "Recovery watchdog detected crash marker, relaunching app")
472
+ watchdogStateFile.delete()
473
+ cancelRecoveryWatchdogAlarm(appContext)
474
+ launchRecoveryRestart(appContext)
475
+ return
476
+ }
477
+
478
+ if (System.currentTimeMillis() >= deadlineAt) {
479
+ watchdogStateFile.delete()
480
+ cancelRecoveryWatchdogAlarm(appContext)
481
+ return
482
+ }
483
+
484
+ scheduleRecoveryWatchdogTick(appContext, WATCHDOG_TICK_INTERVAL_MS)
485
+ }
486
+
487
+ private fun getBundleStoreDir(context: Context): File = File(context.getExternalFilesDir(null) ?: context.filesDir, "bundle-store")
488
+
489
+ private fun getRecoveryWatchdogIntent(context: Context): Intent =
490
+ Intent(context, HotUpdaterRecoveryReceiver::class.java).setAction(WATCHDOG_ACTION)
491
+
492
+ private fun getRecoveryWatchdogPendingIntent(context: Context): PendingIntent =
493
+ PendingIntent.getBroadcast(
494
+ context,
495
+ 0,
496
+ getRecoveryWatchdogIntent(context),
497
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
498
+ )
499
+
500
+ private fun scheduleRecoveryWatchdogTick(
501
+ context: Context,
502
+ delayMs: Long,
503
+ ) {
504
+ val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
505
+ val triggerAt = SystemClock.elapsedRealtime() + delayMs
506
+ alarmManager.set(
507
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
508
+ triggerAt,
509
+ getRecoveryWatchdogPendingIntent(context),
510
+ )
511
+ }
512
+
513
+ private fun cancelRecoveryWatchdogAlarm(context: Context) {
514
+ val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
515
+ alarmManager.cancel(getRecoveryWatchdogPendingIntent(context))
516
+ }
517
+
518
+ private fun launchRecoveryRestart(context: Context) {
519
+ val restartIntent =
520
+ Intent(context, HotUpdaterRestartActivity::class.java).apply {
521
+ putExtra(HotUpdaterRestartActivity.EXTRA_PACKAGE_NAME, context.packageName)
522
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
523
+ addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
524
+ }
525
+
526
+ try {
527
+ context.startActivity(restartIntent)
528
+ } catch (e: Exception) {
529
+ Log.e(TAG, "Failed to launch recovery restart", e)
530
+ }
531
+ }
532
+ }
533
+ }
@@ -0,0 +1,14 @@
1
+ package com.hotupdater
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+
7
+ class HotUpdaterRecoveryReceiver : BroadcastReceiver() {
8
+ override fun onReceive(
9
+ context: Context,
10
+ intent: Intent,
11
+ ) {
12
+ HotUpdaterRecoveryManager.handleRecoveryWatchdog(context)
13
+ }
14
+ }
@@ -145,16 +145,10 @@ class HotUpdaterModule internal constructor(
145
145
  // No-op
146
146
  }
147
147
 
148
- override fun notifyAppReady(params: ReadableMap): WritableNativeMap {
148
+ override fun notifyAppReady(): WritableNativeMap {
149
149
  val result = WritableNativeMap()
150
- val bundleId = params.getString("bundleId")
151
- if (bundleId == null) {
152
- result.putString("status", "STABLE")
153
- return result
154
- }
155
-
156
150
  val impl = getInstance()
157
- val statusMap = impl.notifyAppReady(bundleId)
151
+ val statusMap = impl.notifyAppReady()
158
152
 
159
153
  result.putString("status", statusMap["status"] as? String ?: "STABLE")
160
154
  statusMap["crashedBundleId"]?.let {