chore: Fix app

This commit is contained in:
2026-07-14 00:41:05 +02:00
parent 3e090933fd
commit edc4d935d8
98 changed files with 2878 additions and 155 deletions
@@ -1,11 +1,13 @@
import translationsEn from '../../../../../../public/i18n/en.json';
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
import type { MessageNotificationPayload } from '../../logic/message-notification.rules';
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from './capacitor-plugin-loader';
const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call';
const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call';
const MESSAGE_CHANNEL_ID = 'toju-messages';
function mobileLabel(key: string): string {
const value = key.split('.').reduce<unknown>((current, part) => {
@@ -47,6 +49,13 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
visibility: 1
});
await LocalNotifications.createChannel({
id: MESSAGE_CHANNEL_ID,
name: mobileLabel('mobile.notifications.messagesChannel'),
importance: 4,
visibility: 1
});
await LocalNotifications.registerActionTypes({
types: [
{
@@ -136,6 +145,37 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
});
}
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;
}
const granted = await this.requestPermission();
if (!granted) {
return;
}
await LocalNotifications.schedule({
notifications: [
{
id: payload.id,
title: payload.title,
body: payload.body,
channelId: MESSAGE_CHANNEL_ID,
autoCancel: true,
group: payload.tag,
extra: {
kind: 'message',
tag: payload.tag
}
}
]
});
}
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
@@ -1,4 +1,5 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import type { MessageNotificationPayload } from '../../logic/message-notification.rules';
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
@@ -50,6 +51,21 @@ export class WebMobileNotificationsAdapter implements MobileNotificationAdapter
};
}
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
const granted = await this.requestPermission();
if (!granted) {
return;
}
const notification = new Notification(payload.title, {
body: payload.body,
tag: payload.tag
});
notification.onclick = () => window.focus();
}
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
return;
}
@@ -1,10 +1,12 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
import type { MessageNotificationPayload } from '../logic/message-notification.rules';
import type { RuntimePlatform } from '../logic/platform-detection.rules';
export interface MobileNotificationAdapter {
initialize(): Promise<void>;
requestPermission(): Promise<boolean>;
showCallNotification(payload: CallNotificationPayload): Promise<void>;
showMessageNotification(payload: MessageNotificationPayload): Promise<void>;
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
}
@@ -62,6 +62,16 @@ describe('ensure-mobile-capture-permissions', () => {
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
});
it('defers to WebView capture when the native prompt was dismissed', async () => {
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'prompt' })),
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'prompt' }))
};
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
});
it('blocks capture when the native shell explicitly denies microphone access', async () => {
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { MESSAGE_NOTIFICATION_BASE_ID, buildMessageNotification } from './message-notification.rules';
describe('buildMessageNotification', () => {
it('builds a payload with title, body and a collapse tag', () => {
const payload = buildMessageNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
expect(payload.title).toBe('general - Toju HQ');
expect(payload.body).toBe('Alice: hello');
expect(payload.tag).toBe('toju-message-general - Toju HQ');
});
it('derives a stable numeric id from the tag so newer messages replace the same notification', () => {
const first = buildMessageNotification({ title: 'general - Toju HQ', body: 'first' });
const second = buildMessageNotification({ title: 'general - Toju HQ', body: 'second' });
expect(first.id).toBe(second.id);
expect(Number.isInteger(first.id)).toBe(true);
});
it('uses distinct ids for distinct tags', () => {
const general = buildMessageNotification({ title: 'general - Toju HQ', body: 'x' });
const random = buildMessageNotification({ title: 'random - Toju HQ', body: 'x' });
expect(general.id).not.toBe(random.id);
});
it('keeps ids outside the call-notification id ranges', () => {
const payload = buildMessageNotification({ title: 't', body: 'b' });
expect(payload.id).toBeGreaterThanOrEqual(MESSAGE_NOTIFICATION_BASE_ID);
});
it('honors an explicit tag override', () => {
const payload = buildMessageNotification({ title: 't', body: 'b', tag: 'room-42' });
expect(payload.tag).toBe('toju-message-room-42');
});
});
@@ -0,0 +1,33 @@
export interface MessageNotificationPayload {
id: number;
title: string;
body: string;
tag: string;
}
/** Base id above the incoming (1000+) and active (2000+) call notification ranges. */
export const MESSAGE_NOTIFICATION_BASE_ID = 3000;
const MESSAGE_NOTIFICATION_ID_SPAN = 9973;
/** Build a local notification payload for an incoming chat message; the tag collapses per-channel. */
export function buildMessageNotification(input: { title: string; body: string; tag?: string }): MessageNotificationPayload {
const tag = `toju-message-${input.tag ?? input.title}`;
return {
id: MESSAGE_NOTIFICATION_BASE_ID + hashTag(tag),
title: input.title,
body: input.body,
tag
};
}
function hashTag(tag: string): number {
let hash = 0;
for (let index = 0; index < tag.length; index += 1) {
hash = (hash * 31 + tag.charCodeAt(index)) % MESSAGE_NOTIFICATION_ID_SPAN;
}
return hash;
}
@@ -15,8 +15,10 @@ import {
findMissingLauncherResources,
findStockCapacitorResources,
isBrandLauncherBackgroundColor,
NOTIFICATION_STATUS_ICON_NAME,
readAdaptiveIconBackgroundColor,
REQUIRED_LAUNCHER_ICON_FILES,
REQUIRED_NOTIFICATION_ICON_FILES,
REQUIRED_SPLASH_FILES,
resolveIconPixelSize,
SPLASH_ICON_RATIO
@@ -32,7 +34,11 @@ function sha256OfResource(resRelativePath: string): string {
}
describe('mobile-android-launcher-icon.rules', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const allRequired = [
...REQUIRED_LAUNCHER_ICON_FILES,
...REQUIRED_SPLASH_FILES,
...REQUIRED_NOTIFICATION_ICON_FILES
];
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
@@ -45,6 +51,42 @@ describe('mobile-android-launcher-icon.rules', () => {
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
});
it('ships a notification status-bar icon for every density', () => {
expect(findMissingLauncherResources(presentFiles, REQUIRED_NOTIFICATION_ICON_FILES)).toEqual([]);
});
it('references the notification status icon from the Capacitor config', () => {
const capacitorConfig = readFileSync(resolve(process.cwd(), 'capacitor.config.ts'), 'utf8');
expect(capacitorConfig).toContain(`smallIcon: '${NOTIFICATION_STATUS_ICON_NAME}'`);
});
it('renders the notification status icon as an alpha-only white glyph', async () => {
const iconPath = resolve(RES_DIR, 'drawable-xxxhdpi/ic_stat_metoyou.png');
const { data, info } = await sharp(iconPath).ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let opaquePixels = 0;
let transparentPixels = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
const alpha = data[offset + 3];
if (alpha > 224) {
opaquePixels += 1;
expect(data[offset]).toBeGreaterThan(224);
expect(data[offset + 1]).toBeGreaterThan(224);
expect(data[offset + 2]).toBeGreaterThan(224);
} else if (alpha < 32) {
transparentPixels += 1;
}
}
expect(opaquePixels).toBeGreaterThan(0);
expect(transparentPixels).toBeGreaterThan(0);
});
it('replaces every stock Capacitor placeholder with the brand asset', () => {
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
@@ -49,6 +49,14 @@ export const REQUIRED_LAUNCHER_ICON_FILES: readonly string[] = ANDROID_ICON_DENS
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
);
/** Resource name (no extension) the Capacitor LocalNotifications config must reference as `smallIcon`. */
export const NOTIFICATION_STATUS_ICON_NAME = 'ic_stat_metoyou';
/** res-relative notification status-bar icon files (alpha-only white glyph, one per density). */
export const REQUIRED_NOTIFICATION_ICON_FILES: readonly string[] = ANDROID_ICON_DENSITIES.map(
(density) => `drawable-${density}/${NOTIFICATION_STATUS_ICON_NAME}.png`
);
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
export const REQUIRED_SPLASH_FILES: readonly string[] = [
'drawable/splash.png',
@@ -24,13 +24,19 @@ describe('mobile-media-permission.rules', () => {
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
});
it('requires microphone permission for voice capture', () => {
it('only blocks voice capture on an explicit native denial', () => {
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
// Dismissed dialogs ('prompt') defer to the WebView getUserMedia permission flow.
expect(isVoiceCaptureAllowed({ microphone: 'prompt' })).toBe(true);
expect(isVoiceCaptureAllowed({ microphone: 'prompt-with-rationale' })).toBe(true);
expect(isVoiceCaptureAllowed({})).toBe(true);
});
it('requires camera permission for camera capture', () => {
it('only blocks camera capture on an explicit native denial', () => {
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(false);
expect(isCameraCaptureAllowed({ camera: 'denied' })).toBe(false);
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(true);
expect(isCameraCaptureAllowed({})).toBe(true);
});
});
@@ -17,12 +17,21 @@ export function shouldPreflightMobileCapturePermissions(runtime: RuntimePlatform
return runtime === 'capacitor';
}
/**
* Only an explicit native denial blocks capture. Any other state (granted, a
* dismissed prompt, or an unknown value) defers to the WebView getUserMedia
* permission flow, which re-prompts through Capacitor's WebChromeClient.
*/
function isCaptureBlockedByNativeDenial(state: MobileMediaPermissionState | undefined): boolean {
return state === 'denied';
}
/** Resolve whether voice capture can proceed after a native permission request. */
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
return isMobileCapturePermissionGranted(result.microphone);
return !isCaptureBlockedByNativeDenial(result.microphone);
}
/** Resolve whether camera capture can proceed after a native permission request. */
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
return isMobileCapturePermissionGranted(result.camera);
return !isCaptureBlockedByNativeDenial(result.camera);
}
@@ -0,0 +1,76 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const pluginState = vi.hoisted(() => ({
plugin: null as null | {
startVoiceForegroundService: () => Promise<void>;
stopVoiceForegroundService: () => Promise<void>;
},
isNative: true
}));
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin))
}));
vi.mock('./platform-detection.rules', () => ({
isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative)
}));
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from './mobile-voice-foreground-session';
describe('mobile-voice-foreground-session', () => {
beforeEach(async () => {
pluginState.isNative = true;
pluginState.plugin = {
startVoiceForegroundService: vi.fn(async () => undefined),
stopVoiceForegroundService: vi.fn(async () => undefined)
};
// Reset internal session flag between tests.
await stopMobileVoiceForegroundSession();
vi.clearAllMocks();
});
it('starts the native foreground service on Capacitor shells', async () => {
await startMobileVoiceForegroundSession();
expect(pluginState.plugin?.startVoiceForegroundService).toHaveBeenCalledTimes(1);
});
it('does nothing off Capacitor shells', async () => {
pluginState.isNative = false;
await startMobileVoiceForegroundSession();
expect(pluginState.plugin?.startVoiceForegroundService).not.toHaveBeenCalled();
});
it('stops the native foreground service after a start', async () => {
await startMobileVoiceForegroundSession();
await stopMobileVoiceForegroundSession();
expect(pluginState.plugin?.stopVoiceForegroundService).toHaveBeenCalledTimes(1);
});
it('swallows native bridge failures', async () => {
pluginState.plugin = {
startVoiceForegroundService: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))),
stopVoiceForegroundService: vi.fn(async () => undefined)
};
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
});
it('handles a missing plugin gracefully', async () => {
pluginState.plugin = null;
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
await expect(stopMobileVoiceForegroundSession()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,49 @@
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { isCapacitorNativeRuntime } from './platform-detection.rules';
let sessionActive = false;
/**
* Keep Android microphone capture alive while any voice session (voice channel
* or direct call) is active by running the native foreground service. Without
* it Android kills WebRTC capture shortly after the app backgrounds.
*/
export async function startMobileVoiceForegroundSession(): Promise<void> {
if (!isCapacitorNativeRuntime() || sessionActive) {
return;
}
const plugin = await loadMetoyouMobilePlugin();
if (!plugin?.startVoiceForegroundService) {
return;
}
try {
await plugin.startVoiceForegroundService();
sessionActive = true;
} catch {
// Native bridge unavailable; capture continues while the app stays foregrounded.
}
}
/** Stop the Android voice foreground service once no voice session remains. */
export async function stopMobileVoiceForegroundSession(): Promise<void> {
if (!isCapacitorNativeRuntime() || !sessionActive) {
return;
}
sessionActive = false;
const plugin = await loadMetoyouMobilePlugin();
if (!plugin?.stopVoiceForegroundService) {
return;
}
try {
await plugin.stopVoiceForegroundService();
} catch {
// Service already gone.
}
}
@@ -0,0 +1,94 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { MobileAppLifecycleService } from './mobile-app-lifecycle.service';
import { MobilePlatformService } from './mobile-platform.service';
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
type VisibilityListener = () => void;
interface DocumentStub {
hidden: boolean;
listeners: VisibilityListener[];
addEventListener: (type: string, listener: VisibilityListener) => void;
}
function installDocumentStub(): DocumentStub {
const stub: DocumentStub = {
hidden: false,
listeners: [],
addEventListener(type: string, listener: VisibilityListener) {
if (type === 'visibilitychange') {
stub.listeners.push(listener);
}
}
};
(globalThis as { document?: unknown }).document = stub;
return stub;
}
function createService() {
const injector = Injector.create({
providers: [
{
provide: MobilePlatformService,
useValue: {
refreshRuntimeDetection: vi.fn(),
runtime: vi.fn(() => 'browser')
}
},
{
provide: MobileRuntimePermissionsService,
useValue: {
initialize: vi.fn(async () => undefined)
}
}
]
});
return runInInjectionContext(injector, () => new MobileAppLifecycleService());
}
describe('MobileAppLifecycleService', () => {
let documentStub: DocumentStub;
beforeEach(() => {
documentStub = installDocumentStub();
});
afterEach(() => {
delete (globalThis as { document?: unknown }).document;
});
it('fans app-state changes out to every registered handler', async () => {
const service = createService();
await service.initialize();
const first = vi.fn();
const second = vi.fn();
service.onAppStateChange(first);
service.onAppStateChange(second);
documentStub.hidden = true;
documentStub.listeners.forEach((listener) => listener());
expect(first).toHaveBeenCalledWith(false);
expect(second).toHaveBeenCalledWith(false);
});
it('keeps handlers registered before initialize', async () => {
const service = createService();
const handler = vi.fn();
service.onAppStateChange(handler);
await service.initialize();
documentStub.hidden = false;
documentStub.listeners.forEach((listener) => listener());
expect(handler).toHaveBeenCalledWith(true);
});
});
@@ -12,10 +12,15 @@ import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.se
export class MobileAppLifecycleService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
private readonly appStateHandlers = new Set<(isActive: boolean) => void>();
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
private initialized = false;
constructor() {
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
@@ -30,8 +35,15 @@ export class MobileAppLifecycleService {
this.initialized = true;
}
/** Register an app foreground/background listener; every registered handler is invoked (fan-out). */
onAppStateChange(handler: (isActive: boolean) => void): void {
this.adapter.onAppStateChange(handler);
this.appStateHandlers.add(handler);
}
private dispatchAppStateChange(isActive: boolean): void {
for (const handler of this.appStateHandlers) {
handler(isActive);
}
}
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
@@ -46,6 +58,7 @@ export class MobileAppLifecycleService {
}
).then((adapter) => {
this.adapter = adapter;
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
return adapter;
});
}
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
import { buildMessageNotification } from '../logic/message-notification.rules';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
@@ -44,6 +45,13 @@ export class MobileNotificationsService {
await adapter.showCallNotification(buildInCallNotification(input));
}
async showMessage(input: { title: string; body: string; tag?: string }): Promise<void> {
await this.initialize();
const adapter = await this.ensureAdapter();
await adapter.showMessageNotification(buildMessageNotification(input));
}
async dismissIncomingCall(callId: string): Promise<void> {
const adapter = await this.ensureAdapter();
@@ -6,6 +6,7 @@
*/
import { Subject } from 'rxjs';
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../../mobile/logic/ensure-mobile-capture-permissions';
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from '../../mobile/logic/mobile-voice-foreground-session';
import { ChatEvent } from '../../../shared-kernel';
import { LatencyProfile } from '../realtime.constants';
import { PeerData } from '../realtime.types';
@@ -248,6 +249,7 @@ export class MediaManager {
this.isVoiceActive = true;
this.voiceConnected$.next();
void startMobileVoiceForegroundSession();
return this.localMediaStream;
} catch (error) {
this.logger.error('Failed to getUserMedia', error);
@@ -288,6 +290,7 @@ export class MediaManager {
this.currentVoiceRoomId = undefined;
this.currentVoiceServerId = undefined;
this.allowedVoicePeerIds.clear();
void stopMobileVoiceForegroundSession();
}
/**
@@ -315,6 +318,7 @@ export class MediaManager {
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next();
void startMobileVoiceForegroundSession();
}
/**