feat(splash): hold Android system splash on screen until web mascot paints via custom LaunchSplash plugin

This commit is contained in:
heaven 2026-05-04 18:31:10 +03:00
parent 817dad383c
commit b2f3b668c5
22 changed files with 326 additions and 25 deletions

View file

@ -0,0 +1,16 @@
package chat.vojo.app;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "LaunchSplash")
public class LaunchSplashPlugin extends Plugin {
@PluginMethod
public void ready(PluginCall call) {
MainActivity.releaseLaunchSplash();
call.resolve();
}
}

View file

@ -4,12 +4,26 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.activity.EdgeToEdge;
import androidx.core.splashscreen.SplashScreen;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
public static volatile boolean isInForeground = false;
private static volatile boolean launchSplashReady = false;
// Safety net for setKeepOnScreenCondition: if JS never calls
// launchSplash.ready() (boot crash, exception during config load before
// AuthMascot mounts, network hang in useClientConfig, deep-link straight
// into AuthLayout where the centered AuthMascot variant doesn't render,
// ) the splash would otherwise hang indefinitely and the user can't
// interact with anything. 5s covers normal cold boots on mid-range
// Android (config + bundle parse + first paint typically lands inside
// 1-2s) with comfortable headroom; past it we drop the splash and let
// whatever the web side has rendered take over including blank
// AuthLayout, which is at least recoverable.
private static final long SPLASH_SAFETY_TIMEOUT_MS = 5000L;
// Short debounce on the onPauserenderRegistry edge so an in-flight JS
// removeIncomingRing bridge call (e.g. user accepted/declined, then
@ -33,13 +47,33 @@ public class MainActivity extends BridgeActivity {
private final Runnable cancelRunnable = () ->
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
public static void releaseLaunchSplash() {
launchSplashReady = true;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
if (savedInstanceState == null) {
launchSplashReady = false;
}
// Custom plugins must be registered before super.onCreate so BridgeActivity
// can wire them into the WebView bridge on load. Registering after
// super.onCreate would make the plugin invisible to JS until the next relaunch.
registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class);
registerPlugin(LaunchSplashPlugin.class);
// AndroidX SplashScreen must be installed before super.onCreate().
// Keep it until the web splash confirms its first visible frame is
// ready, OR the safety timeout elapses (see SPLASH_SAFETY_TIMEOUT_MS).
final long splashStartMs = System.currentTimeMillis();
SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
splashScreen.setKeepOnScreenCondition(() -> {
if (launchSplashReady) return false;
return System.currentTimeMillis() - splashStartMs < SPLASH_SAFETY_TIMEOUT_MS;
});
EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
// Force light icons on both system bars: our CSS is permanently dark

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches web safe-area / DM 1:1 chat background (DAWN.bg2) so the
native splash, the WebView body, and the in-app AuthSplashScreen all
share a single backdrop and read as one continuous splash. -->
<color name="splash_bg">#0d0e11</color>
</resources>

View file

@ -2,10 +2,12 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
@ -13,12 +15,39 @@
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<!-- Bridges the gap between native splash exit and the WebView's first
body paint: without this the window paints transparent/black for
~200ms while the bundle hydrates, producing a visible black flash
between the native and the in-app splash. Matches splash_bg so
cold start reads as one continuous backdrop. -->
<item name="android:windowBackground">@color/splash_bg</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Launch theme: Android 12+ system splash (Theme.SplashScreen via
androidx.core.splashscreen). Renders the mascot centered on the same
#0d0e11 backdrop the web AuthSplashScreen uses, so cold start reads
as one continuous splash (native → WebView mount → web splash) instead
of three visual jumps. MainActivity installs AndroidX SplashScreen
before super.onCreate() and keeps it visible until Capacitor's local
WebView has loaded the app shell. -->
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<!-- Theme.SplashScreen only sets the native android:windowActionBar /
android:windowNoTitle attrs. Capacitor's BridgeActivity extends
AppCompatActivity, whose ActionBar delegate reads the un-prefixed
AppCompat attrs — without these two overrides, AppCompat keeps
its ActionBar enabled, paints the activity label ("Vojo" from
strings.xml/title_activity_main) at the top of the WebView, and
persists past the splash exit. -->
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/black</item>
<item name="windowSplashScreenBackground">@color/splash_bg</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/vojo_mascot_splash</item>
<!-- Intentionally NO windowSplashScreenIconBackgroundColor: setting it
switches the system to the "with-background" canvas, which is
actually 240dp (vs 288dp without) — the colored ring would just
shrink the visible icon zone. Background already matches via
windowSplashScreenBackground above. -->
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

View file

@ -17,6 +17,13 @@ const config: CapacitorConfig = {
android: {
// Keep default: resolveServiceWorkerRequests = true
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
//
// WebView bg color before first body paint. Without this the WebView
// paints transparent/black during bundle hydration, which combined with
// the post-splash window theme produced a visible black flash between
// the Android 12+ system splash and the in-app AuthSplashScreen mascot.
// Matches the native splash + AuthLayout + body backgrounds.
backgroundColor: '#0d0e11',
},
plugins: {
PushNotifications: {

View file

@ -1,27 +1,82 @@
import React, { Ref } from 'react';
import React, { Ref, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import mascotPoster from '../../../../public/res/img/mascot.png';
import mascotWebm from '../../../../public/res/img/mascot.webm';
import { launchSplash } from '../../plugins/launchSplash';
import {
attachMascotVideo,
detachMascotVideo,
mascotVideoReady,
} from './mascotSingleton';
import * as css from './styles.css';
type AuthMascotProps = {
mascotRef?: Ref<HTMLDivElement>;
// True for the splash-gate variant (cold start, sync wait): mascot is
// centered both axes to mirror the Android 12+ system splash icon, so the
// native → WebView → web-splash handoff reads as one continuous splash.
// False (default) keeps the auth-page positioning (top-anchored) used by
// login / register / reset-password.
centered?: boolean;
};
export function AuthMascot({ mascotRef }: AuthMascotProps) {
export function AuthMascot({ mascotRef, centered }: AuthMascotProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const didSignalLaunchReadyRef = useRef(false);
// Singleton <video> attach/detach. Preserves currentTime across the
// ConfigConfigLoading → SpecVersionsLoader → ClientRootLoading remounts
// so the mascot loop reads as one continuous animation instead of two
// restarts mid-boot.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
attachMascotVideo(container, css.AuthMascotVideo);
return () => {
detachMascotVideo(container);
};
}, []);
// Native splash hand-off: only the centered (splash-gate) variant signals
// the Android system splash to drop. Auth pages (login/register) don't
// signal — they're already past the cold-boot phase by the time they
// mount. requestAnimationFrame×2 ensures the browser has actually
// committed the paint before we ask the OS to release its splash.
const signalLaunchReady = useCallback(() => {
if (!centered || didSignalLaunchReadyRef.current) return;
didSignalLaunchReadyRef.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
launchSplash.ready();
});
});
}, [centered]);
useEffect(() => {
if (!centered) return undefined;
let cancelled = false;
mascotVideoReady().then(() => {
if (!cancelled) signalLaunchReady();
});
return () => {
cancelled = true;
};
}, [centered, signalLaunchReady]);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof mascotRef === 'function') mascotRef(node);
else if (mascotRef && typeof mascotRef === 'object') {
(mascotRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
},
[mascotRef]
);
return (
<div className={css.AuthMascot} aria-hidden="true" ref={mascotRef}>
<video
className={css.AuthMascotVideo}
autoPlay
loop
muted
playsInline
preload="auto"
poster={mascotPoster}
>
<source src={mascotWebm} type="video/webm" />
</video>
</div>
<div
className={centered ? css.AuthMascotCentered : css.AuthMascot}
aria-hidden="true"
ref={setRefs}
/>
);
}

View file

@ -10,13 +10,18 @@ type AuthSplashScreenProps = {
};
export function AuthSplashScreen({ children }: AuthSplashScreenProps) {
// No children = bare splash gate (cold init, sync wait): center the mascot
// to match the Android 12+ native splash and bridge the native → web
// handoff. With children (errors, missing-IDB, retry dialogs) keep the
// top-anchored mascot so the dialog has the lower half free.
const splash = !children;
return (
<div className={css.AuthLayout} style={authLayoutRootVars}>
<div className={css.AuthStack}>
<AuthMascot />
<div className={splash ? `${css.AuthStack} ${css.AuthStackSplash}` : css.AuthStack}>
<AuthMascot centered={splash} />
{children && <div className={css.AuthSplashContent}>{children}</div>}
</div>
<AuthFooter />
{!splash && <AuthFooter />}
</div>
);
}

View file

@ -0,0 +1,103 @@
import mascotPoster from '../../../../public/res/img/mascot.png';
import mascotWebm from '../../../../public/res/img/mascot.webm';
/*
* Module-level singleton <video> for the auth/splash mascot.
*
* AuthSplashScreen renders in three different React tree positions during
* cold boot (ConfigConfigLoading SpecVersionsLoader fallback
* ClientRootLoading), and React unmount/mount across positions destroys and
* re-creates the underlying <video> element. autoplay then restarts from
* frame 0 for every new instance, which the user perceives as the mascot
* loop "skipping" mid-boot.
*
* Keeping one <video> on the module level and attaching/detaching it across
* AuthMascot mounts preserves currentTime, so the animation reads as one
* continuous loop. We also expose a readiness Promise so AuthMascot can
* still gate the native splash hand-off on the FIRST canplay/loadeddata
* event subsequent attaches resolve instantly because the media is
* already buffered.
*
* KNOWN ARCHITECTURAL CAVEAT: this is a module-level singleton with mutable
* state and imperative DOM manipulation pragmatic but non-idiomatic for
* Concurrent React. The canonical fix is React 19.2's <Activity mode="hidden">
* (formerly <Offscreen>), which preserves video currentTime by design. When
* we upgrade React to 19.2+, replace this file with an <Activity>-wrapped
* AuthMascot rendered once at App.tsx top-level, controlled by a jotai atom
* that AuthSplashScreen sets/clears on mount. See:
* https://react.dev/reference/react/Activity
* Constraints of the current implementation:
* - Single instance only (rendering two AuthMascot simultaneously breaks).
* - Not SSR-safe (createElement at module load).
* - HMR-fragile (the singleton survives reloads with stale closure refs).
*/
let videoEl: HTMLVideoElement | null = null;
let readyPromise: Promise<void> | null = null;
const ensureVideo = (): HTMLVideoElement => {
if (videoEl) return videoEl;
const v = document.createElement('video');
v.autoplay = true;
v.loop = true;
v.muted = true;
v.playsInline = true;
v.preload = 'auto';
v.poster = mascotPoster;
v.setAttribute('aria-hidden', 'true');
const source = document.createElement('source');
source.src = mascotWebm;
source.type = 'video/webm';
v.appendChild(source);
readyPromise = new Promise<void>((resolve) => {
if (v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
resolve();
return;
}
const onReady = () => {
v.removeEventListener('canplay', onReady);
v.removeEventListener('loadeddata', onReady);
v.removeEventListener('error', onReady);
resolve();
};
v.addEventListener('canplay', onReady, { once: true });
v.addEventListener('loadeddata', onReady, { once: true });
// Resolve on error too: if the codec is unsupported (e.g. legacy WebView
// that doesn't decode webm) we still need to release the native splash so
// the user sees AuthLayout / login. The poster <img> falls back as the
// visible mascot. Without this branch readyPromise hangs and the native
// splash sits on the safety timeout.
v.addEventListener('error', onReady, { once: true });
});
videoEl = v;
return v;
};
export const attachMascotVideo = (container: HTMLElement, className: string): void => {
const v = ensureVideo();
if (v.className !== className) v.className = className;
if (v.parentElement === container) return;
container.appendChild(v);
// detach from previous parent is implicit (DOM moves the node), and
// re-running play() handles the rare case where Chrome paused the media
// while it was orphan-detached between commits.
v.play().catch(() => undefined);
};
export const detachMascotVideo = (container: HTMLElement): void => {
if (!videoEl) return;
if (videoEl.parentElement === container) {
container.removeChild(videoEl);
}
};
// First frame ready (poster decoded OR video buffered to canplay).
// Subsequent calls return the resolved Promise instantly.
export const mascotVideoReady = (): Promise<void> => {
ensureVideo();
return readyPromise ?? Promise.resolve();
};

View file

@ -14,8 +14,9 @@ export const AuthLayout = style({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background:
'radial-gradient(ellipse 80% 60% at 50% 25%, rgba(45, 47, 50, 0.4) 0%, rgba(45, 47, 50, 0) 70%), radial-gradient(ellipse at center, #161718 0%, #121314 45%, #0c0d0e 100%)',
// Flat DAWN.bg2 — same backdrop as the Android native splash and the DM 1:1
// chat surface so cold start / auth read as one continuous canvas.
background: '#0d0e11',
color: '#e8e4df',
'@media': {
@ -52,6 +53,10 @@ export const AuthStack = style({
boxSizing: 'border-box',
});
export const AuthStackSplash = style({
padding: 0,
});
/* ── Mascot ── */
export const AuthMascot = style({
position: 'absolute',
@ -64,6 +69,24 @@ export const AuthMascot = style({
transform: 'translateX(-50%)',
});
/*
* Splash-gate variant: mascot fills the viewport-sized stack and centers both axes.
* Mirrors the Android 12+ system splash (centered icon on flat background)
* so the native splash WebView mount web AuthSplashScreen handoff reads
* as one continuous splash that "matures" into a richer render, instead of
* the previous three-jump sequence (centered launcher icon on black
* blank dark body top-anchored mascot on dark gradient).
*/
export const AuthMascotCentered = style({
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 0,
pointerEvents: 'none',
});
export const AuthMascotHidden = style({
display: 'none',
});

View file

@ -0,0 +1,22 @@
import { registerPlugin } from '@capacitor/core';
import { isAndroidPlatform } from '../utils/capacitor';
interface LaunchSplashPlugin {
ready(): Promise<void>;
}
const plugin = registerPlugin<LaunchSplashPlugin>('LaunchSplash', {
web: {
ready: async () => undefined,
},
});
let didRelease = false;
export const launchSplash = {
ready(): Promise<void> {
if (didRelease || !isAndroidPlatform()) return Promise.resolve();
didRelease = true;
return plugin.ready().catch(() => undefined);
},
};