chore: Fix app
This commit is contained in:
+40
@@ -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();
|
||||
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
+43
-1
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user