-
Notifications
You must be signed in to change notification settings - Fork 14
fix(mobile): keep the branded splash on screen during Android cold start #6355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
iscekic
wants to merge
1
commit into
main
Choose a base branch
from
kwf/explorer-app-cold-loading-cold-start-shows-a-bare-white-s-b6c69-4d7e
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
188 changes: 188 additions & 0 deletions
188
apps/mobile/plugins/android-splash-window-background.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| /** | ||
| * Native backing-surface adapter for the shared branded splash. | ||
| * | ||
| * Android lacks the root-backed storyboard loading view Expo uses on iOS; | ||
| * its window drawable must cover the pre-React gap after the system splash | ||
| * exits. This adapter does not hide the splash: AnimatedSplashOverlay owns | ||
| * that lifecycle on BOTH platforms. The marker/delay below only restores the | ||
| * otherwise-covered Android window background for later rotation. | ||
| * | ||
| * Two platform defaults break the branded launch, and each needs its own edit: | ||
| * | ||
| * 1. `expo-splash-screen` writes `Theme.App.SplashScreen` with the splash-screen | ||
| * attributes (`windowSplashScreenBackground`, `windowSplashScreenAnimatedIcon`) | ||
| * but never sets `android:windowBackground`. The theme therefore inherits the | ||
| * AppCompat DayNight default for the window surface — plain white in day mode — | ||
| * and any launch frame drawn before the splash window falls back to it: a bare | ||
| * white screen with no logo, wordmark or progress affordance (the cold-start | ||
| * finding `app-cold-loading`). | ||
| * | ||
| * 2. `postSplashScreenTheme` points the window at `AppTheme` as soon as the | ||
| * splash exits, and `AppTheme`'s window background is the app background. On | ||
| * Android 12+ the system dismisses its splash window about a second after the | ||
| * activity is created — long before the Metro bundle has loaded and rendered — | ||
| * so the window surface shows the bare, logo-less app background for the whole | ||
| * multi-second load. `Theme.App.Launch` keeps the brand drawable as the | ||
| * post-splash window surface, and `MainActivity` only hands the window back to | ||
| * the app background once the app's own React content appears. | ||
| * | ||
| * The hand-back is keyed on React's `CONTENT_APPEARED` marker rather than the | ||
| * first draw: the window draws at splash dismissal, seconds before React exists, | ||
| * so a first-draw hand-back would restore the bare surface for the load. The | ||
| * brand surface must not outlive the launch either — the rotation-surface plugin | ||
| * pins `AppTheme`'s window background to the app background so a rotation never | ||
| * paints a foreign frame until React draws again — hence the explicit hand-back | ||
| * the moment the app content is up. | ||
| * | ||
| * Pure data in, data out: the config plugin in | ||
| * `withBrandedSplash.js` owns the mod plumbing, and the unit | ||
| * test drives this module directly. | ||
| */ | ||
|
|
||
| /** The style expo-splash-screen writes for the launch theme. */ | ||
| const THEME_NAME = 'Theme.App.SplashScreen'; | ||
| /** The window surface attribute Android falls back to before content draws. */ | ||
| const WINDOW_BACKGROUND_ITEM = 'android:windowBackground'; | ||
| /** The style Android hands the window to once the splash exits. */ | ||
| const POST_SPLASH_THEME_ITEM = 'postSplashScreenTheme'; | ||
| /** The post-splash theme: the app theme with the brand launch surface. */ | ||
| const POST_SPLASH_THEME_NAME = 'Theme.App.Launch'; | ||
| const POST_SPLASH_THEME_PARENT = '@style/AppTheme'; | ||
| /** The splash color expo-splash-screen writes from `app.config.ts`. */ | ||
| const SPLASH_BACKGROUND_COLOR = '@color/splashscreen_background'; | ||
| /** Brand surface: the splash color with the dark Kilo mark centred on it. */ | ||
| const SPLASH_WINDOW_DRAWABLE_NAME = 'splashscreen_window_background'; | ||
| const SPLASH_WINDOW_DRAWABLE_REF = `@drawable/${SPLASH_WINDOW_DRAWABLE_NAME}`; | ||
| const SPLASH_LOGO_DRAWABLE_REF = '@drawable/splashscreen_logo'; | ||
| /** The app background the rotation-surface plugin writes (values-night aware). */ | ||
| const APP_BACKGROUND_COLOR_REF = 'R.color.app_background'; | ||
| /** The expo `MainActivity` template's super call, the onCreate anchor. */ | ||
| const ON_CREATE_SUPER_CALL = 'super.onCreate(null)'; | ||
| /** | ||
| * How long after React's `CONTENT_APPEARED` the window surface is handed back | ||
| * to the app background. The marker fires when the React root gets its first | ||
| * child, but the app's own opaque surfaces (the root provider views) paint a | ||
| * beat later — on the emulator that gap is ~1.5 s, and the window background is | ||
| * what shows through it. A late hand-back is invisible (the splash overlay and | ||
| * then the app tree cover the window), so this only has to clear the paint gap | ||
| * with margin; it is not a deadline. | ||
| */ | ||
| const LAUNCH_SURFACE_HAND_BACK_DELAY_MS = 3000; | ||
| /** | ||
| * Hands the window surface back to the app background once the app's own | ||
| * surfaces have painted, so the brand drawable only covers the launch and a | ||
| * later rotation still paints the app background the rotation-surface plugin | ||
| * pins to `AppTheme`. | ||
| * | ||
| * `ReactMarker` is a process-global registry, so the listener also has to be | ||
| * dropped when the activity dies. A launch that never reaches | ||
| * `CONTENT_APPEARED` — a bundle load failure, a crash before React mounts, or | ||
| * the activity being destroyed while still loading — would otherwise leave the | ||
| * listener registered and hold this activity and its whole view hierarchy for | ||
| * the rest of the process, one leaked activity per recreation. The lifecycle | ||
| * observer removes it on `ON_DESTROY`, which is the activity's own `onDestroy`. | ||
| */ | ||
| const ON_CREATE_INJECTION = [ | ||
| 'val splashMarkerListener = object : com.facebook.react.bridge.ReactMarker.MarkerListener {', | ||
| ' override fun logMarker(name: com.facebook.react.bridge.ReactMarkerConstants, tag: String?, instanceKey: Int) {', | ||
| ' if (name != com.facebook.react.bridge.ReactMarkerConstants.CONTENT_APPEARED) return', | ||
| ' com.facebook.react.bridge.ReactMarker.removeListener(this)', | ||
| ' window.decorView.postDelayed({', | ||
| ` if (!isFinishing) window.setBackgroundDrawableResource(${APP_BACKGROUND_COLOR_REF})`, | ||
| ` }, ${LAUNCH_SURFACE_HAND_BACK_DELAY_MS}L)`, | ||
| ' }', | ||
| '}', | ||
| 'com.facebook.react.bridge.ReactMarker.addListener(splashMarkerListener)', | ||
| 'lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {', | ||
| ' override fun onDestroy(owner: androidx.lifecycle.LifecycleOwner) {', | ||
| ' com.facebook.react.bridge.ReactMarker.removeListener(splashMarkerListener)', | ||
|
iscekic marked this conversation as resolved.
|
||
| ' }', | ||
| '})', | ||
| ] | ||
| .map(line => ` ${line}`) | ||
| .join('\n'); | ||
|
|
||
| function setItem(theme, name, value) { | ||
| theme.item ??= []; | ||
| const existing = theme.item.find(item => item.$?.name === name); | ||
| if (existing) { | ||
| existing._ = value; | ||
| return; | ||
| } | ||
| theme.item.push({ $: { name }, _: value }); | ||
| } | ||
|
|
||
| /** | ||
| * Pins the launch theme's window surface to the brand splash drawable and hands | ||
| * the post-splash window to `Theme.App.Launch`, which keeps the same drawable | ||
| * until `MainActivity` restores the app background. A styles file without the | ||
| * splash theme (an upstream that stopped emitting it, or a different prebuild | ||
| * order) is returned unchanged so the plugin stays inert, matching the | ||
| * rotation-surface plugin. | ||
| */ | ||
| function applySplashWindowBackground(styles) { | ||
| const themes = styles?.resources?.style ?? []; | ||
| const splashTheme = themes.find(theme => theme.$?.name === THEME_NAME); | ||
| if (!splashTheme) { | ||
| return styles; | ||
| } | ||
| setItem(splashTheme, WINDOW_BACKGROUND_ITEM, SPLASH_WINDOW_DRAWABLE_REF); | ||
| setItem(splashTheme, POST_SPLASH_THEME_ITEM, `@style/${POST_SPLASH_THEME_NAME}`); | ||
| let postSplashTheme = themes.find(theme => theme.$?.name === POST_SPLASH_THEME_NAME); | ||
| if (!postSplashTheme) { | ||
| postSplashTheme = { $: { name: POST_SPLASH_THEME_NAME, parent: POST_SPLASH_THEME_PARENT } }; | ||
| themes.push(postSplashTheme); | ||
| } | ||
| setItem(postSplashTheme, WINDOW_BACKGROUND_ITEM, SPLASH_WINDOW_DRAWABLE_REF); | ||
| return styles; | ||
| } | ||
|
|
||
| /** | ||
| * The brand launch surface as a layer-list: the splash color with the dark Kilo | ||
| * mark centred on it, so a launch frame that only draws the window surface still | ||
| * shows the branded splash instead of a bare logo-less color. | ||
| */ | ||
| function splashWindowDrawable() { | ||
| return `<?xml version="1.0" encoding="utf-8"?> | ||
| <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| <item android:drawable="${SPLASH_BACKGROUND_COLOR}" /> | ||
| <item android:drawable="${SPLASH_LOGO_DRAWABLE_REF}" android:gravity="center" /> | ||
| </layer-list> | ||
| `; | ||
| } | ||
|
|
||
| /** | ||
| * Hands the post-splash window surface back to the app background when the app's | ||
| * React content appears. Idempotent; a file without the `onCreate` super call — | ||
| * and a Java activity, where the Kotlin object member write would not compile — | ||
| * is returned unchanged, so the plugin stays inert instead of breaking a | ||
| * prebuild. | ||
| */ | ||
| function injectMainActivityLaunchSurface(contents, language) { | ||
| if ( | ||
| language !== 'kt' || | ||
| contents.includes(ON_CREATE_INJECTION) || | ||
| !contents.includes(ON_CREATE_SUPER_CALL) | ||
| ) { | ||
| return contents; | ||
| } | ||
| return contents.replace(ON_CREATE_SUPER_CALL, `${ON_CREATE_SUPER_CALL}\n${ON_CREATE_INJECTION}`); | ||
| } | ||
|
|
||
| module.exports = { | ||
| THEME_NAME, | ||
| WINDOW_BACKGROUND_ITEM, | ||
| POST_SPLASH_THEME_ITEM, | ||
| POST_SPLASH_THEME_NAME, | ||
| SPLASH_BACKGROUND_COLOR, | ||
| SPLASH_WINDOW_DRAWABLE_NAME, | ||
| SPLASH_WINDOW_DRAWABLE_REF, | ||
| SPLASH_LOGO_DRAWABLE_REF, | ||
| APP_BACKGROUND_COLOR_REF, | ||
| ON_CREATE_SUPER_CALL, | ||
| LAUNCH_SURFACE_HAND_BACK_DELAY_MS, | ||
| ON_CREATE_INJECTION, | ||
| applySplashWindowBackground, | ||
| splashWindowDrawable, | ||
| injectMainActivityLaunchSurface, | ||
| }; | ||
190 changes: 190 additions & 0 deletions
190
apps/mobile/plugins/android-splash-window-background.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { createRequire } from 'node:module'; | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| type StyleItem = { $: { name: string }; _?: string }; | ||
| type StyleGroup = { $: { name: string; parent?: string }; item?: StyleItem[] }; | ||
| type StylesXml = { resources: { style: StyleGroup[] } }; | ||
|
|
||
| const require = createRequire(import.meta.url); | ||
| const { | ||
| LAUNCH_SURFACE_HAND_BACK_DELAY_MS, | ||
| ON_CREATE_INJECTION, | ||
| ON_CREATE_SUPER_CALL, | ||
| POST_SPLASH_THEME_ITEM, | ||
| POST_SPLASH_THEME_NAME, | ||
| SPLASH_BACKGROUND_COLOR, | ||
| SPLASH_LOGO_DRAWABLE_REF, | ||
| SPLASH_WINDOW_DRAWABLE_REF, | ||
| THEME_NAME, | ||
| WINDOW_BACKGROUND_ITEM, | ||
| applySplashWindowBackground, | ||
| injectMainActivityLaunchSurface, | ||
| splashWindowDrawable, | ||
| } = require('./android-splash-window-background.js') as { | ||
| LAUNCH_SURFACE_HAND_BACK_DELAY_MS: number; | ||
| ON_CREATE_INJECTION: string; | ||
| ON_CREATE_SUPER_CALL: string; | ||
| POST_SPLASH_THEME_ITEM: string; | ||
| POST_SPLASH_THEME_NAME: string; | ||
| SPLASH_BACKGROUND_COLOR: string; | ||
| SPLASH_LOGO_DRAWABLE_REF: string; | ||
| SPLASH_WINDOW_DRAWABLE_REF: string; | ||
| THEME_NAME: string; | ||
| WINDOW_BACKGROUND_ITEM: string; | ||
| applySplashWindowBackground: (styles: StylesXml) => StylesXml; | ||
| injectMainActivityLaunchSurface: (contents: string, language: string) => string; | ||
| splashWindowDrawable: () => string; | ||
| }; | ||
|
|
||
| // The shape expo-splash-screen's style mod writes: the launch theme carries the | ||
| // splash attributes only, and AppTheme owns the post-splash window surface. | ||
| function splashStyles(): StylesXml { | ||
| return { | ||
| resources: { | ||
| style: [ | ||
| { | ||
| $: { name: 'AppTheme', parent: 'Theme.AppCompat.DayNight.NoActionBar' }, | ||
| item: [{ $: { name: WINDOW_BACKGROUND_ITEM }, _: '@color/app_background' }], | ||
| }, | ||
| { | ||
| $: { name: THEME_NAME, parent: 'Theme.SplashScreen' }, | ||
| item: [ | ||
| { $: { name: 'windowSplashScreenBackground' }, _: SPLASH_BACKGROUND_COLOR }, | ||
| { $: { name: 'windowSplashScreenAnimatedIcon' }, _: '@drawable/splashscreen_logo' }, | ||
| { $: { name: POST_SPLASH_THEME_ITEM }, _: '@style/AppTheme' }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| // The onCreate expo-splash-screen's mod writes into the generated MainActivity. | ||
| const MAIN_ACTIVITY = `package com.kilocode.kiloapp | ||
| import expo.modules.splashscreen.SplashScreenManager | ||
|
|
||
| import android.os.Bundle | ||
|
|
||
| import com.facebook.react.ReactActivity | ||
|
|
||
| class MainActivity : ReactActivity() { | ||
| override fun onCreate(savedInstanceState: Bundle?) { | ||
| // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af | ||
| SplashScreenManager.registerOnActivity(this) | ||
| // @generated end expo-splashscreen | ||
| ${ON_CREATE_SUPER_CALL} | ||
| } | ||
| }`; | ||
|
|
||
| function styleNamed(styles: StylesXml, name: string): StyleGroup { | ||
| const theme = styles.resources.style.find(group => group.$?.name === name); | ||
| if (!theme) { | ||
| throw new Error(`missing ${name}`); | ||
| } | ||
| return theme; | ||
| } | ||
|
|
||
| function itemsNamed(theme: StyleGroup, name: string): StyleItem[] { | ||
| return (theme.item ?? []).filter(item => item.$?.name === name); | ||
| } | ||
|
|
||
| describe('applySplashWindowBackground', () => { | ||
| it('pins the launch theme window background to the brand launch surface', () => { | ||
| const styles = applySplashWindowBackground(splashStyles()); | ||
|
|
||
| const pinned = itemsNamed(styleNamed(styles, THEME_NAME), WINDOW_BACKGROUND_ITEM); | ||
| expect(pinned).toHaveLength(1); | ||
| expect(pinned[0]?._).toBe(SPLASH_WINDOW_DRAWABLE_REF); | ||
| }); | ||
|
|
||
| it('keeps the brand surface after the splash exits', () => { | ||
| const styles = applySplashWindowBackground(splashStyles()); | ||
|
|
||
| const postSplash = itemsNamed(styleNamed(styles, THEME_NAME), POST_SPLASH_THEME_ITEM); | ||
| expect(postSplash[0]?._).toBe(`@style/${POST_SPLASH_THEME_NAME}`); | ||
| const launchTheme = styleNamed(styles, POST_SPLASH_THEME_NAME); | ||
| expect(launchTheme.$?.parent).toBe('@style/AppTheme'); | ||
| expect(itemsNamed(launchTheme, WINDOW_BACKGROUND_ITEM)[0]?._).toBe(SPLASH_WINDOW_DRAWABLE_REF); | ||
| }); | ||
|
|
||
| it('leaves the post-splash AppTheme window background on the app background', () => { | ||
| const styles = applySplashWindowBackground(splashStyles()); | ||
|
|
||
| const appTheme = styleNamed(styles, 'AppTheme'); | ||
| expect(itemsNamed(appTheme, WINDOW_BACKGROUND_ITEM)[0]?._).toBe('@color/app_background'); | ||
| }); | ||
|
|
||
| it('is idempotent: re-applying does not duplicate style items', () => { | ||
| const styles = applySplashWindowBackground(applySplashWindowBackground(splashStyles())); | ||
|
|
||
| expect(itemsNamed(styleNamed(styles, THEME_NAME), WINDOW_BACKGROUND_ITEM)).toHaveLength(1); | ||
| expect( | ||
| styles.resources.style.filter(group => group.$?.name === POST_SPLASH_THEME_NAME) | ||
| ).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('stays inert when the launch theme is absent', () => { | ||
| const styles: StylesXml = { resources: { style: [] } }; | ||
|
|
||
| expect(applySplashWindowBackground(styles)).toEqual(styles); | ||
| }); | ||
| }); | ||
|
|
||
| describe('splashWindowDrawable', () => { | ||
| it('draws the splash color with the dark Kilo mark centred on it', () => { | ||
| const drawable = splashWindowDrawable(); | ||
|
|
||
| expect(drawable).toContain(`android:drawable="${SPLASH_BACKGROUND_COLOR}"`); | ||
| expect(drawable).toContain(`android:drawable="${SPLASH_LOGO_DRAWABLE_REF}"`); | ||
| expect(drawable).toContain('android:gravity="center"'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('injectMainActivityLaunchSurface', () => { | ||
| it('hands the window surface back to the app background after the app surfaces paint', () => { | ||
| const contents = injectMainActivityLaunchSurface(MAIN_ACTIVITY, 'kt'); | ||
|
|
||
| expect(contents).toContain(`${ON_CREATE_SUPER_CALL}\n${ON_CREATE_INJECTION}`); | ||
| expect(contents).toContain('ReactMarkerConstants.CONTENT_APPEARED'); | ||
| expect(contents).toContain('R.color.app_background'); | ||
| // The hand-back is delayed past React's marker: the window background shows | ||
| // through the app tree for a beat after `CONTENT_APPEARED`, and restoring it | ||
| // there is exactly the bare frame this plugin exists to remove. | ||
| expect(contents).toContain('window.decorView.postDelayed({'); | ||
| expect(contents).toContain(`${LAUNCH_SURFACE_HAND_BACK_DELAY_MS}L`); | ||
| // The window must never be held: the system dismisses its splash ~1 s in. | ||
| expect(contents).not.toContain('addOnPreDrawListener'); | ||
| }); | ||
|
|
||
| it('is idempotent: re-applying does not duplicate the injection', () => { | ||
| const once = injectMainActivityLaunchSurface(MAIN_ACTIVITY, 'kt'); | ||
| const twice = injectMainActivityLaunchSurface(once, 'kt'); | ||
|
|
||
| expect(twice).toBe(once); | ||
| }); | ||
|
|
||
| it('drops the marker listener when the activity is destroyed', () => { | ||
| const contents = injectMainActivityLaunchSurface(MAIN_ACTIVITY, 'kt'); | ||
|
|
||
| // ReactMarker is a process-global registry: a launch that never reaches | ||
| // CONTENT_APPEARED — a bundle load failure, a crash before React mounts, or | ||
| // the activity being destroyed while loading — must not keep the activity | ||
| // and its whole view hierarchy alive, so removal is tied to the activity | ||
| // lifecycle and not only to the marker. | ||
| expect(contents).toContain('lifecycle.addObserver('); | ||
| expect(contents).toContain('override fun onDestroy('); | ||
| expect(contents).toContain('ReactMarker.removeListener(splashMarkerListener)'); | ||
| expect(contents.match(/ReactMarker\.addListener\(/g)).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('stays inert on a Java activity, where the Kotlin write would not compile', () => { | ||
| expect(injectMainActivityLaunchSurface(MAIN_ACTIVITY, 'java')).toBe(MAIN_ACTIVITY); | ||
| }); | ||
|
|
||
| it('stays inert when the onCreate super call is absent', () => { | ||
| const contents = 'class MainActivity : ReactActivity()'; | ||
|
|
||
| expect(injectMainActivityLaunchSurface(contents, 'kt')).toBe(contents); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.