feat(splash): hold Android system splash on screen until web mascot paints via custom LaunchSplash plugin
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 onPause→renderRegistry 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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable/vojo_mascot_splash.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
7
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
103
src/app/pages/auth/mascotSingleton.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
22
src/app/plugins/launchSplash.ts
Normal 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);
|
||||
},
|
||||
};
|
||||