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 + }); + } +}