mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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+",
|
||||
|
||||
Reference in New Issue
Block a user