@hot-updater/react-native 0.27.1 → 0.29.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 (87) 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 +325 -210
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
  9. package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
  10. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +51 -13
  12. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  13. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  14. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
  15. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
  16. package/android/src/newarch/HotUpdaterModule.kt +16 -25
  17. package/android/src/oldarch/HotUpdaterModule.kt +20 -26
  18. package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +340 -232
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  21. package/ios/HotUpdater/Internal/CohortService.swift +63 -0
  22. package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
  23. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  24. package/ios/HotUpdater/Internal/HotUpdater.mm +376 -70
  25. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  26. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  27. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +321 -9
  28. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
  29. package/lib/commonjs/DefaultResolver.js +3 -5
  30. package/lib/commonjs/DefaultResolver.js.map +1 -1
  31. package/lib/commonjs/checkForUpdate.js +2 -0
  32. package/lib/commonjs/checkForUpdate.js.map +1 -1
  33. package/lib/commonjs/index.js +13 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/native.js +211 -39
  36. package/lib/commonjs/native.js.map +1 -1
  37. package/lib/commonjs/native.spec.js +443 -0
  38. package/lib/commonjs/native.spec.js.map +1 -0
  39. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  40. package/lib/commonjs/types.js.map +1 -1
  41. package/lib/commonjs/wrap.js +4 -5
  42. package/lib/commonjs/wrap.js.map +1 -1
  43. package/lib/module/DefaultResolver.js +3 -5
  44. package/lib/module/DefaultResolver.js.map +1 -1
  45. package/lib/module/checkForUpdate.js +3 -1
  46. package/lib/module/checkForUpdate.js.map +1 -1
  47. package/lib/module/index.js +14 -1
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/native.js +204 -34
  50. package/lib/module/native.js.map +1 -1
  51. package/lib/module/native.spec.js +442 -0
  52. package/lib/module/native.spec.js.map +1 -0
  53. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  54. package/lib/module/types.js.map +1 -1
  55. package/lib/module/wrap.js +5 -6
  56. package/lib/module/wrap.js.map +1 -1
  57. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/index.d.ts +14 -1
  59. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/native.d.ts +43 -23
  61. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +32 -8
  63. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/types.d.ts +6 -3
  65. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/wrap.d.ts +3 -6
  67. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  68. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  69. package/lib/typescript/module/index.d.ts +14 -1
  70. package/lib/typescript/module/index.d.ts.map +1 -1
  71. package/lib/typescript/module/native.d.ts +43 -23
  72. package/lib/typescript/module/native.d.ts.map +1 -1
  73. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +32 -8
  74. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  75. package/lib/typescript/module/types.d.ts +6 -3
  76. package/lib/typescript/module/types.d.ts.map +1 -1
  77. package/lib/typescript/module/wrap.d.ts +3 -6
  78. package/lib/typescript/module/wrap.d.ts.map +1 -1
  79. package/package.json +6 -6
  80. package/src/DefaultResolver.ts +4 -4
  81. package/src/checkForUpdate.ts +4 -0
  82. package/src/index.ts +21 -0
  83. package/src/native.spec.ts +480 -0
  84. package/src/native.ts +285 -39
  85. package/src/specs/NativeHotUpdater.ts +36 -6
  86. package/src/types.ts +7 -3
  87. package/src/wrap.tsx +8 -12
@@ -14,8 +14,6 @@ data class BundleMetadata(
14
14
  val stableBundleId: String? = null,
15
15
  val stagingBundleId: String? = null,
16
16
  val verificationPending: Boolean = false,
17
- val verificationAttemptedAt: Long? = null,
18
- val stagingExecutionCount: Int? = null,
19
17
  val updatedAt: Long = System.currentTimeMillis(),
20
18
  ) {
21
19
  companion object {
@@ -45,18 +43,6 @@ data class BundleMetadata(
45
43
  null
46
44
  },
47
45
  verificationPending = json.optBoolean("verificationPending", false),
48
- verificationAttemptedAt =
49
- if (json.has("verificationAttemptedAt") && !json.isNull("verificationAttemptedAt")) {
50
- json.getLong("verificationAttemptedAt")
51
- } else {
52
- null
53
- },
54
- stagingExecutionCount =
55
- if (json.has("stagingExecutionCount") && !json.isNull("stagingExecutionCount")) {
56
- json.getInt("stagingExecutionCount")
57
- } else {
58
- null
59
- },
60
46
  updatedAt = json.optLong("updatedAt", System.currentTimeMillis()),
61
47
  )
62
48
 
@@ -100,8 +86,6 @@ data class BundleMetadata(
100
86
  put("stableBundleId", stableBundleId ?: JSONObject.NULL)
101
87
  put("stagingBundleId", stagingBundleId ?: JSONObject.NULL)
102
88
  put("verificationPending", verificationPending)
103
- put("verificationAttemptedAt", verificationAttemptedAt ?: JSONObject.NULL)
104
- put("stagingExecutionCount", stagingExecutionCount ?: JSONObject.NULL)
105
89
  put("updatedAt", updatedAt)
106
90
  }
107
91
 
@@ -237,3 +221,76 @@ data class CrashedHistory(
237
221
  bundles.clear()
238
222
  }
239
223
  }
224
+
225
+ data class PendingCrashRecovery(
226
+ val launchedBundleId: String?,
227
+ val shouldRollback: Boolean,
228
+ ) {
229
+ companion object {
230
+ fun fromJson(json: JSONObject): PendingCrashRecovery =
231
+ PendingCrashRecovery(
232
+ launchedBundleId =
233
+ if (json.has("bundleId") && !json.isNull("bundleId")) {
234
+ json.getString("bundleId").takeIf { it.isNotEmpty() }
235
+ } else {
236
+ null
237
+ },
238
+ shouldRollback = json.optBoolean("shouldRollback", false),
239
+ )
240
+ }
241
+ }
242
+
243
+ data class LaunchSelection(
244
+ val bundleUrl: String,
245
+ val launchedBundleId: String?,
246
+ val shouldRollbackOnCrash: Boolean,
247
+ )
248
+
249
+ data class LaunchReport(
250
+ val status: String = "STABLE",
251
+ val crashedBundleId: String? = null,
252
+ ) {
253
+ companion object {
254
+ private const val TAG = "LaunchReport"
255
+ const val LAUNCH_REPORT_FILENAME = "launch-report.json"
256
+
257
+ fun fromJson(json: JSONObject): LaunchReport =
258
+ LaunchReport(
259
+ status = json.optString("status", "STABLE"),
260
+ crashedBundleId =
261
+ if (json.has("crashedBundleId") && !json.isNull("crashedBundleId")) {
262
+ json.getString("crashedBundleId").takeIf { it.isNotEmpty() }
263
+ } else {
264
+ null
265
+ },
266
+ )
267
+
268
+ fun loadFromFile(file: File): LaunchReport? =
269
+ try {
270
+ if (!file.exists()) {
271
+ null
272
+ } else {
273
+ fromJson(JSONObject(file.readText()))
274
+ }
275
+ } catch (e: Exception) {
276
+ Log.e(TAG, "Failed to load launch report", e)
277
+ null
278
+ }
279
+ }
280
+
281
+ fun toJson(): JSONObject =
282
+ JSONObject().apply {
283
+ put("status", status)
284
+ put("crashedBundleId", crashedBundleId ?: JSONObject.NULL)
285
+ }
286
+
287
+ fun saveToFile(file: File): Boolean =
288
+ try {
289
+ file.parentFile?.mkdirs()
290
+ file.writeText(toJson().toString(2))
291
+ true
292
+ } catch (e: Exception) {
293
+ Log.e(TAG, "Failed to save launch report", e)
294
+ false
295
+ }
296
+ }
@@ -0,0 +1,73 @@
1
+ package com.hotupdater
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.provider.Settings
6
+ import java.util.UUID
7
+
8
+ class CohortService(
9
+ private val context: Context,
10
+ ) {
11
+ private val prefs: SharedPreferences =
12
+ context.getSharedPreferences("HotUpdaterCohort", Context.MODE_PRIVATE)
13
+
14
+ companion object {
15
+ // Keep the legacy key so existing custom cohorts continue to work.
16
+ private const val COHORT_KEY = "custom_cohort"
17
+ private const val FALLBACK_IDENTIFIER_KEY = "fallback_identifier"
18
+ }
19
+
20
+ private fun hashString(value: String): Int {
21
+ var hash = 0
22
+ for (char in value) {
23
+ hash = (hash shl 5) - hash + char.code
24
+ }
25
+ return hash
26
+ }
27
+
28
+ private fun defaultNumericCohort(identifier: String): String {
29
+ val hash = hashString(identifier)
30
+ val normalized = ((hash % 1000) + 1000) % 1000
31
+ return (normalized + 1).toString()
32
+ }
33
+
34
+ private fun fallbackIdentifier(): String {
35
+ val fallback = prefs.getString(FALLBACK_IDENTIFIER_KEY, null)
36
+ if (!fallback.isNullOrEmpty()) {
37
+ return fallback
38
+ }
39
+
40
+ val generated = UUID.randomUUID().toString()
41
+ prefs.edit().putString(FALLBACK_IDENTIFIER_KEY, generated).apply()
42
+ return generated
43
+ }
44
+
45
+ fun setCohort(cohort: String) {
46
+ if (cohort.isEmpty()) {
47
+ return
48
+ }
49
+ prefs.edit().putString(COHORT_KEY, cohort).apply()
50
+ }
51
+
52
+ fun getCohort(): String {
53
+ val cohort = prefs.getString(COHORT_KEY, null)
54
+ if (!cohort.isNullOrEmpty()) {
55
+ return cohort
56
+ }
57
+
58
+ val androidId =
59
+ Settings.Secure.getString(
60
+ context.contentResolver,
61
+ Settings.Secure.ANDROID_ID,
62
+ )
63
+ val initialCohort =
64
+ if (!androidId.isNullOrEmpty()) {
65
+ defaultNumericCohort(androidId)
66
+ } else {
67
+ defaultNumericCohort(fallbackIdentifier())
68
+ }
69
+
70
+ prefs.edit().putString(COHORT_KEY, initialCohort).apply()
71
+ return initialCohort
72
+ }
73
+ }
@@ -12,14 +12,14 @@ class DecompressService {
12
12
  private const val TAG = "DecompressService"
13
13
  }
14
14
 
15
- // Array of available strategies in order of detection priority
16
- // Order matters: Try ZIP first (clear magic bytes), then TAR.GZ (GZIP magic bytes), then TAR.BR (fallback)
17
- private val strategies =
15
+ // Strategies with reliable file signatures that can be validated cheaply.
16
+ // TAR.BR is attempted only as the final fallback because Brotli has no reliable magic bytes.
17
+ private val signatureStrategies =
18
18
  listOf(
19
19
  ZipDecompressionStrategy(),
20
20
  TarGzDecompressionStrategy(),
21
- TarBrDecompressionStrategy(),
22
21
  )
22
+ private val tarBrStrategy = TarBrDecompressionStrategy()
23
23
 
24
24
  /**
25
25
  * Extracts a compressed file to the destination directory.
@@ -39,45 +39,51 @@ class DecompressService {
39
39
  val fileName = file.name
40
40
  val fileSize = if (file.exists()) file.length() else 0L
41
41
 
42
- // Try each strategy's validation
43
- for (strategy in strategies) {
42
+ // Try each signature-based strategy first.
43
+ for (strategy in signatureStrategies) {
44
44
  if (strategy.isValid(filePath)) {
45
45
  Log.d(TAG, "Using strategy for $fileName")
46
46
  return strategy.decompress(filePath, destinationPath, progressCallback)
47
47
  }
48
48
  }
49
49
 
50
- // No valid strategy found - provide detailed error message
51
- val errorMessage =
52
- """
53
- Failed to decompress file: $fileName ($fileSize bytes)
50
+ Log.d(TAG, "No ZIP/TAR.GZ signature matched for $fileName, trying TAR.BR fallback")
54
51
 
55
- Tried strategies: ZIP (magic bytes 0x504B0304), TAR.GZ (magic bytes 0x1F8B), TAR.BR (file extension)
56
-
57
- Supported formats:
58
- - ZIP archives (.zip)
59
- - GZIP compressed TAR archives (.tar.gz)
60
- - Brotli compressed TAR archives (.tar.br)
61
-
62
- Please verify the file is not corrupted and matches one of the supported formats.
63
- """.trimIndent()
52
+ if (tarBrStrategy.decompress(filePath, destinationPath, progressCallback)) {
53
+ Log.d(TAG, "Using TAR.BR fallback for $fileName")
54
+ return true
55
+ }
64
56
 
57
+ val errorMessage = createInvalidArchiveMessage(fileName, fileSize)
65
58
  Log.e(TAG, errorMessage)
66
59
  return false
67
60
  }
68
61
 
69
62
  /**
70
- * Validates if a file is a valid compressed archive.
63
+ * Validates if a file matches one of the signature-based archive formats.
71
64
  * @param filePath Path to the file to validate
72
65
  * @return true if the file is a valid compressed archive
73
66
  */
74
67
  fun isValidZipFile(filePath: String): Boolean {
75
- for (strategy in strategies) {
68
+ for (strategy in signatureStrategies) {
76
69
  if (strategy.isValid(filePath)) {
77
70
  return true
78
71
  }
79
72
  }
80
- Log.d(TAG, "No valid strategy found for file: $filePath")
73
+ Log.d(TAG, "No ZIP/TAR.GZ signature matched for file: $filePath. TAR.BR is handled during extraction fallback.")
81
74
  return false
82
75
  }
76
+
77
+ private fun createInvalidArchiveMessage(
78
+ fileName: String,
79
+ fileSize: Long,
80
+ ): String =
81
+ """
82
+ The downloaded bundle file is not a valid compressed archive: $fileName ($fileSize bytes)
83
+
84
+ Supported formats:
85
+ - ZIP archives (.zip)
86
+ - GZIP compressed TAR archives (.tar.gz)
87
+ - Brotli compressed TAR archives (.tar.br)
88
+ """.trimIndent()
83
89
  }
@@ -48,7 +48,7 @@ class HotUpdaterException(
48
48
  fun extractionFormatError(cause: Throwable? = null) =
49
49
  HotUpdaterException(
50
50
  "EXTRACTION_FORMAT_ERROR",
51
- "Invalid or corrupted bundle archive format",
51
+ "The downloaded bundle file is not a valid compressed archive",
52
52
  cause,
53
53
  )
54
54
 
@@ -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.
@@ -383,11 +389,43 @@ class HotUpdaterImpl {
383
389
  */
384
390
  fun getBaseURL(): String = bundleStorage.getBaseURL()
385
391
 
392
+ /**
393
+ * Gets the current active bundle ID from bundle storage.
394
+ * Reads manifest.json first and falls back to the legacy BUNDLE_ID file.
395
+ * Built-in bundle fallback is handled in JS.
396
+ */
397
+ fun getBundleId(): String? = bundleStorage.getBundleId()
398
+
399
+ /**
400
+ * Gets the current manifest from bundle storage.
401
+ */
402
+ fun getManifest(): Map<String, Any?> = bundleStorage.getManifest()
403
+
386
404
  suspend fun resetChannel(): Boolean {
387
405
  val success = bundleStorage.resetChannel()
388
406
  if (success) {
389
407
  preferences.setItem(CHANNEL_STORAGE_KEY, null)
408
+ currentLaunchSelection = null
390
409
  }
391
410
  return success
392
411
  }
412
+
413
+ private fun prepareLaunchIfNeeded(): LaunchSelection {
414
+ currentLaunchSelection?.let { return it }
415
+
416
+ val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
417
+ val selection = bundleStorage.prepareLaunch(pendingRecovery)
418
+ recoveryManager.startMonitoring(
419
+ bundleId = selection.launchedBundleId,
420
+ shouldRollback = selection.shouldRollbackOnCrash,
421
+ onContentAppeared = { launchedBundleId ->
422
+ bundleStorage.markLaunchCompleted(launchedBundleId)
423
+ },
424
+ onRecoveryRestartRequested = {
425
+ restartApplication(context)
426
+ },
427
+ )
428
+ currentLaunchSelection = selection
429
+ return selection
430
+ }
393
431
  }