ra-cordova-ota-update
v1.0.5
Published
Production-ready OTA (over-the-air) update system for Ionic + Angular + Cordova applications. Downloads, verifies, activates and rolls back updated web assets without requiring a new App Store / Google Play release.
Readme
ra-cordova-ota-update
Production-ready OTA (over-the-air) update system for Ionic + Angular + Cordova apps — a CodePush-style mechanism that downloads updated Angular web assets and activates them without a new App Store / Google Play release.
- Real Cordova plugin (Android/Java, iOS/Objective-C). Not Capacitor.
- Updates web assets only. Never touches native code.
- Clean, Angular-injectable public API:
RaOTA. force,background, andoptionalupdate types. No permanent skip.
import { RaOTA } from 'ra-cordova-ota-update';
constructor(private otaUpdate: RaOTA) {}
const info = await this.otaUpdate.checkForUpdate({
url: 'https://example.com/ota/latest.json',
});Table of contents
- Installation
- Angular import & DI
- Architecture
- RaOTA API
- TypeScript types
- Manifest format
- ZIP format
- SHA-256 verification
- Update types: force / background / optional
- Pending updates & activation
- Rollback & startup confirmation
- OTA version vs. app version
- Android notes
- iOS notes
- Security
- Storage layout
- Download progress
- Failure recovery
- Limitations
Installation
cordova plugin add ra-cordova-ota-update
npm install ra-cordova-ota-updateBoth steps are required: cordova plugin add installs the native
Android/iOS code and the JS bridge into your Cordova project;
npm install makes the RaOTA Angular service and its TypeScript types
resolvable from your app's node_modules at build time. They ship from the
same package.
Angular import & DI
import { RaOTA } from 'ra-cordova-ota-update';
@Injectable({ providedIn: 'root' })
// or in any component/service:
export class UpdateService {
constructor(private otaUpdate: RaOTA) {}
}RaOTA is itself @Injectable({ providedIn: 'root' }), so:
- No
providers: [RaOTA]needed anywhere. - No
new RaOTA()needed anywhere. - No factory /
InjectionTokenneeded anywhere. - No
window.cordova,window.cordova.plugins, orcordova.plugins.raOtaUpdatein your application code, ever. The bridge is a private implementation detail ofRaOTA.
The package's package.json exports/main/module/types all resolve
to the compiled RaOTA class at the package root, so
import { RaOTA } from 'ra-cordova-ota-update' works immediately after
npm install — never ra-cordova-ota-update/dist/... or
ra-cordova-ota-update/www/....
Architecture
Angular application
|
v
RaOTA <-- the only thing your app talks to
|
v
Cordova JS bridge (www/ra-ota-update.js, private)
|
v
Native Android (Java) / iOS (Objective-C)
|
v
OTA file system (private app storage)
|
v
Android WebView / WKWebViewRaOTA internally calls cordova.plugins.raOtaUpdate.*, which calls
cordova.exec(...). That is purely an implementation detail — it can
change in a future version without breaking your code.
RaOTA API
All methods return Promises.
| Method | Description |
|---|---|
| checkForUpdate(options) | Fetches the JSON manifest and compares it to the current OTA version. Returns OtaUpdateInfo. |
| downloadUpdate(manifest, onProgress?) | Downloads, verifies (SHA-256), extracts, and validates the ZIP; stores it as pending. Used for background updates and optional → "Later". |
| installUpdate(manifest, onProgress?) | downloadUpdate + applyPendingUpdate + restart in one call. Used for force updates and optional → "Update". |
| applyPendingUpdate() | Atomically activates the pending OTA (current → backup, pending → current). Does not restart. |
| markUpdateSuccessful() | Call after your app finishes initializing post-activation, to confirm the new bundle is healthy. |
| rollback() | Restores the previous known-good OTA (or the bundled app if there's no backup). |
| clearUpdate() | Removes the pending OTA and temp files. Never touches the active OTA. |
| getCurrentVersion() | The active OTA version, or null if running the bundled app. |
| getPendingVersion() | The pending OTA version, or null. |
| hasPendingUpdate() | true/false. |
| getStatus() | { status, currentVersion, pendingVersion }. |
| restart() | Reloads the WebView into the active OTA (or bundled app). |
RaOTA also exposes downloadProgress$: Observable<OtaDownloadProgress>
for apps that prefer RxJS over the onProgress callback.
RaOTA deliberately does not implement getAppVersion(). Get the
native application version from
@awesome-cordova-plugins/app-version/ngx
in your own Angular code — see OTA version vs. app version.
TypeScript types
import {
RaOTA,
OtaUpdateType, // 'force' | 'background' | 'optional'
OtaManifest,
OtaUpdateInfo,
OtaDownloadProgress,
OtaStatus,
OtaError,
OtaStatusSnapshot,
} from 'ra-cordova-ota-update';See dist/index.d.ts / ts-src/types.ts for full definitions. No any is
used in the public API.
Manifest format
Your OTA server serves a JSON document such as:
{
"version": 42,
"type": "background",
"url": "https://example.com/ota/releases/42/update.zip",
"sha256": "ABCDEF...",
"size": 18439221,
"minAppVersion": {
"ios": "8.5.0",
"android": "8.2.0"
},
"message": {
"en": "A new update is available.",
"ar": "يتوفر تحديث جديد."
}
}Only version, type, and url are required. sha256, size,
minAppVersion, and message are all optional.
ZIP format
The ZIP must contain the contents of your Angular www/ directory,
with index.html at the ZIP root:
✅ correct ❌ incorrect
update.zip update.zip
├── index.html └── www/
├── main.js ├── index.html
├── polyfills.js └── ...
├── styles.css
└── assets/If index.html is not found at the ZIP root after extraction, the update
is rejected with INVALID_OTA and no files are activated or marked
pending.
SHA-256 verification
If the manifest includes sha256, the native layer computes the SHA-256 of
the downloaded ZIP and compares it (case-insensitively) to the manifest
value before extraction. On mismatch:
- the ZIP is deleted,
- nothing is extracted,
- nothing is marked pending or activated,
- the Promise rejects with
{ code: 'CHECKSUM_MISMATCH', ... }.
If sha256 is omitted, verification is skipped — but path-traversal
protection and index.html validation still run.
Update types
There are exactly three types. There is no "skip forever."
UPDATE FOUND
|
+--------------+--------------+
| | |
FORCE BACKGROUND OPTIONAL
| | |
DOWNLOAD DOWNLOAD ASK USER
| | / \
VERIFY VERIFY UPDATE LATER
| | | |
INSTALL PENDING INSTALL PENDING
| | | |
RESTART KEEP RESTART KEEP
|
NEXT APP START
|
ACTIVATE PENDING
|
NEW OTA ACTIVEconst info = await this.otaUpdate.checkForUpdate({ url: MANIFEST_URL });
if (!info.available) return;
switch (info.type) {
case 'force':
await this.otaUpdate.installUpdate(info as OtaManifest);
break;
case 'background':
await this.otaUpdate.downloadUpdate(info as OtaManifest);
break; // activates automatically on next app start
case 'optional': {
const wantsUpdate = await askUser(info.message?.en);
if (wantsUpdate) {
await this.otaUpdate.installUpdate(info as OtaManifest);
} else {
await this.otaUpdate.downloadUpdate(info as OtaManifest); // "Later"
}
break;
}
}Call this once, early, on every app start (e.g. in your root component or an app initializer) to activate anything left pending from a previous session:
if (await this.otaUpdate.hasPendingUpdate()) {
await this.otaUpdate.applyPendingUpdate();
await this.otaUpdate.restart();
}Pending updates & activation
Pending state is persisted natively (a metadata.json file in private
app storage, plus the extracted pending/ directory) — never only in
JavaScript memory. It survives app close, app restart, and device reboot.
Activation is atomic:
current -> backup
pending -> currentThe plugin never partially replaces the active OTA: it only removes the
old current after the new one has fully moved into place, and only
clears pending after a successful move.
Rollback & startup confirmation
After applyPendingUpdate(), the plugin marks its internal state as
"activating" and does not clear that flag until your app calls
markUpdateSuccessful():
// e.g. in your app's root component, after your app has confirmed
// it initialized correctly (data loaded, no fatal boot errors, etc.)
await this.otaUpdate.markUpdateSuccessful();If the app is killed or crashes before markUpdateSuccessful() is ever
called, the next app launch detects the still-"activating" flag and
automatically rolls back to the previous backup (or to the bundled app,
if there is no backup) before your JavaScript even runs.
This is best-effort protection — it can catch "app never got far
enough to confirm" scenarios, but it cannot detect every possible
JavaScript error (e.g. a bug that leaves the UI in a broken-but-not-crashed
state). Design your own health checks accordingly, and call
rollback() manually if you detect a broken update at runtime.
OTA version vs. app version
These are two completely independent version systems. Never compare them to each other.
Native app version (App Store / Google Play): 8.6.0
Current OTA version: 41
New OTA version: 42RaOTA compares 42 > 41 (OTA vs. OTA). It never compares 42 against
8.6.0.
If a manifest has no OTA installed yet, the current OTA version is treated
as "the bundled application" (getCurrentVersion() returns null), and
any manifest version is considered newer.
minAppVersion in the manifest is preserved and returned by
checkForUpdate(), but RaOTA does not evaluate it. Get the running
native app version yourself with
@awesome-cordova-plugins/app-version/ngx and compare it against
info.minAppVersion?.[platform] using semantic versioning in your own
Angular code before deciding whether to apply an update.
Android notes
- Java, no external storage permissions — everything lives under
context.getFilesDir()/ota/. - ZIP extraction includes explicit path-traversal ("zip slip") protection:
absolute paths,
..segments, and Windows drive-letter paths are all rejected before anything is written to disk. - OTA assets are served through Cordova's normal
https://<hostname>/origin — never through afile://redirect. The WebView is never navigated away from Cordova's default page. Instead, the plugin contributes aCordovaPluginPathHandler(RaOtaUpdate.getPathHandler()), which Cordova'sWebViewAssetLoaderconsults on every resource request before falling back to the bundledwww/assets: ifota/current/<requested path>exists, it's streamed back directly with the correct MIME type; otherwise the request falls through untouched to the normal bundled asset (or the next plugin's handler). This means:cordova.js,cordova_plugins.js, and everything underplugins/are always served from the bundled build (the OTA never needs to include them), so the bridge always matches whichever native plugins are actually compiled into the running APK.- Every other Cordova plugin keeps working normally on an active OTA, since the page origin, scheme, and bridge injection are completely unaffected — this plugin only overrides individual file contents, never the navigation/origin model.
- No
setAllowFileAccess*WebView settings are needed or touched. restart()simply reloadshttps://<hostname>/index.html(the same URL Cordova always uses); the path handler resolves fresh on every request, so it automatically reflects whichever OTA is current at that moment — no separate "load the OTA" vs. "load bundled" code path.
iOS notes
- Objective-C, storage under the app's
Application Supportsandbox (excluded from iCloud/iTunes backup). - ZIP extraction uses the
SSZipArchiveCocoaPod (declared inplugin.xmlvia<framework type="podspec">);cordova build iosrunspod installautomatically as part of a standard CocoaPods-integrated cordova-ios build. SHA-256 usesCommonCrypto, which ships with the iOS SDK. - Extraction includes a defense-in-depth containment check on top of the ZIP library's own protections: every extracted path is verified to resolve inside the destination directory, and symlinked entries are rejected outright.
- The active OTA is loaded with
[webView loadFileURL:allowingReadAccessToURL:], granting the WKWebView read access to the OTA directory so relative asset URLs resolve correctly.
Security
- OTA can update only web assets (HTML, JS, CSS, images, fonts).
- OTA cannot update: Java, Kotlin, Swift, Objective-C, Cordova native
plugins,
AndroidManifest.xml,Info.plist, native frameworks, the.apk/.ipaitself, or native permissions. Any of those require a normal App Store / Google Play release. - The bundled
www/(orfile:///android_asset/www) is never modified — it's always available as the ultimate fallback. - SHA-256 verification (when the manifest provides it) happens before extraction.
- ZIP path-traversal protection on both platforms.
- No
skippedVersion/wasSkipped/ permanent-skip state exists anywhere in the plugin.
Storage layout
ota/
metadata.json currentVersion, pendingVersion, activating flag, ...
current/ the currently active OTA (served by the WebView)
pending/ downloaded + verified, waiting for applyPendingUpdate()
backup/ previous known-good OTA, for rollback
tmp/ scratch space for downloads/extraction (always cleaned up)Download progress
await this.otaUpdate.downloadUpdate(manifest, (progress) => {
console.log(`${progress.percentage.toFixed(1)}% (${progress.downloadedBytes}/${progress.totalBytes})`);
});
// or, RxJS-style:
this.otaUpdate.downloadProgress$.subscribe((p) => this.progressBar = p.percentage);totalBytes/percentage will be 0 if the server response didn't
include a usable content length.
Failure recovery
Every native operation is written to fail closed:
- Network/HTTP failure during download → nothing is written to
pending. - SHA-256 mismatch → ZIP deleted, nothing extracted or activated.
- ZIP traversal detected, or extraction fails → extracted files deleted, nothing staged.
- Missing
index.htmlat ZIP root → extracted files deleted,INVALID_OTA. - Interrupted activation (app killed mid-
applyPendingUpdate()or crashed beforemarkUpdateSuccessful()) → automatic rollback on next launch. rollback()with no backup → falls back to the bundled application.
All errors reject the returned Promise with a structured OtaError
({ code, message }) — see OtaErrorCode in the type definitions.
Limitations
- Android vs. iOS asset serving differ. Android serves OTA assets
through Cordova's
https://<hostname>/origin via aCordovaPluginPathHandler(see Android notes) — the WebView's origin/scheme never changes. iOS still loads the OTA with[webView loadFileURL:allowingReadAccessToURL:](afile://URL scoped to the OTA directory), which does not carry the same "other plugins keep working transparently" guarantee if a specific iOS plugin assumes the bundledfile://origin — most plugins built againstWKWebVieware unaffected in practice, but this is a real platform difference to be aware of. Bringing iOS to the same asset-loader-based model as Android would requireWKURLSchemeHandler— ask if you'd like that added. On iOS, thefile://redirect happens from the plugin'spluginInitialize()hook, which runs very early but not necessarily before the very first paint of the bundledindex.html; a very brief flash of the bundled page is theoretically possible on slow devices. markUpdateSuccessful()is best-effort and cannot catch every class of JavaScript failure — see Rollback & startup confirmation.- The OTA system only updates what ships inside your Angular
www/output. Anything requiring a native rebuild (new Cordova plugins, native permission changes,config.xmlchanges, etc.) still requires a normal store release. checkForUpdate()fetches your manifest URL directly from the WebView context (viafetch), so your OTA server must respond with the appropriate CORS headers if the manifest is hosted on a different origin than your app expects to call.
