From aa50151fa1e6646e0833f22656c28cb475cc233f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 30 Sep 2026 15:40:12 +0200 Subject: [PATCH] fix(app): handle Android fold configuration in place Android fold transitions change smallestScreenWidthDp. Without declaring that configuration change, MainActivity is recreated and the foreground History route is lost. Keep the activity mounted so the existing responsive runtime receives the new geometry. (cherry picked from commit ad3394ea5e582119ab5e907f0e614fe54c535428) --- packages/app/app.config.js | 2 + .../native-fold-history-route.android.sh | 83 +++++++++++++++++++ .../with-android-fold-configuration.js | 21 +++++ packages/app/src/constants/layout.ts | 10 +-- .../{index.native.ts => index.android.ts} | 13 +-- packages/app/src/styles/breakpoints.test.ts | 14 ---- packages/app/src/styles/breakpoints.ts | 11 --- packages/app/src/styles/unistyles.ts | 9 +- 8 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-fold-history-route.android.sh create mode 100644 packages/app/plugins/with-android-fold-configuration.js rename packages/app/src/orientation/{index.native.ts => index.android.ts} (81%) delete mode 100644 packages/app/src/styles/breakpoints.test.ts delete mode 100644 packages/app/src/styles/breakpoints.ts 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, },