diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9f77115759..94ee7bd191 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -309,8 +309,11 @@ const config: ExpoConfig = { options: SENTRY_NATIVE_OPTIONS, }, ], + // One native splash configuration and shared AnimatedSplashOverlay lifecycle + // for iOS and Android. The wrapper documents the native backing-surface + // capability exception and owns its mod ordering with expo-splash-screen. [ - 'expo-splash-screen', + './plugins/withBrandedSplash', { image: './assets/images/logo-mark.png', backgroundColor: '#FAF74F', diff --git a/apps/mobile/plugins/android-splash-window-background.js b/apps/mobile/plugins/android-splash-window-background.js new file mode 100644 index 0000000000..b8d89c8d81 --- /dev/null +++ b/apps/mobile/plugins/android-splash-window-background.js @@ -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)', + ' }', + '})', +] + .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 ` + + + + +`; +} + +/** + * 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, +}; diff --git a/apps/mobile/plugins/android-splash-window-background.test.ts b/apps/mobile/plugins/android-splash-window-background.test.ts new file mode 100644 index 0000000000..e5a18be5e5 --- /dev/null +++ b/apps/mobile/plugins/android-splash-window-background.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/plugins/branded-splash.test.ts b/apps/mobile/plugins/branded-splash.test.ts new file mode 100644 index 0000000000..d3f381d452 --- /dev/null +++ b/apps/mobile/plugins/branded-splash.test.ts @@ -0,0 +1,187 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { compileModsAsync, type ConfigPlugin, type ExportedConfig } from 'expo/config-plugins'; +import { afterEach, describe, expect, it } from 'vitest'; + +const require = createRequire(import.meta.url); +const projectRoot = fileURLToPath(new URL('..', import.meta.url)); +const withBrandedSplash = require('./withBrandedSplash.js') as ConfigPlugin< + { image: string; backgroundColor: string; imageWidth: number } | undefined +>; + +const temporaryProjects: string[] = []; + +afterEach(() => { + for (const root of temporaryProjects.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function createAndroidProject() { + const root = mkdtempSync(path.join(tmpdir(), 'branded-splash-')); + temporaryProjects.push(root); + const main = path.join(root, 'android/app/src/main'); + const values = path.join(main, 'res/values'); + const java = path.join(main, 'java/com/kilocode/kiloapp'); + mkdirSync(values, { recursive: true }); + mkdirSync(java, { recursive: true }); + writeFileSync( + path.join(values, 'styles.xml'), + '