import { NitroModules } from 'react-native-nitro-modules'; import type { NitroOta } from './NitroOta.nitro'; import { checkOTAVersion, hasCompatibleUpdate, type OTAVersionCheckResult, } from './otaVersionChecker'; const NitroOtaHybridObject = NitroModules.createHybridObject('NitroOta'); // --------------------------------------------------------------------------- // onRollback listener infrastructure // --------------------------------------------------------------------------- type RollbackCallback = (record: RollbackHistoryRecord) => void; const rollbackListeners = new Set(); // Cache of crash rollbacks detected from the previous session (loaded once). let _crashRollbacksFromPreviousSession: RollbackHistoryRecord[] | null = null; function _notifyRollback(record: RollbackHistoryRecord) { rollbackListeners.forEach((cb) => cb(record)); } async function _loadCrashRollbacksOnce(): Promise { if (_crashRollbacksFromPreviousSession !== null) { return _crashRollbacksFromPreviousSession; } try { const history = await getRollbackHistory(); // Only entries beyond the last acknowledged index are "new". // This prevents re-firing the same crash rollback on every subsequent launch. const notifiedCount = NitroOtaHybridObject.getNotifiedRollbackCount(); const newEntries = history .slice(notifiedCount) .filter((r) => r.reason === 'crash_detected'); _crashRollbacksFromPreviousSession = newEntries; if (newEntries.length > 0) { // Acknowledge immediately so the next session won't re-report these. NitroOtaHybridObject.acknowledgeRollbackHistory(); } } catch { _crashRollbacksFromPreviousSession = []; } return _crashRollbacksFromPreviousSession; } /** * A single rollback history record. * Stored on-device every time a rollback is performed (crash or manual). */ export interface RollbackHistoryRecord { /** Unix timestamp in milliseconds when the rollback occurred */ timestamp: number; /** The OTA version that was active before the rollback */ fromVersion: string; /** * The version that was restored. * "original" means the original app bundle (no OTA). */ toVersion: string; /** * Why the rollback happened. * "crash_detected" — automatic crash handler triggered rollback * "manual" — user called rollbackToPreviousBundle() * "max_rollbacks_exceeded" — rollback counter > 3; reset to original bundle * Any other string — custom reason passed to markCurrentBundleAsBad() */ reason: 'crash_detected' | 'manual' | 'max_rollbacks_exceeded' | string; } /** * Target versions for OTA updates */ export interface OTATargetVersions { /** List of Android app versions this OTA can be safely installed on. Leave empty to target all versions */ android?: string[]; /** List of iOS app versions this OTA can be safely installed on. Leave empty to target all versions */ ios?: string[]; } /** * OTA version configuration structure for ota.version.json */ export interface OTAVersionConfig { /** The OTA version identifier */ version: string; /** Target app versions for different platforms */ targetVersions?: OTATargetVersions; /** Whether the version follows semantic versioning (x.y.z format). Defaults to false */ isSemver?: boolean; /** Optional release notes for this OTA update */ releaseNotes?: string; } export function checkForOTAUpdates(versionCheckUrl: string): Promise { console.log('checkForUpdates', versionCheckUrl); return NitroOtaHybridObject.checkForUpdates(versionCheckUrl); } export function downloadZipFromUrl( downloadUrl: string, onProgress?: (received: number, total: number) => void, bundleFilePath?: string ): Promise { console.log('downloadZipFromUrl', downloadUrl); return NitroOtaHybridObject.downloadZipFromUrl( downloadUrl, onProgress ?? null, bundleFilePath ?? null ); } export function getStoredOtaVersion(): string | null { return NitroOtaHybridObject.getStoredOtaVersion(); } export function getStoredUnzippedPath(): string | null { return NitroOtaHybridObject.getStoredUnzippedPath(); } export function reloadApp(): void { NitroOtaHybridObject.reloadApp(); } /** * Rollback to the previous OTA bundle. * * Blacklists the current (bad) version so it is never re-downloaded. * Increments the consecutive rollback counter. If the counter exceeds 3, * all OTA data is cleared and the original app bundle will be used on next launch. * Call reloadApp() after this to apply the change. * * @returns true if rollback succeeded (previous bundle activated or reset to original) */ export async function rollbackToPreviousBundle(): Promise { const success = await NitroOtaHybridObject.rollbackToPreviousBundle(); if (success) { // Notify listeners with the persisted history entry. Guard separately so // a listener failure never masks the rollback result. try { const history = await getRollbackHistory(); const lastRecord = history[history.length - 1]; if (lastRecord) { _notifyRollback(lastRecord); } } catch { // Notification is best-effort — do not re-throw. } } return success; } /** * Confirm that the current OTA bundle is working correctly. * * Call this after verifying that critical app flows work on the new bundle * (e.g., after a successful login or key screen load). Once confirmed, the * automatic crash-rollback guard is disabled for this bundle — future crashes * will not trigger a rollback. * * If you never call this after a new bundle is applied, the crash handler will * roll back the bundle if the app crashes on the next launch. */ export function confirmBundle(): void { NitroOtaHybridObject.confirmBundle(); } /** * Returns the list of blacklisted OTA version strings. * Blacklisted versions will never be downloaded again. */ export async function getBlacklistedVersions(): Promise { const json = await NitroOtaHybridObject.getBlacklistedVersions(); try { return JSON.parse(json) as string[]; } catch { return []; } } /** * Returns the rollback history. * Each entry records one rollback event (crash or manual). */ export async function getRollbackHistory(): Promise { const json = await NitroOtaHybridObject.getRollbackHistory(); try { return JSON.parse(json) as RollbackHistoryRecord[]; } catch { return []; } } /** * Manually marks the current bundle as bad, blacklists it, and triggers a rollback. * Call reloadApp() after this to apply. * @param reason - A description of why the bundle is being marked as bad */ export async function markCurrentBundleAsBad(reason: string): Promise { await NitroOtaHybridObject.markCurrentBundleAsBad(reason); // Notify listeners with the last history entry (written natively). // Guard separately so a listener failure never masks the original call. try { const history = await getRollbackHistory(); const lastRecord = history[history.length - 1]; if (lastRecord) { _notifyRollback(lastRecord); } } catch { // Notification is best-effort — do not re-throw. } } /** * Subscribe to rollback events. * * The callback is fired in two situations: * 1. **Crash rollback from the previous session** — if the crash handler * rolled back the bundle before JS started (detected on first call by * inspecting the persisted rollback history). * 2. **Manual rollback in the current session** — when * `rollbackToPreviousBundle()` or `markCurrentBundleAsBad()` is called * and succeeds. * * @param callback - Receives the `RollbackHistoryRecord` describing the rollback. * @returns An unsubscribe function. Call it to remove the listener. * * @example * ```typescript * const unsubscribe = onRollback((record) => { * console.log('Rollback!', record.reason, record.fromVersion, '→', record.toVersion); * }); * // Later: * unsubscribe(); * ``` */ export function onRollback(callback: RollbackCallback): () => void { rollbackListeners.add(callback); // Fire any crash rollbacks from the previous session immediately. _loadCrashRollbacksOnce().then((records) => { records.forEach((r) => callback(r)); }); return () => { rollbackListeners.delete(callback); }; } /** * Gets the current app version from the native bundle * Note: This requires react-native-device-info or similar package to be installed * If not available, returns a default version */ export function getAppVersion(): string { try { // Try to get from react-native-device-info if available const DeviceInfo = require('react-native-device-info'); return DeviceInfo.getVersion(); } catch { console.warn( 'OTA: react-native-device-info not found. Using default app version. Install react-native-device-info for accurate version checking.' ); // Return a default version if DeviceInfo is not available return '1.0.0'; } } /** * Checks for OTA updates with enhanced version comparison * Supports both plain text (ota.version) and JSON (ota.version.json) formats * * @param versionCheckUrl - URL to check for version information * @param appVersion - Optional app version (auto-detected if not provided) * @returns OTAVersionCheckResult with detailed update information */ export async function checkForOTAUpdatesJS( versionCheckUrl?: string, appVersion?: string ): Promise { if (!versionCheckUrl) { return null; } const currentOtaVersion = getStoredOtaVersion(); const currentAppVersion = appVersion || getAppVersion(); try { const result = await checkOTAVersion( versionCheckUrl, currentOtaVersion, currentAppVersion ); return result; } catch (error) { console.error('OTA: Error checking for updates:', error); throw error; } } /** * Simple check for compatible OTA updates (backward compatible API) * @param versionCheckUrl - URL to check for version information * @param appVersion - Optional app version (auto-detected if not provided) * @returns true if a compatible update is available, false otherwise */ export async function hasOTAUpdate( versionCheckUrl?: string, appVersion?: string ): Promise { if (!versionCheckUrl) { return false; } const currentOtaVersion = getStoredOtaVersion(); const currentAppVersion = appVersion || getAppVersion(); try { return await hasCompatibleUpdate( versionCheckUrl, currentOtaVersion, currentAppVersion ); } catch (error) { console.error('OTA: Error checking for compatible updates:', error); return false; } } /** * OTA Update Manager class for handling over-the-air updates */ export class OTAUpdateManager { private downloadUrl: string; private versionCheckUrl?: string; constructor(downloadUrl: string, versionCheckUrl?: string) { this.downloadUrl = downloadUrl; this.versionCheckUrl = versionCheckUrl; } /** * Checks if there are any updates available by comparing versions */ async checkForUpdates(): Promise { try { const urlToCheck = this.versionCheckUrl || this.downloadUrl; console.log( `OTA: Checking for updates using version check URL: ${urlToCheck}` ); return await checkForOTAUpdates(urlToCheck); } catch (error) { console.error('OTA: Failed to check for updates:', error); throw error; } } /** * Checks for updates using JS implementation with detailed version comparison * @param appVersion - Optional app version (auto-detected if not provided) * @returns Detailed version check result */ async checkForUpdatesJS( appVersion?: string ): Promise { return await checkForOTAUpdatesJS(this.versionCheckUrl, appVersion); } /** * Simple check for compatible updates (backward compatible) * @param appVersion - Optional app version (auto-detected if not provided) * @returns true if a compatible update is available */ async hasCompatibleUpdate(appVersion?: string): Promise { return await hasOTAUpdate(this.versionCheckUrl, appVersion); } /** * Downloads and extracts the OTA update. * @param onProgress - Optional callback invoked with (bytesReceived, totalBytes) * during the download. `totalBytes` is -1 if the server does not send Content-Length. */ async downloadUpdate( onProgress?: (received: number, total: number) => void, bundleFilePath?: string ): Promise { try { console.log(`OTA: Downloading update from ${this.downloadUrl}`); const path = await downloadZipFromUrl( this.downloadUrl, onProgress, bundleFilePath ); console.log(`OTA: Update downloaded to: ${path}`); return path; } catch (error) { console.error('OTA: Failed to download update:', error); throw error; } } /** * Gets the current stored OTA version */ getVersion(): string | null { const version = getStoredOtaVersion(); console.log(`OTA: Current version: ${version}`); return version; } /** * Gets the stored unzipped path */ getUnzippedPath(): string | null { const path = getStoredUnzippedPath(); console.log(`OTA: Unzipped path: ${path}`); return path; } /** * Reloads the app */ reloadApp(): void { NitroOtaHybridObject.reloadApp(); } /** * Rollback to the previous OTA bundle. * Blacklists the current version so it is not re-downloaded. * Call reloadApp() after this to apply the change. * @returns true if rollback was applied */ async rollback(): Promise { const success = await rollbackToPreviousBundle(); if (success) { console.log('OTA: Rollback successful. Call reloadApp() to apply.'); } return success; } /** * Confirm the current OTA bundle is working correctly. * Disables the automatic crash-rollback guard for this bundle. * Call this after verifying critical flows work on the new bundle. */ confirm(): void { confirmBundle(); console.log('OTA: Bundle confirmed as working.'); } /** * Returns the list of blacklisted OTA version strings. */ async getBlacklist(): Promise { return getBlacklistedVersions(); } /** * Returns the rollback history. */ async getHistory(): Promise { return getRollbackHistory(); } /** * Manually marks the current bundle as bad, blacklists it, and triggers a rollback. * Call reloadApp() after this to apply. * @param reason - Why the bundle is being marked as bad (default: 'manual') */ async markAsBad(reason: string = 'manual'): Promise { await markCurrentBundleAsBad(reason); console.log( 'OTA: Bundle marked as bad. Call reloadApp() to apply rollback.' ); } /** * Subscribe to rollback events for this manager's bundle. * * Fires when: * - A crash rollback happened in the previous session (detected on registration). * - `rollback()` or `markAsBad()` is called in the current session. * * @param callback - Receives the `RollbackHistoryRecord` of the rollback event. * @returns An unsubscribe function. */ onRollback(callback: (record: RollbackHistoryRecord) => void): () => void { return onRollback(callback); } /** * Schedule a background OTA check that runs natively (no JavaScript callbacks needed). * * @param interval - Delay in seconds before the check runs * * Note: * - Android: Uses WorkManager, works even when app is closed * - iOS: Uses background tasks, behavior depends on iOS version and permissions * - Safe to call multiple times (replaces existing scheduled tasks) * - URLs are passed from JavaScript (doesn't rely on stored preferences) * * @example * ```typescript * const manager = new OTAUpdateManager(downloadUrl, versionCheckUrl); * manager.scheduleBackgroundCheck(3600); // Check every hour * ``` */ scheduleBackgroundCheck(interval: number): void { if (!this.versionCheckUrl) { throw new Error( 'Version check URL is required to schedule background checks' ); } NitroOtaHybridObject.scheduleBackgroundOTACheck( this.versionCheckUrl, this.downloadUrl, interval ); } } export { githubOTA } from './githubUtils'; export type { OTAVersionCheckResult } from './otaVersionChecker'; export { checkOTAVersion, hasCompatibleUpdate as checkCompatibleUpdate, } from './otaVersionChecker';