@otaupdate/react-native 1.0.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/README.md +321 -0
- package/android/build.gradle +70 -0
- package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
- package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
- package/app.plugin.js +3 -0
- package/expo-module.config.json +6 -0
- package/ios/OtaUpdate.h +46 -0
- package/ios/OtaUpdate.m +302 -0
- package/ios/OtaUpdateInstaller.h +25 -0
- package/ios/OtaUpdateInstaller.m +278 -0
- package/ios/OtaUpdateStore.h +69 -0
- package/ios/OtaUpdateStore.m +283 -0
- package/lib/OtaUpdate.d.ts +28 -0
- package/lib/OtaUpdate.d.ts.map +1 -0
- package/lib/OtaUpdate.js +254 -0
- package/lib/OtaUpdate.js.map +1 -0
- package/lib/api.d.ts +36 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +89 -0
- package/lib/api.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/native.d.ts +34 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +34 -0
- package/lib/native.js.map +1 -0
- package/lib/types.d.ts +112 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +27 -0
- package/lib/types.js.map +1 -0
- package/lib/useOtaUpdate.d.ts +17 -0
- package/lib/useOtaUpdate.d.ts.map +1 -0
- package/lib/useOtaUpdate.js +81 -0
- package/lib/useOtaUpdate.js.map +1 -0
- package/lib/withOtaUpdate.d.ts +10 -0
- package/lib/withOtaUpdate.d.ts.map +1 -0
- package/lib/withOtaUpdate.js +21 -0
- package/lib/withOtaUpdate.js.map +1 -0
- package/package.json +54 -0
- package/plugin/build/index.d.ts +11 -0
- package/plugin/build/index.js +97 -0
- package/react-native-ota-update.podspec +43 -0
- package/react-native.config.js +21 -0
- package/src/OtaUpdate.ts +293 -0
- package/src/api.ts +122 -0
- package/src/index.ts +55 -0
- package/src/native.ts +64 -0
- package/src/types.ts +125 -0
- package/src/useOtaUpdate.ts +96 -0
- package/src/withOtaUpdate.tsx +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# @otaupdate/react-native
|
|
2
|
+
|
|
3
|
+
Over-the-air JS bundle updates for React Native. Ships the JS + assets of a
|
|
4
|
+
release to installed apps without going through the App Store or Play Store.
|
|
5
|
+
|
|
6
|
+
Works in **bare React Native** and **Expo** (via a config plugin). Native code
|
|
7
|
+
handles download, SHA-256 verification, unzip, bundle swap-in, and
|
|
8
|
+
rollback-on-failure; JS handles the update check and the app-facing API.
|
|
9
|
+
|
|
10
|
+
> **What OTA can and cannot ship.** Anything that lives in the JS bundle and its
|
|
11
|
+
> assets: screens, logic, images, styles. Nothing native: a new native module, a
|
|
12
|
+
> permission, an SDK version bump, or an `Info.plist` change still needs a store
|
|
13
|
+
> release. Shipping a JS bundle that calls a native API the installed binary
|
|
14
|
+
> doesn't have will crash the app — use `targetBinaryVersion` to keep those
|
|
15
|
+
> releases apart.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @otaupdate/react-native
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Bare React Native
|
|
26
|
+
|
|
27
|
+
**iOS**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cd ios && pod install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then point React Native at the OTA bundle. In `AppDelegate.swift` (RN 0.77+):
|
|
34
|
+
|
|
35
|
+
```swift
|
|
36
|
+
import OtaUpdate
|
|
37
|
+
|
|
38
|
+
override func bundleURL() -> URL? {
|
|
39
|
+
#if DEBUG
|
|
40
|
+
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
|
|
41
|
+
#else
|
|
42
|
+
return OtaUpdate.bundleURL() // <- was Bundle.main.url(forResource: "main", …)
|
|
43
|
+
#endif
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or in `AppDelegate.mm` (RN 0.76 and earlier):
|
|
48
|
+
|
|
49
|
+
```objc
|
|
50
|
+
#import <OtaUpdate/OtaUpdate.h>
|
|
51
|
+
|
|
52
|
+
- (NSURL *)getBundleURL {
|
|
53
|
+
#if DEBUG
|
|
54
|
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
|
|
55
|
+
#else
|
|
56
|
+
return [OtaUpdate bundleURL]; // <- was [[NSBundle mainBundle] URLForResource:…]
|
|
57
|
+
#endif
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Add the configuration to `ios/<YourApp>/Info.plist`:
|
|
62
|
+
|
|
63
|
+
```xml
|
|
64
|
+
<key>OtaDeploymentKey</key>
|
|
65
|
+
<string>YOUR_IOS_DEPLOYMENT_KEY</string>
|
|
66
|
+
<key>OtaServerUrl</key>
|
|
67
|
+
<string>https://ota.example.com</string>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Android**
|
|
71
|
+
|
|
72
|
+
Autolinking registers the module. Add the one override in
|
|
73
|
+
`android/app/src/main/java/.../MainApplication.kt`:
|
|
74
|
+
|
|
75
|
+
```kotlin
|
|
76
|
+
import com.otaupdate.OtaUpdate
|
|
77
|
+
|
|
78
|
+
override val reactNativeHost: ReactNativeHost =
|
|
79
|
+
object : DefaultReactNativeHost(this) {
|
|
80
|
+
override fun getJSMainModuleName(): String = "index"
|
|
81
|
+
|
|
82
|
+
override fun getJSBundleFile(): String? =
|
|
83
|
+
OtaUpdate.getJSBundleFile(applicationContext) // <- add this
|
|
84
|
+
// …
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
And the configuration in `android/app/src/main/res/values/strings.xml`:
|
|
89
|
+
|
|
90
|
+
```xml
|
|
91
|
+
<string name="ota_deployment_key" translatable="false">YOUR_ANDROID_DEPLOYMENT_KEY</string>
|
|
92
|
+
<string name="ota_server_url" translatable="false">https://ota.example.com</string>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Expo
|
|
96
|
+
|
|
97
|
+
Add the config plugin to `app.json`. There is nothing to patch by hand — the
|
|
98
|
+
plugin writes the configuration, and the bundle swap-in is wired up
|
|
99
|
+
automatically (see [how Expo differs](#how-expo-differs) below):
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"expo": {
|
|
104
|
+
"plugins": [
|
|
105
|
+
[
|
|
106
|
+
"@otaupdate/react-native",
|
|
107
|
+
{
|
|
108
|
+
"serverUrl": "https://ota.example.com",
|
|
109
|
+
"iosDeploymentKey": "YOUR_IOS_DEPLOYMENT_KEY",
|
|
110
|
+
"androidDeploymentKey": "YOUR_ANDROID_DEPLOYMENT_KEY"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx expo prebuild --clean # or just let EAS Build run it
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
> The native module does not exist in **Expo Go**. Use a development build
|
|
123
|
+
> (`npx expo run:ios` / `eas build --profile development`). In Expo Go the SDK
|
|
124
|
+
> logs a warning and no-ops instead of crashing.
|
|
125
|
+
|
|
126
|
+
#### How Expo differs
|
|
127
|
+
|
|
128
|
+
Expo apps are **bridgeless**: `MainApplication` builds a `ReactHost` straight
|
|
129
|
+
from `ExpoReactHostFactory` and never exposes a `ReactNativeHost`, so there is
|
|
130
|
+
no `getJSBundleFile()` to override. Instead this package registers a
|
|
131
|
+
`ReactNativeHostHandler` — Expo's own extension point, the same one
|
|
132
|
+
`expo-updates` uses — which Expo asks for the bundle path at startup. Expo
|
|
133
|
+
autolinking discovers it; you don't wire anything up.
|
|
134
|
+
|
|
135
|
+
Two consequences worth knowing:
|
|
136
|
+
|
|
137
|
+
- The config plugin **does not modify `MainApplication`** on Android. If you go
|
|
138
|
+
looking for an edit there after `prebuild`, its absence is correct.
|
|
139
|
+
- The handler returns `null` when developer support is on, so a debug build
|
|
140
|
+
keeps loading from Metro and a downloaded bundle never shadows your live
|
|
141
|
+
reload.
|
|
142
|
+
|
|
143
|
+
This package is therefore linked by **both** linkers, and needs to be: React
|
|
144
|
+
Native autolinking registers `OtaUpdatePackage` (the native module JS calls),
|
|
145
|
+
while Expo autolinking registers `OtaUpdateExpoPackage` (the bundle path). Expo
|
|
146
|
+
normally drops a package from React Native autolinking when it is also an Expo
|
|
147
|
+
module with its own Gradle file, which would leave `NativeModules.OtaUpdate`
|
|
148
|
+
undefined — the shipped `react-native.config.js` opts out of that skip. Don't
|
|
149
|
+
delete it.
|
|
150
|
+
|
|
151
|
+
Verified against Expo SDK 57 / React Native 0.86 with the new architecture, on both platforms.
|
|
152
|
+
|
|
153
|
+
## Platform verification status
|
|
154
|
+
|
|
155
|
+
**Android — verified end to end.** On an Expo SDK 57 / RN 0.86 release build
|
|
156
|
+
(new architecture, bridgeless) running on an emulator: publish → download →
|
|
157
|
+
SHA-256 verify → bundle swap on restart, `IMMEDIATE` in-process reload, and
|
|
158
|
+
rollback rescuing a device already on the bad build. Confirmed by screenshot,
|
|
159
|
+
native log, and server-side install reports.
|
|
160
|
+
|
|
161
|
+
**iOS — verified end to end.** On an Expo SDK 57 / RN 0.86 Release build
|
|
162
|
+
(Xcode 26, iPhone 17 Pro simulator, iOS 26.5): publish → download → SHA-256
|
|
163
|
+
verify → bundle swap on restart, `IMMEDIATE` in-process reload, and rollback
|
|
164
|
+
restoring the earlier bundle byte-for-byte. Confirmed by screenshot, package
|
|
165
|
+
hash and server-side install reports.
|
|
166
|
+
|
|
167
|
+
One library fix came out of that first real build: the podspec now sets
|
|
168
|
+
`DEFINES_MODULE = YES`. React Native and Expo build pods as static libraries,
|
|
169
|
+
and an Objective-C static library is not importable as a Swift module unless
|
|
170
|
+
CocoaPods emits a module map — without it every Expo app with a Swift
|
|
171
|
+
AppDelegate failed to compile with `no such module 'OtaUpdate'`, even though
|
|
172
|
+
the pod linked correctly and the Objective-C itself was fine.
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Use it
|
|
178
|
+
|
|
179
|
+
The one-liner — checks on app start and on every foreground, installs on next
|
|
180
|
+
restart:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
import { withOtaUpdate } from '@otaupdate/react-native';
|
|
184
|
+
|
|
185
|
+
function App() {
|
|
186
|
+
return <YourApp />;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default withOtaUpdate(App);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
That is enough for most apps. `withOtaUpdate` also calls `notifyAppReady()` on
|
|
193
|
+
mount, which is what arms rollback protection — see below.
|
|
194
|
+
|
|
195
|
+
### Manual control
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import OtaUpdate, { InstallMode, SyncStatus } from '@otaupdate/react-native';
|
|
199
|
+
|
|
200
|
+
const status = await OtaUpdate.sync({
|
|
201
|
+
installMode: InstallMode.ON_NEXT_RESTART,
|
|
202
|
+
mandatoryInstallMode: InstallMode.IMMEDIATE,
|
|
203
|
+
onSyncStatusChange: (s) => console.log(SyncStatus[s]),
|
|
204
|
+
onDownloadProgress: ({ receivedBytes, totalBytes }) =>
|
|
205
|
+
console.log(`${Math.round((receivedBytes / totalBytes) * 100)}%`),
|
|
206
|
+
// Ask before installing. Mandatory releases ignore this.
|
|
207
|
+
shouldInstall: (update) => confirmWithUser(update.description),
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Update UI with the hook
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
import { useOtaUpdate, SyncStatus } from '@otaupdate/react-native';
|
|
215
|
+
|
|
216
|
+
function UpdateBanner() {
|
|
217
|
+
const { available, progress, isSyncing, check, update } = useOtaUpdate();
|
|
218
|
+
|
|
219
|
+
useEffect(() => { void check(); }, []);
|
|
220
|
+
if (!available) return null;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<View>
|
|
224
|
+
<Text>Version {available.label} is available</Text>
|
|
225
|
+
{available.description ? <Text>{available.description}</Text> : null}
|
|
226
|
+
{progress ? (
|
|
227
|
+
<Text>{Math.round((progress.receivedBytes / progress.totalBytes) * 100)}%</Text>
|
|
228
|
+
) : null}
|
|
229
|
+
<Button title="Update" onPress={() => update()} disabled={isSyncing} />
|
|
230
|
+
</View>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Rollback protection — the part not to skip
|
|
238
|
+
|
|
239
|
+
An update that crashes on boot would be unrecoverable without this, so the SDK
|
|
240
|
+
treats every freshly installed bundle as unproven:
|
|
241
|
+
|
|
242
|
+
1. `install()` marks the package **pending**.
|
|
243
|
+
2. The next launch boots into it and flags it as *loading*.
|
|
244
|
+
3. `notifyAppReady()` **confirms** it. From then on it is permanent.
|
|
245
|
+
4. If the app launches again while the package is still *loading* — i.e. the
|
|
246
|
+
last run never reached `notifyAppReady()` — the native side reverts to the
|
|
247
|
+
previous bundle, blacklists the bad hash, and reports `rolled_back` to the
|
|
248
|
+
server so it shows up in the dashboard.
|
|
249
|
+
|
|
250
|
+
`withOtaUpdate` and `startAutoSync` call `notifyAppReady()` for you. If you wire
|
|
251
|
+
things up yourself, **you must call it**, after your critical startup path:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { notifyAppReady } from '@otaupdate/react-native';
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
void notifyAppReady();
|
|
258
|
+
}, []);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Call it too early and a crash a second later still counts as healthy; call it
|
|
262
|
+
too late (or never) and every update rolls itself back.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Install modes
|
|
267
|
+
|
|
268
|
+
| Mode | When the new bundle loads |
|
|
269
|
+
|---|---|
|
|
270
|
+
| `ON_NEXT_RESTART` *(default)* | The next time the user cold-starts the app. Never interrupts. |
|
|
271
|
+
| `ON_NEXT_RESUME` | The next foreground, after `minimumBackgroundDuration` seconds in the background. |
|
|
272
|
+
| `IMMEDIATE` | Right away, with a JS reload. Reserved for mandatory fixes — it interrupts whatever the user was doing. |
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## API
|
|
277
|
+
|
|
278
|
+
| Export | Purpose |
|
|
279
|
+
|---|---|
|
|
280
|
+
| `sync(options)` | Check → download → install. Concurrent calls share one run. |
|
|
281
|
+
| `checkForUpdate()` | Just the check. Returns `{ update, reason }`. |
|
|
282
|
+
| `notifyAppReady()` | Confirm the running bundle (see rollback above). |
|
|
283
|
+
| `restartApp(onlyIfPending?)` | Reload the JS bundle. |
|
|
284
|
+
| `getCurrentPackage()` | Running label, hash, pending/first-run flags. |
|
|
285
|
+
| `clearUpdates()` | Wipe all downloads, revert to the binary bundle. |
|
|
286
|
+
| `startAutoSync(options)` | The app-start/resume loop `withOtaUpdate` uses. |
|
|
287
|
+
| `withOtaUpdate(App, options)` | Root-component HOC. |
|
|
288
|
+
| `useOtaUpdate(options)` | Hook for update UI. |
|
|
289
|
+
| `isNativeModuleAvailable` | `false` in Expo Go and on web. |
|
|
290
|
+
|
|
291
|
+
`checkForUpdate()` returns a `reason` when there is no update — `up_to_date`,
|
|
292
|
+
`not_in_rollout`, `no_matching_binary_version`, or `update_app_version` (a
|
|
293
|
+
release exists but needs a newer native binary, i.e. prompt for a store update).
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Publishing
|
|
298
|
+
|
|
299
|
+
From the project root, with the [CLI](../cli/README.md):
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
ota release-react MyApp-iOS 1.4.0 --platform ios -d Production -r 20
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`targetBinaryVersion` (`1.4.0` above) is the native app version the bundle is
|
|
306
|
+
compatible with. Devices on other binary versions never receive it.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Troubleshooting
|
|
311
|
+
|
|
312
|
+
| Symptom | Cause |
|
|
313
|
+
|---|---|
|
|
314
|
+
| `The native module … is not available` | Native rebuild missing (`pod install` / Gradle), or running in Expo Go. A Metro reload is not enough. |
|
|
315
|
+
| Update downloads but never applies (bare RN) | `getJSBundleFile` / `bundleURL` override missing — the app keeps loading the bundle from the binary. |
|
|
316
|
+
| Update downloads but never applies (Expo) | The `ReactNativeHostHandler` was not autolinked. Re-run `npx expo prebuild --clean`; check `android/app/build/generated/autolinking` mentions `OtaUpdateExpoPackage`. |
|
|
317
|
+
| Download fails while the check succeeds | The presigned URL points at a host the device can't reach. Set `S3_PUBLIC_ENDPOINT` (and `PUBLIC_API_URL`) to a LAN IP or public hostname, not `localhost`. |
|
|
318
|
+
| `CLEARTEXT communication not permitted` | Android blocks plain HTTP from API 28. Use HTTPS, or `usesCleartextTraffic` for local testing only. |
|
|
319
|
+
| Every update rolls back | `notifyAppReady()` is never reached — usually an early crash, or the call sits behind a screen the user has to navigate to. |
|
|
320
|
+
| `no_matching_binary_version` | The release's `targetBinaryVersion` does not cover the installed native version. |
|
|
321
|
+
| Works in debug, not release | Debug builds load from Metro; the OTA path only runs in release builds. |
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.safeExtGet = { prop, fallback ->
|
|
3
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
4
|
+
}
|
|
5
|
+
repositories {
|
|
6
|
+
google()
|
|
7
|
+
mavenCentral()
|
|
8
|
+
}
|
|
9
|
+
dependencies {
|
|
10
|
+
classpath("com.android.tools.build:gradle:8.6.0")
|
|
11
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet('kotlinVersion', '1.9.24')}")
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
apply plugin: "com.android.library"
|
|
16
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
17
|
+
|
|
18
|
+
// Expo apps are bridgeless and have no ReactNativeHost to override, so the
|
|
19
|
+
// bundle path is supplied through Expo's ReactNativeHostHandler instead (see
|
|
20
|
+
// src/expo/java). That source set is only compiled when Expo is actually
|
|
21
|
+
// present — bare React Native projects have no expo-modules-core to link
|
|
22
|
+
// against and use the getJSBundleFile() override on ReactNativeHost.
|
|
23
|
+
def expoModulesCore = findProject(':expo-modules-core')
|
|
24
|
+
def isExpoProject = expoModulesCore != null
|
|
25
|
+
|
|
26
|
+
android {
|
|
27
|
+
namespace "com.otaupdate"
|
|
28
|
+
compileSdkVersion safeExtGet('compileSdkVersion', 34)
|
|
29
|
+
|
|
30
|
+
sourceSets {
|
|
31
|
+
main {
|
|
32
|
+
if (isExpoProject) {
|
|
33
|
+
java.srcDirs += 'src/expo/java'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
defaultConfig {
|
|
39
|
+
minSdkVersion safeExtGet('minSdkVersion', 23)
|
|
40
|
+
targetSdkVersion safeExtGet('targetSdkVersion', 34)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
compileOptions {
|
|
44
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
45
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
kotlinOptions {
|
|
49
|
+
jvmTarget = "17"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lintOptions {
|
|
53
|
+
abortOnError false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
repositories {
|
|
58
|
+
mavenCentral()
|
|
59
|
+
google()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
dependencies {
|
|
63
|
+
implementation "com.facebook.react:react-android"
|
|
64
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.24')}"
|
|
65
|
+
|
|
66
|
+
// compileOnly: the host app already ships expo-modules-core at runtime.
|
|
67
|
+
if (isExpoProject) {
|
|
68
|
+
compileOnly project(':expo-modules-core')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package com.otaupdate
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import expo.modules.core.interfaces.Package
|
|
5
|
+
import expo.modules.core.interfaces.ReactNativeHostHandler
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Expo integration.
|
|
9
|
+
*
|
|
10
|
+
* Modern Expo apps are bridgeless: `MainApplication` builds a `ReactHost`
|
|
11
|
+
* straight from `ExpoReactHostFactory` and there is no `ReactNativeHost` whose
|
|
12
|
+
* `getJSBundleFile()` we could override. Expo's supported extension point for
|
|
13
|
+
* exactly this is `ReactNativeHostHandler` — the same one `expo-updates` uses —
|
|
14
|
+
* so we register one instead of rewriting the app's `MainApplication`.
|
|
15
|
+
*
|
|
16
|
+
* That also means the config plugin does not have to patch Kotlin source, which
|
|
17
|
+
* is what broke against the RN 0.86 template.
|
|
18
|
+
*
|
|
19
|
+
* Discovery: Expo autolinking scans a module's Android sources for files named
|
|
20
|
+
* `*Package.kt` that import `expo.modules.core.interfaces.Package`. This file
|
|
21
|
+
* only compiles when `:expo-modules-core` is on the classpath — see the
|
|
22
|
+
* conditional source set in `android/build.gradle`, which keeps bare React
|
|
23
|
+
* Native builds (no Expo) working.
|
|
24
|
+
*/
|
|
25
|
+
class OtaUpdateExpoPackage : Package {
|
|
26
|
+
override fun createReactNativeHostHandlers(context: Context): List<ReactNativeHostHandler> =
|
|
27
|
+
listOf(
|
|
28
|
+
object : ReactNativeHostHandler {
|
|
29
|
+
override fun getJSBundleFile(useDeveloperSupport: Boolean): String? {
|
|
30
|
+
// Debug builds load from Metro. Handing back a downloaded bundle
|
|
31
|
+
// there would silently replace the developer's live-reloading code.
|
|
32
|
+
if (useDeveloperSupport) return null
|
|
33
|
+
return OtaUpdate.getJSBundleFile(context)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package com.otaupdate
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.util.Log
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Entry point used by the host app's `MainApplication`.
|
|
9
|
+
*
|
|
10
|
+
* In `ReactNativeHost`:
|
|
11
|
+
*
|
|
12
|
+
* override fun getJSBundleFile(): String? = OtaUpdate.getJSBundleFile(applicationContext)
|
|
13
|
+
*
|
|
14
|
+
* That single override is what makes updates take effect: React Native asks
|
|
15
|
+
* for the bundle path at start-up, and we hand back the most recent healthy
|
|
16
|
+
* downloaded bundle (or null, meaning "use the one in the APK").
|
|
17
|
+
*/
|
|
18
|
+
object OtaUpdate {
|
|
19
|
+
|
|
20
|
+
private const val DEFAULT_ASSET_BUNDLE = "index.android.bundle"
|
|
21
|
+
private const val META_DEPLOYMENT_KEY = "com.otaupdate.DEPLOYMENT_KEY"
|
|
22
|
+
private const val META_SERVER_URL = "com.otaupdate.SERVER_URL"
|
|
23
|
+
|
|
24
|
+
@Volatile private var store: OtaUpdateStore? = null
|
|
25
|
+
@Volatile private var initialized = false
|
|
26
|
+
|
|
27
|
+
@JvmStatic
|
|
28
|
+
@Synchronized
|
|
29
|
+
fun store(context: Context): OtaUpdateStore =
|
|
30
|
+
store ?: OtaUpdateStore(context.applicationContext).also { store = it }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the JS bundle for this launch and advances the rollback state
|
|
34
|
+
* machine. Safe to call more than once; only the first call has effects.
|
|
35
|
+
*/
|
|
36
|
+
@JvmStatic
|
|
37
|
+
fun getJSBundleFile(context: Context): String? = getJSBundleFile(context, DEFAULT_ASSET_BUNDLE)
|
|
38
|
+
|
|
39
|
+
@JvmStatic
|
|
40
|
+
@Synchronized
|
|
41
|
+
fun getJSBundleFile(context: Context, assetsBundleName: String): String? {
|
|
42
|
+
return try {
|
|
43
|
+
val s = store(context)
|
|
44
|
+
if (!initialized) {
|
|
45
|
+
initialized = true
|
|
46
|
+
s.initializeAfterRestart()
|
|
47
|
+
} else {
|
|
48
|
+
// Resolved again without a new process: an in-process reload from an
|
|
49
|
+
// IMMEDIATE or ON_NEXT_RESUME install. Promote the pending package,
|
|
50
|
+
// but do not let it look like a boot that failed to confirm.
|
|
51
|
+
s.promotePendingIfAny()
|
|
52
|
+
}
|
|
53
|
+
// null tells React Native to load `assets://index.android.bundle`.
|
|
54
|
+
s.currentBundlePath()
|
|
55
|
+
} catch (e: Exception) {
|
|
56
|
+
// Never let OTA state break app start-up.
|
|
57
|
+
Log.e(OtaUpdateStore.TAG, "failed to resolve OTA bundle; using the binary bundle", e)
|
|
58
|
+
null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Build-time configuration --------------------------------------------
|
|
63
|
+
|
|
64
|
+
@JvmStatic
|
|
65
|
+
fun deploymentKey(context: Context): String =
|
|
66
|
+
stringResource(context, "ota_deployment_key")
|
|
67
|
+
?: metaData(context, META_DEPLOYMENT_KEY)
|
|
68
|
+
?: ""
|
|
69
|
+
|
|
70
|
+
@JvmStatic
|
|
71
|
+
fun serverUrl(context: Context): String =
|
|
72
|
+
stringResource(context, "ota_server_url") ?: metaData(context, META_SERVER_URL) ?: ""
|
|
73
|
+
|
|
74
|
+
@JvmStatic
|
|
75
|
+
fun appVersion(context: Context): String =
|
|
76
|
+
try {
|
|
77
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0.0.0"
|
|
78
|
+
} catch (e: PackageManager.NameNotFoundException) {
|
|
79
|
+
"0.0.0"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private fun stringResource(context: Context, name: String): String? {
|
|
83
|
+
val id = context.resources.getIdentifier(name, "string", context.packageName)
|
|
84
|
+
if (id == 0) return null
|
|
85
|
+
return context.getString(id).takeIf { it.isNotBlank() }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun metaData(context: Context, key: String): String? =
|
|
89
|
+
try {
|
|
90
|
+
val info = context.packageManager.getApplicationInfo(
|
|
91
|
+
context.packageName,
|
|
92
|
+
PackageManager.GET_META_DATA,
|
|
93
|
+
)
|
|
94
|
+
info.metaData?.getString(key)?.takeIf { it.isNotBlank() }
|
|
95
|
+
} catch (e: Exception) {
|
|
96
|
+
null
|
|
97
|
+
}
|
|
98
|
+
}
|