@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
|
-
|
|
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
|
-
*
|
|
229
|
+
* Restarts the whole process so the new bundle is loaded.
|
|
225
230
|
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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()
|