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'),
+ ''
+ );
+ writeFileSync(
+ path.join(java, 'MainActivity.kt'),
+ `package com.kilocode.kiloapp
+import android.os.Bundle
+class MainActivity : ReactActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(null)
+ }
+}`
+ );
+ return {
+ root,
+ values,
+ drawable: path.join(main, 'res/drawable/splashscreen_window_background.xml'),
+ };
+}
+
+describe('shared branded splash', () => {
+ it('does not create a drawable when the splash theme and resources are absent', async () => {
+ const { root, values, drawable } = createAndroidProject();
+ const config = withBrandedSplash(
+ { name: 'Kilo', slug: 'kilo-app', _internal: { projectRoot } },
+ undefined
+ );
+
+ await compileModsAsync(config, { projectRoot: root, platforms: ['android'] });
+
+ expect(readFileSync(path.join(values, 'styles.xml'), 'utf8')).not.toContain('Theme.App.Launch');
+ expect(existsSync(drawable)).toBe(false);
+ expect(existsSync(path.dirname(drawable))).toBe(false);
+ });
+
+ it('writes the backing drawable after Expo generates the splash theme on a clean prebuild', async () => {
+ const { root, values, drawable } = createAndroidProject();
+ const compile = () =>
+ compileModsAsync(
+ withBrandedSplash(
+ { name: 'Kilo', slug: 'kilo-app', _internal: { projectRoot } },
+ {
+ image: path.join(projectRoot, 'assets/images/logo-mark.png'),
+ backgroundColor: '#FAF74F',
+ imageWidth: 100,
+ }
+ ),
+ { projectRoot: root, platforms: ['android'] }
+ );
+
+ await compile();
+
+ const contents = readFileSync(drawable, 'utf8');
+ expect(contents).toContain('android:drawable="@color/splashscreen_background"');
+ expect(contents).toContain('android:drawable="@drawable/splashscreen_logo"');
+ const styles = readFileSync(path.join(values, 'styles.xml'), 'utf8');
+ expect(styles).toContain('@drawable/splashscreen_window_background');
+ expect(styles).toContain('@style/Theme.App.Launch');
+ expect(readFileSync(path.join(values, 'colors.xml'), 'utf8')).toContain('#FAF74F');
+ expect(existsSync(path.join(values, '../drawable-mdpi/splashscreen_logo.png'))).toBe(true);
+
+ await compile();
+
+ expect(readFileSync(drawable, 'utf8')).toBe(contents);
+ const repeatedStyles = readFileSync(path.join(values, 'styles.xml'), 'utf8');
+ expect(repeatedStyles.match(/