fix: Bug - Android app view is below titlebar and action bar on android
Respect Android system bar insets with safe-area shell padding, inset-aware modal and bottom-sheet layouts, transparent edge-to-edge themes, and a Capacitor SystemBars refresh on mobile startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -201,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
|
|||||||
|
|
||||||
## Safe area (Android)
|
## Safe area (Android)
|
||||||
|
|
||||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads.
|
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
|
||||||
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
||||||
- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode.
|
- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
|
||||||
|
- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
|
||||||
|
|
||||||
## Self-hosted HTTPS signal servers (Android)
|
## Self-hosted HTTPS signal servers (Android)
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,31 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="android:background">@drawable/splash</item>
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<div
|
<div
|
||||||
appThemeNode="appRoot"
|
appThemeNode="appRoot"
|
||||||
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
||||||
|
[class.metoyou-safe-area-shell]="isMobile()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
|
class="fixed metoyou-fixed-safe-viewport z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
|
||||||
[class.opacity-100]="animating()"
|
[class.opacity-100]="animating()"
|
||||||
[class.opacity-0]="!animating()"
|
[class.opacity-0]="!animating()"
|
||||||
(click)="onBackdropClick()"
|
(click)="onBackdropClick()"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
||||||
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
<div class="fixed metoyou-fixed-safe-viewport z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
||||||
<div
|
<div
|
||||||
appThemeNode="settingsModalSurface"
|
appThemeNode="settingsModalSurface"
|
||||||
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
|
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
import {
|
import {
|
||||||
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
it
|
it,
|
||||||
|
vi
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules';
|
import {
|
||||||
|
applyMobileSafeAreaDefaults,
|
||||||
|
getSafeAreaInsetCSSValue,
|
||||||
|
MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS,
|
||||||
|
MOBILE_FIXED_SAFE_VIEWPORT_CLASS,
|
||||||
|
MOBILE_SAFE_AREA_SHELL_CLASS,
|
||||||
|
syncMobileSafeAreaInsets
|
||||||
|
} from './mobile-safe-area.rules';
|
||||||
|
|
||||||
|
const systemBars = vi.hoisted(() => ({
|
||||||
|
setStyle: vi.fn(() => Promise.resolve())
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@capacitor/core', () => ({
|
||||||
|
SystemBars: systemBars,
|
||||||
|
SystemBarsStyle: { Dark: 'DARK' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./platform-detection.rules', () => ({
|
||||||
|
isCapacitorNativeRuntime: vi.fn(() => false)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||||
|
|
||||||
describe('mobile-safe-area.rules', () => {
|
describe('mobile-safe-area.rules', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(false);
|
||||||
|
systemBars.setStyle.mockClear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it('builds CSS values with Capacitor and env fallbacks', () => {
|
it('builds CSS values with Capacitor and env fallbacks', () => {
|
||||||
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
|
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
|
||||||
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
|
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exports the global safe-area layout classes used by the mobile shell and overlays', () => {
|
||||||
|
expect(MOBILE_FIXED_SAFE_VIEWPORT_CLASS).toBe('metoyou-fixed-safe-viewport');
|
||||||
|
expect(MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS).toBe('metoyou-fixed-safe-bottom-sheet');
|
||||||
|
expect(MOBILE_SAFE_AREA_SHELL_CLASS).toBe('metoyou-safe-area-shell');
|
||||||
|
});
|
||||||
|
|
||||||
it('sets default safe-area variables on the document root', () => {
|
it('sets default safe-area variables on the document root', () => {
|
||||||
const properties = new Map<string, string>();
|
const properties = new Map<string, string>();
|
||||||
const root = {
|
const root = {
|
||||||
@@ -34,4 +70,18 @@ describe('mobile-safe-area.rules', () => {
|
|||||||
it('ignores null roots instead of throwing', () => {
|
it('ignores null roots instead of throwing', () => {
|
||||||
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
|
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asks Capacitor SystemBars to refresh CSS insets on native shells', async () => {
|
||||||
|
const onDOMReady = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(true);
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
CapacitorSystemBarsAndroidInterface: { onDOMReady }
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncMobileSafeAreaInsets();
|
||||||
|
|
||||||
|
expect(onDOMReady).toHaveBeenCalledTimes(1);
|
||||||
|
expect(systemBars.setStyle).toHaveBeenCalledWith({ style: 'DARK' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||||
|
|
||||||
const SAFE_AREA_SIDES = [
|
const SAFE_AREA_SIDES = [
|
||||||
'top',
|
'top',
|
||||||
'right',
|
'right',
|
||||||
@@ -7,6 +9,15 @@ const SAFE_AREA_SIDES = [
|
|||||||
|
|
||||||
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
|
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
|
||||||
|
|
||||||
|
/** Global class that insets fixed full-viewport overlays below the status bar and above the nav bar. */
|
||||||
|
export const MOBILE_FIXED_SAFE_VIEWPORT_CLASS = 'metoyou-fixed-safe-viewport';
|
||||||
|
|
||||||
|
/** Global class for bottom sheets that anchor above the Android navigation bar. */
|
||||||
|
export const MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS = 'metoyou-fixed-safe-bottom-sheet';
|
||||||
|
|
||||||
|
/** Global class applied to the mobile app shell so routed content respects system insets. */
|
||||||
|
export const MOBILE_SAFE_AREA_SHELL_CLASS = 'metoyou-safe-area-shell';
|
||||||
|
|
||||||
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
|
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
|
||||||
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
|
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
|
||||||
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
|
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
|
||||||
@@ -28,3 +39,20 @@ export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request Capacitor Android SystemBars to recompute CSS inset variables after the SPA boots. */
|
||||||
|
export async function syncMobileSafeAreaInsets(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidInterface = (window as Window & {
|
||||||
|
CapacitorSystemBarsAndroidInterface?: { onDOMReady?: () => void };
|
||||||
|
}).CapacitorSystemBarsAndroidInterface;
|
||||||
|
|
||||||
|
androidInterface?.onDOMReady?.();
|
||||||
|
|
||||||
|
const { SystemBars, SystemBarsStyle } = await import('@capacitor/core');
|
||||||
|
|
||||||
|
await SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
|
import { syncMobileSafeAreaInsets } from '../logic/mobile-safe-area.rules';
|
||||||
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
||||||
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
@@ -24,6 +25,7 @@ export class MobileAppLifecycleService {
|
|||||||
|
|
||||||
await adapter.initialize();
|
await adapter.initialize();
|
||||||
this.mobilePlatform.refreshRuntimeDetection();
|
this.mobilePlatform.refreshRuntimeDetection();
|
||||||
|
await syncMobileSafeAreaInsets();
|
||||||
await this.runtimePermissions.initialize();
|
await this.runtimePermissions.initialize();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
appThemeNode="bottomSheetSurface"
|
appThemeNode="bottomSheetSurface"
|
||||||
class="bottom-sheet-panel fixed inset-x-0 bottom-0 z-[141] flex max-h-[85vh] flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
|
class="bottom-sheet-panel fixed metoyou-fixed-safe-bottom-sheet z-[141] flex flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
|
||||||
[style.transform]="'translateY(' + translateY() + 'px)'"
|
[style.transform]="'translateY(' + translateY() + 'px)'"
|
||||||
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
|
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
|
||||||
[attr.aria-label]="title() || ariaLabel()"
|
[attr.aria-label]="title() || ariaLabel()"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@if (dismissable()) {
|
@if (dismissable()) {
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60"
|
class="fixed metoyou-fixed-safe-viewport bg-black/60"
|
||||||
[class.backdrop-blur-sm]="blur()"
|
[class.backdrop-blur-sm]="blur()"
|
||||||
[style.z-index]="zIndex()"
|
[style.z-index]="zIndex()"
|
||||||
(click)="dismiss()"
|
(click)="dismiss()"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
></div>
|
></div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60"
|
class="fixed metoyou-fixed-safe-viewport bg-black/60"
|
||||||
[class.backdrop-blur-sm]="blur()"
|
[class.backdrop-blur-sm]="blur()"
|
||||||
[style.z-index]="zIndex()"
|
[style.z-index]="zIndex()"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|||||||
@@ -37,7 +37,10 @@
|
|||||||
.metoyou-bottom-sheet-panel {
|
.metoyou-bottom-sheet-panel {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
max-height: 85vh;
|
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))) !important;
|
||||||
|
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) !important;
|
||||||
|
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) !important;
|
||||||
border-top-left-radius: 1rem;
|
border-top-left-radius: 1rem;
|
||||||
border-top-right-radius: 1rem;
|
border-top-right-radius: 1rem;
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
@@ -126,12 +129,32 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metoyou-safe-area-shell {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metoyou-fixed-safe-viewport {
|
||||||
|
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
|
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metoyou-fixed-safe-bottom-sheet {
|
||||||
|
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Keep border-box on every element (Tailwind preflight does this too). Do not use
|
* Keep border-box on every element (Tailwind preflight does this too). Do not use
|
||||||
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
|
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
|
||||||
|
|||||||
Reference in New Issue
Block a user