diff --git a/packages/app/app.config.js b/packages/app/app.config.js index c3fe69fd0b..0e1312419a 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -3,6 +3,7 @@ const path = require("node:path"); const pkg = require("./package.json"); const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size"); const withAndroidProfileable = require("./plugins/with-android-profileable"); +const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration"); const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking"); const withPasteInput = require("./plugins/with-paste-input"); const withAndroidScroll = require("./modules/paseo-scroll/app.plugin"); @@ -149,6 +150,7 @@ export default { plugins: [ "expo-router", withPasteInput, + withAndroidFoldConfiguration, withAndroidScroll, [withAndroidAsyncStorageSize, 64], ...buildProfile.cameraPlugins, diff --git a/packages/app/e2e/mobile/agent-device/native-fold-history-route.android.sh b/packages/app/e2e/mobile/agent-device/native-fold-history-route.android.sh new file mode 100644 index 0000000000..f031226223 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-fold-history-route.android.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run only on a task-owned foldable emulator with a connected Paseo debug app. +# Usage: native-fold-history-route.android.sh +serial=${1:?adb serial required} +avd_name=${2:?expected AVD name required} +session=${3:?agent-device session required} +workspace_name=${4:?workspace name required} + +actual_avd=$(adb -s "$serial" shell getprop ro.boot.qemu.avd_name | tr -d '\r') +if [[ "$actual_avd" != "$avd_name" ]]; then + echo "Refusing device $serial: expected $avd_name, found $actual_avd" >&2 + exit 2 +fi + +initial_state=$(adb -s "$serial" shell cmd device_state print-state | tr -d '\r') +if [[ "$initial_state" != "0" && "$initial_state" != "2" ]]; then + echo "Expected CLOSED=0 or OPENED=2; got $initial_state" >&2 + exit 2 +fi +trap 'adb -s "$serial" shell cmd device_state state "$initial_state" >/dev/null' EXIT + +wait_for_history() { + local snapshot + for _ in {1..20}; do + snapshot=$(agent-device snapshot --session "$session" -i) + if rg -q '\[group\] "History"' <<<"$snapshot"; then + return 0 + fi + sleep 0.5 + done + echo "$snapshot" >&2 + return 1 +} + +wait_for_compact_workspace() { + local snapshot + for _ in {1..20}; do + snapshot=$(agent-device snapshot --session "$session" -i) + if rg -q 'Unable to find node on an unmounted component|RootErrorBoundary' <<<"$snapshot"; then + echo "Workspace hit a React Native error after folding" >&2 + echo "$snapshot" >&2 + return 1 + fi + if rg -q 'Switch tabs \(' <<<"$snapshot"; then + return 0 + fi + sleep 0.5 + done + echo "$snapshot" >&2 + return 1 +} + +adb -s "$serial" shell cmd device_state state 2 +sleep 3 +agent-device press 'text="History"' --session "$session" +if ! wait_for_history; then + echo "History route did not open on the wide foldable" >&2 + exit 1 +fi + +adb -s "$serial" shell cmd device_state state 0 +sleep 4 +if ! wait_for_history; then + echo "History route was lost after folding CLOSED" >&2 + exit 1 +fi + +adb -s "$serial" shell cmd device_state state 2 +sleep 4 +if ! wait_for_history; then + echo "History route was lost after unfolding OPENED" >&2 + exit 1 +fi +agent-device press "label=\"$workspace_name\"" --session "$session" +adb -s "$serial" shell cmd device_state state 0 +sleep 4 +if ! wait_for_compact_workspace; then + echo "Compact workspace failed after returning from History" >&2 + exit 1 +fi +echo "History retained and compact workspace mounted after OPENED -> CLOSED -> OPENED -> CLOSED" diff --git a/packages/app/plugins/with-android-fold-configuration.js b/packages/app/plugins/with-android-fold-configuration.js new file mode 100644 index 0000000000..85c525758b --- /dev/null +++ b/packages/app/plugins/with-android-fold-configuration.js @@ -0,0 +1,21 @@ +const { withAndroidManifest } = require("expo/config-plugins"); + +function withAndroidFoldConfiguration(config) { + return withAndroidManifest(config, (modConfig) => { + const activity = modConfig.modResults.manifest.application?.[0]?.activity?.find( + (entry) => entry.$?.["android:name"] === ".MainActivity", + ); + if (!activity) { + throw new Error("Could not configure fold changes without MainActivity"); + } + + // A fold changes smallestScreenWidthDp as well as screenSize. Let React Native and + // Unistyles receive the configuration change without Android recreating MainActivity. + const changes = new Set((activity.$["android:configChanges"] ?? "").split("|").filter(Boolean)); + changes.add("smallestScreenSize"); + activity.$["android:configChanges"] = [...changes].join("|"); + return modConfig; + }); +} + +module.exports = withAndroidFoldConfiguration; diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index b47572e148..721708715c 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,6 +1,5 @@ +import { useUnistyles } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; -import { useLayoutWindowWidth } from "@/constants/window-width"; -import { isCompactWindowWidth } from "@/styles/breakpoints"; export const FOOTER_HEIGHT = 75; @@ -35,11 +34,12 @@ export { } from "./platform"; /** - * The rendered root width owns pane layout. Android fold transitions can - * leave both RN Dimensions and Unistyles' runtime breakpoint at the old size. + * Reactive hook — re-renders the component when the breakpoint changes. + * Always use this instead of reading UnistylesRuntime.breakpoint directly. */ export function useIsCompactFormFactor(): boolean { - return isCompactWindowWidth(useLayoutWindowWidth()); + const { rt } = useUnistyles(); + return rt.breakpoint === "xs" || rt.breakpoint === "sm"; } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/orientation/index.native.ts b/packages/app/src/orientation/index.android.ts similarity index 81% rename from packages/app/src/orientation/index.native.ts rename to packages/app/src/orientation/index.android.ts index 6ed0e89aa6..9df941324d 100644 --- a/packages/app/src/orientation/index.native.ts +++ b/packages/app/src/orientation/index.android.ts @@ -1,22 +1,17 @@ import * as ScreenOrientation from "expo-screen-orientation"; import { useEffect, useState } from "react"; -import { Dimensions, Platform } from "react-native"; +import { Dimensions } from "react-native"; import { useLayoutWindowWidth } from "@/constants/window-width"; import { getAndroidPhysicalScreenSize } from "./screen-geometry"; import { resolveAndroidOrientationPolicy } from "./policy"; -const isAndroid = Platform.OS === "android"; - export function useAdaptiveOrientation(): boolean { const layoutWindowWidth = useLayoutWindowWidth(); - const [screen, setScreen] = useState(() => - isAndroid ? getAndroidPhysicalScreenSize() : Dimensions.get("screen"), - ); + const [screen, setScreen] = useState(getAndroidPhysicalScreenSize); const policy = resolveAndroidOrientationPolicy(screen); - const [ready, setReady] = useState(() => !isAndroid || policy === "system"); + const [ready, setReady] = useState(() => policy === "system"); useEffect(() => { - if (!isAndroid) return; const updateScreen = () => { const nextScreen = getAndroidPhysicalScreenSize(); setScreen((current) => @@ -31,8 +26,6 @@ export function useAdaptiveOrientation(): boolean { }, [layoutWindowWidth]); useEffect(() => { - if (!isAndroid) return; - let active = true; const request = policy === "portrait" diff --git a/packages/app/src/styles/breakpoints.test.ts b/packages/app/src/styles/breakpoints.test.ts deleted file mode 100644 index e7b849ccbc..0000000000 --- a/packages/app/src/styles/breakpoints.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { BREAKPOINTS, isCompactWindowWidth } from "./breakpoints"; - -describe("pane layout width", () => { - it("keeps a narrow window compact on a large physical display", () => { - expect(isCompactWindowWidth(443)).toBe(true); - }); - - it("switches at the same medium breakpoint used by styles", () => { - expect(isCompactWindowWidth(BREAKPOINTS.md - 1)).toBe(true); - expect(isCompactWindowWidth(BREAKPOINTS.md)).toBe(false); - expect(isCompactWindowWidth(852)).toBe(false); - }); -}); diff --git a/packages/app/src/styles/breakpoints.ts b/packages/app/src/styles/breakpoints.ts deleted file mode 100644 index 93f7cc22c6..0000000000 --- a/packages/app/src/styles/breakpoints.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const BREAKPOINTS = { - xs: 0, - sm: 576, - md: 720, - lg: 992, - xl: 1200, -} as const; - -export function isCompactWindowWidth(width: number): boolean { - return width < BREAKPOINTS.md; -} diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index fecb3624dc..b679d5e694 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -1,10 +1,15 @@ import { StyleSheet } from "react-native-unistyles"; -import { BREAKPOINTS } from "./breakpoints"; import { REGISTERED_THEMES } from "./theme"; StyleSheet.configure({ themes: REGISTERED_THEMES, - breakpoints: BREAKPOINTS, + breakpoints: { + xs: 0, + sm: 576, + md: 720, + lg: 992, + xl: 1200, + }, settings: { adaptiveThemes: true, },