diff --git a/android/gradle.properties b/android/gradle.properties index a16c21f..ae08f2e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -58,6 +58,6 @@ expo.webp.animated=false EX_DEV_CLIENT_NETWORK_INSPECTOR=true # Use legacy packaging to compress native libraries in the resulting APK. -expo.useLegacyPackaging=false +expo.useLegacyPackaging=true -expo.inlineModules.watchedDirectories=[] \ No newline at end of file +expo.inlineModules.watchedDirectories=[] diff --git a/docs/superpowers/plans/2026-09-17-android-native-library-loading-implementation.md b/docs/superpowers/plans/2026-09-17-android-native-library-loading-implementation.md new file mode 100644 index 0000000..310e0a7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-android-native-library-loading-implementation.md @@ -0,0 +1,222 @@ +# Android Native Library Loading Compatibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Package React Native's ARM libraries so Android 11 and OnePlus cloned runtimes can load `libreactnative.so`, and reject any release bundle that omits it. + +**Architecture:** Keep the React Native new architecture and the existing ARM-only release scope. Switch Android native libraries from direct APK loading to extraction, then strengthen the existing standalone AAB verifier to inspect the ZIP entry listing for the two supported ABIs before the signed artifact can be archived or uploaded. + +**Tech Stack:** Expo 57, React Native 0.86, Android Gradle Plugin, Node.js 24, Jest, Android App Bundle, GitHub Actions, Fastlane. + +--- + +## File map + +- `android/gradle.properties`: owns the checked-in Android native-library packaging mode. +- `tests/contracts/android-release.test.ts`: enforces the release packaging contract at repository-test time. +- `scripts/verify-android-aab.mjs`: validates manifest metadata, signing, digest, and required native bundle entries. +- `tests/scripts/verify-android-aab.test.ts`: exercises the standalone AAB verifier through controlled command fixtures. + +### Task 1: Extract native libraries on installation + +**Files:** +- Modify: `tests/contracts/android-release.test.ts` +- Modify: `android/gradle.properties` + +- [ ] **Step 1: Write the failing packaging contract** + +Read `android/gradle.properties` in the existing release contract and assert the selected packaging mode: + +```ts +const gradleProperties = readProjectFile("android/gradle.properties"); + +it("extracts native libraries for Android 11 compatibility", () => { + expect(gradleProperties).toMatch(/^expo\.useLegacyPackaging=true$/m); + expect(gradleProperties).toMatch(/^newArchEnabled=true$/m); +}); +``` + +- [ ] **Step 2: Run the contract and verify it fails** + +Run: + +```bash +npm test -- --runInBand tests/contracts/android-release.test.ts +``` + +Expected: FAIL because `expo.useLegacyPackaging` is still `false`. + +- [ ] **Step 3: Enable extracted native-library packaging** + +Change the exact Gradle property while leaving the new architecture enabled: + +```properties +expo.useLegacyPackaging=true +``` + +- [ ] **Step 4: Run the contract and verify it passes** + +Run: + +```bash +npm test -- --runInBand tests/contracts/android-release.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the packaging change** + +```bash +git add android/gradle.properties tests/contracts/android-release.test.ts +git commit -m "fix(android): extract native libraries on install" +``` + +### Task 2: Reject bundles missing React Native ARM libraries + +**Files:** +- Modify: `tests/scripts/verify-android-aab.test.ts` +- Modify: `scripts/verify-android-aab.mjs` +- Modify: `tests/contracts/android-release.test.ts` + +- [ ] **Step 1: Extend the verifier fixture with a bundle-entry command** + +Create an executable fixture command that prints an entry list and pass it through `AAB_LIST_COMMAND`: + +```ts +const listBundle = path.join(directory, "list-bundle"); +writeFileSync( + listBundle, + `#!/bin/sh\nprintf '%s\\n' 'base/lib/armeabi-v7a/libreactnative.so' 'base/lib/arm64-v8a/libreactnative.so'\n`, +); +chmodSync(listBundle, 0o755); +return { aab, bundletool, keytool, listBundle }; +``` + +Set `AAB_LIST_COMMAND: files.listBundle` in `verify()`. + +- [ ] **Step 2: Write failing ABI coverage tests** + +Allow the fixture to receive `bundleEntries`, then add: + +```ts +it.each(["armeabi-v7a", "arm64-v8a"])( + "rejects a bundle missing libreactnative.so for %s", + (missingAbi) => { + const entries = ["armeabi-v7a", "arm64-v8a"] + .filter((abi) => abi !== missingAbi) + .map((abi) => `base/lib/${abi}/libreactnative.so`); + const result = verify(fixture({ bundleEntries: entries })); + expect(result.status).toBe(1); + expect(result.stderr).toContain("verification failed"); + }, +); +``` + +- [ ] **Step 3: Run the verifier tests and confirm the new tests fail** + +Run: + +```bash +npm test -- --runInBand tests/scripts/verify-android-aab.test.ts +``` + +Expected: the missing-ABI cases FAIL because the verifier does not inspect bundle entries yet. + +- [ ] **Step 4: Implement exact native-entry verification** + +Add the supported ABI contract and execute either the injected command or system `unzip`: + +```js +const requiredNativeEntries = [ + "base/lib/armeabi-v7a/libreactnative.so", + "base/lib/arm64-v8a/libreactnative.so", +]; + +const listCommand = process.env.AAB_LIST_COMMAND?.trim(); +const entryOutput = listCommand + ? run(listCommand, [aab]) + : run("unzip", ["-Z1", aab]); +const entries = new Set(entryOutput.split("\n").filter(Boolean)); +if (requiredNativeEntries.some((entry) => !entries.has(entry))) fail(); +``` + +Keep `fail()` generic so no environment value or sensitive signing detail is printed. + +- [ ] **Step 5: Require the verifier contract in release automation tests** + +Add these expectations to the existing exact-bundle test: + +```ts +expect(verifier).toContain("base/lib/armeabi-v7a/libreactnative.so"); +expect(verifier).toContain("base/lib/arm64-v8a/libreactnative.so"); +expect(verifier).toContain('run("unzip", ["-Z1", aab])'); +``` + +- [ ] **Step 6: Run the focused tests and verify they pass** + +Run: + +```bash +npm test -- --runInBand tests/scripts/verify-android-aab.test.ts tests/contracts/android-release.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the AAB safeguard** + +```bash +git add scripts/verify-android-aab.mjs tests/scripts/verify-android-aab.test.ts tests/contracts/android-release.test.ts +git commit -m "test(android): verify React Native libraries in AAB" +``` + +### Task 3: Validate and publish the corrected internal build + +**Files:** +- No source changes expected. + +- [ ] **Step 1: Run the complete repository verification** + +Run: + +```bash +npm run check +``` + +Expected: lint, TypeScript, Jest, and Expo Doctor all pass. + +- [ ] **Step 2: Inspect the final diff and repository state** + +Run: + +```bash +git diff HEAD~2 --check +git status --short +``` + +Expected: no whitespace errors and no uncommitted files. + +- [ ] **Step 3: Push the reviewed commits to `main`** + +```bash +git push origin main +``` + +Expected: the remote `main` advances to the verified local commit. + +- [ ] **Step 4: Complete the OneSignal FCM bootstrap before building** + +Create a fresh short-lived OneSignal organization key from the authenticated dashboard, store it only for the bootstrap run, execute `configure-onesignal-fcm.yml`, verify the successful FCM v1 read-back, then delete the temporary GitHub secrets and revoke the temporary organization key. Update `EXPO_PUBLIC_ONESIGNAL_APP_ID` in `openings-dev/mobile` to `c49d82df-9d48-4283-b746-4afe280cda5e` only after that success. + +- [ ] **Step 5: Dispatch the internal release** + +Run: + +```bash +gh workflow run android-internal.yml --repo openings-dev/mobile --ref main -f version_code=5 -f version_name=0.1.3 -f validate_only=false +``` + +Expected: preflight, build, AAB native-library verification, artifact preservation, and Google Play internal upload all succeed. + +- [ ] **Step 6: Confirm delivery scope** + +Verify the successful workflow conclusion and that version `0.1.3 (5)` is on the Google Play internal track. Do not trigger the production promotion workflow. diff --git a/docs/superpowers/specs/2026-09-17-android-native-library-loading-design.md b/docs/superpowers/specs/2026-09-17-android-native-library-loading-design.md new file mode 100644 index 0000000..f708ba9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-android-native-library-loading-design.md @@ -0,0 +1,29 @@ +# Android native library loading compatibility + +## Context + +Openings Mobile `0.1.2 (4)` crashes during `MainApplication.onCreate` on physical OnePlus 8 Pro devices running Android 11. SoLoader cannot find `libreactnative.so`. Crashlytics shows an ARM64 application directory alongside `x86_64` direct-APK sources and `com.mojito.framework`, which indicates a cloned or virtualized application runtime handling Play-generated splits inconsistently. + +The release currently sets `expo.useLegacyPackaging=false`, so Android loads native libraries directly from APK splits. The internal-release workflow also deliberately builds only `armeabi-v7a` and `arm64-v8a`. + +## Decision + +Release builds will use legacy native-library packaging. Android will extract the ARM native libraries into the application's native library directory instead of depending on direct loading from Play-generated APK splits. The React Native new architecture and the existing ARM architecture scope remain enabled. + +This is preferred over adding x86 architectures because the affected hardware is ARM64 and the crash is caused by an inconsistent runtime view of the installed splits. Disabling React Native's new architecture would be a broad rollback that does not address that packaging mismatch. + +## Build and release safeguards + +- Set `expo.useLegacyPackaging=true` in the checked-in Android Gradle configuration. +- Extend AAB verification to inspect the bundle and require `libreactnative.so` for both supported ABIs: `armeabi-v7a` and `arm64-v8a`. +- Keep version, certificate, and digest verification unchanged. +- Run the repository verification suite before publishing. +- Publish the corrected build as `0.1.3 (5)` to Google Play internal testing only. + +## Failure handling + +The release must fail before upload if either supported ABI lacks `libreactnative.so`, if the bundle metadata or signing certificate is wrong, or if repository checks fail. No production promotion is part of this change. + +## Verification + +Automated tests will cover a valid bundle listing and missing-library cases for each supported ABI. The release workflow will execute the strengthened verifier against the exact signed AAB that is uploaded. Crashlytics validation remains observational after testers install the internal build; the existing production issue cannot be marked fixed until the corrected version runs on affected devices without recurring crashes. diff --git a/scripts/verify-android-aab.mjs b/scripts/verify-android-aab.mjs index 35baac5..f9afac8 100644 --- a/scripts/verify-android-aab.mjs +++ b/scripts/verify-android-aab.mjs @@ -59,6 +59,17 @@ const keytoolOutput = run(process.env.KEYTOOL_COMMAND?.trim() || "keytool", [ const certificateMatch = keytoolOutput.match(/SHA-?256:\s*([0-9A-F:]+)/i); if (!certificateMatch || normalizeFingerprint(certificateMatch[1]) !== expectedCertificate) fail(); +const requiredNativeEntries = [ + "base/lib/armeabi-v7a/libreactnative.so", + "base/lib/arm64-v8a/libreactnative.so", +]; +const listCommand = process.env.AAB_LIST_COMMAND?.trim(); +const entryOutput = listCommand + ? run(listCommand, [aab]) + : run("unzip", ["-Z1", aab]); +const entries = new Set(entryOutput.split("\n").filter(Boolean)); +if (requiredNativeEntries.some((entry) => !entries.has(entry))) fail(); + const digest = createHash("sha256").update(readFileSync(aab)).digest("hex"); const expectedDigest = process.env.AAB_EXPECTED_SHA256?.trim().toLowerCase(); if (expectedDigest && digest !== expectedDigest) fail(); diff --git a/tests/contracts/android-release.test.ts b/tests/contracts/android-release.test.ts index d7440df..855025a 100644 --- a/tests/contracts/android-release.test.ts +++ b/tests/contracts/android-release.test.ts @@ -14,6 +14,7 @@ describe("Android release automation", () => { const fastfile = readProjectFile("fastlane/Fastfile"); const gemfile = readProjectFile("Gemfile"); const gradle = readProjectFile("android/app/build.gradle"); + const gradleProperties = readProjectFile("android/gradle.properties"); const gitignore = readProjectFile(".gitignore"); const eslintConfig = readProjectFile("eslint.config.mjs"); const internalWorkflow = readProjectFile( @@ -106,6 +107,11 @@ describe("Android release automation", () => { ); }); + it("extracts native libraries for Android 11 compatibility", () => { + expect(gradleProperties).toMatch(/^expo\.useLegacyPackaging=true$/m); + expect(gradleProperties).toMatch(/^newArchEnabled=true$/m); + }); + it("keeps all local Android release credentials and artifacts untracked", () => { expect(gitignore).toContain("docs/credentials/"); expect(gitignore).toContain("android/keystore.properties"); @@ -167,6 +173,9 @@ describe("Android release automation", () => { expect(verifier).toContain("ANDROID_VERSION_CODE"); expect(verifier).toContain("ANDROID_VERSION_NAME"); expect(verifier).toContain("AAB_EXPECTED_SHA256"); + expect(verifier).toContain("base/lib/armeabi-v7a/libreactnative.so"); + expect(verifier).toContain("base/lib/arm64-v8a/libreactnative.so"); + expect(verifier).toContain('run("unzip", ["-Z1", aab])'); expect(internalWorkflow.match(/node scripts\/verify-android-aab\.mjs/g)).toHaveLength(2); expect(productionWorkflow).toContain("node scripts/verify-android-aab.mjs"); expect(productionWorkflow).not.toContain( diff --git a/tests/scripts/verify-android-aab.test.ts b/tests/scripts/verify-android-aab.test.ts index e8f0ad5..a2265f9 100644 --- a/tests/scripts/verify-android-aab.test.ts +++ b/tests/scripts/verify-android-aab.test.ts @@ -6,17 +6,32 @@ import { spawnSync } from "node:child_process"; const PROJECT_ROOT = path.resolve(__dirname, "../.."); const VERIFIER = path.join(PROJECT_ROOT, "scripts/verify-android-aab.mjs"); -function fixture(options: { certificate?: string; versionCode?: string; versionName?: string } = {}) { +function fixture(options: { + bundleEntries?: string[]; + certificate?: string; + versionCode?: string; + versionName?: string; +} = {}) { const directory = mkdtempSync(path.join(tmpdir(), "openings-aab-")); const aab = path.join(directory, "app-release.aab"); const bundletool = path.join(directory, "bundletool"); const keytool = path.join(directory, "keytool"); + const listBundle = path.join(directory, "list-bundle"); + const bundleEntries = options.bundleEntries ?? [ + "base/lib/armeabi-v7a/libreactnative.so", + "base/lib/arm64-v8a/libreactnative.so", + ]; writeFileSync(aab, "signed bundle fixture"); writeFileSync(bundletool, `#!/bin/sh\ncase "$*" in\n *versionCode*) printf '%s\\n' '${options.versionCode ?? "42"}' ;;\n *versionName*) printf '%s\\n' '${options.versionName ?? "1.2.3"}' ;;\n *) exit 2 ;;\nesac\n`); writeFileSync(keytool, `#!/bin/sh\nprintf '%s\\n' 'Owner: CN=Openings' 'SHA256: ${options.certificate ?? "AA:BB:CC"}'\n`); + writeFileSync( + listBundle, + `#!/bin/sh\n${bundleEntries.map((entry) => `printf '%s\\n' '${entry}'`).join("\n")}\n`, + ); chmodSync(bundletool, 0o755); chmodSync(keytool, 0o755); - return { aab, bundletool, keytool }; + chmodSync(listBundle, 0o755); + return { aab, bundletool, keytool, listBundle }; } function verify(files: ReturnType, overrides: Record = {}) { @@ -27,6 +42,7 @@ function verify(files: ReturnType, overrides: Record { expect(result.status).toBe(1); expect(result.stderr).toContain("verification failed"); }); + + it.each(["armeabi-v7a", "arm64-v8a"])( + "rejects a bundle missing libreactnative.so for %s", + (missingAbi) => { + const bundleEntries = ["armeabi-v7a", "arm64-v8a"] + .filter((abi) => abi !== missingAbi) + .map((abi) => `base/lib/${abi}/libreactnative.so`); + const result = verify(fixture({ bundleEntries })); + expect(result.status).toBe(1); + expect(result.stderr).toContain("verification failed"); + }, + ); });