diff --git a/.github/workflows/ota-update-pos.yaml b/.github/workflows/ota-update-pos.yaml index 9d74c277..400d4f23 100644 --- a/.github/workflows/ota-update-pos.yaml +++ b/.github/workflows/ota-update-pos.yaml @@ -1,13 +1,154 @@ name: OTA Update Mobile POS +run-name: "Mobile POS OTA - production" -permissions: {} +permissions: + id-token: write + contents: read on: workflow_dispatch: + inputs: + message: + description: 'Update message (shown in EAS dashboard)' + required: false + type: string + default: '' jobs: - placeholder: + publish-update: runs-on: ubuntu-latest steps: - - name: Echo placeholder - run: echo "OTA Update Mobile POS placeholder workflow" + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/ci-setup + with: + root-path: dapps/pos-app + package-manager: npm + + - name: Create env file + run: | + if [ -z "${{ secrets.POS_ENV_FILE }}" ]; then + echo "Error: POS_ENV_FILE secret is empty or not set" + exit 1 + fi + echo "${{ secrets.POS_ENV_FILE }}" > dapps/pos-app/.env + + - name: Compute current fingerprints + id: fingerprints + run: | + cd dapps/pos-app + ANDROID_FINGERPRINT=$(npx expo-updates fingerprint:generate --platform android 2>/dev/null | tail -1 | jq -r '.hash') + IOS_FINGERPRINT=$(npx expo-updates fingerprint:generate --platform ios 2>/dev/null | tail -1 | jq -r '.hash') + echo "android=$ANDROID_FINGERPRINT" >> "$GITHUB_OUTPUT" + echo "ios=$IOS_FINGERPRINT" >> "$GITHUB_OUTPUT" + echo "Current Android fingerprint: $ANDROID_FINGERPRINT" + echo "Current iOS fingerprint: $IOS_FINGERPRINT" + + - name: Fetch last native build fingerprints + run: | + git fetch origin fingerprints-dont-remove --depth=1 2>/dev/null || true + git show origin/fingerprints-dont-remove:native-fingerprints/production/android.txt > /tmp/native-fingerprint-android.txt 2>/dev/null || true + git show origin/fingerprints-dont-remove:native-fingerprints/production/ios.txt > /tmp/native-fingerprint-ios.txt 2>/dev/null || true + + - name: Check for native changes + id: native-check + env: + CURRENT_ANDROID_FINGERPRINT: ${{ steps.fingerprints.outputs.android }} + CURRENT_IOS_FINGERPRINT: ${{ steps.fingerprints.outputs.ios }} + run: | + HAS_NATIVE_CHANGES=false + + ANDROID_FINGERPRINT_PATH=/tmp/native-fingerprint-android.txt + IOS_FINGERPRINT_PATH=/tmp/native-fingerprint-ios.txt + + if [ -s "$ANDROID_FINGERPRINT_PATH" ]; then + LAST_ANDROID_FINGERPRINT=$(cat "$ANDROID_FINGERPRINT_PATH") + echo "Last native Android fingerprint: $LAST_ANDROID_FINGERPRINT" + echo "Current Android fingerprint: $CURRENT_ANDROID_FINGERPRINT" + + if [ "$LAST_ANDROID_FINGERPRINT" != "$CURRENT_ANDROID_FINGERPRINT" ]; then + HAS_NATIVE_CHANGES=true + echo "::error::Android native changes detected. Current fingerprint does not match the last native Android build." + fi + else + echo "No previous native Android fingerprint found for channel 'production'." + fi + + if [ -s "$IOS_FINGERPRINT_PATH" ]; then + LAST_IOS_FINGERPRINT=$(cat "$IOS_FINGERPRINT_PATH") + echo "Last native iOS fingerprint: $LAST_IOS_FINGERPRINT" + echo "Current iOS fingerprint: $CURRENT_IOS_FINGERPRINT" + + if [ "$LAST_IOS_FINGERPRINT" != "$CURRENT_IOS_FINGERPRINT" ]; then + HAS_NATIVE_CHANGES=true + echo "::error::iOS native changes detected. Current fingerprint does not match the last native iOS build." + fi + else + echo "No previous native iOS fingerprint found for channel 'production'." + fi + + if [ "$HAS_NATIVE_CHANGES" = "true" ]; then + echo "has_native_changes=true" >> "$GITHUB_OUTPUT" + echo "::error::Native changes detected. You must create a new native release before publishing an OTA update." + exit 1 + fi + + if [ ! -s "$ANDROID_FINGERPRINT_PATH" ] || [ ! -s "$IOS_FINGERPRINT_PATH" ]; then + echo "At least one platform fingerprint is missing. This can happen after initial OTA setup." + echo "Proceeding with OTA publish." + fi + + echo "has_native_changes=false" >> "$GITHUB_OUTPUT" + echo "Fingerprints match — safe to publish OTA update." + + - name: Setup EAS CLI + run: npm install -g eas-cli + + - name: Publish OTA update + id: eas-update + working-directory: dapps/pos-app + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + eas update \ + --channel production \ + --message "${{ inputs.message || format('OTA update from {0}', github.ref_name) }}" \ + --non-interactive + + - name: Send Slack notification + if: always() && !cancelled() + uses: slackapi/slack-github-action@v2.1.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": "OTA Update Report - Mobile POS", + "blocks": [ + { + "type": "header", + "text": { "type": "plain_text", "text": "📦 OTA Update Report - Mobile POS" } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Channel:*\n`production`" }, + { "type": "mrkdwn", "text": "*Branch:*\n`${{ github.ref_name }}`" }, + { "type": "mrkdwn", "text": "*Status:*\n`${{ steps.native-check.outputs.has_native_changes == 'true' && '❌ Blocked — native changes require full release' || (steps.eas-update.outcome == 'success' && '✅ Published' || '❌ Failed') }}`" }, + { "type": "mrkdwn", "text": "*Message:*\n`${{ inputs.message || 'No message' }}`" } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { "type": "plain_text", "text": "View Workflow Run" }, + "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + ] + } + ] + } diff --git a/.github/workflows/release-android-base.yaml b/.github/workflows/release-android-base.yaml index a0d12910..26c73324 100644 --- a/.github/workflows/release-android-base.yaml +++ b/.github/workflows/release-android-base.yaml @@ -2,7 +2,7 @@ name: release-android-base permissions: id-token: write - contents: read + contents: write on: workflow_call: @@ -46,6 +46,11 @@ on: description: "Firebase App ID for distribution" required: true type: string + eas-update-channel: + description: "EAS Update channel to embed in the native build (leave empty for non-OTA apps)" + required: false + default: '' + type: string secrets: gsa-key: required: true @@ -85,6 +90,26 @@ jobs: run: | cd ${{ inputs.root-path }} npx expo prebuild --platform android + env: + EAS_UPDATE_CHANNEL: ${{ inputs.eas-update-channel }} + + - name: Save native fingerprint + if: ${{ inputs.eas-update-channel != '' }} + run: | + cd ${{ inputs.root-path }} + FINGERPRINT=$(npx expo-updates fingerprint:generate --platform android 2>/dev/null | tail -1 | jq -r '.hash') + echo "Native fingerprint (Android): $FINGERPRINT" + + cd "$GITHUB_WORKSPACE" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin fingerprints-dont-remove:fingerprints-dont-remove 2>/dev/null || git checkout --orphan fingerprints-dont-remove + git checkout fingerprints-dont-remove + mkdir -p native-fingerprints/${{ inputs.eas-update-channel }} + echo "$FINGERPRINT" > native-fingerprints/${{ inputs.eas-update-channel }}/android.txt + git add native-fingerprints/ + git diff --cached --quiet || git commit -m "Update Android native fingerprint (${{ inputs.eas-update-channel }})" + git push origin fingerprints-dont-remove - name: Install Java 17 uses: actions/setup-java@v3 diff --git a/.github/workflows/release-appkit.yaml b/.github/workflows/release-appkit.yaml index a3d4a8d2..c553d0df 100644 --- a/.github/workflows/release-appkit.yaml +++ b/.github/workflows/release-appkit.yaml @@ -3,7 +3,7 @@ run-name: "AppKit - ${{ inputs.platform == 'both' && '🍎 iOS & 🤖 Android' | permissions: id-token: write - contents: read + contents: write on: workflow_dispatch: diff --git a/.github/workflows/release-ios-base.yaml b/.github/workflows/release-ios-base.yaml index 0d016a4a..e6914613 100644 --- a/.github/workflows/release-ios-base.yaml +++ b/.github/workflows/release-ios-base.yaml @@ -2,7 +2,7 @@ name: release-ios-base permissions: id-token: write - contents: read + contents: write on: workflow_call: @@ -58,6 +58,11 @@ on: required: false default: false type: boolean + eas-update-channel: + description: "EAS Update channel to embed in the native build (leave empty for non-OTA apps)" + required: false + default: '' + type: string secrets: sentry-file: required: true @@ -121,6 +126,26 @@ jobs: run: | cd ${{ inputs.root-path }} npx expo prebuild --platform ios + env: + EAS_UPDATE_CHANNEL: ${{ inputs.eas-update-channel }} + + - name: Save native fingerprint + if: ${{ inputs.eas-update-channel != '' }} + run: | + cd ${{ inputs.root-path }} + FINGERPRINT=$(npx expo-updates fingerprint:generate --platform ios 2>/dev/null | tail -1 | jq -r '.hash') + echo "Native fingerprint (iOS): $FINGERPRINT" + + cd "$GITHUB_WORKSPACE" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin fingerprints-dont-remove:fingerprints-dont-remove 2>/dev/null || git checkout --orphan fingerprints-dont-remove + git checkout fingerprints-dont-remove + mkdir -p native-fingerprints/${{ inputs.eas-update-channel }} + echo "$FINGERPRINT" > native-fingerprints/${{ inputs.eas-update-channel }}/ios.txt + git add native-fingerprints/ + git diff --cached --quiet || git commit -m "Update iOS native fingerprint (${{ inputs.eas-update-channel }})" + git push origin fingerprints-dont-remove - name: Set Xcode paths run: | diff --git a/.github/workflows/release-pos-poc.yaml b/.github/workflows/release-pos-poc.yaml index 82f33957..530345bf 100644 --- a/.github/workflows/release-pos-poc.yaml +++ b/.github/workflows/release-pos-poc.yaml @@ -3,7 +3,7 @@ run-name: "Mobile POS (PoC) - ${{ inputs.platform == 'both' && '🍎 iOS & 🤖 permissions: id-token: write - contents: read + contents: write on: workflow_dispatch: diff --git a/.github/workflows/release-pos.yaml b/.github/workflows/release-pos.yaml index 7e29f133..bac43d56 100644 --- a/.github/workflows/release-pos.yaml +++ b/.github/workflows/release-pos.yaml @@ -3,7 +3,7 @@ run-name: "Mobile POS - ${{ inputs.platform == 'both' && '🍎 iOS & 🤖 Androi permissions: id-token: write - contents: read + contents: write on: workflow_dispatch: @@ -33,6 +33,7 @@ jobs: package-manager: 'npm' is-expo-project: true firebase-app-id: ${{ vars.POS_ANDROID_FIREBASE_APP_ID }} + eas-update-channel: 'production' secrets: env-file: ${{ secrets.POS_ENV_FILE }} sentry-file: ${{ secrets.POS_SENTRY_FILE }} @@ -58,6 +59,7 @@ jobs: package-manager: 'npm' testflight-groups: 'External' is-expo-project: true + eas-update-channel: 'production' secrets: env-file: ${{ secrets.POS_ENV_FILE }} sentry-file: ${{ secrets.POS_SENTRY_FILE }} diff --git a/.github/workflows/release-walletkit.yaml b/.github/workflows/release-walletkit.yaml index 24d9c47a..178054c9 100644 --- a/.github/workflows/release-walletkit.yaml +++ b/.github/workflows/release-walletkit.yaml @@ -3,7 +3,7 @@ run-name: "WalletKit - ${{ inputs.platform == 'both' && '🍎 iOS & 🤖 Android permissions: id-token: write - contents: read + contents: write on: workflow_dispatch: diff --git a/dapps/pos-app/README.md b/dapps/pos-app/README.md index df06533c..2fdbb13f 100644 --- a/dapps/pos-app/README.md +++ b/dapps/pos-app/README.md @@ -98,6 +98,51 @@ The release APK will be generated at `android/app/build/outputs/apk/release/app- > **⚠️ Security Note**: Never commit `secrets.properties` or keystore files to version control. +## OTA Updates + +The app supports Over-The-Air (OTA) updates via [EAS Update](https://docs.expo.dev/eas-update/introduction/). This lets you push JavaScript-only changes to devices without rebuilding and redistributing the native app. + +### Channels + +- **`production`** — updates for production builds (Firebase/TestFlight releases) + +### Publishing an Update + +**Via GitHub Actions** (recommended): + +1. Go to GitHub Actions → "OTA Update Mobile POS" +2. Optionally add a message describing the changes +3. Run the workflow + +The workflow will automatically verify that no native changes are included. If native changes are detected, the workflow fails with a clear error — you must create a new native release first. + +**Via CLI:** + +```bash +cd dapps/pos-app +eas update --channel production --message "fix: description of changes" +``` + +### Verifying an Update + +Open Settings in the app — the version text shows the current OTA update ID and channel. + +### Limitations + +OTA updates only deliver JavaScript bundle changes. The following require a full native release: + +- Adding/removing native modules or permissions +- Changing `app.json` native configuration (iOS entitlements, Android permissions, etc.) +- Upgrading the Expo SDK or React Native version + +### Rollback + +```bash +eas update:rollback --channel production +``` + +If the app crashes after an OTA update, `expo-updates` automatically falls back to the previous working bundle. + ## Creating Custom Variants To create a branded variant for a specific client: diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index cd8cadb9..a39da6e1 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -137,6 +137,13 @@ "experiments": { "typedRoutes": true, "reactCompiler": true - } + }, + "extra": { + "router": {}, + "eas": { + "projectId": "caf3a0d7-e413-45c2-b3b9-879cd30b3501" + } + }, + "owner": "reown-mobile" } } diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index c2aab3ad..47344149 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -25,6 +25,7 @@ import * as Sentry from "@sentry/react-native"; import { WalletConnectLoading } from "@/components/walletconnect-loading"; import { Spacing } from "@/constants/spacing"; +import { useOTAUpdates } from "@/hooks/use-ota-updates"; import { useLogsStore } from "@/store/useLogsStore"; import { useSettingsStore } from "@/store/useSettingsStore"; import { getDeviceIdentifier } from "@/utils/misc"; @@ -81,6 +82,9 @@ export default Sentry.wrap(function RootLayout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [deviceId]); + // OTA Updates - check on foreground + useOTAUpdates(); + // Request Bluetooth permission on first app load (Android only) // Apply credentials from URL query params (web only) useUrlCredentials(); diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index 33677607..eb89783a 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -4,18 +4,11 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; import { showErrorToast } from "@/utils/toast"; -import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { router } from "expo-router"; import { Platform, StyleSheet, View } from "react-native"; export default function HomeScreen() { - const [assets] = useAssets([ - require("@/assets/images/plus.png"), - require("@/assets/images/clock.png"), - require("@/assets/images/gear.png"), - ]); - const Theme = useTheme(); const { merchantId, isPartnerApiKeySet } = useSettingsStore(); @@ -47,7 +40,7 @@ export default function HomeScreen() { ]} > (); - const [assets] = useAssets([require("@/assets/images/warning_circle.png")]); const handleRetry = () => { router.dismissTo("/amount"); @@ -30,7 +28,7 @@ export default function PaymentFailureScreen() { (); - const [assets] = useAssets([require("@/assets/images/wc_logo_blue.png")]); const [qrUri, setQrUri] = useState(""); const [paymentId, setPaymentId] = useState(null); @@ -196,7 +194,10 @@ export default function ScanScreen() { logoBorderRadius={100} onPress={handleCopyPaymentUrl} > - + diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 302b843d..d8430424 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -25,6 +25,7 @@ import { } from "@/utils/printer"; import { showErrorToast } from "@/utils/toast"; import * as Application from "expo-application"; +import * as Updates from "expo-updates"; import Constants from "expo-constants"; import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; @@ -304,6 +305,8 @@ export default function SettingsScreen() { style={styles.versionText} > Version {appVersion} ({buildVersion}) + {Updates.updateId ? `\nUpdate: ${Updates.updateId.slice(0, 8)}` : ""} + {Updates.channel ? ` (${Updates.channel})` : ""} diff --git a/dapps/pos-app/components/close-button.tsx b/dapps/pos-app/components/close-button.tsx index 6dea1e7a..1d04acae 100644 --- a/dapps/pos-app/components/close-button.tsx +++ b/dapps/pos-app/components/close-button.tsx @@ -1,6 +1,5 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; -import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { Platform, StyleProp, StyleSheet, ViewStyle } from "react-native"; import { Button } from "./button"; @@ -13,7 +12,6 @@ interface CloseButtonProps { export function CloseButton({ style, onPress, themeMode }: CloseButtonProps) { const Theme = useTheme(themeMode); - const [assets] = useAssets([require("@/assets/images/close.png")]); return (