mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 <adb-serial> <avd-name> <agent-device-session> <workspace-name>
|
||||
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"
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
+3
-10
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user