Premium OTA update solution for React Native using TurboModules + bsdiff binary patching. Delivers significantly smaller delta updates compared to full bundle replacement.
- Binary delta patches — after the first OTA, clients download only the diff from the OTA bundle they already run (typically a few hundred KB)
- POSIX
mmap()Zero-Copy Engine — patches directly in mapped pages, saving ~85–90% heap RAM - ARM NEON SIMD Fast-Path — vectorized diff patching via Clang SIMD
- Silent Background Updates — download and apply updates completely in the background; activates on next cold start or when you call
reloadBundle() - Auto Native Version Detection — auto-detects
versionName(Android) andCFBundleShortVersionString(iOS); no manual version sync in JS - Asymmetric Cryptography (RSA & ECDSA) — sign patches on server/CI with Private Key; client verifies with Public Key (zero secret leakage)
- Shorebird-style CLI —
patch android|ios, release version read from the native project and confirmed with one Enter - One-Command Version Synchronization —
react-native-bspatch version <x.y.z>syncspackage.json, Androidbuild.gradle, and iOS Xcode project - Full bundle for the first OTA — store installs (and clients outside the patch window, or whose patch fails) download the complete bundle; no base-bundle capture needed
- Atomic bundle swap — never leaves the app in a broken state
- Crash detection + auto-rollback — native-side protection runs before JS boots; if the updated bundle crashes, the app automatically rolls back to the previous version
- Integrity verification — SHA-256 hash check on both source bundle and result bundle
- Disk Garbage Collection — automatically removes obsolete bundle versions and dangling temporary files
- OTA assets without a JS resolver — images/assets are downloaded once per content hash and placed next to the bundle, where React Native looks for them by default (correct from the first render)
- Structured telemetry — all OTA events forwarded to your analytics via
onEventcallback - New Architecture ready — built with TurboModules (no legacy Bridge)
- React Native 0.80+ with the New Architecture (TurboModule event emitters for download progress)
- No other runtime dependencies: downloads and file I/O are native to this module
npm install react-native-bspatch
cd ios && pod installCMake will be invoked automatically via Gradle. Ensure android/app/build.gradle includes:
android {
defaultConfig {
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
}
}
}No additional steps — auto-linked via CocoaPods.
import { OtaProvider, OtaUpdateMode } from 'react-native-bspatch';
const config = {
// appVersion is optional! Auto-detected from native (versionName / CFBundleShortVersionString)
baseAppVersion: 1, // OTA version baked into the first APK/IPA (usually 1)
updateMode: OtaUpdateMode.BACKGROUND_DOWNLOAD, // Silent download without interrupting user
allowDebugUpdates: false, // default: OTA is blocked in __DEV__
customServer: {
baseUrl: Config.OTA_BASE_URL!, // R2 public URL, no fallback
channel: Config.ENVIRONMENT!, // flavor: development / staging / production
},
};
export default function App() {
return (
<OtaProvider config={config}>
{/* rest of your app */}
</OtaProvider>
);
}import { useOta, OtaStatus } from 'react-native-bspatch';
function UpdateBanner() {
const { status, currentVersion, latestVersion, isUpdateReady, reloadBundle, applyUpdate } = useOta();
if (isUpdateReady) {
return (
<Button
title="Update downloaded! Restart to apply"
onPress={reloadBundle}
/>
);
}
if (status === OtaStatus.UPDATE_AVAILABLE) {
return (
<Button
title={`Update to V${latestVersion} →`}
onPress={applyUpdate}
/>
);
}
return <Text>Already up to date (V{currentVersion})</Text>;
}type OtaConfig = {
/**
* Current app versionName (e.g. "1.0.2").
* OPTIONAL: If omitted, auto-detected from native app:
* - Android: PackageInfo.versionName
* - iOS: CFBundleShortVersionString
*/
appVersion?: string;
/**
* OTA version number baked into the APK/IPA at build time.
* Must match the version field in your server's ota_registry.json.
* Usually starts at 1.
*/
baseAppVersion: number;
/**
* Allow OTA checks and patch application while React Native is running in
* development mode (`__DEV__`).
* Default: false
*/
allowDebugUpdates?: boolean;
/**
* When to check / apply updates.
* - MANUAL: call checkUpdate() / applyUpdate() manually
* - ON_APP_START: check and apply automatically on cold start
* - ON_APP_FOREGROUND: check when app returns to foreground
* - BACKGROUND_DOWNLOAD: download and patch in background; applies on next cold start or reloadBundle()
*/
updateMode?: OtaUpdateMode;
/** Network settings */
network?: {
retry?: number; // attempts per file (default: 3); each retry resumes the partial download
timeoutMs?: number; // idle timeout in ms (default: 30000)
wifiOnly?: boolean; // automatic downloads only on Wi-Fi/Ethernet (applyUpdate() always downloads)
};
/** Minimum time between automatic checks (start, foreground, checkUpdate()). Default 10 min. */
minCheckIntervalMs?: number;
/**
* Optional telemetry callback — all OTA lifecycle events are forwarded here.
* Use it to pipe events to your analytics service.
*/
onEvent?: (entry: OtaLogEntry) => void;
/**
* POST every event as JSON to `url` (fire-and-forget), with installId, platform,
* appVersion, appBuild, runtimeFingerprint, flavor and the running OTA version attached.
* A Cloudflare Worker writing to D1/Analytics Engine is enough for a dashboard.
*/
telemetry?: { url: string; headers?: Record<string, string> };
/**
* SPKI public key(s), PEM or base64 (comma-separated for several). Defaults to the
* signing + backup keys `patch` embedded in the app: leave it unset.
*/
publicKey?: string;
/** Custom storage provider. Omit to use the default static-host provider. */
storage?: OtaStorageProvider;
/**
* Static storage layout (public, read-only — never credentials). Required unless
* `storage` is set; invalid values disable OTA (red box in __DEV__), no fallback.
*/
customServer?: {
baseUrl: string; // R2 public URL (r2.dev or custom domain)
channel: string; // flavor
baseFolder?: string; // default: 'ota'
headers?: Record<string, string>; // sent with every request (e.g. auth for a private server)
};
};enum OtaUpdateMode {
MANUAL = 'MANUAL', // No auto behavior
ON_APP_START = 'ON_APP_START', // Check + apply on every cold start
ON_APP_FOREGROUND = 'ON_APP_FOREGROUND', // Check when app returns to foreground
BACKGROUND_DOWNLOAD = 'BACKGROUND_DOWNLOAD', // Silent download; applies on next cold start or reloadBundle()
}For safety, OTA checks and patch application are disabled by default while React Native runs in __DEV__.
If you explicitly want to test OTA inside a debug build, set:
allowDebugUpdates: true| Status | Meaning |
|---|---|
IDLE |
Initial state |
CHECKING |
Fetching manifest |
UPDATE_AVAILABLE |
A newer version exists |
UP_TO_DATE |
Already on the latest version |
DOWNLOADING |
Downloading patch or full bundle |
APPLYING |
Applying binary patch or decompressing |
SYNCING_ASSETS |
Downloading version-specific assets |
SUCCESS |
Update applied; app is reloading |
ERROR |
Update failed |
Your OTA server must expose a ota_registry.json at the configured path (e.g. https://ota.yourcompany.com/bspatch/ota_registry.json):
{
"apps": {
"1.0.0": {
"flavors": {
"production": {
"patches": [
{
"id": "full-to-v3-production-android",
"appVersion": "1.0.0",
"fromVersion": 1,
"toVersion": 3,
"bundleHash": "sha256_of_v3_bundle",
"platform": "android",
"fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc",
"bundleSize": 8558054,
"assetManifestUrl": "asset-manifest_1.0.0_v3_android_production.json",
"assetManifestHash": "sha256_of_the_asset_manifest",
"flavor": "production",
"bundleSignature": "base64…",
"entrySignature": "base64…"
},
{
"id": "v2-to-v3-production-android",
"appVersion": "1.0.0",
"filename": "patch_1.0.0_v2_to_v3_production_android.patch",
"fromVersion": 2,
"toVersion": 3,
"baseHash": "sha256_of_v2_bundle",
"bundleHash": "sha256_of_v3_bundle",
"platform": "android",
"fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc"
}
]
}
}
}
}
}Every version also gets release_<app>_v<N>_<platform>_<flavor>.json next to its bundle: who published it and when, release notes (-m), git commit/branch/dirty, bundle (raw + gzip), patch and asset sizes, and the signing key id. Clients never read it; it's there for a dashboard. It is public like everything in the bucket, so keep notes free of secrets.
Every version has one full-bundle entry (no filename) plus patches from the last 5 OTA versions. The client applies the patch whose fromVersion is the OTA version installed on the device; otherwise (store build = v1, too far behind, or patch failure) it installs fullBundleUrl. The store bundle is never a patch source, so it never has to match byte-for-byte.
The native OtaBundleResolver runs before JS boots on every cold start:
1. Read metadata.json
2. If lastLaunchSuccessful == false → retry (see 7) or rollback to previous version
3. If currentVersion == baseAppVersion → use APK/IPA bundle
4. Otherwise → load OTA bundle from ota/v{version}/index.android.bundle (or main.jsbundle)
5. Unless this version is already verified: mark lastLaunchSuccessful = false (pending)
6. JS boots → OtaProvider renders the app → marks the running version healthy + verified
7. If that never happens (crash on load or first render, or the app is killed first) → next
boot retries the same version, up to 3 unverified launches in total; after that it rolls
back and records the version in failedVersions so it is never re-applied
8. A verified version is never rolled back later (a crash after it rendered is an app bug,
and killing the app during a download must not discard a good bundle)
This means crash protection is native-side and zero-cost — it doesn't require JS to run first.
The default provider (R2StorageProvider, formerly GitHubRawStorageProvider) reads the bucket's public URL with the customServer layout. Implement the interface only for a backend that needs something else (signed URLs, an API):
interface OtaStorageProvider {
/** Fetch the list of available patches */
fetchManifest(config: OtaConfig): Promise<OtaPatch[]>;
/** Get a signed download URL for a patch or full bundle */
getUpdateFileUrl(filename: string, config: OtaConfig): Promise<string>;
/** Get a signed download URL for a content-addressed asset */
getAssetUrl(hash: string, ext: string, config: OtaConfig): Promise<string>;
}Pass your implementation via config.storage.
const {
status, // OtaStatus
message, // Human-readable status message
progress, // 0–100 download/progress percentage
currentVersion, // OTA version number currently running
latestVersion, // Highest version available on server
pendingCount, // Number of pending updates
isChecking, // True while fetching manifest
metrics, // OtaMetrics | null — timing/size data after update
isUpdateReady, // True if background update is downloaded and ready
isMandatory, // The ready update is mandatory: it reloads as soon as the app is in the foreground
installId, // Stable random ID of this install (rollout bucket, QA lists, telemetry)
applyUpdate, // () => Promise<void> — download and apply update immediately
applyVersion, // (version) => Promise<void> — download and apply a specific version from the manifest
applyEntry, // (entry) => Promise<void> — install exactly this registry entry (QA/version list); skips kill switch/rollout/QA list, never the native/Hermes checks
resetToFactory, // () => Promise<void> — wipe OTA state, reload APK/IPA
markAsHealthy, // () => Promise<void> — manually mark launch OK
checkUpdate, // ({ force }?) => Promise<void> — check now (throttled by minCheckIntervalMs unless force)
reloadBundle, // () => void — immediately reload JS bundle to apply ready update
} = useOta();// Checkout: a mandatory update waits until this screen unmounts (or blocked turns false)
useOtaReloadBlock(isPaying);Applies a bsdiff patch directly. Used internally; available for advanced use cases.
import { applyPatch } from 'react-native-bspatch';
await applyPatch('/path/to/old.bundle', '/path/to/new.bundle', '/path/to/patch.patch');These are called automatically by OtaProvider. Only call manually if you manage OTA outside the provider.
The library ships with an automated build pipeline (tools/build_ota.sh) that handles the entire release workflow: bundling, Hermes compilation, asset discovery, bsdiff patch generation, and server upload.
npx react-native-bspatch version 1.0.5 # set native version (package.json, Gradle, Xcode)
npx react-native-bspatch patch # first run also creates the signing keys and wires the native project
# … build + release 1.0.5 to the stores (this is OTA v1) …
npx react-native-bspatch patch android # build + publish the next OTA
npx react-native-bspatch patch ios --flavor staging -m "fix checkout"
npx react-native-bspatch patch --dry-run # build everything, upload nothing
npx react-native-bspatch status --flavor production # what's live, who published it, controls
npx react-native-bspatch doctor # check storage URL, keys, native wiring, fingerprint
npx react-native-bspatch rollout 7 20 --flavor production # v7 to 20% of installs (100 = everyone)
npx react-native-bspatch disable 7 # kill switch (enable 7 undoes it)
npx react-native-bspatch mandatory 7 # apply v7 as soon as it's downloaded (--off undoes it)
npx react-native-bspatch qa 7 --ids <installId>,… # v7 only for these installs (--off undoes it)
npx react-native-bspatch promote --from staging --flavor production # ship staging's bundle, no build
npx react-native-bspatch rollback --to 5 # publish v5's bundle again as the next version
npx react-native-bspatch reset --flavor staging --push # wipe this release's OTAsEvery command takes --flavor, android/ios, --release-version, -y; patch, promote, rollback and status take --json (machine-readable output for CI). Controls act on the live version's registry entries and aren't signed: they can only hold an update back or release one that is already signed. Clients pick changes up on their next check.
| Option | Default | Description |
|---|---|---|
android / ios |
both | Platform to patch |
--flavor <name> |
dev |
Channel (dev→development, prod→production) |
--release-version <x.y.z> |
read from versionName / MARKETING_VERSION, confirmed by prompt |
Store release the OTA targets |
-m "<notes>" |
asked in a terminal | Release notes, kept in the version's release_*.json |
-y |
Skip the prompts, use the detected version | |
--reset-cache |
Clear the Metro cache before bundling (it's cleared automatically when .env.<flavor> changed since the last build) |
|
--dry-run |
Build, diff and sign, but upload nothing | |
--json |
Print the version's release_*.json after the summary |
The release version is read from the native project (versionName / MARKETING_VERSION) and confirmed in a prompt — Enter keeps it. -y or --release-version skip the prompt (CI). Every patch ships a full bundle plus bsdiff patches from the last 5 OTA versions; v1 (the store build) is never a patch source, so nothing has to be captured from the store binary.
- Bundle —
npx react-native bundle+hermescgenerate Hermes bytecode (.hbc), uploaded gzipped (.hbc.gz, ~60% smaller) as the version's full bundle. Clients download it resumably and decompress it natively. - Assets — Every asset is copied to the content-addressed
asset-store/({sha256}.{ext}) and listed in the per-version manifest with its Metro path. Clients download each hash once (cached inota/assets/) and copy it toota/vN/<path>— the location React Native resolves for a bundle loaded fromota/vN/(drawable-*/,raw/on Android;assets/…on iOS). - Patch —
bsdiffgenerates a delta from each of the last 5 OTA bundles (v2+) to the new one. - Registry — Updates
ota_registry.jsonwith newbaseHash,bundleHash, and asset manifest URL for each platform/channel. - Upload — Uploads the bundle, patches and new assets to R2, then the registry.
- Verify — Reads the release back through
OTA_BASE_URL, the way the app will: the registry must list the new version, every file must be served and the entries must verify against the signing key. An app pointed at another bucket fails here, not weeks later.
An OTA bundle only installs on a binary with the same native code. setup (run by every patch) adds a step to the Xcode "Bundle React Native code and images" phase, and the library's Gradle file a resValue, that stamp the store build with a fingerprint of its native dependencies: react-native, every direct dependency with native code (name@version), ios/Podfile.lock / the Gradle dependency lines, and this library's own native sources (so a JS-only release of the library still ships by OTA). patch records the same fingerprint in every entry; a client skips entries for other native code, so adding a native module and publishing an OTA can't crash installed apps. The summary flags it when the fingerprint changed since the live version. Not covered: your own AppDelegate/MainApplication code (changing it needs a store build anyway).
Entries also carry the Hermes bytecode version and a requires list of client features (e.g. gzip): a client skips what its Hermes can't read or what it doesn't support, instead of failing on it.
# 1. Patch AppDelegate.swift / MainApplication.kt
npx react-native-bspatch setup
# 2. Release your app to App Store / Google Play (this is v1)
# 3. For each OTA update:
npx react-native-bspatch patch android
npx react-native-bspatch patch iosThe build reads two files from the project root:
| File | Contents | Read by the app? |
|---|---|---|
.env.<flavor> (.env.development, .env.staging, ...) |
OTA_BASE_URL |
Yes — react-native-config bakes it into the APK/IPA |
.env.ota |
Upload secrets (OTA_R2_*) |
No |
Caution
Never put upload secrets in .env.<flavor>: react-native-config ships every key of that file inside the app binary.
The app and the build share OTA_BASE_URL from .env.<flavor>, so they can't drift apart:
customServer: {
baseUrl: Config.OTA_BASE_URL!, // no fallback: OTA is disabled (red box in dev) if missing
channel: Config.ENVIRONMENT!,
},Builds publish to Cloudflare R2, the only supported storage for now.
R2 setup: enable the bucket's Public Development URL (or a custom domain) and create an R2 API token with Object Read & Write on the bucket. Uploads use curl --aws-sigv4, no extra CLI. Bundles, patches and assets are uploaded once with an immutable cache header; ota_registry.json is uploaded last with no-cache. Bundles are stored gzip Content-Encoding (Hermes bytecode shrinks ~60%): every client's HTTP stack decodes them transparently, older app versions included.
# .env.<flavor>
OTA_BASE_URL=https://pub-xxxx.r2.dev
# .env.ota
OTA_R2_ACCOUNT_ID=...
OTA_R2_BUCKET=...
OTA_R2_ACCESS_KEY_ID=...
OTA_R2_SECRET_ACCESS_KEY=...| Variable | File | Required | Default | Description |
|---|---|---|---|---|
OTA_BASE_URL |
.env.<flavor> |
Yes | - | R2 public URL (r2.dev or custom domain) |
OTA_FOLDER |
.env.<flavor> |
No | ota |
Folder prefix in storage; only set it to share one bucket with other data (and set customServer.baseFolder to match) |
OTA_R2_* |
.env.ota |
Yes | - | ACCOUNT_ID, BUCKET, ACCESS_KEY_ID, SECRET_ACCESS_KEY |
OTA_PRIVATE_KEY |
shell env (CI) | No | - | PEM private key; locally keep ota_private_key.pem in the project root instead (.env.ota values are single-line) |
Keep both files out of git (.env.* in .gitignore).
In production, enabling patch signature verification is strongly recommended to prevent man-in-the-middle (MITM) attacks and malicious patch injection.
react-native-bspatch supports bank-grade asymmetric cryptography (RSA-2048 & ECDSA P-256):
- Private Key (
ota_private_key.pem): Stored only on your build machine / CI server. Used to sign patch files. Never shipped inside the client app. - Public Key (
ota_public_key.pem): Embedded in your client app viaconfig.publicKey. Even if a bad actor reverse-engineers your APK/IPA, they cannot forge updates without the Private Key.
# Generate RSA-2048 keypair
node ./node_modules/react-native-bspatch/tools/generate_keys.js
# Or generate ECDSA (P-256) keypair:
node ./node_modules/react-native-bspatch/tools/generate_keys.js --type ecThis creates:
ota_private_key.pem➔ Add to.gitignore! Keep safe on CI.ota_public_key.pem➔ Copy the public key string into your app config:
const otaConfig: OtaConfig = {
publicKey: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...',
...
};patch handles the key itself, so there is nothing to set up:
- No key yet (
ota_private_key.pem/OTA_PRIVATE_KEY) and the app has never been signed: it creates the signing key and a backup key (ota_private_key.backup.pem) in the project root, gitignores both, and embeds both public keys in the app (Info.plist→BsPatchPublicKey,android/app/src/main/res/values/bspatch_public_key.xml).OtaProviderverifies with them whenconfig.publicKeyisn't set. Commit those files; store builds from then on verify every update. - Move the backup key offline (password manager) and delete it from the machine. If the signing key is ever lost or leaked, put the backup in its place as
ota_private_key.pem: installed apps already trust it, so no store build is needed. - Key present: it must be one of the public keys the app already has (embedded, or
publicKey:in JS), otherwisepatchstops before building. An app without embedded keys gets them (plus a backup) on the next patch. - Key missing but the app was signed before (new machine, CI):
patchstops and asks for the original (or backup) key instead of creating one, since installed apps would reject anything signed with a new key. On CI, setOTA_PRIVATE_KEY.
When you run tools/build_ota.sh, it automatically searches for ota_private_key.pem (or the OTA_PRIVATE_KEY environment variable). If found, it signs each patch (signature), the resulting bundle (bundleSignature) and each registry entry (entrySignature) with SHA-256 and injects the signatures into ota_registry.json. The bundle signature is checked on both the patch path and the full-bundle fallback. If the key is present but a file can't be signed, the build fails.
entrySignature covers the fields that decide what a client installs: appVersion, flavor, platform, fromVersion, toVersion, bundleHash, assetManifestHash and minAppVersion. The client checks that appVersion, flavor and platform are its own and that the asset manifest matches its hash, so whoever serves the registry (say, a lapsed domain) can't replay another flavor's, platform's or release's signed bundle, or swap images.
Upgrading from 4.x: clients with
publicKeyreject entries withoutentrySignature. Publish once for each platform after upgrading, before shipping a store build with the new version.
On the client device:
- Android: Uses Android Security Provider (
SHA256withRSA/SHA256withECDSA). - iOS: Uses Apple Security framework (
SecKeyVerifySignature). - Fail closed: when
publicKeyis set, a missing or invalid signature (bundle, patch or registry entry) rejects the update. HMAC/symmetric keys are no longer supported (the public key ships in the app, so it can't be a secret).
config.appVersion is optional. The library automatically queries the native app version at runtime:
- Android:
PackageInfo.versionName - iOS:
CFBundleShortVersionString
Fail-Fast Safety: The library never falls back silently to
"1.0.0". If native version information is missing or corrupted, it immediately throws a descriptive error to prevent version mismatches.
When releasing a new native binary version (e.g. 1.0.3):
npx react-native-bspatch version 1.0.3This updates package.json, android/app/build.gradle (versionName, increments versionCode) and ios/*.xcodeproj/project.pbxproj (MARKETING_VERSION, increments CURRENT_PROJECT_VERSION).
patch android reads versionName, patch ios reads MARKETING_VERSION, and asks for confirmation:
Release version for android [1.0.2]:
Press Enter to keep it or type another version. -y or --release-version <x.y.z> skip the prompt (CI).
- POSIX
mmap()Zero-Copy Patching: Unlike standard bspatch which allocates heap memory withmalloc(),react-native-bspatchmemory-maps the input and output files usingmmap()andMADV_SEQUENTIAL. Memory overhead is reduced by ~85–90%, virtually eliminating out-of-memory crashes on low-end devices. - SIMD Vectorization: The inner patching loop includes
#pragma clang loop vectorize(enable)for Clang ARM NEON 128-bit vectorization. - 64KB Streaming Buffers: File hashing and stream copying are upgraded to 64KB chunk sizes across Android and iOS.
- Disk Garbage Collection: Calling
markHealthy()automatically deletes obsolete bundle versions and dangling.tmpfiles from disk, keeping app storage minimal.
react-native-bspatch/
├── src/
│ ├── index.ts # Public JS entry point
│ ├── NativeBsPatch.ts # TurboModule spec
│ └── ota/
│ ├── otaCore.ts # Pipeline: patch from installed OTA bundle, else full bundle
│ ├── otaMetadata.ts # Locked metadata updates, health marking, disk GC
│ ├── otaLogger.ts # Structured event telemetry
│ ├── OtaProvider.tsx # React provider + update modes
│ ├── types.ts
│ └── storage/
│ └── R2Storage.ts # Default provider: R2 public URL
├── android/src/main/java/vn/reactnativebspatch/
│ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
│ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
├── ios/
│ ├── RNBsPatch.mm # applyPatch, verifySignature, reload
│ └── OtaBundleResolver.swift # Bundle resolution + crash rollback (before JS)
├── cpp/bsdiff/ # bspatch (C, mmap)
└── tools/
├── cli.js # bin entry (Yarn Berry runs bins with node)
├── build_ota.sh # CLI: patch / reset / setup / version
├── upload_ota.sh # Upload to Cloudflare R2
├── update_registry.js # Writes ota_registry.json entries + signatures
├── setup_project.js # AppDelegate / MainApplication wiring
├── bump_version.js # Native version sync
└── generate_keys.js # RSA / EC signing keypair
- The first OTA after a store install, and clients more than 5 OTA versions behind, download the full bundle (patches are only built from the last 5 OTA versions)
- Health is confirmed when the app first renders; a crash after that is not rolled back
- OTA version numbers are shared by both platforms of a flavor, so
patch androidthenpatch iosgives iOS the next number - Each retained OTA version keeps its own copy of its assets next to the bundle (~asset size × up to 4 versions); downloads are still once per content hash