Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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=[]
expo.inlineModules.watchedDirectories=[]
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions scripts/verify-android-aab.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions tests/contracts/android-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 30 additions & 2 deletions tests/scripts/verify-android-aab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fixture>, overrides: Record<string, string> = {}) {
Expand All @@ -27,6 +42,7 @@ function verify(files: ReturnType<typeof fixture>, overrides: Record<string, str
AAB_EXPECTED_CERT_SHA256: "AABBCC",
ANDROID_VERSION_CODE: "42",
ANDROID_VERSION_NAME: "1.2.3",
AAB_LIST_COMMAND: files.listBundle,
BUNDLETOOL_COMMAND: files.bundletool,
KEYTOOL_COMMAND: files.keytool,
...overrides,
Expand Down Expand Up @@ -62,4 +78,16 @@ describe("Android App Bundle verifier", () => {
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");
},
);
});