From bdea95511d323177ddf349523bb979bb1715945d Mon Sep 17 00:00:00 2001 From: Myx Date: Thu, 11 Jun 2026 21:06:51 +0200 Subject: [PATCH] fix: Bug - Android app doesn't ask for permissions Prompt for microphone, camera, and notification runtime permissions during Capacitor startup, and fall back to WebView getUserMedia when the native preflight bridge fails so voice joins still surface Android permission dialogs. Co-authored-by: Cursor --- agents-docs/features/mobile-capacitor.md | 4 +- .../capacitor-mobile-notifications.adapter.ts | 4 +- .../capacitor/metoyou-mobile.plugin.ts | 12 +- .../src/app/infrastructure/mobile/index.ts | 1 + .../ensure-mobile-capture-permissions.spec.ts | 73 +++++++++++ .../ensure-mobile-capture-permissions.ts | 4 +- .../mobile-runtime-permissions.rules.spec.ts | 41 ++++++ .../logic/mobile-runtime-permissions.rules.ts | 26 ++++ ...request-mobile-runtime-permissions.spec.ts | 89 +++++++++++++ .../request-mobile-runtime-permissions.ts | 122 ++++++++++++++++++ .../services/mobile-app-lifecycle.service.ts | 3 + ...mobile-runtime-permissions.service.spec.ts | 78 +++++++++++ .../mobile-runtime-permissions.service.ts | 37 ++++++ 13 files changed, 487 insertions(+), 7 deletions(-) create mode 100644 toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.ts create mode 100644 toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.spec.ts create mode 100644 toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.ts diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md index d833891..95bfedb 100644 --- a/agents-docs/features/mobile-capacitor.md +++ b/agents-docs/features/mobile-capacitor.md @@ -134,7 +134,9 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`: | `POST_NOTIFICATIONS` | Incoming/active call notifications | | `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session | -Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. +Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins. + +On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call. ### iOS (APNs) diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts index 30f5221..93b673d 100644 --- a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/capacitor-mobile-notifications.adapter.ts @@ -78,10 +78,12 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd if (PushNotifications) { const permission = await PushNotifications.checkPermissions(); - if (permission.receive === 'prompt') { + if (permission.receive !== 'granted') { await PushNotifications.requestPermissions(); } } + + await this.requestPermission(); } async requestPermission(): Promise { diff --git a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts index 7154d1f..0c47d88 100644 --- a/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts +++ b/toju-app/src/app/infrastructure/mobile/adapters/capacitor/metoyou-mobile.plugin.ts @@ -1,6 +1,5 @@ -import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules'; - import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules'; +import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules'; export interface MetoyouMobilePlugin { requestVoiceCapturePermissions(): Promise; @@ -25,7 +24,14 @@ export async function loadMetoyouMobilePlugin(): Promise registerPlugin('MetoyouMobile')) + .then(async ({ Capacitor, registerPlugin }) => { + if (!Capacitor.isPluginAvailable('MetoyouMobile')) { + console.warn('[mobile] MetoyouMobile plugin is not implemented on this shell.'); + return null; + } + + return registerPlugin('MetoyouMobile'); + }) .catch(() => null); } diff --git a/toju-app/src/app/infrastructure/mobile/index.ts b/toju-app/src/app/infrastructure/mobile/index.ts index e1e8472..1d6cac8 100644 --- a/toju-app/src/app/infrastructure/mobile/index.ts +++ b/toju-app/src/app/infrastructure/mobile/index.ts @@ -1,5 +1,6 @@ export * from './logic/platform-detection.rules'; export * from './logic/call-notification.rules'; +export * from './services/mobile-runtime-permissions.service'; export * from './services/mobile-platform.service'; export * from './services/mobile-notifications.service'; export * from './services/mobile-media.service'; diff --git a/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.spec.ts new file mode 100644 index 0000000..4a75c8e --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.spec.ts @@ -0,0 +1,73 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +const pluginState = vi.hoisted(() => ({ + plugin: null as null | { + requestVoiceCapturePermissions: () => Promise<{ microphone?: string }>; + requestCameraCapturePermissions: () => Promise<{ camera?: string }>; + }, + isNative: true +})); + +vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({ + loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin)) +})); + +vi.mock('./platform-detection.rules', () => ({ + detectRuntimePlatform: vi.fn(({ capacitorIsNative }: { capacitorIsNative: boolean }) => + capacitorIsNative ? 'capacitor' : 'browser'), + isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative) +})); + +import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin'; +import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from './ensure-mobile-capture-permissions'; + +describe('ensure-mobile-capture-permissions', () => { + beforeEach(() => { + pluginState.isNative = true; + pluginState.plugin = { + requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'granted' })), + requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' })) + }; + + vi.mocked(loadMetoyouMobilePlugin).mockClear(); + }); + + it('skips native preflight on browser shells', async () => { + pluginState.isNative = false; + + await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true); + expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled(); + }); + + it('defers to WebView capture when the native plugin is unavailable', async () => { + pluginState.plugin = null; + + await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true); + await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true); + }); + + it('defers to WebView capture when the native plugin call fails', async () => { + pluginState.plugin = { + requestVoiceCapturePermissions: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))), + requestCameraCapturePermissions: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))) + }; + + 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' })), + requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' })) + }; + + await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(false); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts b/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts index ad16193..9f03937 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/ensure-mobile-capture-permissions.ts @@ -30,7 +30,7 @@ export async function ensureMobileVoiceCapturePermissions(): Promise { return isVoiceCaptureAllowed(result); } catch { - return false; + return true; } } @@ -51,6 +51,6 @@ export async function ensureMobileCameraCapturePermissions(): Promise { return isCameraCaptureAllowed(result); } catch { - return false; + return true; } } diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.spec.ts new file mode 100644 index 0000000..cb0c2af --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.spec.ts @@ -0,0 +1,41 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + areMobileRuntimePermissionsGranted, + shouldPromptForNotificationPermission, + shouldRequestMobileRuntimePermissions +} from './mobile-runtime-permissions.rules'; + +describe('mobile-runtime-permissions.rules', () => { + it('only requests runtime permissions on Capacitor shells', () => { + expect(shouldRequestMobileRuntimePermissions('capacitor')).toBe(true); + expect(shouldRequestMobileRuntimePermissions('browser')).toBe(false); + expect(shouldRequestMobileRuntimePermissions('electron')).toBe(false); + }); + + it('prompts for notification permissions until granted', () => { + expect(shouldPromptForNotificationPermission('granted')).toBe(false); + expect(shouldPromptForNotificationPermission('prompt')).toBe(true); + expect(shouldPromptForNotificationPermission('denied')).toBe(true); + }); + + it('requires every runtime permission alias to be granted', () => { + expect(areMobileRuntimePermissionsGranted({ + voiceGranted: true, + cameraGranted: true, + localNotificationsGranted: true, + pushNotificationsGranted: true + })).toBe(true); + + expect(areMobileRuntimePermissionsGranted({ + voiceGranted: false, + cameraGranted: true, + localNotificationsGranted: true, + pushNotificationsGranted: true + })).toBe(false); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.ts new file mode 100644 index 0000000..723451d --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-runtime-permissions.rules.ts @@ -0,0 +1,26 @@ +import type { RuntimePlatform } from './platform-detection.rules'; + +export type MobileNotificationPermissionState = 'granted' | 'denied' | 'prompt' | string; + +/** Capacitor shells should preflight Android/iOS runtime permissions during bootstrap. */ +export function shouldRequestMobileRuntimePermissions(runtime: RuntimePlatform): boolean { + return runtime === 'capacitor'; +} + +/** Whether a notification permission alias still needs a runtime prompt. */ +export function shouldPromptForNotificationPermission(state: MobileNotificationPermissionState | undefined): boolean { + return state !== 'granted'; +} + +/** Whether every requested runtime permission alias was granted. */ +export function areMobileRuntimePermissionsGranted(snapshot: { + voiceGranted: boolean; + cameraGranted: boolean; + localNotificationsGranted: boolean; + pushNotificationsGranted: boolean; +}): boolean { + return snapshot.voiceGranted + && snapshot.cameraGranted + && snapshot.localNotificationsGranted + && snapshot.pushNotificationsGranted; +} diff --git a/toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.spec.ts new file mode 100644 index 0000000..fb32b31 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.spec.ts @@ -0,0 +1,89 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { requestMobileRuntimePermissions } from './request-mobile-runtime-permissions'; + +describe('requestMobileRuntimePermissions', () => { + const loadMetoyouMobilePlugin = vi.fn(); + const loadLocalNotifications = vi.fn(); + const loadPushNotifications = vi.fn(); + + beforeEach(() => { + loadMetoyouMobilePlugin.mockReset(); + loadLocalNotifications.mockReset(); + loadPushNotifications.mockReset(); + + loadMetoyouMobilePlugin.mockResolvedValue({ + requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'granted' })), + requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' })) + }); + + loadLocalNotifications.mockResolvedValue({ + checkPermissions: vi.fn(() => Promise.resolve({ display: 'prompt' })), + requestPermissions: vi.fn(() => Promise.resolve({ display: 'granted' })) + }); + + loadPushNotifications.mockResolvedValue({ + checkPermissions: vi.fn(() => Promise.resolve({ receive: 'prompt' })), + requestPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })) + }); + }); + + it('returns null on non-Capacitor shells', async () => { + await expect(requestMobileRuntimePermissions({ + runtime: 'browser', + loadMetoyouMobilePlugin, + loadLocalNotifications, + loadPushNotifications + })).resolves.toBeNull(); + + expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled(); + }); + + it('requests voice, camera, and notification permissions on Capacitor shells', async () => { + const snapshot = await requestMobileRuntimePermissions({ + runtime: 'capacitor', + loadMetoyouMobilePlugin, + loadLocalNotifications, + loadPushNotifications + }); + + expect(snapshot).toEqual({ + voiceGranted: true, + cameraGranted: true, + localNotificationsGranted: true, + pushNotificationsGranted: true + }); + + expect(loadMetoyouMobilePlugin).toHaveBeenCalledTimes(1); + + const localNotifications = await loadLocalNotifications.mock.results[0]?.value; + const pushNotifications = await loadPushNotifications.mock.results[0]?.value; + + expect(localNotifications.requestPermissions).toHaveBeenCalledTimes(1); + expect(pushNotifications.requestPermissions).toHaveBeenCalledTimes(1); + }); + + it('still requests notification permissions when the native media plugin is unavailable', async () => { + loadMetoyouMobilePlugin.mockResolvedValue(null); + + const snapshot = await requestMobileRuntimePermissions({ + runtime: 'capacitor', + loadMetoyouMobilePlugin, + loadLocalNotifications, + loadPushNotifications + }); + + expect(snapshot).toEqual({ + voiceGranted: false, + cameraGranted: false, + localNotificationsGranted: true, + pushNotificationsGranted: true + }); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.ts b/toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.ts new file mode 100644 index 0000000..1decd5a --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/logic/request-mobile-runtime-permissions.ts @@ -0,0 +1,122 @@ +import type { RuntimePlatform } from './platform-detection.rules'; +import { + isCameraCaptureAllowed, + isVoiceCaptureAllowed, + type MobileCapturePermissionResult +} from './mobile-media-permission.rules'; +import { shouldPromptForNotificationPermission, shouldRequestMobileRuntimePermissions } from './mobile-runtime-permissions.rules'; + +export interface MobileRuntimePermissionSnapshot { + voiceGranted: boolean; + cameraGranted: boolean; + localNotificationsGranted: boolean; + pushNotificationsGranted: boolean; +} + +export interface MobileRuntimePermissionRequestDeps { + runtime: RuntimePlatform; + loadMetoyouMobilePlugin: () => Promise<{ + requestVoiceCapturePermissions: () => Promise; + requestCameraCapturePermissions: () => Promise; + } | null>; + loadLocalNotifications: () => Promise<{ + checkPermissions: () => Promise<{ display?: string }>; + requestPermissions: () => Promise<{ display?: string }>; + } | null>; + loadPushNotifications: () => Promise<{ + checkPermissions: () => Promise<{ receive?: string }>; + requestPermissions: () => Promise<{ receive?: string }>; + } | null>; +} + +/** Prompt for the Android/iOS runtime permissions the mobile shell needs at startup. */ +export async function requestMobileRuntimePermissions( + deps: MobileRuntimePermissionRequestDeps +): Promise { + if (!shouldRequestMobileRuntimePermissions(deps.runtime)) { + return null; + } + + const plugin = await deps.loadMetoyouMobilePlugin(); + const localNotifications = await deps.loadLocalNotifications(); + const pushNotifications = await deps.loadPushNotifications(); + const voiceGranted = await requestVoiceCapturePermission(plugin); + const cameraGranted = await requestCameraCapturePermission(plugin); + const localNotificationsGranted = await requestLocalNotificationPermission(localNotifications); + const pushNotificationsGranted = await requestPushNotificationPermission(pushNotifications); + + return { + voiceGranted, + cameraGranted, + localNotificationsGranted, + pushNotificationsGranted + }; +} + +async function requestVoiceCapturePermission( + plugin: Awaited> +): Promise { + if (!plugin?.requestVoiceCapturePermissions) { + return false; + } + + try { + const result = await plugin.requestVoiceCapturePermissions(); + + return isVoiceCaptureAllowed(result); + } catch { + return false; + } +} + +async function requestCameraCapturePermission( + plugin: Awaited> +): Promise { + if (!plugin?.requestCameraCapturePermissions) { + return false; + } + + try { + const result = await plugin.requestCameraCapturePermissions(); + + return isCameraCaptureAllowed(result); + } catch { + return false; + } +} + +async function requestLocalNotificationPermission( + localNotifications: Awaited> +): Promise { + if (!localNotifications) { + return false; + } + + const permission = await localNotifications.checkPermissions(); + + if (!shouldPromptForNotificationPermission(permission.display)) { + return true; + } + + const requested = await localNotifications.requestPermissions(); + + return requested.display === 'granted'; +} + +async function requestPushNotificationPermission( + pushNotifications: Awaited> +): Promise { + if (!pushNotifications) { + return false; + } + + const permission = await pushNotifications.checkPermissions(); + + if (!shouldPromptForNotificationPermission(permission.receive)) { + return true; + } + + const requested = await pushNotifications.requestPermissions(); + + return requested.receive === 'granted'; +} diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts index 043eac7..383a64b 100644 --- a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts @@ -4,11 +4,13 @@ import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules'; import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts'; import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter'; import { MobilePlatformService } from './mobile-platform.service'; +import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service'; /** Facade for foreground/background lifecycle events. */ @Injectable({ providedIn: 'root' }) export class MobileAppLifecycleService { private readonly mobilePlatform = inject(MobilePlatformService); + private readonly runtimePermissions = inject(MobileRuntimePermissionsService); private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter(); private adapterReady: Promise | null = null; private initialized = false; @@ -22,6 +24,7 @@ export class MobileAppLifecycleService { await adapter.initialize(); this.mobilePlatform.refreshRuntimeDetection(); + await this.runtimePermissions.initialize(); this.initialized = true; } diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.spec.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.spec.ts new file mode 100644 index 0000000..b4fe185 --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.spec.ts @@ -0,0 +1,78 @@ +import '@angular/compiler'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import { ViewportService } from '../../../core/platform/viewport.service'; +import { requestMobileRuntimePermissions } from '../logic/request-mobile-runtime-permissions'; +import { MobilePlatformService } from './mobile-platform.service'; +import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service'; + +vi.mock('../logic/request-mobile-runtime-permissions', () => ({ + requestMobileRuntimePermissions: vi.fn(() => Promise.resolve({ + voiceGranted: true, + cameraGranted: true, + localNotificationsGranted: true, + pushNotificationsGranted: true + })) +})); + +function createService(runtime: 'browser' | 'capacitor'): MobileRuntimePermissionsService { + const injector = Injector.create({ + providers: [ + MobileRuntimePermissionsService, + MobilePlatformService, + { + provide: ElectronBridgeService, + useValue: { isAvailable: false } + }, + { + provide: ViewportService, + useValue: { + isMobile: () => runtime === 'capacitor' + } + } + ] + }); + + if (runtime === 'capacitor') { + vi.stubGlobal('window', { + Capacitor: { + isNativePlatform: () => true + } + }); + } + + return runInInjectionContext(injector, () => injector.get(MobileRuntimePermissionsService)); +} + +describe('MobileRuntimePermissionsService', () => { + beforeEach(() => { + vi.mocked(requestMobileRuntimePermissions).mockClear(); + vi.unstubAllGlobals(); + }); + + it('requests runtime permissions once on Capacitor shells', async () => { + const service = createService('capacitor'); + + await service.initialize(); + await service.initialize(); + + expect(requestMobileRuntimePermissions).toHaveBeenCalledTimes(1); + }); + + it('does not request runtime permissions on browser shells', async () => { + const service = createService('browser'); + + await service.initialize(); + + expect(requestMobileRuntimePermissions).toHaveBeenCalledTimes(1); + expect(vi.mocked(requestMobileRuntimePermissions).mock.calls[0]?.[0].runtime).toBe('browser'); + }); +}); diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.ts new file mode 100644 index 0000000..5b6ed1d --- /dev/null +++ b/toju-app/src/app/infrastructure/mobile/services/mobile-runtime-permissions.service.ts @@ -0,0 +1,37 @@ +import { Injectable, inject } from '@angular/core'; + +import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader'; +import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin'; +import { requestMobileRuntimePermissions } from '../logic/request-mobile-runtime-permissions'; +import { MobilePlatformService } from './mobile-platform.service'; + +/** Prompts for Android/iOS runtime permissions once per Capacitor shell startup. */ +@Injectable({ providedIn: 'root' }) +export class MobileRuntimePermissionsService { + private readonly mobilePlatform = inject(MobilePlatformService); + private initialized = false; + private initializePromise: Promise | null = null; + + initialize(): Promise { + if (this.initialized) { + return Promise.resolve(); + } + + if (!this.initializePromise) { + this.initializePromise = this.requestPermissions().finally(() => { + this.initialized = true; + }); + } + + return this.initializePromise; + } + + private async requestPermissions(): Promise { + await requestMobileRuntimePermissions({ + runtime: this.mobilePlatform.runtime(), + loadMetoyouMobilePlugin, + loadLocalNotifications: loadCapacitorLocalNotificationsPlugin, + loadPushNotifications: loadCapacitorPushNotificationsPlugin + }); + } +}