diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 35529de5a8..fb22f250c7 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -32,6 +32,11 @@ on: description: 'versionName override (optional; defaults to package.json)' required: false type: string + pushDebug: + description: 'Enable OneSignal verbose logging in this build' + required: false + default: false + type: boolean permissions: contents: read @@ -104,19 +109,8 @@ jobs: NEXT_PUBLIC_SAFARI_WEB_ID=${{ vars.NEXT_PUBLIC_SAFARI_WEB_ID }} NEXT_PUBLIC_ONESIGNAL_WEBHOOK=${{ vars.NEXT_PUBLIC_ONESIGNAL_WEBHOOK }} EOF - - - name: Decode google-services.json - # FCM credentials for native push. When the secret is unset the file - # is skipped and build.gradle simply omits the google-services plugin - # (push disabled, build still succeeds). - env: - ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }} - run: | - if [ -n "$ANDROID_GOOGLE_SERVICES_JSON" ]; then - echo "$ANDROID_GOOGLE_SERVICES_JSON" | base64 -d > android/app/google-services.json - echo "✅ google-services.json written" - else - echo "⚠️ ANDROID_GOOGLE_SERVICES_JSON unset — native push disabled for this build" + if [ "${{ github.event.inputs.pushDebug }}" = "true" ]; then + echo "NEXT_PUBLIC_ONESIGNAL_DEBUG=true" >> .env.production.local fi - name: Build signed AAB @@ -144,9 +138,6 @@ jobs: releaseFiles: android/app/build/outputs/bundle/release/app-release.aab tracks: ${{ github.event.inputs.track || 'internal' }} status: completed - # First releases have no reviewed base, so Play can't auto-submit - # for review; commit the edit and review from the Console instead. - changesNotSentForReview: true # Staged production rollout: set status: inProgress + userFraction: 0.1, # then promote in Play Console once crash/error rates look clean. diff --git a/.github/workflows/capgo-deploy-ios.yml b/.github/workflows/capgo-deploy-ios.yml index 684399f0c1..3d80abf999 100644 --- a/.github/workflows/capgo-deploy-ios.yml +++ b/.github/workflows/capgo-deploy-ios.yml @@ -8,7 +8,7 @@ name: Deploy OTA Update — iOS only (Capgo) on: push: - branches: [feat/mobile-release] + branches: [mobile-release] workflow_dispatch: inputs: channel: diff --git a/.github/workflows/capgo-deploy.yml b/.github/workflows/capgo-deploy.yml index ffafe6d01e..550dc9e878 100644 --- a/.github/workflows/capgo-deploy.yml +++ b/.github/workflows/capgo-deploy.yml @@ -11,7 +11,7 @@ on: channel: description: 'Capgo channel to deploy to' required: true - default: 'staging' + default: 'production' type: choice options: - development diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml index 1f66c72df5..0cd90ab139 100644 --- a/.github/workflows/ios-release.yml +++ b/.github/workflows/ios-release.yml @@ -27,6 +27,11 @@ on: description: 'versionName override (optional; defaults to project MARKETING_VERSION)' required: false type: string + pushDebug: + description: 'Enable OneSignal verbose logging in this build' + required: false + default: false + type: boolean permissions: contents: read @@ -60,6 +65,20 @@ jobs: - name: Install dependencies run: pnpm install + - name: Verify SumSub Cordova plugin materialized + # `cap sync ios` silently drops a Cordova plugin (exit 0, no folder) + # when node_modules isn't fully materialized, and the archive then + # fails a minute later with a cryptic SwiftPM error. Catch the real + # cause — a partial pnpm install — here, where a re-run recovers. + run: | + P=node_modules/@sumsub/cordova-idensic-mobile-sdk-plugin + if [ ! -f "$P/package.json" ] || [ ! -f "$P/plugin.xml" ]; then + echo "::error::$P is not fully installed (package.json/plugin.xml missing). This is the intermittent iOS-release flake — cap sync would drop the plugin and the archive would fail cryptically. Re-run the job (pnpm install did not materialize the plugin)." + ls -la "$P" 2>&1 || true + exit 1 + fi + echo "SumSub Cordova plugin materialized." + - name: Production web env run: | # NEXT_PUBLIC_* values are baked into the static export at build time. @@ -83,6 +102,9 @@ jobs: NEXT_PUBLIC_SAFARI_WEB_ID=${{ vars.NEXT_PUBLIC_SAFARI_WEB_ID }} NEXT_PUBLIC_ONESIGNAL_WEBHOOK=${{ vars.NEXT_PUBLIC_ONESIGNAL_WEBHOOK }} EOF + if [ "${{ github.event.inputs.pushDebug }}" = "true" ]; then + echo "NEXT_PUBLIC_ONESIGNAL_DEBUG=true" >> .env.production.local + fi - name: Build web + sync iOS run: | @@ -110,6 +132,13 @@ jobs: cp /tmp/profile.mobileprovision "$PROFILE_DIR/$PROFILE_UUID.mobileprovision" echo "name=$PROFILE_NAME" >> "$GITHUB_OUTPUT" echo "Installed provisioning profile: $PROFILE_NAME ($PROFILE_UUID)" + # A profile without aps-environment ships an app that can never + # register with APNs, even when the entitlements file asks for it. + PROFILE_APS=$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:aps-environment' /tmp/profile.plist 2>/dev/null || true) + if [ "$PROFILE_APS" != "production" ]; then + echo "::error::provisioning profile aps-environment is '${PROFILE_APS:-missing}' — enable Push Notifications on the App ID, regenerate the profile, and update IOS_PROVISIONING_PROFILE_BASE64" + exit 1 + fi - name: Archive & export IPA env: @@ -118,11 +147,15 @@ jobs: # Monotonic, always-increments-even-on-rerun. TestFlight requires each # upload's build number to exceed the last. IOS_BUILD_NUMBER: ${{ github.run_number }} - IOS_VERSION_NAME: ${{ github.event.inputs.versionName || '' }} + IOS_VERSION_INPUT: ${{ github.event.inputs.versionName || '' }} + IOS_TAG_NAME: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | - # Only override MARKETING_VERSION when an explicit versionName is supplied; - # otherwise keep the project's MARKETING_VERSION. CURRENT_PROJECT_VERSION is - # always the CI run number. Info.plist reads both via $(...) build settings. + # MARKETING_VERSION comes from the explicit versionName input, else from + # the pushed tag (v1.0.31 -> 1.0.31); a bare tag push must not silently + # ship the project default 1.0. CURRENT_PROJECT_VERSION is always the CI + # run number. Info.plist reads both via $(...) build settings. + IOS_VERSION_NAME="$IOS_VERSION_INPUT" + if [ -z "$IOS_VERSION_NAME" ] && [ -n "$IOS_TAG_NAME" ]; then IOS_VERSION_NAME="${IOS_TAG_NAME#v}"; fi MARKETING_ARG=() if [ -n "$IOS_VERSION_NAME" ]; then MARKETING_ARG=("MARKETING_VERSION=$IOS_VERSION_NAME"); fi @@ -171,6 +204,17 @@ jobs: -exportPath build/ios \ -exportOptionsPlist /tmp/ExportOptions.plist + - name: Verify push entitlements in exported IPA + run: | + unzip -q build/ios/App.ipa -d /tmp/ipa-check + APS=$(codesign -d --entitlements :- /tmp/ipa-check/Payload/App.app 2>/dev/null \ + | plutil -extract aps-environment raw -o - - || true) + echo "aps-environment=$APS" + if [ "$APS" != "production" ]; then + echo "::error::exported ipa aps-environment is '${APS:-missing}', expected production — push would be dead in this build" + exit 1 + fi + - name: Upload to TestFlight uses: apple-actions/upload-testflight-build@1ad58030672057aa084b4e96beb6f7a8c627f9e6 # v5 with: diff --git a/android/app/build.gradle b/android/app/build.gradle index cf4bb6fbc3..c6a65a06a5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -100,11 +100,7 @@ dependencies { apply from: 'capacitor.build.gradle' -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") -} +// No google-services plugin: OneSignal registers with FCM on its own — +// PushRegistratorFCM builds FirebaseOptions in code from the sender ID served +// by the OneSignal dashboard, and firebase-messaging arrives transitively via +// com.onesignal:notifications. See docs/NATIVE-RELEASE.md §Android push. diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index d023f8ac12..a968012f38 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -11,12 +11,16 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') implementation project(':capacitor-browser') + implementation project(':capacitor-camera') implementation project(':capacitor-clipboard') + implementation project(':capacitor-device') implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') + implementation project(':capacitor-preferences') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-crisp') + implementation project(':capgo-capacitor-native-biometric') implementation project(':capgo-capacitor-passkey') implementation project(':capgo-capacitor-updater') implementation project(':onesignal-capacitor-plugin') diff --git a/android/app/src/main/java/me/peanut/wallet/MainActivity.java b/android/app/src/main/java/me/peanut/wallet/MainActivity.java index 943813c9fc..c2b07d2be1 100644 --- a/android/app/src/main/java/me/peanut/wallet/MainActivity.java +++ b/android/app/src/main/java/me/peanut/wallet/MainActivity.java @@ -8,6 +8,8 @@ import com.getcapacitor.BridgeActivity; import com.getcapacitor.Bridge; +import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; public class MainActivity extends BridgeActivity { @@ -55,7 +57,7 @@ private WebResourceResponse findPageHtml(WebView view, String path) { // 1. try exact path try { String cleanPath = path.endsWith("/") ? path : path + "/"; - InputStream is = view.getContext().getAssets().open("public" + cleanPath + "index.html"); + InputStream is = openAppContent(view, cleanPath); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) {} @@ -69,7 +71,7 @@ private WebResourceResponse findPageHtml(WebView view, String path) { String tryPath = String.join("/", segments); if (!tryPath.endsWith("/")) tryPath += "/"; try { - InputStream is = view.getContext().getAssets().open("public" + tryPath + "index.html"); + InputStream is = openAppContent(view, tryPath); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) { segments[i] = original; @@ -85,19 +87,42 @@ private WebResourceResponse findPageHtml(WebView view, String path) { parentPath = parentPath.substring(0, parentPath.lastIndexOf("/")); if (parentPath.isEmpty()) break; try { - InputStream is = view.getContext().getAssets().open("public" + parentPath + "/index.html"); + InputStream is = openAppContent(view, parentPath + "/"); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) {} } // 4. root fallback try { - InputStream is = view.getContext().getAssets().open("public/index.html"); + InputStream is = openAppContent(view, "/"); return new WebResourceResponse("text/html", "UTF-8", is); } catch (Exception ignored) {} return null; } + + /** + * Opens the index.html for a directory-style path ("/setup/"), + * honoring an active OTA bundle. When CapacitorUpdater has + * pointed the server base path at an on-disk bundle, HTML must + * come from that bundle — the APK's assets are a stale export + * whose chunk references no longer exist, and serving them + * bricks navigation (stuck splash loop after logout). Only when + * no bundle is active (base path isn't a directory) do we read + * the bundled assets. + */ + private InputStream openAppContent(WebView view, String cleanPath) throws Exception { + String rel = (cleanPath.startsWith("/") ? cleanPath.substring(1) : cleanPath) + "index.html"; + Bridge activeBridge = getBridge(); + String basePath = activeBridge != null ? activeBridge.getServerBasePath() : null; + if (basePath != null && !basePath.isEmpty()) { + File base = new File(basePath); + if (base.isDirectory()) { + return new FileInputStream(new File(base, rel)); + } + } + return view.getContext().getAssets().open("public/" + rel); + } }); } } diff --git a/android/build.gradle b/android/build.gradle index 549e498190..802a70454f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,6 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.13.0' - classpath 'com.google.gms:google-services:4.4.4' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 9d8d764e58..8ab36420ff 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -8,15 +8,24 @@ project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacito include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser/android') +include ':capacitor-camera' +project(':capacitor-camera').projectDir = new File('../node_modules/.pnpm/@capacitor+camera@8.2.0_@capacitor+core@8.2.0/node_modules/@capacitor/camera/android') + include ':capacitor-clipboard' project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard/android') +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/device/android') + include ':capacitor-haptics' project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics/android') include ':capacitor-keyboard' project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard/android') +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/preferences/android') + include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen/android') @@ -26,6 +35,9 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@c include ':capgo-capacitor-crisp' project(':capgo-capacitor-crisp').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp/android') +include ':capgo-capacitor-native-biometric' +project(':capgo-capacitor-native-biometric').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric/android') + include ':capgo-capacitor-passkey' project(':capgo-capacitor-passkey').projectDir = new File('../node_modules/@capgo/capacitor-passkey/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 91a84133bf..6786e8f4f9 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -39,8 +39,13 @@ const config: CapacitorConfig = { publicKey: '-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAr0HzEca/1vuvWcJ8/xYB6tx0j4uJMzw/kT34GnjyMlRmLLUIO9sj\nroXaUGaNoqlOCx73b7Qgp10TLPOAVxmoHV9ZJ4BS9cMCl5mvzB4qIdl6FZLcl3g5\nk5Nkj4w22nskqbBqL7eqMXpk4DD9oWRclnaZC/lCpok1n2AWy4EMZrshemBQ6iXr\ncppo+WByPbqmh/GbHvJyRvkx4Rgt2LSBJBI3laP3eEDkujCq1ZH9qgcIE4MXO5xq\n7c6LsLjN5wkQiNPSPI81zAbqBThhqodKzwav0FwIE1pyiJeGk1nV5Ji5kUgpFNwY\nY78iDVq4OP2jPfWO4jXnnJtnGN7aeKDMEQIDAQAB\n-----END RSA PUBLIC KEY-----\n', }, + // CapacitorHttp OFF: its Android GET proxy (_capacitor_http_interceptor_) + // stalls under load, timing out every in-flight request (PEANUT-UI-R44). + // Requests go direct from the webview — CORS allows https://localhost and + // capacitor://, and auth rides the Authorization header from native + // Preferences (src/utils/auth-token.ts) instead of the plugin's cookie jar. CapacitorHttp: { - enabled: true, + enabled: false, }, CapacitorPasskey: { // shim patches navigator.credentials.create/get so browser WebAuthn code works natively. diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index da469da3d5..d7b4d5924f 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -204,6 +204,7 @@ the build is reproducible, the AAB lands on a Play track. | `ANDROID_KEYSTORE_BASE64` | `base64 -w0 peanut-release.keystore` | | `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | signing creds | | `PLAY_SERVICE_ACCOUNT_JSON` | Google Play Developer API service account (least-priv "Release manager") | +| `ANDROID_GOOGLE_SERVICES_JSON` | `base64 -w0 google-services.json` — **optional**; OneSignal push does not read it (see §Android push) | | `SUBMODULE_TOKEN` | read access to the `src/content` submodule | | `CAPGO_API_KEY` | OTA (already used by `capgo-deploy.yml`) | | prod `NEXT_PUBLIC_*` | the values the static export bakes in (OneSignal, Sentry, chain, …) | @@ -263,21 +264,26 @@ with **no backend or sequence changes**. The web/native split lives behind deps autolink on `cap sync`. - `AndroidManifest.xml`: `POST_NOTIFICATIONS` (the Android 13+ runtime prompt, driven by the plugin's `requestPermission()`). -- `android/app/build.gradle` already conditionally applies the `google-services` plugin - **only when `google-services.json` is present** — its absence disables push but never - fails the build. +- No `google-services.json` and no `google-services` Gradle plugin: OneSignal's Android + SDK never reads that file. `PushRegistratorFCM` builds `FirebaseOptions` in code from its + own baked-in defaults plus the sender ID served by the OneSignal dashboard, and + `com.onesignal:notifications` pulls `firebase-messaging` in transitively. The plugin only + wires Firebase's *automatic* initialization, which OneSignal bypasses. - `scripts/native-build.js` warns when `NEXT_PUBLIC_ONESIGNAL_APP_ID` is unset (the app id is inlined into the static bundle; without it the native SDK can't initialize). -**Provider setup (do once, no code):** -1. **Firebase:** create/locate the Firebase project for `me.peanut.wallet`, download - `google-services.json`, place it at `android/app/google-services.json` (gitignored). -2. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → +**Provider setup (do once, no code, dashboard only):** +1. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → upload the **FCM v1 service account JSON** (Firebase → Project settings → Service - accounts → Generate private key). -3. **CI:** set the `ANDROID_GOOGLE_SERVICES_JSON` secret to `base64 -w0 google-services.json`. - The `Decode google-services.json` step in `android-release.yml` writes it before the - build (and skips gracefully when unset). + accounts → Generate private key) and set the **Sender ID** (Firebase project number). + This is the *only* thing Android push depends on — there is no build-side FCM config. + + Note: the file OneSignal wants here is the **service account private key**, not + `google-services.json`. The two are different Firebase files: `google-services.json` is + an app-side client config; the service account JSON is a server credential OneSignal + uses to send. Uploading `google-services.json` into OneSignal will not work. + +No `ANDROID_GOOGLE_SERVICES_JSON` secret is needed; it has been removed from the build. **Verify (real device/emulator with Play Services):** `node scripts/native-build.js && npx cap sync android && ./gradlew assembleDebug`, install, diff --git a/eslint.config.js b/eslint.config.js index 051b8f20b2..4a7aa6fb04 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,8 @@ module.exports = [ 'public/**', 'src/content/**', 'android/**', + 'ios/**', + 'build/**', 'src/types/api.generated.ts', 'coverage/**', 'playwright-report/**', @@ -83,6 +85,8 @@ module.exports = [ 'react/prop-types': 'off', // Allow unescaped quotes — too noisy and prettier handles spacing 'react/no-unescaped-entities': 'off', + // `jsx`/`global` are styled-jsx's