Skip to content
Open
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
145 changes: 106 additions & 39 deletions apps/mobile/plugins/withAndroidTabletOrientation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,61 @@ const { withMainActivity } = require("expo/config-plugins");
// portrait. iOS doesn't have this problem: iPads must support all orientations
// because the app is multitasking-capable, so only iPhones end up portrait-only.
// Mirror that split on Android: keep the manifest lock for phones and lift it at
// runtime on tablets (smallest width >= 600dp, the standard tablet breakpoint),
// since requestedOrientation set at runtime overrides the manifest value.
// FULL_USER allows all four orientations while still respecting the user's
// auto-rotate lock, matching iPad behavior. Foldables change
// smallestScreenWidthDp on fold/unfold without recreating the activity
// (smallestScreenSize is in the manifest's configChanges), so the policy is
// re-evaluated in onConfigurationChanged: unfolding past the tablet breakpoint
// unlocks rotation, and folding back restores the portrait lock.
// runtime when the window is a tablet/fold (smallest width >= 600dp) OR a desktop
// shell (UI_MODE_TYPE_DESK: Samsung DeX, Pixel Desktop, ChromeOS).
// requestedOrientation set at runtime overrides the manifest value. FULL_USER
// allows all four orientations while still respecting the user's auto-rotate
// lock, matching iPad behavior.
//
// Foldables change smallestScreenWidthDp on fold/unfold without recreating the
// activity (smallestScreenSize is in the manifest's configChanges), and desktop
// windowing changes density the same way. Re-evaluate in onConfigurationChanged.
//
// Keep the unlock predicate in lockstep with
// apps/mobile/src/lib/androidWindowing.ts.

const ORIENTATION_METHODS = `
const WINDOWING_IMPORTS = `
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import com.facebook.react.uimanager.DisplayMetricsHolder`;

const WINDOWING_METHODS = `
// Applied in onCreate and re-applied on fold/unfold and desktop-window
// attach; added by withAndroidTabletOrientation.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
applyWindowedOrientation()
rebindWindowDisplayMetrics()
}

private fun applyWindowedOrientation() {
val config = resources.configuration
val uiModeType = config.uiMode and Configuration.UI_MODE_TYPE_MASK
val unlockOrientation =
config.smallestScreenWidthDp >= 600 || uiModeType == Configuration.UI_MODE_TYPE_DESK
requestedOrientation =
if (unlockOrientation) {
ActivityInfo.SCREEN_ORIENTATION_FULL_USER
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}

// RN's host re-inits DisplayMetricsHolder from the React/application
// context, which keeps the phone's density inside a desktop window. Super
// already ran that path; re-bind from this activity so Yoga and text paint
// use the window metrics, then relayout.
private fun rebindWindowDisplayMetrics() {
DisplayMetricsHolder.initDisplayMetrics(this)
window.decorView.post { window.decorView.requestLayout() }
}
`;

const WINDOWING_ON_CREATE_CALL = `
applyWindowedOrientation()
rebindWindowDisplayMetrics()`;

const LEGACY_ORIENTATION_METHODS = `
// Applied in onCreate and re-applied on fold/unfold; added by
// withAndroidTabletOrientation.
override fun onConfigurationChanged(newConfig: Configuration) {
Expand All @@ -31,9 +76,6 @@ const ORIENTATION_METHODS = `
}
`;

const ORIENTATION_ON_CREATE_CALL = `
applyTabletOrientation()`;

function insertAfter(contents, anchor, insertion, description) {
const index = contents.indexOf(anchor);
if (index === -1) {
Expand All @@ -45,36 +87,61 @@ function insertAfter(contents, anchor, insertion, description) {
return contents.slice(0, end) + insertion + contents.slice(end);
}

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

if (contents.includes("applyTabletOrientation")) {
if (!contents.includes(LEGACY_ORIENTATION_METHODS.trim())) {
throw new Error(
"withAndroidTabletOrientation: found applyTabletOrientation but could not replace the legacy injection; update the plugin anchors.",
);
}
if (contents.includes("SCREEN_ORIENTATION_FULL_USER")) {
return nextConfig;
contents = contents.replace(LEGACY_ORIENTATION_METHODS, WINDOWING_METHODS);
contents = contents.replace("\n applyTabletOrientation()", WINDOWING_ON_CREATE_CALL);
if (!contents.includes("import com.facebook.react.uimanager.DisplayMetricsHolder")) {
contents = insertAfter(
contents,
"import android.content.res.Configuration",
"\nimport com.facebook.react.uimanager.DisplayMetricsHolder",
"the Configuration import from the legacy injection",
);
}
return contents;
}

contents = insertAfter(
contents,
"import android.os.Bundle",
"\nimport android.content.pm.ActivityInfo\nimport android.content.res.Configuration",
"the android.os.Bundle import",
);
contents = insertAfter(
contents,
"class MainActivity : ReactActivity() {",
ORIENTATION_METHODS,
"the MainActivity class declaration",
);
contents = insertAfter(
contents,
"super.onCreate(null)",
ORIENTATION_ON_CREATE_CALL,
"the super.onCreate call",
);
contents = insertAfter(
contents,
"import android.os.Bundle",
WINDOWING_IMPORTS,
"the android.os.Bundle import",
);
contents = insertAfter(
contents,
"class MainActivity : ReactActivity() {",
WINDOWING_METHODS,
"the MainActivity class declaration",
);
contents = insertAfter(
contents,
"super.onCreate(null)",
WINDOWING_ON_CREATE_CALL,
"the super.onCreate call",
);
return contents;
}

nextConfig.modResults.contents = contents;
function withAndroidTabletOrientation(config) {
return withMainActivity(config, (nextConfig) => {
if (nextConfig.modResults.language !== "kt") {
throw new Error("withAndroidTabletOrientation: MainActivity must be Kotlin.");
}
nextConfig.modResults.contents = patchMainActivity(nextConfig.modResults.contents);
return nextConfig;
});
};
}

withAndroidTabletOrientation.patchMainActivity = patchMainActivity;

module.exports = withAndroidTabletOrientation;
2 changes: 2 additions & 0 deletions apps/mobile/src/components/CompactBrandTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ export function CompactBrandTitle(
<Text
allowFontScaling={props.allowFontScaling}
className="font-t3-medium text-[21px] tracking-[-0.5px] text-foreground-muted"
style={Platform.OS === "android" ? { includeFontPadding: false } : undefined}
>
Code
</Text>
<View className="rounded-full bg-subtle px-1.5 py-0.5">
<Text
allowFontScaling={props.allowFontScaling}
className="font-t3-bold text-[9px] tracking-[0.9px] text-foreground-muted uppercase"
style={Platform.OS === "android" ? { includeFontPadding: false } : undefined}
>
{stageLabel}
</Text>
Expand Down
22 changes: 16 additions & 6 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { SearchBarCommands } from "react-native-screens";

import { AppText as Text } from "../../components/AppText";
import { CompactBrandTitle } from "../../components/CompactBrandTitle";
import { ControlPillMenu } from "../../components/ControlPill";
import { SymbolView } from "../../components/AppSymbol";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import {
ANDROID_SIDEBAR_PAGE_TITLE_ROW_MIN_HEIGHT,
androidSidebarPageTitleProps,
} from "../../lib/layoutMetrics";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useProjects, useThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
Expand Down Expand Up @@ -1311,18 +1314,25 @@ function ThreadNavigationSidebarPane(
pointerEvents="auto"
style={{ paddingTop: insets.top }}
>
<View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">
<View
className="flex-row items-end gap-0.5 pr-2 pl-5"
style={{ minHeight: ANDROID_SIDEBAR_PAGE_TITLE_ROW_MIN_HEIGHT }}
>
{/* Title slot doubles as the connection status surface: while an
environment reconnects, the brand fades to a status label in
environment reconnects, "Threads" fades to a status label in
place (no layout shift in the list below). */}
<WorkspaceConnectionTitle
grow
onPress={props.onOpenEnvironmentSettings}
size="pageTitle"
brand={
<View className="h-11 flex-1 justify-center">
<CompactBrandTitle allowFontScaling={false} />
</View>
<Text
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
{...androidSidebarPageTitleProps()}
className="flex-1 font-t3-bold text-foreground"
numberOfLines={1}
>
Threads
</Text>
}
/>
<View className="flex-row items-center gap-2.5">
Expand Down
139 changes: 139 additions & 0 deletions apps/mobile/src/lib/androidWindowing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as NodeModule from "node:module";
import { describe, expect, it } from "vite-plus/test";

import {
ANDROID_TABLET_SMALLEST_WIDTH_DP,
ANDROID_UI_MODE_TYPE_CAR,
ANDROID_UI_MODE_TYPE_DESK,
ANDROID_UI_MODE_TYPE_NORMAL,
shouldUnlockAndroidScreenOrientation,
} from "./androidWindowing";

const require = NodeModule.createRequire(import.meta.url);
const { patchMainActivity } = require("../../plugins/withAndroidTabletOrientation.cjs") as {
patchMainActivity: (contents: string) => string;
};

const EXPO_MAIN_ACTIVITY = `package com.t3tools.t3code
import android.os.Bundle
import com.facebook.react.ReactActivity

class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
}
}
`;

const LEGACY_MAIN_ACTIVITY = `package com.t3tools.t3code
import android.os.Bundle
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import com.facebook.react.ReactActivity

class MainActivity : ReactActivity() {
// Applied in onCreate and re-applied on fold/unfold; added by
// withAndroidTabletOrientation.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
applyTabletOrientation()
}

private fun applyTabletOrientation() {
requestedOrientation = if (resources.configuration.smallestScreenWidthDp >= 600) {
ActivityInfo.SCREEN_ORIENTATION_FULL_USER
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
applyTabletOrientation()
}
}
`;

describe("shouldUnlockAndroidScreenOrientation", () => {
it("keeps phones in the portrait manifest lock", () => {
expect(
shouldUnlockAndroidScreenOrientation({
smallestScreenWidthDp: 411,
uiModeType: ANDROID_UI_MODE_TYPE_NORMAL,
}),
).toBe(false);
});

it("unlocks foldables and tablets at the 600dp smallest-width breakpoint", () => {
expect(
shouldUnlockAndroidScreenOrientation({
smallestScreenWidthDp: ANDROID_TABLET_SMALLEST_WIDTH_DP,
uiModeType: ANDROID_UI_MODE_TYPE_NORMAL,
}),
).toBe(true);
expect(
shouldUnlockAndroidScreenOrientation({
smallestScreenWidthDp: ANDROID_TABLET_SMALLEST_WIDTH_DP - 1,
uiModeType: ANDROID_UI_MODE_TYPE_NORMAL,
}),
).toBe(false);
});

it("unlocks desktop windows (DeX, Pixel Desktop, ChromeOS) even when smallest width is phone-sized", () => {
expect(
shouldUnlockAndroidScreenOrientation({
smallestScreenWidthDp: 411,
uiModeType: ANDROID_UI_MODE_TYPE_DESK,
}),
).toBe(true);
});

it("does not treat other UI modes as desktop", () => {
expect(
shouldUnlockAndroidScreenOrientation({
smallestScreenWidthDp: 411,
uiModeType: ANDROID_UI_MODE_TYPE_CAR,
}),
).toBe(false);
});
});

describe("withAndroidTabletOrientation MainActivity patch", () => {
it("unlocks desk-mode windows and rebinds display metrics from the activity", () => {
const patched = patchMainActivity(EXPO_MAIN_ACTIVITY);
expect(patched).toContain("UI_MODE_TYPE_DESK");
expect(patched).toContain(`smallestScreenWidthDp >= ${ANDROID_TABLET_SMALLEST_WIDTH_DP}`);
expect(patched).toContain("DisplayMetricsHolder.initDisplayMetrics(this)");
expect(patched).toContain("applyWindowedOrientation()");
expect(patched).toContain("rebindWindowDisplayMetrics()");
});

it("emits the complete phone/tablet/desk policy and configuration rebind path", () => {
const patched = patchMainActivity(EXPO_MAIN_ACTIVITY);
expect(patched).toContain(`val uiModeType = config.uiMode and Configuration.UI_MODE_TYPE_MASK`);
expect(patched).toContain(
`val unlockOrientation =\n config.smallestScreenWidthDp >= ${ANDROID_TABLET_SMALLEST_WIDTH_DP} || uiModeType == Configuration.UI_MODE_TYPE_DESK`,
);
expect(patched).toContain(
`if (unlockOrientation) {\n ActivityInfo.SCREEN_ORIENTATION_FULL_USER\n } else {\n ActivityInfo.SCREEN_ORIENTATION_PORTRAIT`,
);
expect(patched).toContain(
"DisplayMetricsHolder.initDisplayMetrics(this)\n window.decorView.post { window.decorView.requestLayout() }",
);
expect(patched).toContain(
"super.onConfigurationChanged(newConfig)\n applyWindowedOrientation()\n rebindWindowDisplayMetrics()",
);
});

it("replaces the tablet-only injection on existing MainActivity files", () => {
const patched = patchMainActivity(LEGACY_MAIN_ACTIVITY);
expect(patched).not.toContain("applyTabletOrientation");
expect(patched).toContain("UI_MODE_TYPE_DESK");
expect(patched).toContain("DisplayMetricsHolder.initDisplayMetrics(this)");
expect(patched).toContain("applyWindowedOrientation()\n rebindWindowDisplayMetrics()");
});

it("is idempotent", () => {
const once = patchMainActivity(EXPO_MAIN_ACTIVITY);
expect(patchMainActivity(once)).toBe(once);
});
});
26 changes: 26 additions & 0 deletions apps/mobile/src/lib/androidWindowing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Android windowing policy for orientation. The Kotlin in
* `plugins/withAndroidTabletOrientation.cjs` must stay in lockstep with this
* function: phones stay portrait-locked, everything else that is a real
* window (tablet/fold inner screen, DeX, Pixel Desktop, ChromeOS) unlocks.
*
* `uiModeType` is `Configuration.uiMode & UI_MODE_TYPE_MASK`.
*/
export const ANDROID_TABLET_SMALLEST_WIDTH_DP = 600;

/** `android.content.res.Configuration.UI_MODE_TYPE_NORMAL` */
export const ANDROID_UI_MODE_TYPE_NORMAL = 1;
/** `android.content.res.Configuration.UI_MODE_TYPE_DESK` — DeX, Pixel Desktop, ChromeOS. */
export const ANDROID_UI_MODE_TYPE_DESK = 2;
/** `android.content.res.Configuration.UI_MODE_TYPE_CAR` */
export const ANDROID_UI_MODE_TYPE_CAR = 3;

export function shouldUnlockAndroidScreenOrientation(input: {
readonly smallestScreenWidthDp: number;
readonly uiModeType: number;
}): boolean {
if (input.smallestScreenWidthDp >= ANDROID_TABLET_SMALLEST_WIDTH_DP) {
return true;
}
return input.uiModeType === ANDROID_UI_MODE_TYPE_DESK;
}
Loading
Loading