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
68 changes: 68 additions & 0 deletions apps/mobile/plugins/nativeConfigTransforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,71 @@ export function buildNetworkSecurityConfigXml(input: NativeConfigInput): string
lines.push("</network-security-config>", "");
return lines.join("\n");
}

/* ------------------------------------------------------ release signing ---- */

/** Marks the signing block this transform inserts, so a second pass is a no-op. */
const RELEASE_SIGNING_MARKER = "// OpenMapX release signing";

/**
* The signing configuration a release reads from `release-signing.properties`
* in the generated Android project, which `release/build-android.mts` copies
* there from the operator's mode-0600 file outside the repository. Without that
* file no `release` configuration exists and the build is left unsigned.
*/
const RELEASE_SIGNING_CONFIG = ` ${RELEASE_SIGNING_MARKER}: keystore and passwords come from
// release-signing.properties, which release/build-android.mts copies here
// from a file outside the repository. Without it the release stays unsigned.
def openmapxSigningFile = rootProject.file("release-signing.properties")
if (openmapxSigningFile.exists()) {
def openmapxSigning = new Properties()
openmapxSigningFile.withInputStream { openmapxSigning.load(it) }
def missing = ["storeFile", "storePassword", "keyAlias", "keyPassword"].findAll {
!openmapxSigning.getProperty(it)
}
if (!missing.isEmpty()) {
throw new GradleException("release-signing.properties lacks " + missing.join(", "))
}
release {
storeFile file(openmapxSigning.getProperty("storeFile"))
storePassword openmapxSigning.getProperty("storePassword")
keyAlias openmapxSigning.getProperty("keyAlias")
keyPassword openmapxSigning.getProperty("keyPassword")
}
}
`;

/**
* Signs the release build type with the operator's own key instead of the
* template's debug key.
*
* Expo's generated `app/build.gradle` signs releases with `debug.keystore`,
* whose password is public: an APK signed with it could be replaced on a phone
* by anyone's build. A release is now signed only from the external properties
* file, and is unsigned when there is none. The debug build type is unchanged.
*/
export function applyAndroidReleaseSigning(gradle: string): string {
if (gradle.includes(RELEASE_SIGNING_MARKER)) return gradle;

const signingConfigs = /^([^\S\n]*)signingConfigs \{\n/m.exec(gradle);
const releaseType =
/(buildTypes \{[\s\S]*?\n[^\S\n]*release \{[\s\S]*?)signingConfig signingConfigs\.debug/.exec(
gradle,
);
if (!signingConfigs || !releaseType) {
throw new Error(
"app/build.gradle has no signingConfigs block or release signing line to replace; " +
"Expo's template changed, so update applyAndroidReleaseSigning",
);
}

const withReleaseType =
gradle.slice(0, releaseType.index) +
releaseType[1] +
'signingConfig signingConfigs.findByName("release")' +
gradle.slice(releaseType.index + releaseType[0].length);
const insertAt = signingConfigs.index + signingConfigs[0].length;
return (
withReleaseType.slice(0, insertAt) + RELEASE_SIGNING_CONFIG + withReleaseType.slice(insertAt)
);
}
64 changes: 64 additions & 0 deletions apps/mobile/plugins/withOpenMapXNativeConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type AndroidManifestLike,
applyAndroidApplicationSecurity,
applyAndroidPermissions,
applyAndroidReleaseSigning,
applyAppBoundDomains,
applyAppTransportSecurity,
applyAssociatedDomains,
Expand Down Expand Up @@ -342,3 +343,66 @@ describe("iOS storage protection", () => {
expect(JSON.stringify(applyDataProtection(once))).toBe(JSON.stringify(once));
});
});

describe("Android release signing", () => {
// The signing and build-type blocks of Expo's generated `app/build.gradle`.
const EXPO_APP_GRADLE = `android {
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableMinifyInReleaseBuilds
}
}
}
`;

function releaseBlock(gradle: string): string {
const start = gradle.indexOf(" release {", gradle.indexOf("buildTypes {"));
return gradle.slice(start, gradle.indexOf("\n }", start));
}

it("signs the release build type from the external properties file", () => {
const gradle = applyAndroidReleaseSigning(EXPO_APP_GRADLE);
expect(gradle).toContain('rootProject.file("release-signing.properties")');
for (const key of ["storeFile", "storePassword", "keyAlias", "keyPassword"]) {
expect(gradle).toContain(`"${key}"`);
}
expect(releaseBlock(gradle)).toContain('signingConfig signingConfigs.findByName("release")');
});

it("never signs a release with the template's public debug key", () => {
expect(releaseBlock(applyAndroidReleaseSigning(EXPO_APP_GRADLE))).not.toContain(
"signingConfigs.debug",
);
});

it("keeps the debug build type on the debug key", () => {
const gradle = applyAndroidReleaseSigning(EXPO_APP_GRADLE);
const debugStart = gradle.indexOf(" debug {", gradle.indexOf("buildTypes {"));
expect(gradle.slice(debugStart, gradle.indexOf("}", debugStart))).toContain(
"signingConfig signingConfigs.debug",
);
});

it("is idempotent", () => {
const once = applyAndroidReleaseSigning(EXPO_APP_GRADLE);
expect(applyAndroidReleaseSigning(once)).toBe(once);
});

it("refuses a build file whose signing blocks it does not recognise", () => {
expect(() => applyAndroidReleaseSigning("android {}\n")).toThrow(/signing/);
});
});
16 changes: 16 additions & 0 deletions apps/mobile/plugins/withOpenMapXNativeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ExpoConfig } from "expo/config";
import type { ConfigPlugin } from "expo/config-plugins";
import {
withAndroidManifest,
withAppBuildGradle,
withDangerousMod,
withEntitlementsPlist,
withInfoPlist,
Expand All @@ -16,6 +17,7 @@ import {
type AndroidManifestLike,
applyAndroidApplicationSecurity,
applyAndroidPermissions,
applyAndroidReleaseSigning,
applyAppBoundDomains,
applyAppTransportSecurity,
applyAssociatedDomains,
Expand Down Expand Up @@ -102,6 +104,20 @@ export const withOpenMapXNativeConfig: ConfigPlugin = (config) => {
return manifestConfig;
});

// Release builds only: a development build's release variant keeps Expo's
// debug signing, so a contributor can still install it locally.
if (input.release) {
next = withAppBuildGradle(next, (gradleConfig) => {
if (gradleConfig.modResults.language !== "groovy") {
throw new Error("release signing expects the generated app/build.gradle to be Groovy");
}
gradleConfig.modResults.contents = applyAndroidReleaseSigning(
gradleConfig.modResults.contents,
);
return gradleConfig;
});
}

next = withDangerousMod(next, [
"android",
(danger) => {
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/release/build-android.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
/**
* Builds and signs the Android App Bundle locally.
*
* `--apk` builds a signed APK instead, to install directly on a test phone;
* `--unsigned` builds an unsigned APK as a tooling rehearsal.
*
* Keystore path, alias and passwords come from a mode-0600 properties file
* outside the repository, never from command-line arguments — arguments land in
* shell history and in every process listing on the machine.
Expand All @@ -11,7 +14,7 @@
*/

import { execFileSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { distDir, mobileRoot, readToolchains, readVersion } from "./context.ts";
Expand All @@ -33,6 +36,7 @@ const propertiesPath =
resolve(homedir(), ".openmapx/android-release.properties");

const unsigned = process.argv.includes("--unsigned");
const apk = unsigned || process.argv.includes("--apk");

if (!unsigned) {
if (!existsSync(propertiesPath)) {
Expand All @@ -54,6 +58,9 @@ if (!unsigned) {
process.exit(1);
}
copyFileSync(propertiesPath, resolve(androidDir, "release-signing.properties"));
} else {
// A copy left by an earlier signed build would sign this one after all.
rmSync(resolve(androidDir, "release-signing.properties"), { force: true });
}

const javaHome = process.env.JAVA_HOME;
Expand All @@ -65,16 +72,20 @@ if (!javaHome) {
process.exit(1);
}

const task = unsigned ? "assembleRelease" : "bundleRelease";
const task = apk ? "assembleRelease" : "bundleRelease";
console.log(`[release:android] gradle :app:${task}`);
execFileSync("./gradlew", [`:app:${task}`, "--no-daemon"], {
cwd: androidDir,
stdio: "inherit",
env: { ...process.env, JAVA_HOME: javaHome },
});

const built = unsigned
? resolve(androidDir, "app/build/outputs/apk/release/app-release.apk")
// Gradle names an APK without a signing configuration `-unsigned`.
const built = apk
? resolve(
androidDir,
`app/build/outputs/apk/release/app-release${unsigned ? "-unsigned" : ""}.apk`,
)
: resolve(androidDir, "app/build/outputs/bundle/release/app-release.aab");

if (!existsSync(built)) {
Expand Down
27 changes: 19 additions & 8 deletions docs/docs/developer/mobile-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,22 @@ keyPassword=REPLACE_LOCALLY
```

Keep it outside the repository. `OPENMAPX_ANDROID_SIGNING_PROPERTIES` overrides
its path. The script copies it into the ignored generated Android directory;
protect and clean that copy as secret material. Never pass passwords as command
arguments. `pnpm mobile:release:android --unsigned` selects an APK rehearsal
instead of the signed AAB path; it is not a store candidate.
its path. The script copies it into the ignored generated Android directory as
`release-signing.properties`; protect and clean that copy as secret material.
Never pass passwords as command arguments.

A release prebuild signs the release build type from that copy. It never falls
back to the template's `debug.keystore`, whose password is public, so a release
built without the file is unsigned. A development build's release variant keeps
the debug key.

The script builds a signed AAB by default. `--apk` builds a signed APK instead,
to install directly on a test phone. `--unsigned` builds an unsigned APK as a
tooling rehearsal. Neither APK is a store candidate.

To test a release build on your own phone, sign it with a separate local test
key, not the upload key. Android refuses to update an app with an APK signed by
another key, so uninstall the test build before installing a store build.

### Version and signing checks before building

Expand All @@ -126,10 +138,9 @@ absence or an unreadable tag is treated by the script as no previous release.

The descriptor is **not wired into all native build inputs**: `app.config.ts`
hardcodes `version: "1.0.0"` and does not set iOS `buildNumber` or Android
`versionCode` from it. The committed Android build script copies signing
properties but does not itself configure Gradle to consume them. Confirm and fix
native version/signing configuration before release; a manifest copied from the
descriptor cannot establish the values or signing identity inside the binary.
`versionCode` from it. Confirm and fix native version configuration before
release; a manifest copied from the descriptor cannot establish the values
inside the binary. Inspect the signing identity of the built artifact as well.

### What the manifest promises

Expand Down
Loading