@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.
- package/android/build.gradle +12 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/AndroidManifestNew.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +9 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +143 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +170 -204
- package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +39 -13
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
- package/android/src/newarch/HotUpdaterModule.kt +2 -8
- package/android/src/oldarch/HotUpdaterModule.kt +2 -8
- package/android/src/oldarch/HotUpdaterSpec.kt +1 -1
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +189 -203
- package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
- package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
- package/ios/HotUpdater/Internal/HotUpdater.mm +265 -11
- package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +293 -9
- package/lib/commonjs/native.js +18 -21
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/native.spec.js +86 -0
- package/lib/commonjs/native.spec.js.map +1 -0
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/wrap.js +4 -5
- package/lib/commonjs/wrap.js.map +1 -1
- package/lib/module/native.js +17 -20
- package/lib/module/native.js.map +1 -1
- package/lib/module/native.spec.js +85 -0
- package/lib/module/native.spec.js.map +1 -0
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/wrap.js +5 -6
- package/lib/module/wrap.js.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +4 -15
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.spec.d.ts +2 -0
- package/lib/typescript/commonjs/native.spec.d.ts.map +1 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +4 -8
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +2 -3
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +2 -5
- package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +4 -15
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/native.spec.d.ts +2 -0
- package/lib/typescript/module/native.spec.d.ts.map +1 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +4 -8
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +2 -3
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +2 -5
- package/lib/typescript/module/wrap.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/native.spec.ts +84 -0
- package/src/native.ts +20 -19
- package/src/specs/NativeHotUpdater.ts +4 -6
- package/src/types.ts +2 -3
- 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 =
|
|
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(
|
|
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,
|
|
347
|
+
putExtra(HotUpdaterRestartActivity.EXTRA_PACKAGE_NAME, applicationContext.packageName)
|
|
345
348
|
putExtra(HotUpdaterRestartActivity.EXTRA_TARGET_PID, Process.myPid())
|
|
346
349
|
}
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
*
|
|
360
|
-
*
|
|
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(
|
|
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(
|
|
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(
|
|
151
|
+
val statusMap = impl.notifyAppReady()
|
|
158
152
|
|
|
159
153
|
result.putString("status", statusMap["status"] as? String ?: "STABLE")
|
|
160
154
|
statusMap["crashedBundleId"]?.let {
|