Files
orca/mobile/plugins/android-window-density.js
T
Kelvin Amoaba 7e935c5961 fix(mobile): keep the app's state and layout when entering split screen on Android (#36977)
* fix(mobile): keep Android split screen from restarting or squeezing the app

One UI gives a split-screen pane its own density. Android recreated
MainActivity on that change, and React Native seeds its display metrics
from the application context, which keeps the full-screen density, so
the pane was laid out at the wrong scale. A config plugin declares
density in configChanges and re-seeds the metrics from the activity.

* fix(mobile): catch React Native's rotation re-seed before the frame draws

A one-shot listener added on configuration change missed a cold start or
a 180-degree flip at a non-zero rotation, where React Native's root view
re-seeds from the application context without a configuration change.

* fix(mobile): publish re-seeded display metrics in one step

Text measurement reads the holder off the UI thread, so build the
pane-density screen metrics before publishing them.
2026-11-01 15:16:27 +00:00

104 lines
4.0 KiB
JavaScript

const { AndroidConfig, withAndroidManifest, withMainActivity } = require('expo/config-plugins')
// Why: One UI gives a split-screen pane its own density (#36305). Without `density` in
// configChanges Android recreates MainActivity, closing the open session and orphaning the image
// picker. Keeping the activity is not enough: React Native seeds its DisplayMetricsHolder from the
// application context, which keeps the full-screen density, so the pane is laid out at the wrong
// scale. MainActivity re-seeds it from its own resources after React Native does.
const MARKER = 'orca: window density'
const CLASS_HEADER = 'class MainActivity : ReactActivity() {'
const IMPORTS = [
'android.content.res.Configuration',
'android.os.Bundle',
'android.util.DisplayMetrics',
'com.facebook.react.uimanager.DisplayMetricsHolder'
]
const METHODS = `
// ${MARKER}, generated by plugins/android-window-density.js.
// React Native re-seeds from the application context when it creates the root view (onCreate)
// and in onConfigurationChanged, so these run before the next measure. Its root view's layout
// listener also re-seeds whenever the rotation differs from the last one it saw, after measuring;
// the pre-draw check runs after every layout listener and asks for another pass, in which React
// Native re-sends the window size to JS.
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
window.decorView.viewTreeObserver.addOnPreDrawListener {
val held = DisplayMetricsHolder.getScreenDisplayMetrics().densityDpi
if (held != resources.displayMetrics.densityDpi) {
useWindowDensity()
window.decorView.requestLayout()
}
true
}
}
override fun onResume() {
super.onResume()
useWindowDensity()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
useWindowDensity()
}
// React Native's initDisplayMetrics with the pane's density on the real display size, built before
// it is published because text measurement reads it off the UI thread.
@Suppress("DEPRECATION")
private fun useWindowDensity() {
val pane = resources.displayMetrics
val screen = DisplayMetrics().apply {
windowManager.defaultDisplay.getRealMetrics(this)
density = pane.density
densityDpi = pane.densityDpi
scaledDensity = pane.scaledDensity
}
DisplayMetricsHolder.setWindowDisplayMetrics(pane)
DisplayMetricsHolder.setScreenDisplayMetrics(screen)
}
`
function patchMainActivity(contents) {
if (contents.includes(MARKER)) {
return contents
}
// A changed template must fail prebuild rather than drop or double an override.
if (
!contents.includes(CLASS_HEADER) ||
!contents.trimEnd().endsWith('}') ||
/fun (onPostCreate|onResume|onConfigurationChanged)\(/.test(contents)
) {
throw new Error(
"android-window-density: Expo's MainActivity template changed; update the plugin before building."
)
}
const imports = IMPORTS.filter((name) => !contents.includes(`import ${name}\n`))
.map((name) => `import ${name}\n`)
.join('')
const body = contents.trimEnd().slice(0, -1).trimEnd()
return `${body.replace(/^(package [^\n]+\n)/, `$1${imports}`)}\n${METHODS}}\n`
}
module.exports = function withAndroidWindowDensity(config) {
config = withAndroidManifest(config, (cfg) => {
const activity = AndroidConfig.Manifest.getMainActivityOrThrow(cfg.modResults)
const changes = (activity.$['android:configChanges'] ?? '').split('|').filter(Boolean)
if (!changes.includes('density')) {
activity.$['android:configChanges'] = [...changes, 'density'].join('|')
}
return cfg
})
return withMainActivity(config, (cfg) => {
if (cfg.modResults.language !== 'kt') {
throw new Error(
`android-window-density: expected a Kotlin MainActivity, got ${cfg.modResults.language}`
)
}
cfg.modResults.contents = patchMainActivity(cfg.modResults.contents)
return cfg
})
}