Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ const config: ExpoConfig = {
"./plugins/withAndroidModernPopupMenu.cjs",
"./plugins/withAndroidModernAlertDialog.cjs",
"./plugins/withAndroidPredictiveBackCompat.cjs",
// MainActivity mods run in reverse order; extend the tablet-orientation override.
"./plugins/withAndroidWindowDisplayMetrics.cjs",
"./plugins/withAndroidTabletOrientation.cjs",
...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
],
Expand Down
87 changes: 87 additions & 0 deletions apps/mobile/plugins/withAndroidWindowDisplayMetrics.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const { withMainActivity } = require("expo/config-plugins");

// React Native measures text with application-display density, which differs
// from the activity on external displays. Expo constructs its root after
// onCreate, resetting those metrics; ReactHost resets them on configuration
// changes too. Rebind after both, before text is measured.
const DISPLAY_METRICS_METHODS = `
// Expo constructs the root view after onCreate; its constructor resets metrics.
override fun setContentView(view: View?) {
super.setContentView(view)
DisplayMetricsHolder.initDisplayMetrics(this)
}
`;

function insertAfter(contents, anchor, insertion, description) {
const index = contents.indexOf(anchor);
if (index === -1) {
throw new Error(
`withAndroidWindowDisplayMetrics: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`,
);
}
const end = index + anchor.length;
return contents.slice(0, end) + insertion + contents.slice(end);
}

function addImport(contents, importLine) {
if (contents.includes(importLine)) {
return contents;
}
return insertAfter(
contents,
"import android.os.Bundle",
`\n${importLine}`,
"the android.os.Bundle import",
);
}

module.exports = function withAndroidWindowDisplayMetrics(config) {
return withMainActivity(config, (nextConfig) => {
let contents = nextConfig.modResults.contents;
if (nextConfig.modResults.language !== "kt") {
throw new Error("withAndroidWindowDisplayMetrics: MainActivity must be Kotlin.");
}
if (contents.includes("DisplayMetricsHolder.initDisplayMetrics(this)")) {
return nextConfig;
}

contents = addImport(contents, "import android.view.View");
contents = addImport(contents, "import com.facebook.react.uimanager.DisplayMetricsHolder");
contents = insertAfter(
contents,
"class MainActivity : ReactActivity() {",
DISPLAY_METRICS_METHODS,
"the MainActivity class declaration",
);
// Extend the tablet-orientation override, after ReactHost resets metrics.
const overrideStart = contents.indexOf(
"override fun onConfigurationChanged(newConfig: Configuration) {",
);
if (overrideStart !== -1) {
contents =
contents.slice(0, overrideStart) +
insertAfter(
contents.slice(overrideStart),
"super.onConfigurationChanged(newConfig)",
"\n DisplayMetricsHolder.initDisplayMetrics(this)",
"the super call in the onConfigurationChanged override",
);
} else {
contents = addImport(contents, "import android.content.res.Configuration");
contents = insertAfter(
contents,
"class MainActivity : ReactActivity() {",
`
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
DisplayMetricsHolder.initDisplayMetrics(this)
}
`,
"the MainActivity class declaration",
);
}

nextConfig.modResults.contents = contents;
return nextConfig;
});
};
89 changes: 89 additions & 0 deletions apps/mobile/plugins/withAndroidWindowDisplayMetrics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeModule from "node:module";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { afterEach, describe, expect, it } from "vite-plus/test";

const require = NodeModule.createRequire(import.meta.url);
const { compileModsAsync } = require("expo/config-plugins");
const { getConfig } = require("expo/config");
const template = NodeChildProcess.execFileSync(
"tar",
[
"-xOf",
NodePath.join(NodePath.dirname(require.resolve("expo/package.json")), "template.tgz"),
"package/android/app/src/main/java/com/helloworld/MainActivity.kt",
],
{ encoding: "utf8" },
);
const projectRoots = [];

afterEach(() => {
for (const root of projectRoots.splice(0)) NodeFS.rmSync(root, { recursive: true, force: true });
});

async function prebuildMainActivity(contents) {
const projectRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "display-metrics-plugin-"));
projectRoots.push(projectRoot);
const activityPath = NodePath.join(
projectRoot,
"android/app/src/main/java/com/helloworld/MainActivity.kt",
);
NodeFS.mkdirSync(NodePath.dirname(activityPath), { recursive: true });
NodeFS.writeFileSync(activityPath, contents);
const { exp } = getConfig(NodePath.resolve(import.meta.dirname, ".."), {
isModdedConfig: true,
});
// Keep the app's registration order: Expo executes same-type mods in reverse.
// Unrelated native-file mods need the rest of a complete prebuild project.
const mainActivity = exp.mods?.android?.mainActivity;
await compileModsAsync(
{ ...exp, mods: { android: mainActivity ? { mainActivity } : {} } },
{ projectRoot, platforms: ["android"] },
);
return NodeFS.readFileSync(activityPath, "utf8");
}

function methodBody(contents, name) {
const methods = [
...contents.matchAll(
new RegExp(`^ (?:override|private) fun ${name}\\([^]*?\\) \\{\\n([^]*?)^ \\}`, "gm"),
),
];
expect(methods, `exactly one ${name} declaration`).toHaveLength(1);
return methods[0][1].trim();
}

describe("withAndroidWindowDisplayMetrics", () => {
it("rebinds the generated activity after root installation and configuration changes, preserving orientation", async () => {
const result = await prebuildMainActivity(template);

expect(methodBody(result, "setContentView")).toMatch(
/^super\.setContentView\(view\)\s+DisplayMetricsHolder\.initDisplayMetrics\(this\)$/,
);
expect(methodBody(result, "onConfigurationChanged")).toMatch(
/^super\.onConfigurationChanged\(newConfig\)\s+DisplayMetricsHolder\.initDisplayMetrics\(this\)\s+applyTabletOrientation\(\)$/,
);
const onCreate = methodBody(result, "onCreate");
expect(onCreate).toContain("applyTabletOrientation()");
expect(onCreate).not.toContain("DisplayMetricsHolder.initDisplayMetrics(this)");
expect(methodBody(result, "applyTabletOrientation")).toMatch(
/requestedOrientation = if \(resources\.configuration\.smallestScreenWidthDp >= 600\) \{\s+ActivityInfo\.SCREEN_ORIENTATION_FULL_USER\s+\} else \{\s+ActivityInfo\.SCREEN_ORIENTATION_PORTRAIT\s+\}/,
);

expect(result).toContain("import android.view.View");
expect(result).toContain("import com.facebook.react.uimanager.DisplayMetricsHolder");
expect(result.match(/^import android\.content\.res\.Configuration$/gm)).toHaveLength(1);
});

it("leaves the composed activity unchanged when prebuild runs again", async () => {
const once = await prebuildMainActivity(template);
const twice = await prebuildMainActivity(once);

expect(methodBody(once, "setContentView")).toContain(
"DisplayMetricsHolder.initDisplayMetrics(this)",
);
expect(twice).toBe(once);
});
});
Loading