feat: Add deafen to pc, fix mobiel view, fix freeze on startup

This commit is contained in:
2026-06-05 15:27:06 +02:00
parent 35f52b0356
commit a675f12e61
85 changed files with 2499 additions and 519 deletions

View File

@@ -6,7 +6,7 @@ export class CapacitorMobileAppLifecycleAdapter implements MobileAppLifecycleAda
private handler: ((isActive: boolean) => void) | null = null;
async initialize(): Promise<void> {
const App = loadCapacitorAppPlugin();
const App = await loadCapacitorAppPlugin();
if (!App) {
return;

View File

@@ -1,9 +1,15 @@
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
/** iOS CallKit bridge via the MetoyouMobile native plugin. */
export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
async startActiveCall(callId: string, displayName: string): Promise<void> {
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
const result = await MetoyouMobile.startCallKitSession({ callId, displayName });
@@ -16,6 +22,12 @@ export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
}
async endActiveCall(callId: string): Promise<void> {
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
await MetoyouMobile.endCallKitSession({ callId });
} catch (error) {

View File

@@ -1,6 +1,6 @@
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter';
/** Capacitor media adapter with native speaker routing and background voice session hooks. */
@@ -8,14 +8,18 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
private backgroundSessionActive = false;
override async setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
try {
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
return;
} catch {
// Android plugin unavailable in web builds; fall through to iOS audio session.
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (MetoyouMobile) {
try {
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
return;
} catch {
// Android plugin unavailable in web builds; fall through to iOS audio session.
}
}
const AudioSession = loadCapacitorAudioSessionPlugin();
const AudioSession = await loadCapacitorAudioSessionPlugin();
if (!AudioSession) {
return;
@@ -31,6 +35,12 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
this.backgroundSessionActive = true;
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
await MetoyouMobile.startVoiceForegroundService();
} catch (error) {
@@ -45,6 +55,12 @@ export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implement
this.backgroundSessionActive = false;
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
try {
await MetoyouMobile.stopVoiceForegroundService();
} catch (error) {

View File

@@ -12,8 +12,8 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
private listenersRegistered = false;
async initialize(): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const PushNotifications = loadCapacitorPushNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
const PushNotifications = await loadCapacitorPushNotificationsPlugin();
if (!LocalNotifications) {
return;
@@ -72,7 +72,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
}
async requestPermission(): Promise<boolean> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return false;
@@ -90,7 +90,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
}
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;
@@ -122,7 +122,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
}
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
if (!LocalNotifications) {
return;

View File

@@ -1,5 +1,5 @@
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
import { MetoyouMobile } from './metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from './metoyou-mobile.plugin';
import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter';
/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */
@@ -20,6 +20,12 @@ export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPi
return;
}
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
const result = await MetoyouMobile.enterNativePictureInPicture();
this.nativeSupported = result.supported;
@@ -38,6 +44,12 @@ export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPi
await super.exit();
}
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return;
}
await MetoyouMobile.exitNativePictureInPicture().catch(() => {});
}
}

View File

@@ -12,6 +12,12 @@ const capacitorState = vi.hoisted(() => ({
isPluginAvailable: true,
platform: 'android'
}));
const appPlugin = vi.hoisted(() => ({
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
}));
const localNotificationsPlugin = vi.hoisted(() => ({
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
@@ -22,24 +28,26 @@ vi.mock('@capacitor/core', () => ({
}));
vi.mock('@capacitor/app', () => ({
App: {
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
}
App: appPlugin
}));
vi.mock('@capacitor/local-notifications', () => ({
LocalNotifications: {
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
}
LocalNotifications: localNotificationsPlugin
}));
import { App } from '@capacitor/app';
import { LocalNotifications } from '@capacitor/local-notifications';
import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader';
function stubCapacitorWindow(): void {
vi.stubGlobal('window', {
Capacitor: {
isNativePlatform: () => capacitorState.isNativePlatform
}
});
}
describe('capacitor-plugin-loader', () => {
beforeEach(() => {
vi.stubGlobal('window', {});
stubCapacitorWindow();
});
afterEach(() => {
@@ -49,27 +57,28 @@ describe('capacitor-plugin-loader', () => {
vi.unstubAllGlobals();
});
it('returns registered plugin instances synchronously without wrapping them in a Promise', () => {
const appPlugin = loadCapacitorAppPlugin();
const notificationsPlugin = loadCapacitorLocalNotificationsPlugin();
it('returns registered plugin instances from dynamic imports on native shells', async () => {
const resolvedAppPlugin = await loadCapacitorAppPlugin();
const resolvedNotificationsPlugin = await loadCapacitorLocalNotificationsPlugin();
expect(appPlugin).toBe(App);
expect(notificationsPlugin).toBe(LocalNotifications);
expect(appPlugin).not.toBeInstanceOf(Promise);
expect(notificationsPlugin).not.toBeInstanceOf(Promise);
expect(resolvedAppPlugin).toBe(appPlugin);
expect(resolvedNotificationsPlugin).toBe(localNotificationsPlugin);
expect(resolvedAppPlugin).not.toBeInstanceOf(Promise);
expect(resolvedNotificationsPlugin).not.toBeInstanceOf(Promise);
});
it('returns null when the plugin is unavailable on the active native shell', () => {
it('returns null when the plugin is unavailable on the active native shell', async () => {
capacitorState.isPluginAvailable = false;
expect(loadCapacitorAppPlugin()).toBeNull();
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
expect(await loadCapacitorAppPlugin()).toBeNull();
expect(await loadCapacitorLocalNotificationsPlugin()).toBeNull();
});
it('returns null on non-native shells', () => {
it('returns null on non-native shells without importing Capacitor plugins', async () => {
capacitorState.isNativePlatform = false;
stubCapacitorWindow();
expect(loadCapacitorAppPlugin()).toBeNull();
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
expect(await loadCapacitorAppPlugin()).toBeNull();
expect(await loadCapacitorLocalNotificationsPlugin()).toBeNull();
});
});

View File

@@ -1,12 +1,32 @@
import { App } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { Device } from '@capacitor/device';
import { LocalNotifications } from '@capacitor/local-notifications';
import { PushNotifications } from '@capacitor/push-notifications';
import { AudioSession } from '@capgo/capacitor-audio-session';
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
if (typeof window === 'undefined' || !Capacitor.isNativePlatform()) {
type CapacitorCoreModule = typeof import('@capacitor/core');
let capacitorCoreModulePromise: Promise<CapacitorCoreModule> | null = null;
const pluginPromises = new Map<string, Promise<unknown>>();
async function loadCapacitorCore(): Promise<CapacitorCoreModule['Capacitor'] | null> {
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
return null;
}
if (!capacitorCoreModulePromise) {
capacitorCoreModulePromise = import('@capacitor/core');
}
const module = await capacitorCoreModulePromise;
return module.Capacitor;
}
async function resolveCapacitorPlugin<T>(
pluginName: string,
loader: () => Promise<T>
): Promise<T | null> {
const Capacitor = await loadCapacitorCore();
if (!Capacitor) {
return null;
}
@@ -15,30 +35,54 @@ function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
return null;
}
return plugin;
if (!pluginPromises.has(pluginName)) {
pluginPromises.set(pluginName, loader());
}
return pluginPromises.get(pluginName) as Promise<T>;
}
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
export function loadCapacitorAppPlugin(): typeof App | null {
return resolveCapacitorPlugin('App', App);
export async function loadCapacitorAppPlugin(): Promise<import('@capacitor/app').AppPlugin | null> {
return resolveCapacitorPlugin('App', async () => {
const module = await import('@capacitor/app');
return module.App;
});
}
/** Resolve the Capacitor LocalNotifications plugin on native shells. */
export function loadCapacitorLocalNotificationsPlugin(): typeof LocalNotifications | null {
return resolveCapacitorPlugin('LocalNotifications', LocalNotifications);
export async function loadCapacitorLocalNotificationsPlugin(): Promise<import('@capacitor/local-notifications').LocalNotificationsPlugin | null> {
return resolveCapacitorPlugin('LocalNotifications', async () => {
const module = await import('@capacitor/local-notifications');
return module.LocalNotifications;
});
}
/** Resolve the Capacitor PushNotifications plugin on native shells. */
export function loadCapacitorPushNotificationsPlugin(): typeof PushNotifications | null {
return resolveCapacitorPlugin('PushNotifications', PushNotifications);
export async function loadCapacitorPushNotificationsPlugin(): Promise<import('@capacitor/push-notifications').PushNotificationsPlugin | null> {
return resolveCapacitorPlugin('PushNotifications', async () => {
const module = await import('@capacitor/push-notifications');
return module.PushNotifications;
});
}
/** Resolve the Capacitor Device plugin on native shells. */
export function loadCapacitorDevicePlugin(): typeof Device | null {
return resolveCapacitorPlugin('Device', Device);
export async function loadCapacitorDevicePlugin(): Promise<import('@capacitor/device').DevicePlugin | null> {
return resolveCapacitorPlugin('Device', async () => {
const module = await import('@capacitor/device');
return module.Device;
});
}
/** Resolve the Capacitor AudioSession plugin on native shells. */
export function loadCapacitorAudioSessionPlugin(): typeof AudioSession | null {
return resolveCapacitorPlugin('AudioSession', AudioSession);
export async function loadCapacitorAudioSessionPlugin(): Promise<import('@capgo/capacitor-audio-session').AudioSessionPlugin | null> {
return resolveCapacitorPlugin('AudioSession', async () => {
const module = await import('@capgo/capacitor-audio-session');
return module.AudioSession;
});
}

View File

@@ -1,4 +1,4 @@
import { registerPlugin } from '@capacitor/core';
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
export interface MetoyouMobilePlugin {
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
@@ -11,4 +11,19 @@ export interface MetoyouMobilePlugin {
isRemotePushConfigured(): Promise<{ configured: boolean }>;
}
export const MetoyouMobile = registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
let metoyouMobilePluginPromise: Promise<MetoyouMobilePlugin | null> | null = null;
/** Lazily register the MetoyouMobile Capacitor plugin on native shells only. */
export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | null> {
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
return null;
}
if (!metoyouMobilePluginPromise) {
metoyouMobilePluginPromise = import('@capacitor/core')
.then(({ registerPlugin }) => registerPlugin<MetoyouMobilePlugin>('MetoyouMobile'))
.catch(() => null);
}
return metoyouMobilePluginPromise;
}

View File

@@ -0,0 +1,25 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import { resolveMobileAdapter } from './mobile-capacitor-adapter.rules';
describe('resolveMobileAdapter', () => {
it('returns the web adapter on electron and browser shells', async () => {
const loadCapacitorAdapter = vi.fn(() => Promise.resolve('capacitor'));
await expect(resolveMobileAdapter('electron', 'web', loadCapacitorAdapter)).resolves.toBe('web');
await expect(resolveMobileAdapter('browser', 'web', loadCapacitorAdapter)).resolves.toBe('web');
expect(loadCapacitorAdapter).not.toHaveBeenCalled();
});
it('loads the Capacitor adapter only on native mobile shells', async () => {
const loadCapacitorAdapter = vi.fn(() => Promise.resolve('capacitor'));
await expect(resolveMobileAdapter('capacitor', 'web', loadCapacitorAdapter)).resolves.toBe('capacitor');
expect(loadCapacitorAdapter).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,14 @@
import type { RuntimePlatform } from './platform-detection.rules';
/** Lazily loads a Capacitor-only adapter while keeping web/electron shells on the web fallback. */
export async function resolveMobileAdapter<TWeb, TCapacitor>(
runtime: RuntimePlatform,
webAdapter: TWeb,
loadCapacitorAdapter: () => Promise<TCapacitor>
): Promise<TWeb | TCapacitor> {
if (runtime !== 'capacitor') {
return webAdapter;
}
return loadCapacitorAdapter();
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileAppLifecycleAdapter } from '../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter';
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -9,7 +9,8 @@ import { MobilePlatformService } from './mobile-platform.service';
@Injectable({ providedIn: 'root' })
export class MobileAppLifecycleService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileAppLifecycleAdapter = this.createAdapter();
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
private initialized = false;
async initialize(): Promise<void> {
@@ -17,7 +18,9 @@ export class MobileAppLifecycleService {
return;
}
await this.adapter.initialize();
const adapter = await this.ensureAdapter();
await adapter.initialize();
this.mobilePlatform.refreshRuntimeDetection();
this.initialized = true;
}
@@ -26,9 +29,22 @@ export class MobileAppLifecycleService {
this.adapter.onAppStateChange(handler);
}
private createAdapter(): MobileAppLifecycleAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileAppLifecycleAdapter()
: new WebMobileAppLifecycleAdapter();
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileAppLifecycleAdapter } = await import('../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter');
return new CapacitorMobileAppLifecycleAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -43,17 +43,7 @@ export class MobileCallSessionService {
this.wired = true;
void this.notifications.initialize();
void this.lifecycle.initialize();
this.notifications.onCallAction(({ callId, intent }) => {
void this.router.navigate(['/call', callId]);
this.actionHandler?.(intent, callId);
});
this.lifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
void this.bootstrap();
this.destroyRef.onDestroy(() => {
this.activeSession = null;
@@ -117,6 +107,20 @@ export class MobileCallSessionService {
};
}
private async bootstrap(): Promise<void> {
await this.notifications.initialize();
await this.lifecycle.initialize();
this.notifications.onCallAction(({ callId, intent }) => {
void this.router.navigate(['/call', callId]);
this.actionHandler?.(intent, callId);
});
this.lifecycle.onAppStateChange((isActive) => {
void this.handleAppStateChange(isActive);
});
}
private shouldHandleMobileCalls(): boolean {
return this.mobilePlatform.isNativeMobile();
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileCallKitAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileCallKitAdapter } from '../adapters/capacitor/capacitor-mobile-callkit.adapter';
import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -9,14 +9,15 @@ import { MobilePlatformService } from './mobile-platform.service';
@Injectable({ providedIn: 'root' })
export class MobileCallKitService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileCallKitAdapter = this.createAdapter();
private adapter: MobileCallKitAdapter = new WebMobileCallKitAdapter();
private adapterReady: Promise<MobileCallKitAdapter> | null = null;
startActiveCall(callId: string, displayName: string): Promise<void> {
if (!this.mobilePlatform.isCapacitor()) {
return Promise.resolve();
}
return this.adapter.startActiveCall(callId, displayName);
return this.ensureAdapter().then((adapter) => adapter.startActiveCall(callId, displayName));
}
endActiveCall(callId: string): Promise<void> {
@@ -24,12 +25,25 @@ export class MobileCallKitService {
return Promise.resolve();
}
return this.adapter.endActiveCall(callId);
return this.ensureAdapter().then((adapter) => adapter.endActiveCall(callId));
}
private createAdapter(): MobileCallKitAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileCallKitAdapter()
: new WebMobileCallKitAdapter();
private ensureAdapter(): Promise<MobileCallKitAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileCallKitAdapter } = await import('../adapters/capacitor/capacitor-mobile-callkit.adapter');
return new CapacitorMobileCallKitAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -4,8 +4,8 @@ import {
inject
} from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileMediaAdapter } from '../adapters/capacitor/capacitor-mobile-media.adapter';
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -16,27 +16,41 @@ export class MobileMediaService {
readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported());
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobileMediaAdapter = this.createAdapter();
private adapter: MobileMediaAdapter = new WebMobileMediaAdapter();
private adapterReady: Promise<MobileMediaAdapter> | null = null;
pickAttachments(): Promise<File[]> {
return this.adapter.pickAttachments();
return this.ensureAdapter().then((adapter) => adapter.pickAttachments());
}
setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
return this.adapter.setSpeakerphoneEnabled(enabled);
return this.ensureAdapter().then((adapter) => adapter.setSpeakerphoneEnabled(enabled));
}
startBackgroundAudioSession(): Promise<void> {
return this.adapter.startBackgroundAudioSession();
return this.ensureAdapter().then((adapter) => adapter.startBackgroundAudioSession());
}
stopBackgroundAudioSession(): Promise<void> {
return this.adapter.stopBackgroundAudioSession();
return this.ensureAdapter().then((adapter) => adapter.stopBackgroundAudioSession());
}
private createAdapter(): MobileMediaAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileMediaAdapter()
: new WebMobileMediaAdapter();
private ensureAdapter(): Promise<MobileMediaAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileMediaAdapter } = await import('../adapters/capacitor/capacitor-mobile-media.adapter');
return new CapacitorMobileMediaAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -2,8 +2,8 @@ import { Injectable, inject } from '@angular/core';
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobileNotificationsAdapter } from '../adapters/capacitor/capacitor-mobile-notifications.adapter';
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
import { MobilePlatformService } from './mobile-platform.service';
import { MobilePushRegistrationService } from './mobile-push-registration.service';
@@ -13,7 +13,9 @@ import { MobilePushRegistrationService } from './mobile-push-registration.servic
export class MobileNotificationsService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly pushRegistration = inject(MobilePushRegistrationService);
private readonly adapter: MobileNotificationAdapter = this.createAdapter();
private adapter: MobileNotificationAdapter = new WebMobileNotificationsAdapter();
private adapterReady: Promise<MobileNotificationAdapter> | null = null;
private callActionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null;
private initialized = false;
async initialize(): Promise<void> {
@@ -21,36 +23,65 @@ export class MobileNotificationsService {
return;
}
await this.adapter.initialize();
const adapter = await this.ensureAdapter();
await adapter.initialize();
this.pushRegistration.initialize();
this.initialized = true;
}
async showIncomingCall(displayName: string, callId: string): Promise<void> {
await this.initialize();
await this.adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
const adapter = await this.ensureAdapter();
await adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
}
async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise<void> {
await this.initialize();
await this.adapter.showCallNotification(buildInCallNotification(input));
const adapter = await this.ensureAdapter();
await adapter.showCallNotification(buildInCallNotification(input));
}
async dismissIncomingCall(callId: string): Promise<void> {
await this.adapter.dismissCallNotification(callId, 'incoming');
const adapter = await this.ensureAdapter();
await adapter.dismissCallNotification(callId, 'incoming');
}
async dismissActiveCall(callId: string): Promise<void> {
await this.adapter.dismissCallNotification(callId, 'active');
const adapter = await this.ensureAdapter();
await adapter.dismissCallNotification(callId, 'active');
}
onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.callActionHandler = handler;
this.adapter.onActionSelected(handler);
}
private createAdapter(): MobileNotificationAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobileNotificationsAdapter()
: new WebMobileNotificationsAdapter();
private ensureAdapter(): Promise<MobileNotificationAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileNotificationsAdapter } = await import('../adapters/capacitor/capacitor-mobile-notifications.adapter');
return new CapacitorMobileNotificationsAdapter();
}
).then((adapter) => {
this.adapter = adapter;
if (this.callActionHandler) {
this.adapter.onActionSelected(this.callActionHandler);
}
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobilePersistenceAdapter } from '../adapters/capacitor/capacitor-mobile-persistence.adapter';
import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter';
import { MobilePlatformService } from './mobile-platform.service';
import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service';
@@ -11,19 +11,33 @@ import { MobileSqliteConnectionService } from './mobile-sqlite-connection.servic
export class MobilePersistenceService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
private readonly adapter: MobilePersistenceAdapter = this.createAdapter();
private adapter: MobilePersistenceAdapter = new WebMobilePersistenceAdapter();
private adapterReady: Promise<MobilePersistenceAdapter> | null = null;
get isNativeSqlite(): boolean {
return this.adapter.isNativeSqlite;
}
initialize(): Promise<void> {
return this.adapter.initialize();
return this.ensureAdapter().then((adapter) => adapter.initialize());
}
private createAdapter(): MobilePersistenceAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobilePersistenceAdapter(this.sqliteConnection)
: new WebMobilePersistenceAdapter();
private ensureAdapter(): Promise<MobilePersistenceAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobilePersistenceAdapter } = await import('../adapters/capacitor/capacitor-mobile-persistence.adapter');
return new CapacitorMobilePersistenceAdapter(this.sqliteConnection);
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts';
import { CapacitorMobilePictureInPictureAdapter } from '../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter';
import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -9,23 +9,37 @@ import { MobilePlatformService } from './mobile-platform.service';
@Injectable({ providedIn: 'root' })
export class MobilePictureInPictureService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly adapter: MobilePictureInPictureAdapter = this.createAdapter();
private adapter: MobilePictureInPictureAdapter = new WebMobilePictureInPictureAdapter();
private adapterReady: Promise<MobilePictureInPictureAdapter> | null = null;
isSupported(): boolean {
return this.adapter.isSupported();
}
enter(videoElement: HTMLVideoElement): Promise<void> {
return this.adapter.enter(videoElement);
return this.ensureAdapter().then((adapter) => adapter.enter(videoElement));
}
exit(): Promise<void> {
return this.adapter.exit();
return this.ensureAdapter().then((adapter) => adapter.exit());
}
private createAdapter(): MobilePictureInPictureAdapter {
return this.mobilePlatform.isCapacitor()
? new CapacitorMobilePictureInPictureAdapter()
: new WebMobilePictureInPictureAdapter();
private ensureAdapter(): Promise<MobilePictureInPictureAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobilePictureInPictureAdapter } = await import('../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter');
return new CapacitorMobilePictureInPictureAdapter();
}
).then((adapter) => {
this.adapter = adapter;
return adapter;
});
}
return this.adapterReady;
}
}

View File

@@ -26,17 +26,17 @@ const remotePushState = vi.hoisted(() => ({
}));
vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({
loadCapacitorPushNotificationsPlugin: () => pushState,
loadCapacitorDevicePlugin: () => deviceState
loadCapacitorPushNotificationsPlugin: vi.fn(() => Promise.resolve(pushState)),
loadCapacitorDevicePlugin: vi.fn(() => Promise.resolve(deviceState))
}));
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
MetoyouMobile: {
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve({
isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured }))
}
}))
}));
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { MobilePlatformService } from './mobile-platform.service';
import { MobilePushRegistrationService } from './mobile-push-registration.service';
@@ -63,7 +63,7 @@ describe('MobilePushRegistrationService', () => {
remotePushState.configured = true;
pushState.register.mockClear();
pushState.addListener.mockClear();
vi.mocked(MetoyouMobile.isRemotePushConfigured).mockClear();
vi.mocked(loadMetoyouMobilePlugin).mockClear();
});
afterEach(() => {
@@ -95,7 +95,7 @@ describe('MobilePushRegistrationService', () => {
expect(pushState.register).toHaveBeenCalledTimes(1);
});
expect(MetoyouMobile.isRemotePushConfigured).toHaveBeenCalled();
expect(loadMetoyouMobilePlugin).toHaveBeenCalled();
});
it('does not wire listeners on non-capacitor shells', () => {
@@ -106,6 +106,6 @@ describe('MobilePushRegistrationService', () => {
service.initialize();
expect(pushState.register).not.toHaveBeenCalled();
expect(MetoyouMobile.isRemotePushConfigured).not.toHaveBeenCalled();
expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import { getStoredCurrentUserId } from '../../../core/storage/current-user-stora
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules';
import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules';
import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { MobilePlatformService } from './mobile-platform.service';
/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */
@@ -32,8 +32,8 @@ export class MobilePushRegistrationService {
}
private async registerPushListeners(): Promise<void> {
const PushNotifications = loadCapacitorPushNotificationsPlugin();
const Device = loadCapacitorDevicePlugin();
const PushNotifications = await loadCapacitorPushNotificationsPlugin();
const Device = await loadCapacitorDevicePlugin();
const remotePushConfigured = await this.isRemotePushConfigured();
const skipReason = resolveRemotePushSkipReason({
hasPushPlugin: !!PushNotifications,
@@ -75,6 +75,12 @@ export class MobilePushRegistrationService {
}
private async isRemotePushConfigured(): Promise<boolean> {
const MetoyouMobile = await loadMetoyouMobilePlugin();
if (!MetoyouMobile) {
return false;
}
try {
const result = await MetoyouMobile.isRemotePushConfigured();
@@ -91,7 +97,7 @@ export class MobilePushRegistrationService {
return;
}
const Device = loadCapacitorDevicePlugin();
const Device = await loadCapacitorDevicePlugin();
const deviceInfo = Device ? await Device.getInfo() : null;
const platform = normalizePushPlatform(deviceInfo?.platform ?? '');