@otaupdate/react-native 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -385,3 +385,5 @@ compatible with. Devices on other binary versions never receive it.
385
385
  | Android: mandatory update reload-loops (bare RN) | Fixed in 1.0.2. Before that, `IMMEDIATE`/`ON_NEXT_RESUME` tried an in-place JS reload that reused a bundle path fixed at process start, so the same mandatory update kept re-triggering forever. 1.0.2 does a real process restart instead — update to it. |
386
386
  | A newer update never shows up, app stays on an older-than-expected release | Fixed in 1.0.3. `notifyApplicationReady()` used to clear *any* pending hash unconditionally — if a second update was downloaded and queued for a future restart while the first one was still being confirmed, its pending state was silently wiped and the device never advanced to it. |
387
387
  | Android: old screen briefly visible during a mandatory/resume install | Improved in 1.0.3. The process-restart fix in 1.0.2 is correct but showed Android's default activity-transition animation (old screen sliding/fading out). 1.0.3 suppresses it via `overridePendingTransition(0, 0)` so the switch is instant. |
388
+ | Android: a normal (non-mandatory) update applies once, then randomly reverts to an older release and re-downloads | Fixed in 1.0.6/1.0.7. `org.json`'s `optString(key)` returns the literal string `"null"` (not Kotlin `null`) when a saved field was JSON `null` — every time `currentHash`/`pendingHash` was genuinely unset and then reloaded from disk, it silently became the four-character string `"null"` instead of real `null`, which then got treated as a real (but non-existent) package and fell back to the binary bundle. This was present since the SDK's first release; iOS was never affected (its store type-checks instead of string-coercing). |
389
+ | Android: a release published with no description shows the literal text "null" | Fixed in 1.0.6/1.0.7. Same `org.json` `optString()` behavior as above, applied to the nullable `description` field. |
@@ -3,20 +3,6 @@ package com.otaupdate
3
3
  import android.content.Context
4
4
  import android.content.pm.PackageManager
5
5
  import android.util.Log
6
- import com.facebook.react.ReactHost
7
- import com.facebook.react.ReactPackage
8
- import com.facebook.react.ReactPackageTurboModuleManagerDelegate
9
- import com.facebook.react.bridge.JSBundleLoader
10
- import com.facebook.react.common.annotations.UnstableReactNativeAPI
11
- import com.facebook.react.common.build.ReactBuildConfig
12
- import com.facebook.react.defaults.DefaultComponentsRegistry
13
- import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate
14
- import com.facebook.react.fabric.ComponentFactory
15
- import com.facebook.react.runtime.BindingsInstaller
16
- import com.facebook.react.runtime.JSRuntimeFactory
17
- import com.facebook.react.runtime.ReactHostDelegate
18
- import com.facebook.react.runtime.ReactHostImpl
19
- import com.facebook.react.runtime.hermes.HermesInstance
20
6
 
21
7
  /**
22
8
  * Entry point used by the host app's `MainApplication`.
@@ -28,16 +14,6 @@ import com.facebook.react.runtime.hermes.HermesInstance
28
14
  * That single override is what makes updates take effect: React Native asks
29
15
  * for the bundle path at start-up, and we hand back the most recent healthy
30
16
  * downloaded bundle (or null, meaning "use the one in the APK").
31
- *
32
- * Newer (bridgeless / New Architecture) templates have no `ReactNativeHost` to
33
- * override at all — `MainApplication` builds a `ReactHost` directly. For that
34
- * shape, use `OtaUpdate.createReactHost(...)` in place of the RN template's
35
- * `getDefaultReactHost(...)` call — see its doc comment for why this exists.
36
- *
37
- * This version of the SDK targets RN 0.80+'s `ReactHostDelegate` shape
38
- * (confirmed against the compiled runtime — no `getReactNativeConfig()`, no
39
- * JSC). For RN 0.7x, use `@otaupdate/react-native@1.0.4` instead — see the
40
- * README's "Install" section.
41
17
  */
42
18
  object OtaUpdate {
43
19
 
@@ -48,9 +24,6 @@ object OtaUpdate {
48
24
  @Volatile private var store: OtaUpdateStore? = null
49
25
  @Volatile private var initialized = false
50
26
 
51
- /** Set only when the host app used `createReactHost` — see `reload()` in OtaUpdateModule. */
52
- @Volatile internal var dynamicReactHost: ReactHost? = null
53
-
54
27
  @JvmStatic
55
28
  @Synchronized
56
29
  fun store(context: Context): OtaUpdateStore =
@@ -86,74 +59,6 @@ object OtaUpdate {
86
59
  }
87
60
  }
88
61
 
89
- /**
90
- * Builds a `ReactHost` the same way the RN "new app template"'s
91
- * `getDefaultReactHost(...)` does, but with one difference: the bundle
92
- * loader it hands to React Native is re-resolved on every access instead of
93
- * fixed once at construction time.
94
- *
95
- * Why this exists: React Native's own `DefaultReactHost.getDefaultReactHost`
96
- * takes a plain `jsBundleFilePath` string and bakes it into a
97
- * `DefaultReactHostDelegate` once. `ReactHostImpl` genuinely re-asks its
98
- * delegate for a bundle loader on every reload — confirmed by decompiling
99
- * the compiled runtime, not assumed — but that only helps if the delegate
100
- * itself has something new to say. `DefaultReactHostDelegate` never does,
101
- * which is *why* `IMMEDIATE`/`ON_NEXT_RESUME` installs never took visible
102
- * effect without a full process restart (see `reload()` in
103
- * `OtaUpdateModule`). Expo's own bridgeless host avoids this because its
104
- * `ReactNativeHostHandler` hook is genuinely re-invoked on each reload,
105
- * which is the same property this delegate restores.
106
- *
107
- * Use in `MainApplication` exactly where the template would call
108
- * `getDefaultReactHost`:
109
- *
110
- * override val reactHost: ReactHost by lazy {
111
- * OtaUpdate.createReactHost(
112
- * context = applicationContext,
113
- * packageList = PackageList(this).packages,
114
- * )
115
- * }
116
- *
117
- * This constructs `ReactHostImpl` directly (an `@UnstableReactNativeAPI`
118
- * class) rather than going through the public helper, since the public
119
- * helper has no seam for a dynamic loader. `reload()` only relies on this
120
- * when the host was actually built this way (tracked via
121
- * `dynamicReactHost`); apps that still use `getDefaultReactHost` directly,
122
- * or the older bridge-mode `DefaultReactNativeHost`, keep the
123
- * process-restart fallback — this can't reach into either of those.
124
- *
125
- * `ReactHostDelegate`'s exact method set is an `@UnstableReactNativeAPI`
126
- * surface that has already changed shape once (RN 0.7x had a
127
- * `getReactNativeConfig()` method this interface no longer has as of
128
- * 0.80) — this build targets the current (0.80+) shape. That's the reason
129
- * this functionality ships as a separate SDK version (1.0.5+) rather than
130
- * folding into the one 1.0.4 line that supports both RN ranges.
131
- */
132
- @OptIn(UnstableReactNativeAPI::class)
133
- @JvmStatic
134
- fun createReactHost(
135
- context: Context,
136
- packageList: List<ReactPackage>,
137
- jsMainModulePath: String = "index",
138
- bundleAssetName: String = "index",
139
- useDevSupport: Boolean = ReactBuildConfig.DEBUG,
140
- ): ReactHost {
141
- val appContext = context.applicationContext
142
- val delegate = OtaReactHostDelegate(
143
- context = appContext,
144
- jsMainModulePath = jsMainModulePath,
145
- bundleAssetName = bundleAssetName,
146
- reactPackages = packageList,
147
- jsRuntimeFactory = HermesInstance(),
148
- turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(),
149
- )
150
- val componentFactory = ComponentFactory()
151
- DefaultComponentsRegistry.register(componentFactory)
152
- val host = ReactHostImpl(appContext, delegate, componentFactory, true, useDevSupport)
153
- dynamicReactHost = host
154
- return host
155
- }
156
-
157
62
  // --- Build-time configuration --------------------------------------------
158
63
 
159
64
  @JvmStatic
@@ -191,38 +96,3 @@ object OtaUpdate {
191
96
  null
192
97
  }
193
98
  }
194
-
195
- /**
196
- * The dynamic half of `OtaUpdate.createReactHost` — everything here matches
197
- * `DefaultReactHostDelegate` except `jsBundleLoader`, which is a computed
198
- * property instead of a value fixed at construction. `ReactHostImpl` reads
199
- * this fresh on every reload (confirmed against the compiled runtime), so
200
- * this is what lets an installed update take effect in place, without
201
- * restarting the process.
202
- */
203
- @OptIn(UnstableReactNativeAPI::class)
204
- private class OtaReactHostDelegate(
205
- private val context: Context,
206
- override val jsMainModulePath: String,
207
- private val bundleAssetName: String,
208
- override val reactPackages: List<ReactPackage>,
209
- override val jsRuntimeFactory: JSRuntimeFactory,
210
- override val turboModuleManagerDelegateBuilder: ReactPackageTurboModuleManagerDelegate.Builder,
211
- ) : ReactHostDelegate {
212
-
213
- override val bindingsInstaller: BindingsInstaller? = null
214
-
215
- override val jsBundleLoader: JSBundleLoader
216
- get() {
217
- val path = OtaUpdate.getJSBundleFile(context)
218
- return if (path != null) {
219
- JSBundleLoader.createFileLoader(path)
220
- } else {
221
- JSBundleLoader.createAssetLoader(context, "assets://$bundleAssetName", true)
222
- }
223
- }
224
-
225
- override fun handleInstanceException(error: Exception) {
226
- throw error
227
- }
228
- }
@@ -87,7 +87,12 @@ class OtaUpdateModule(private val reactContext: ReactApplicationContext) :
87
87
  val map = Arguments.createMap().apply {
88
88
  putString("packageHash", hash)
89
89
  putString("label", info.optString("label"))
90
- putString("description", info.optString("description").takeIf { it.isNotEmpty() })
90
+ // Not info.optString("description") org.json's optString() returns
91
+ // the literal string "null" (not Kotlin null) for a JSONObject.NULL
92
+ // value, and description is nullable (see OtaUpdateStore.recordPackage),
93
+ // so a release published with no description would show the text
94
+ // "null" instead of none.
95
+ putString("description", info.opt("description") as? String)
91
96
  putBoolean("isMandatory", info.optBoolean("isMandatory"))
92
97
  putString("bundlePath", info.optString("bundlePath"))
93
98
  putDouble("size", info.optLong("size").toDouble())
@@ -221,37 +226,27 @@ class OtaUpdateModule(private val reactContext: ReactApplicationContext) :
221
226
  }
222
227
 
223
228
  /**
224
- * Applies the new bundle.
229
+ * Restarts the whole process so the new bundle is loaded.
225
230
  *
226
- * If the host app was wired up via `OtaUpdate.createReactHost` (see that
227
- * function's doc comment), `dynamicHost.reload()` genuinely swaps the
228
- * bundle in placeno process restart, no activity transition, no flash
229
- * because that host's delegate re-resolves the bundle path on every reload
230
- * instead of reusing one fixed at construction time.
231
+ * This used to try an in-place JS-context reload (ReactHost.reload() via
232
+ * reflection on the new architecture, recreateReactContextInBackground() as
233
+ * a bridge-mode fallback)but on Android BOTH of those reuse a bundle
234
+ * loader/path that was fixed once at ReactInstanceManager/ReactHost
235
+ * construction time (see ReactInstanceManager#mBundleLoader, a `final`
236
+ * field, and DefaultReactHost's internal singleton caching). Neither ever
237
+ * re-consults MainApplication's getJSBundleFile()/jsBundleFilePath, so an
238
+ * IMMEDIATE or ON_NEXT_RESUME install silently kept reloading the OLD
239
+ * bundle forever — for a mandatory update this produced an infinite
240
+ * reload loop, since the freshly-reloaded old bundle immediately saw the
241
+ * same "mandatory update available" response and tried to install again.
231
242
  *
232
- * Otherwise (an app still on `getDefaultReactHost` directly, or the older
233
- * bridge-mode `DefaultReactNativeHost`) neither of those delegates ever
234
- * re-consults MainApplication's bundle path confirmed against the
235
- * compiled runtime, not assumed so an in-place reload would silently
236
- * keep loading the OLD bundle forever. For a mandatory update that meant
237
- * an infinite reinstall loop, since the never-actually-updated old bundle
238
- * would immediately see the same "mandatory update available" response
239
- * again. For those apps, a genuine process kill + relaunch remains the
240
- * only reliable fallback, the same technique restart libraries like
241
- * react-native-restart use — visible activity-transition animation
242
- * suppressed via overridePendingTransition where possible, but still a
243
- * real (if brief) restart.
244
- *
245
- * iOS never needed any of this: RCTTriggerReloadCommandListeners re-queries
246
- * bundleURL() fresh on every reload regardless of how the app is set up.
243
+ * A genuine process kill + relaunch is the only reliable way to force
244
+ * MainApplication to re-run from scratch and pick up the new bundle path —
245
+ * the same technique restart libraries like react-native-restart use.
246
+ * iOS does not need this: RCTTriggerReloadCommandListeners re-queries
247
+ * bundleURL() fresh on every reload, so it was never affected.
247
248
  */
248
249
  private fun reload() {
249
- val dynamicHost = OtaUpdate.dynamicReactHost
250
- if (dynamicHost != null) {
251
- dynamicHost.reload("OTA update applied")
252
- return
253
- }
254
-
255
250
  val context = reactContext.applicationContext
256
251
  try {
257
252
  val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
@@ -62,9 +62,20 @@ class OtaUpdateStore(private val context: Context) {
62
62
  if (!statusFile.exists()) return
63
63
  try {
64
64
  val json = JSONObject(statusFile.readText())
65
- currentHash = json.optString("currentHash").ifEmpty { null }
66
- lastConfirmedHash = json.optString("lastConfirmedHash").ifEmpty { null }
67
- pendingHash = json.optString("pendingHash").ifEmpty { null }
65
+ // NOT json.optString(key) — org.json's optString() returns the literal
66
+ // string "null" (not Kotlin null, not "") when the stored value is
67
+ // JSONObject.NULL, which `save()` below writes for every one of these
68
+ // fields whenever they're genuinely unset. That turned "no pending
69
+ // update" into a fake pending hash of "null" on every single load,
70
+ // which then got promoted into currentHash, resolved to no real
71
+ // package, and silently fell back to the binary bundle — the exact
72
+ // "reverts to an older app" symptom this was causing. opt(key) returns
73
+ // the raw value (String, JSONObject.NULL, or absent), so casting to
74
+ // String? correctly yields null for anything that isn't an actual
75
+ // string.
76
+ currentHash = json.opt("currentHash") as? String
77
+ lastConfirmedHash = json.opt("lastConfirmedHash") as? String
78
+ pendingHash = json.opt("pendingHash") as? String
68
79
  pendingIsLoading = json.optBoolean("pendingIsLoading", false)
69
80
 
70
81
  val failed = json.optJSONArray("failedHashes") ?: JSONArray()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otaupdate/react-native",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Over-the-air JS bundle updates for React Native \u2014 bare and Expo",
5
5
  "license": "MIT",
6
6
  "publishConfig": {