diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 169803c0eb..903244e3d9 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -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: diff --git a/packages/app/e2e/mobile/fold/android.mjs b/packages/app/e2e/mobile/fold/android.mjs new file mode 100644 index 0000000000..93f23dc111 --- /dev/null +++ b/packages/app/e2e/mobile/fold/android.mjs @@ -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"); +} diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 1930a4db84..af825a6afa 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -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? = null ++ private val _activityConfigurationListener = Consumer { ++ 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, diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index eca0fde25f..312bb90724 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -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+",