Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
188 changes: 188 additions & 0 deletions apps/mobile/plugins/android-splash-window-background.js
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)',
Comment thread
iscekic marked this conversation as resolved.
'lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {',
' override fun onDestroy(owner: androidx.lifecycle.LifecycleOwner) {',
' com.facebook.react.bridge.ReactMarker.removeListener(splashMarkerListener)',
Comment thread
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 apps/mobile/plugins/android-splash-window-background.test.ts
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);
});
});
Loading
Loading