SkyDev OTA |
Orchestrator: 8401 MinIO: 8491 Guidelines All Apps Hub
Developer Master Guide

React Native OTA Integration Guide

Complete step-by-step instructions to register your app, configure native Android/iOS layers, implement in-app update modals, and release your first Over-The-Air bundle.

Target: salonx-customer
⚡

Automated Setup Wizard (Recommended)

Configures dependencies, native Android/iOS layers, network security, isolated src/ota/ modules, and high-velocity mobile release scripts automatically in seconds.

🚀 What the wizard automates for you:
• Installs @hot-updater/react-native & runs iOS CocoaPods gracefully.
• Generates RSA-2048 Code Signing Keypair: Creates ota-release-key.pem at project root (auto-ignored in .gitignore) and embeds src/ota/certs/ota-public-key.pem.
• Generates isolated TypeScript module in src/ota/ including ModalUpdateOta.tsx and AppVersion.tsx.
• Configures Android cleartext network security (LAN Wi-Fi testing) & JS Bundle Loader (MainApplication.kt).
• Configures iOS NSAllowsLocalNetworking (Info.plist) & AppDelegate.swift.
• Injects instant OTA release scripts with auto-versioning: yarn opa -s, yarn opa -p, yarn opi -p, yarn opb -p.

Step 0: Prerequisites (One-Time Setup on Developer Machine)

Pull the latest ota-internal repository and link the CLI tools (ota-setup, ota-deploy) to your global PATH:

Terminal (inside tools/ota-internal repository)
# 1. Navigate to ota-internal directory
cd /path/to/tools/ota-internal

# 2. Pull the latest tool updates
git pull origin develop

# 3. Link CLI tools to global PATH (run once)
npm install -g .

# 4. Verify that the CLI tools are ready
which ota-setup
which ota-deploy

Step 1: Run Automated CLI in your Mobile App directory

Open terminal in your React Native project root (e.g. salonx-owner-application) and run:

Terminal (inside mobile app directory)
# Run global CLI for this application
ota-setup --app=salonx-customer

# (Optional) Skip CocoaPods if you want to run pod install manually later:
ota-setup --app=salonx-customer --skip-pods
🔐 Per-App Code Signing Release Key (Zero-Knowledge Architecture):
The wizard automatically generates an isolated RSA-2048 keypair for this app in certs/ota-release-key.pem (or project root) and appends it to .gitignore.
• Zero Server Storage: The OTA server NEVER stores or sees your private key. Only developers or CI/CD pipelines holding this key can sign updates.
• Back up this key: Save certs/ota-release-key.pem to your team's 1Password / Secret Vault. Without this private key, end-user mobile apps will reject your OTA updates.
• CI/CD Pipelines: In GitHub Actions or Bitbucket, pass this key content via secret: OTA_SIGNING_PRIVATE_KEY.
• If lost: Simply generate a new key and publish a new native version (APK/IPA) to App Store / Google Play (just like rotating an Android keystore).

Step 2: Mount Update Modal in Root Component (App.tsx)

Open your root component (e.g. App.tsx or MainApp.tsx) and mount <ModalUpdateOta /> once globally:

App.tsx (or MainApp.tsx) — Global Modal Singleton
TypeScript (App.tsx)
import React from 'react';
import { RootProvider } from '@/providers';
import { NavigationContainer } from '@react-navigation/native';
import { ModalUpdateOta } from '@/ota'; // or './src/ota'

export default function App() {
  return (
    <RootProvider>
      <NavigationContainer>
        {/* Your application screens & navigators */}
      </NavigationContainer>

      {/* Global Singleton OTA Update Modal (Mounted once at root) */}
      <ModalUpdateOta />
    </RootProvider>
  );
}

Step 3: Display Version & Status via AppVersion Component

The generated <AppVersion /> component displays a live status indicator dot (Red = Update ready, Pulsing Yellow = Downloading, Green = Ready to reload, Slate = Up to date) with pre-configured bottom or header positioning:

src/screens/SignIn.tsx (Login Screen — AppVersion Component)
TypeScript (SignIn.tsx)
import React from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { AppVersion, AppStatus } from '@/ota'; // or './src/ota'

export default function SignInScreen() {
  return (
    <View style={styles.container}>
      <KeyboardAvoidingView
        style={{ flex: 1 }}
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
        {/* Your login form inputs and submit button */}
      </KeyboardAvoidingView>

      {/* Option A: Bottom Center Badge (Default - bottom: 40) */}
      <AppVersion position="bottom" />

      {/* Option B: Top-Right Header Badge (top: 40, right: 16) */}
      {/* <AppVersion position="header" /> */}

      {/* Option C: Ambient Indicator Dot Only (pulsing dot without text, ideal across any screen) */}
      {/* <AppStatus position="header" /> */}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
});

Step 4: Programmatically Query Version & Check Updates

Use the HotUpdater client from @/ota anywhere in your app (e.g. Settings / About page):

TypeScript (API Queries)
import { HotUpdater } from '@/ota';

// 1. Get Current App & Bundle Info
const appVersion = HotUpdater.getAppVersion();     // e.g. "1.0.0"
const bundleId = HotUpdater.getBundleId();         // e.g. "0191eb5d-..." or null
const channel = HotUpdater.getChannel();           // e.g. "production"

// 2. Check for Latest Available Update from Server
async function checkServerUpdate() {
  const updateInfo = await HotUpdater.checkForUpdate({});
  if (updateInfo) {
    console.log('New update available:', updateInfo.id);
    console.log('Changelog:', updateInfo.message);
    console.log('Force update:', updateInfo.shouldForceUpdate);
  } else {
    console.log('App is up to date.');
  }
}

⚡ High-Velocity OTA Release Shortcuts (OPA / OPI / OPB)

Fast, intuitive shortcuts configured directly in package.json:

Terminal (Ultra-Fast OTA Release Shortcuts)
# 🟢 Android Releases (OPA = OTA Publish Android)
yarn opa -s                             # Deploy Android OTA ➔ Staging
yarn opa -p                             # Deploy Android OTA ➔ Production
yarn opa -p -f                          # Deploy Android Production with Force Reload (-f)
yarn opa -s -m "Hotfix cart UI"         # Deploy Android Staging with release note (-m)

# 🍏 iOS Releases (OPI = OTA Publish iOS)
yarn opi -s                             # Deploy iOS OTA ➔ Staging
yarn opi -p                             # Deploy iOS OTA ➔ Production
yarn opi -p -f                          # Deploy iOS Production with Force Reload (-f)

# 🌐 Both Platforms Simultaneously (OPB = OTA Publish Both)
yarn opb -s                             # Deploy Both Android & iOS ➔ Staging
yarn opb -p                             # Deploy Both Android & iOS ➔ Production

# 💡 Quick Cheat Sheet & Help
yarn o                                  # Show Cheat Sheet & commands quick reference
yarn ota                                # Show Cheat Sheet & commands quick reference
yarn o -h                               # Or yarn opa -h / yarn ota -h

# 🔄 Environment Management & Cache Cleaning
yarn env:staging                        # Sync OTA_CHANNEL=staging in .env
yarn env:prod                           # Sync OTA_CHANNEL=production in .env
yarn clean:watchman                     # Reset Metro & Watchman cache

# ⚙️ Explicit Version Override (Optional, auto-detects from package.json by default)
yarn opa -p -v 1.2.0                    # Force target native version 1.2.0
1

Register Application on Hub Dashboard

Before writing mobile client code, create your application entry on the OTA Web Console.

How to do this:
1. Open the Hub: http://localhost:8400/hub.
2. Click the "+ Register New App" button.
3. Enter your Application Name (e.g. SalonX Owner App) and unique App ID (e.g. salonx-owner).
4. Click "Provision & Create App". The system automatically provisions PostgreSQL database ota_salonx_customer and MinIO S3 storage.
2

Install Mobile Client Dependency

Install the official Hot Updater native bridge in your React Native project. No custom local wrapper package is required.

package.json
Terminal / Yarn
# Install official Hot Updater React Native package
yarn add @hot-updater/react-native@^0.36.13

# Install iOS CocoaPods
cd ios && pod install && cd ..
3

Configure Android Native Layer

Configure cleartext network security for LAN Wi-Fi testing and inject the OTA JavaScript bundle loader.

android/app/src/main/res/xml/network_security_config.xml
XML
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
        <domain includeSubdomains="true">127.0.0.1</domain>
        <!-- Include your Host Mac LAN IP -->
        <domain includeSubdomains="true" class="dynamic-ip-domain">192.168.68.125</domain>
    </domain-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>
android/app/src/main/AndroidManifest.xml
XML (Inside <application> tag)
<application
    android:name=".MainApplication"
    android:networkSecurityConfig="@xml/network_security_config"
    ... >
android/app/src/main/java/.../MainApplication.kt
Kotlin (React Native 0.80+ / New Architecture)
import com.hotupdater.HotUpdater

class MainApplication : Application(), ReactApplication {
  override val reactHost: ReactHost by lazy {
    getDefaultReactHost(
      context = applicationContext,
      packageList = PackageList(this).packages,
      jsBundleFilePath = if (BuildConfig.DEBUG) {
        null
      } else {
        HotUpdater.getJSBundleFile(applicationContext)
      },
    )
  }
}
4

Configure iOS Native Layer

Permit local networking in Info.plist and inject the bundle URL in AppDelegate.

ios/<AppName>/Info.plist
XML (Inside <dict>)
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>
ios/<AppName>/AppDelegate.swift
Swift
import HotUpdater

class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
  override func bundleURL() -> URL? {
#if DEBUG
    RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
    HotUpdater.bundleURL()
#endif
  }
}
5

Create Isolated OTA Module (src/ota)

Create src/ota/ota.config.ts and src/ota/ota.service.ts to configure connection endpoints:

src/ota/ota.config.ts
TypeScript
/**
 * ============================================================================
 * AUTO-GENERATED BY @ota-internal (ota-setup)
 * OTA Environment Configuration
 * Verify/adjust the AppENV import path to match your project's env directory.
 * ============================================================================
 */
import { AppENV } from '@/constants/env';

export const OTA_CONFIG = {
  serverUrl: AppENV?.OTA_SERVER_URL || 'https://ota-api.skydev.vn:8401',
  storageUrl: AppENV?.OTA_STORAGE_URL || 'https://ota-api.skydev.vn:8490',
  appId: AppENV?.OTA_APP_ID || 'salonx-customer',
} as const;
src/ota/ota.service.ts
TypeScript
/* eslint-disable */
// @ts-nocheck
import { HotUpdater } from '@hot-updater/react-native';
import { OTA_CONFIG } from './ota.config';

HotUpdater.init({
  baseURL: `${OTA_CONFIG.serverUrl}/apps/${OTA_CONFIG.appId}`,
  requestHeaders: {},
});

export { HotUpdater };
6

Create Update Hook & Standalone Modal UI

Check for updates and display a clean update modal with live progress and "Restart Now" vs "Later" options:

src/ota/useOtaUpdate.ts
TypeScript Hook
/* eslint-disable */
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { HotUpdater, useHotUpdaterStore } from '@hot-updater/react-native';
import './ota.service';

// 1. Utilities
const formatCurrentTime = () => {
  const now = new Date();
  const hours = String(now.getHours()).padStart(2, '0');
  const minutes = String(now.getMinutes()).padStart(2, '0');
  return `${hours}:${minutes}`;
};

const resolveStage = ({ downloadError, hasUpdateInfo, isDownloading, isUpdateDownloaded }) => {
  if (downloadError) return 'UPDATE_FAILED';
  if (isDownloading) return 'DOWNLOADING';
  if (isUpdateDownloaded) return 'READY_TO_RELOAD';
  if (hasUpdateInfo) return 'UPDATE_READY';
  return 'UP_TO_DATE';
};

const resolveStageMeta = (stage, { lastCheckedTime, progressPercent }) => {
  const timeSuffix = lastCheckedTime ? ` (${lastCheckedTime})` : '';

  switch (stage) {
    case 'UPDATE_FAILED':
      return {
        primaryActionLabel: 'Retry Update',
        statusText: `Update failed${timeSuffix}`,
        title: 'Update Failed',
      };
    case 'DOWNLOADING':
      return {
        primaryActionLabel: `Downloading (${progressPercent}%)`,
        statusText: `Downloading (${progressPercent}%)`,
        title: 'Downloading Update...',
      };
    case 'READY_TO_RELOAD':
      return {
        primaryActionLabel: 'Restart App Now',
        statusText: `Update ready to reload${timeSuffix}`,
        title: 'Update Ready',
      };
    case 'UPDATE_READY':
      return {
        primaryActionLabel: 'Download & Install',
        statusText: `Update ready${timeSuffix}`,
        title: 'New Update Available',
      };
    case 'UP_TO_DATE':
    default:
      return {
        primaryActionLabel: 'OK',
        statusText: `Up to date${timeSuffix}`,
        title: 'Up to Date',
      };
  }
};

// 2. Main Hook
export default function useOtaUpdate() {
  const [downloadError, setDownloadError] = useState(null);
  const [isDismissed, setIsDismissed] = useState(false);
  const [isDownloading, setIsDownloading] = useState(false);
  const [lastCheckedTime, setLastCheckedTime] = useState('');
  const [updateInfo, setUpdateInfo] = useState(null);

  const isUpdateDownloaded = useHotUpdaterStore((state) => state.isUpdateDownloaded);
  const progress = useHotUpdaterStore((state) => state.progress);

  useEffect(() => {
    HotUpdater.checkForUpdate({})
      .then(async (info) => {
        setUpdateInfo(info);
        setLastCheckedTime(formatCurrentTime());
        if (info && !isUpdateDownloaded) {
          setIsDownloading(true);
          setDownloadError(null);
          try {
            await info.updateBundle();
          } catch (err) {
            setDownloadError(err?.message || 'Download failed');
          } finally {
            setIsDownloading(false);
          }
        }
      })
      .catch((err) => {
        setDownloadError(err?.message || 'Check update failed');
        setLastCheckedTime(formatCurrentTime());
      });
  }, [isUpdateDownloaded]);

  const handleDownloadOrRestart = useCallback(async () => {
    try {
      if (isUpdateDownloaded) {
        await HotUpdater.reload();
      } else if (updateInfo && !isDownloading) {
        setIsDownloading(true);
        setDownloadError(null);
        await updateInfo.updateBundle();
      } else if (downloadError) {
        setDownloadError(null);
        setIsDownloading(true);
        const info = await HotUpdater.checkForUpdate({});
        setUpdateInfo(info);
        setLastCheckedTime(formatCurrentTime());
        if (info && !isUpdateDownloaded) {
          await info.updateBundle();
        }
      }
    } catch (err) {
      setDownloadError(err?.message || 'Update failed');
    } finally {
      setIsDownloading(false);
    }
  }, [downloadError, isDownloading, isUpdateDownloaded, updateInfo]);

  const handleLater = useCallback(() => {
    setIsDismissed(true);
  }, []);

  const progressPercent = Math.round((progress || 0) * 100);
  const isForceUpdate = Boolean(updateInfo?.shouldForceUpdate);
  const hasOtaUpdate = Boolean(updateInfo) || isUpdateDownloaded || isDownloading || Boolean(downloadError);
  const isVisible = hasOtaUpdate && (!isDismissed || isForceUpdate);

  const appVersion = HotUpdater.getAppVersion?.() || '1.0.0';
  const bundleId = HotUpdater.getBundleId?.() || null;
  const isUpToDate = !downloadError && !updateInfo && !isDownloading && !isUpdateDownloaded;

  const currentStage = resolveStage({
    downloadError,
    hasUpdateInfo: Boolean(updateInfo),
    isDownloading,
    isUpdateDownloaded,
  });

  const stageMeta = resolveStageMeta(currentStage, {
    lastCheckedTime,
    progressPercent,
  });

  const statusText = `v${appVersion} • ${stageMeta.statusText}`;
  const updateStatus = stageMeta.statusText;

  return {
    appVersion,
    bundleId,
    currentStage,
    downloadError,
    isDownloading,
    isError: Boolean(downloadError),
    isForceUpdate,
    isLoading: isDownloading,
    isReadyToReload: isUpdateDownloaded,
    isUpToDate,
    isVisible,
    lastCheckedTime,
    message: updateInfo?.message || (downloadError ? `Update failed: ${downloadError}. Tap to retry.` : 'A new update with improvements is ready to be installed.'),
    onPrimaryPress: handleDownloadOrRestart,
    onSecondaryPress: handleLater,
    primaryLabel: stageMeta.primaryActionLabel,
    progressPercent,
    secondaryLabel: !isForceUpdate ? 'Later' : null,
    statusText,
    title: stageMeta.title,
    updateStatus,
  };
}
src/ota/ModalUpdateOta.tsx
TypeScript Component (Standalone React Native Primitives)
/* eslint-disable */
// @ts-nocheck
import React from 'react';
import { Modal, View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import useOtaUpdate from './useOtaUpdate';

export default function ModalUpdateOta() {
  const {
    isVisible,
    isDownloading,
    isLoading,
    progressPercent,
    title,
    message,
    primaryLabel,
    secondaryLabel,
    onPrimaryPress,
    onSecondaryPress,
  } = useOtaUpdate();

  if (!isVisible) return null;

  return (
    <Modal transparent animationType="fade" visible={isVisible} statusBarTranslucent>
      <View style={styles.overlay}>
        <View style={styles.card}>
          <Text style={styles.title}>{title}</Text>
          <Text style={styles.message}>{message}</Text>

          {isDownloading && (
            <View style={styles.progressContainer}>
              <View style={styles.progressBarBackground}>
                <View style={[styles.progressBarFill, { width: `${Math.max(5, progressPercent)}%` }]} />
              </View>
              <Text style={styles.progressText}>{progressPercent}%</Text>
            </View>
          )}

          <View style={styles.actionsContainer}>
            {secondaryLabel && !isDownloading && (
              <TouchableOpacity activeOpacity={0.7} onPress={onSecondaryPress} disabled={isLoading} style={styles.secondaryButton}>
                <Text style={styles.secondaryButtonText}>{secondaryLabel}</Text>
              </TouchableOpacity>
            )}
            <TouchableOpacity activeOpacity={0.7} onPress={onPrimaryPress} disabled={isLoading} style={[styles.primaryButton, !secondaryLabel && { flex: 1 }]}>
              {isLoading && !isDownloading ? (
                <ActivityIndicator size="small" color="#ffffff" />
              ) : (
                <Text style={styles.primaryButtonText}>{primaryLabel}</Text>
              )}
            </TouchableOpacity>
          </View>
        </View>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  overlay: { flex: 1, backgroundColor: 'rgba(15, 23, 42, 0.75)', justifyContent: 'center', alignItems: 'center', padding: 24 },
  card: { width: '100%', maxWidth: 380, backgroundColor: '#ffffff', borderRadius: 16, padding: 24, elevation: 8 },
  title: { fontSize: 18, fontWeight: '700', color: '#0f172a', marginBottom: 8 },
  message: { fontSize: 14, color: '#475569', lineHeight: 20, marginBottom: 20 },
  progressContainer: { marginBottom: 20 },
  progressBarBackground: { height: 8, backgroundColor: '#e2e8f0', borderRadius: 4, overflow: 'hidden', marginBottom: 6 },
  progressBarFill: { height: '100%', backgroundColor: '#0284c7', borderRadius: 4 },
  progressText: { fontSize: 12, color: '#64748b', textAlign: 'right', fontWeight: '500' },
  actionsContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 12 },
  secondaryButton: { minHeight: 44, paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, borderWidth: 1, borderColor: '#cbd5e1', justifyContent: 'center', alignItems: 'center' },
  secondaryButtonText: { fontSize: 14, fontWeight: '600', color: '#475569' },
  primaryButton: { minHeight: 44, paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8, backgroundColor: '#0284c7', justifyContent: 'center', alignItems: 'center' },
  primaryButtonText: { fontSize: 14, fontWeight: '600', color: '#ffffff' },
});
src/ota/AppVersion.tsx
TypeScript Component (Live Indicator Dot & Positioning Modes)
/* eslint-disable */
// @ts-nocheck
import React, { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleProp, StyleSheet, Text, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native';
import useOtaUpdate from './useOtaUpdate';

export interface AppVersionProps {
  activeOpacity?: number;
  badgeStyle?: StyleProp<ViewStyle>;
  dotStyle?: StyleProp<ViewStyle>;
  position?: 'bottom' | 'header' | 'inline';
  mode?: 'bottom' | 'header' | 'inline';
  onPress?: () => void;
  showDot?: boolean;
  statusOnly?: boolean;
  dotOnly?: boolean;
  style?: StyleProp<ViewStyle>;
  textStyle?: StyleProp<TextStyle>;
}

export const getDotColor = (stage) => {
  switch (stage) {
    case 'UPDATE_FAILED': return '#ef4444';   // Crimson Red (failure / verification error)
    case 'DOWNLOADING': return '#eab308';     // Amber (fast pulsing animation)
    case 'READY_TO_RELOAD': return '#22c55e'; // Emerald Green (downloaded & verified, gentle breathe)
    case 'UPDATE_READY': return '#38bdf8';    // Sky Blue (available on server)
    case 'UP_TO_DATE':
    default: return '#94a3b8';                // Slate Gray (up to date)
  }
};

export default function AppVersion({
  activeOpacity = 0.7,
  badgeStyle,
  dotStyle,
  position,
  mode,
  onPress,
  showDot = true,
  statusOnly = false,
  dotOnly = false,
  style,
  textStyle,
}: AppVersionProps) {
  const isStatusOnly = Boolean(statusOnly || dotOnly);
  const resolvedPosition = position ?? mode ?? (isStatusOnly ? 'header' : 'bottom');
  const { currentStage, isDownloading, isReadyToReload, onPrimaryPress, statusText } = useOtaUpdate();
  const pulseAnim = useRef(new Animated.Value(1)).current;

  useEffect(() => {
    if (isDownloading || isReadyToReload) {
      const anim = Animated.loop(
        Animated.sequence([
          Animated.timing(pulseAnim, { duration: isDownloading ? 500 : 900, toValue: 0.3, useNativeDriver: true }),
          Animated.timing(pulseAnim, { duration: isDownloading ? 500 : 900, toValue: 1, useNativeDriver: true }),
        ])
      );
      anim.start();
      return () => anim.stop();
    }
    pulseAnim.setValue(1);
  }, [isDownloading, isReadyToReload, pulseAnim]);

  const handlePress = useCallback(() => {
    if (onPress) onPress();
    else if (isReadyToReload || currentStage === 'UPDATE_READY' || currentStage === 'UPDATE_FAILED') onPrimaryPress();
  }, [currentStage, isReadyToReload, onPrimaryPress, onPress]);

  const dotColor = getDotColor(currentStage);
  const containerStyle = resolvedPosition === 'header' ? styles.headerContainer : resolvedPosition === 'inline' ? styles.inlineContainer : styles.bottomContainer;

  return (
    <View pointerEvents="box-none" style={[containerStyle, style]}>
      <TouchableOpacity
        activeOpacity={activeOpacity}
        disabled={!onPress && currentStage === 'UP_TO_DATE' && !isDownloading}
        hitSlop={{ bottom: 12, left: 12, right: 12, top: 12 }}
        onPress={handlePress}
        style={[styles.badge, isStatusOnly && styles.badgeStatusOnly, badgeStyle]}>
        {showDot && (
          <Animated.View
            style={[
              styles.dot,
              isStatusOnly && styles.dotStatusOnly,
              { backgroundColor: dotColor },
              (isDownloading || isReadyToReload) && { opacity: pulseAnim },
              dotStyle,
            ]}
          />
        )}
        {!isStatusOnly && <Text style={[styles.versionText, textStyle]}>{statusText}</Text>}
      </TouchableOpacity>
    </View>
  );
}

export function AppStatus(props: Omit<AppVersionProps, 'statusOnly' | 'dotOnly'>) {
  return <AppVersion statusOnly position="header" {...props} />;
}

const styles = StyleSheet.create({
  badge: { alignItems: 'center', flexDirection: 'row', justifyContent: 'center', paddingHorizontal: 8, paddingVertical: 4 },
  badgeStatusOnly: { paddingHorizontal: 6, paddingVertical: 6 },
  bottomContainer: { alignItems: 'center', bottom: 40, justifyContent: 'center', left: 0, position: 'absolute', right: 0, zIndex: 99 },
  dot: { borderRadius: 9999, height: 6, marginRight: 6, width: 6 },
  dotStatusOnly: { height: 8, marginRight: 0, width: 8 },
  headerContainer: { alignItems: 'center', justifyContent: 'center', position: 'absolute', right: 16, top: 40, zIndex: 99 },
  inlineContainer: { alignItems: 'center', flexDirection: 'row', justifyContent: 'center' },
  versionText: { color: '#64748b', fontSize: 12, fontWeight: '500', letterSpacing: 0.2 },
});
src/ota/index.ts
TypeScript Central Export
/* eslint-disable */
// @ts-nocheck
export * from './ota.config';
export * from './ota.service';
export { default as useOtaUpdate } from './useOtaUpdate';
export { default as ModalUpdateOta } from './ModalUpdateOta';
export { default as AppVersion, AppStatus } from './AppVersion';
export * from './AppVersion';
7

Mount Update Modal (Root) & AppVersion Component (Login & Profile)

Mount <ModalUpdateOta /> once globally in your Root Component (App.tsx / MainApp.tsx). Then mount <AppVersion /> on your Login Screen (fixed absolute) or Profile / Settings Screen (flow inline):

App.tsx (or MainApp.tsx) — Mount Modal Once Globally
TypeScript (App.tsx)
import React from 'react';
import { RootProvider } from '@/providers';
import { NavigationContainer } from '@react-navigation/native';
import { ModalUpdateOta } from '@/ota'; // or './src/ota'

export default function App() {
  return (
    <RootProvider>
      <NavigationContainer>
        {/* Your existing application navigators and screens */}
      </NavigationContainer>

      {/* Global OTA Update Modal (Mounted once at root) */}
      <ModalUpdateOta />
    </RootProvider>
  );
}
src/screens/SignIn.tsx (Login Screen — Absolute Badge)
TypeScript (SignIn / Login Screen)
import React from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { AppVersion, AppStatus } from '@/ota';

export default function SignInScreen() {
  return (
    <View style={styles.container}>
      <KeyboardAvoidingView
        style={{ flex: 1 }}
        behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
        {/* Login Screen Form */}
      </KeyboardAvoidingView>

      {/* Option A: Bottom Center Badge (Default - bottom: 40 absolute) */}
      <AppVersion position="bottom" />

      {/* Option B: Top-Right Header Badge (top: 40, right: 16 absolute) */}
      {/* <AppVersion position="header" /> */}

      {/* Option C: Ambient Indicator Dot Only (pulsing dot without text, ideal across any screen) */}
      {/* <AppStatus position="header" /> */}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
});
src/screens/Profile.tsx (Profile / Settings Screen — Inline Flow & Custom Style)
TypeScript (ProfileScreen — Inline Flow)
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { AppVersion, useOtaUpdate } from '@/ota';

export default function ProfileScreen() {
  // Option 1: Drop-in with position="inline" and custom style overrides
  return (
    <ScrollView style={styles.container}>
      {/* User Info / Settings Items */}

      {/* Version Item at bottom of settings */}
      <View style={styles.versionRow}>
        <Text style={styles.label}>App Version</Text>
        <AppVersion
          position="inline"
          badgeStyle={styles.versionBadge}
          textStyle={styles.versionText}
          dotStyle={styles.versionDot}
        />
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f8fafc', padding: 16 },
  versionRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, borderTopWidth: 1, borderColor: '#e2e8f0', marginTop: 24 },
  label: { fontSize: 14, color: '#334155', fontWeight: '500' },
  versionBadge: { backgroundColor: '#f1f5f9', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4 },
  versionText: { fontSize: 13, color: '#0f172a', fontWeight: '600' },
  versionDot: { width: 8, height: 8 },
});
8

Generate Isolated Code Signing Keypair (RSA-2048)

To prevent unauthorized bundle deployment and guarantee update provenance (like an Android release.keystore), create an asymmetric RSA-2048 keypair for this specific application.

🔐 How Per-App Release Keys Work (Zero-Trust Security):
• Private Key (certs/ota-release-key.pem): Stored locally in the mobile app directory and automatically ignored by .gitignore. The OTA server never stores or sees this key! Only developers or CI/CD pipelines possessing this private key can sign and publish updates.
• Public Key (src/ota/certs/ota-public-key.pem): Committed to git and compiled directly into your native app binary so mobile devices verify the cryptographic signature before activating updates.
• CI/CD Integration: In automated build environments (GitHub Actions, Bitbucket, GitLab), inject the private key via the environment variable OTA_SIGNING_PRIVATE_KEY.
• If lost: Simply generate a new key and publish a new native version (APK/IPA) to App Store / Google Play (just like rotating a native release keystore).

Option A: Run Generation from tools/ota-internal CLI

Terminal (inside tools/ota-internal)
# Generate isolated keypair directly into target mobile application:
npm run ota:keys -- --target=/path/to/your-react-native-app

Option B: Run Command directly inside your React Native Project root

Terminal (in your React Native project root)
# 1. Generate RSA-2048 keypair (Private in certs/, Public in src/ota/certs/)
node -e "const crypto=require('crypto'),fs=require('fs');const {publicKey,privateKey}=crypto.generateKeyPairSync('rsa',{modulusLength:2048,publicKeyEncoding:{type:'spki',format:'pem'},privateKeyEncoding:{type:'pkcs8',format:'pem'}});fs.mkdirSync('certs',{recursive:true});fs.writeFileSync('certs/ota-release-key.pem',privateKey,{mode:0o600});fs.mkdirSync('src/ota/certs',{recursive:true});fs.writeFileSync('src/ota/certs/ota-public-key.pem',publicKey);console.log('✔ Generated certs/ota-release-key.pem & src/ota/certs/ota-public-key.pem');"

# 2. Add private keys to .gitignore (NEVER COMMIT PRIVATE KEYS)
echo -e "
# SkyDev OTA Release Private Key (Keep secret)
certs/ota-release-key.pem
ota-release-key.pem
*.pem" >> .gitignore
9

Build & Release OTA Updates (Manual Options)

There are three ways to deploy an OTA update manually, depending on your workflow:

Option 1: Via Global ota-deploy CLI (Fastest)

Run directly from your React Native project root or anywhere on your machine:

Terminal (Global CLI)
# Deploy iOS
ota-deploy --app=salonx-customer --platform=ios --target-version=1.0.0 --target=. --message="Fix login layout"

# Deploy Android
ota-deploy --app=salonx-customer --platform=android --target-version=1.0.0 --target=. --message="Fix login layout"

# Deploy Both Simultaneously
ota-deploy --app=salonx-customer --platform=all --target-version=1.0.0 --target=. --message="Production update"

Option 2: From tools/ota-internal workspace

Terminal (inside tools/ota-internal)
# Deploy real bundle from mobile directory
npm run ota:deploy -- --app=salonx-customer --platform=ios --target-version=1.0.0 --target=/path/to/your/app --message="Release notes"

# Deploy fast synthetic mock bundle (for pipeline testing)
npm run ota:deploy -- --app=salonx-customer --platform=android --target-version=1.0.0 --mock

Option 3: 100% Raw Manual Packaging (Under the Hood Architecture)

If you ever want to package and release without any CLI tools, here is the complete 5-step lifecycle:

1. Compile React Native Metro Bundle:
npx react-native bundle --platform <android|ios> --dev false --entry-file index.js --bundle-output dist/index.<android.bundle|ios.bundle> --assets-dest dist/

2. Create Release Archive (.zip):
Zip the bundle file, assets/ / res/ folder, and hot-updater-manifest.json (with appId, platform, targetVersion).

3. Upload to MinIO S3:
Upload zip to ota-storage/salonx-customer/bundles/<bundleId>.zip via S3 API (Port 8490).

4. Register Record in PostgreSQL:
Insert row into database ota_salonx_customer table bundles with file_hash, storage_uri, target_app_version, enabled=true.

5. Invalidate Server Manifest Cache:
Send HTTP Webhook POST https://ota-api.skydev.vn:8401/apps/salonx-customer/cache/invalidate.
Action successful