diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 7ddfc95029..58d051191d 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,3 +1,4 @@ +const withAndroidOrientation = require("./plugins/with-android-orientation"); const fs = require("node:fs"); const path = require("node:path"); const pkg = require("./package.json"); @@ -142,6 +143,7 @@ export default { searchPaths: ["../../node_modules", "./node_modules"], }, plugins: [ + withAndroidOrientation, "expo-router", withPasteInput, withAndroidScroll, diff --git a/packages/app/app.config.test.ts b/packages/app/app.config.test.ts index b98258ff9a..43e0fa2dcb 100644 --- a/packages/app/app.config.test.ts +++ b/packages/app/app.config.test.ts @@ -59,3 +59,52 @@ describe("Android app config", () => { 30_000, ); }); + +it("allows Android tablets to rotate without a portrait manifest lock", async () => { + const manifest = await resolveAndroidManifest(); + const activity = manifest.application[0].activity.find( + (entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity", + ); + expect(activity.$["android:screenOrientation"]).toBe("@integer/paseo_orientation"); +}, 30_000); + +it("generates portrait phone and unrestricted tablet orientation resources", async () => { + const { mkdtemp, readFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const root = await mkdtemp(join(tmpdir(), "paseo-orientation-")); + try { + const plugin = createRequire(import.meta.url)("./plugins/with-android-orientation.js"); + const config = plugin({ name: "test", slug: "test" }); + await config.mods.android.dangerous({ + modRequest: { platformProjectRoot: root }, + modResults: {}, + }); + const resource = (directory: string) => + readFile(join(root, "app/src/main/res", directory, "paseo_orientation.xml"), "utf8"); + expect(await resource("values")).toContain('name="paseo_orientation">1'); + expect(await resource("values-sw600dp")).toContain('name="paseo_orientation">-1'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("updates the native orientation before launch and when screen size changes", async () => { + const plugin = createRequire(import.meta.url)("./plugins/with-android-orientation.js"); + const config = plugin({ name: "test", slug: "test" }); + const result = await config.mods.android.mainActivity({ + modRequest: {}, + modResults: { + language: "kt", + contents: + "class MainActivity {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(null)\n }\n}", + }, + }); + expect(result.modResults.contents).toContain("smallestScreenWidthDp >= 600"); + expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_UNSPECIFIED"); + expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_PORTRAIT"); + expect(result.modResults.contents).toContain( + "updateOrientation(resources.configuration)\n super.onCreate(null)", + ); + expect(result.modResults.contents).toContain("updateOrientation(newConfig)"); +}); diff --git a/packages/app/plugins/with-android-orientation.js b/packages/app/plugins/with-android-orientation.js new file mode 100644 index 0000000000..44aa0bf29e --- /dev/null +++ b/packages/app/plugins/with-android-orientation.js @@ -0,0 +1,67 @@ +const { withAndroidManifest, withMainActivity, withDangerousMod } = require("expo/config-plugins"); +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = function withAndroidOrientation(config) { + // The manifest locks phones before the first activity frame. Tablets start unlocked. + config = withAndroidManifest(config, (mod) => { + const activity = mod.modResults.manifest.application[0].activity.find( + (item) => item.$["android:name"] === ".MainActivity", + ); + activity.$["android:screenOrientation"] = "@integer/paseo_orientation"; + return mod; + }); + config = withMainActivity(config, (mod) => { + const activity = mod.modResults; + if (activity.language !== "kt") throw new Error("Expected Kotlin MainActivity"); + const marker = " // Paseo orientation policy"; + if (!activity.contents.includes(marker)) { + const anchor = " override fun onCreate(savedInstanceState: Bundle?)"; + if (!activity.contents.includes(anchor)) throw new Error("MainActivity anchor missing"); + activity.contents = activity.contents.replace( + anchor, + `${marker} + private fun updateOrientation(configuration: android.content.res.Configuration) { + val orientation = if (configuration.smallestScreenWidthDp >= 600) + android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + else + android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + if (requestedOrientation != orientation) requestedOrientation = orientation + } + + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + updateOrientation(newConfig) + } + +${anchor}`, + ); + if (!activity.contents.includes(" super.onCreate(null)")) + throw new Error("MainActivity onCreate missing"); + activity.contents = activity.contents.replace( + " super.onCreate(null)", + " updateOrientation(resources.configuration)\n super.onCreate(null)", + ); + } + return mod; + }); + return withDangerousMod(config, [ + "android", + async (mod) => { + const res = path.join(mod.modRequest.platformProjectRoot, "app/src/main/res"); + // ActivityInfo uses 1 for portrait and -1 for unspecified. + for (const [dir, value] of [ + ["values", "1"], + ["values-sw600dp", "-1"], + ]) { + const target = path.join(res, dir); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync( + path.join(target, "paseo_orientation.xml"), + `${value}\n`, + ); + } + return mod; + }, + ]); +};