fix(app): refresh Android layout after background fold changes (#8285)

Backport the Unistyles 3.5.3 Activity configuration fix while keeping 3.2.4 and its existing fixes. Application resources could leave the responsive shell on the previous fold size after resume. Cover the real device-state sequence with a native UI regression.
This commit is contained in:
Mohamed Boudra
2026-10-30 17:22:53 +07:00
committed by GitHub
parent 531370c4ce
commit 9477fe0c00
4 changed files with 268 additions and 0 deletions
+14
View File
@@ -35,6 +35,20 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android background fold recovery, install the release APK on a foldable AVD, open a
connected workspace, close the keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
```
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
changes posture, resumes immediately, and asserts compact/wide workspace controls without
restarting the process. It writes snapshots and native failure state under
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
reload cannot update it.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
+104
View File
@@ -0,0 +1,104 @@
// Run a release build with a connected workspace open and the keyboard hidden.
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const serial = process.env.ANDROID_SERIAL;
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
const session = "android-fold";
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function pid() {
return adb("shell", "pidof", appId).trim();
}
function open() {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
}
function stateId(name) {
const states = adb("shell", "cmd", "device_state", "print-states");
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
assert(match, `Emulator must expose the ${name} posture: ${states}`);
return match[1];
}
async function expectLayout(posture) {
const compact = posture === "CLOSED";
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
const deadline = Date.now() + 15_000;
let nodes = [];
do {
let data;
try {
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
} catch (error) {
// During display handoff Android briefly has no capturable app window.
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
await delay(200);
continue;
}
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
nodes = data.nodes;
if (
nodes.some((node) => node.identifier === expectedId) &&
!nodes.some((node) => node.identifier === unexpectedId)
) {
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
return;
}
await delay(200);
} while (Date.now() < deadline);
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
writeFileSync(
resolve(artifacts, "native-failure.txt"),
adb("shell", "dumpsys", "activity", "top"),
);
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
}
const closed = stateId("CLOSED");
const opened = stateId("OPENED");
const initialState = adb("shell", "cmd", "device_state", "state").match(
/Committed state: DeviceState\{identifier=(\d+)/,
)?.[1];
assert(initialState, "Could not read the initial fold state");
try {
adb("shell", "cmd", "device_state", "state", opened);
open();
await expectLayout("OPENED");
const initialPid = pid();
for (const [posture, id] of [
["CLOSED", closed],
["OPENED", opened],
]) {
adb("shell", "input", "keyevent", "KEYCODE_HOME");
adb("shell", "cmd", "device_state", "state", id);
// Resume immediately: waiting for configuration delivery hides the regression.
open();
await expectLayout(posture);
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
}
} finally {
adb("shell", "cmd", "device_state", "state", initialState);
ad("close");
}
+148
View File
@@ -557,3 +557,151 @@ index a16dac0..6519858 100644
return ColorScheme.Dark
default:
return ColorScheme.Unspecified
diff --git a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt
index 0f8d1f0..154a12a 100644
--- a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt
+++ b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt
@@ -3,11 +3,16 @@ package com.unistyles
import android.os.Build
import android.content.Context
import android.content.res.Configuration
+import android.content.res.Resources
+import android.os.Handler
+import android.os.Looper
import android.util.DisplayMetrics
import android.view.View
import android.view.WindowManager
import androidx.annotation.Keep
+import androidx.core.content.OnConfigurationChangedProvider
import androidx.core.text.TextUtilsCompat
+import androidx.core.util.Consumer
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -22,6 +27,7 @@ import com.margelo.nitro.unistyles.Insets
import com.margelo.nitro.unistyles.Orientation
import com.margelo.nitro.unistyles.UnistyleDependency
import com.margelo.nitro.unistyles.UnistylesNativeMiniRuntime
+import java.lang.ref.WeakReference
import java.util.Locale
@Keep
@@ -30,6 +36,14 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
private val _insets = NativePlatformInsets(reactContext, this::getMiniRuntime) { this.onConfigChange() }
private var _miniRuntime: UnistylesNativeMiniRuntime = buildMiniRuntime()
private val _listener = NativePlatformListener(reactContext, this::getMiniRuntime) { this.diffMiniRuntime() }
+ private var _configurationProvider: WeakReference<OnConfigurationChangedProvider>? = null
+ private val _activityConfigurationListener = Consumer<Configuration> {
+ Handler(Looper.getMainLooper()).post { this.onConfigChange() }
+ }
+
+ // Application resources can lag behind configuration changes
+ private val configurationResources: Resources
+ get() = reactContext.currentActivity?.resources ?: reactContext.resources
init {
checkEdgeToEdge()
@@ -37,12 +51,16 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
}
fun onDestroy() {
+ stopObservingActivityConfiguration()
_listener.onDestroy()
_insets.onDestroy()
reactContext.removeLifecycleEventListener(this)
}
override fun onHostResume() {
+ observeActivityConfiguration()
+ // catch configuration changes missed while no Activity was attached
+ onConfigChange()
_insets.getInitialInsets(false)
_insets.startInsetsListener()
}
@@ -51,7 +69,26 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
_insets.stopInsetsListener()
}
- override fun onHostDestroy() {}
+ override fun onHostDestroy() {
+ stopObservingActivityConfiguration()
+ }
+
+ private fun observeActivityConfiguration() {
+ val provider = reactContext.currentActivity as? OnConfigurationChangedProvider
+
+ if (_configurationProvider?.get() === provider) {
+ return
+ }
+
+ stopObservingActivityConfiguration()
+ provider?.addOnConfigurationChangedListener(_activityConfigurationListener)
+ _configurationProvider = provider?.let { WeakReference(it) }
+ }
+
+ private fun stopObservingActivityConfiguration() {
+ _configurationProvider?.get()?.removeOnConfigurationChangedListener(_activityConfigurationListener)
+ _configurationProvider = null
+ }
override val memorySize: Long
get() = 0
@@ -61,7 +98,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
}
override fun getColorScheme(): ColorScheme {
- val uiMode = reactContext.resources.configuration.uiMode
+ val uiMode = configurationResources.configuration.uiMode
val colorScheme = when (uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) {
Configuration.UI_MODE_NIGHT_YES -> ColorScheme.DARK
@@ -73,15 +110,15 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
}
override fun getFontScale(): Double {
- return reactContext.resources.configuration.fontScale.toDouble()
+ return configurationResources.configuration.fontScale.toDouble()
}
override fun getPixelRatio(): Double {
- return reactContext.resources.displayMetrics.density.toDouble()
+ return configurationResources.displayMetrics.density.toDouble()
}
override fun getOrientation(): Orientation {
- val orientation = when (reactContext.resources.configuration.orientation) {
+ val orientation = when (configurationResources.configuration.orientation) {
Configuration.ORIENTATION_PORTRAIT -> Orientation.PORTRAIT
Configuration.ORIENTATION_LANDSCAPE -> Orientation.LANDSCAPE
else -> Orientation.PORTRAIT
@@ -122,7 +159,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
return Dimensions(screenWidth, screenHeight)
}
else -> {
- val displayMetrics = reactContext.resources.displayMetrics
+ val displayMetrics = configurationResources.displayMetrics
reactContext.currentActivity?.windowManager?.currentWindowMetrics?.bounds?.let {
val boundsWidth = (it.width() / displayMetrics.density).toDouble()
@@ -157,7 +194,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext):
"com.facebook.react.modules.i18nmanager.I18nUtil",
Context.MODE_PRIVATE
)
- val resourcesLocale = reactContext.resources.configuration.locales[0]
+ val resourcesLocale = configurationResources.configuration.locales[0]
val hasForcedRtl = sharedPrefs.getBoolean("RCTI18nUtil_forceRTL", false)
// user preferences
val isRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL
diff --git a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt
index a9c1804..f5a3c20 100644
--- a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt
+++ b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt
@@ -38,7 +38,7 @@ class NativePlatformInsets(
}
fun getInsets(): Insets {
- val density = reactContext.resources.displayMetrics.density
+ val density = (reactContext.currentActivity?.resources ?: reactContext.resources).displayMetrics.density
return Insets(
this._insets.top / density,
+2
View File
@@ -26,6 +26,8 @@ const patchedPackages = [
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
// Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes.
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
// Android configuration: backport 3.5.3; remove the two NativePlatform hunks at >= 3.5.3.
// https://github.com/jpudysz/react-native-unistyles/compare/v3.5.2...v3.5.3
{
nodeModulesPath: "node_modules/react-native-unistyles",
patchPrefix: "react-native-unistyles+",