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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 21:06:51 +02:00
parent 9981aee602
commit bdea95511d
13 changed files with 487 additions and 7 deletions

View File

@@ -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)

View File

@@ -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<boolean> {

View File

@@ -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<MobileCapturePermissionResult>;
@@ -25,7 +24,14 @@ export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | n
if (!metoyouMobilePluginPromise) {
metoyouMobilePluginPromise = import('@capacitor/core')
.then(({ registerPlugin }) => registerPlugin<MetoyouMobilePlugin>('MetoyouMobile'))
.then(async ({ Capacitor, registerPlugin }) => {
if (!Capacitor.isPluginAvailable('MetoyouMobile')) {
console.warn('[mobile] MetoyouMobile plugin is not implemented on this shell.');
return null;
}
return registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
})
.catch(() => null);
}

View File

@@ -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';

View File

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

View File

@@ -30,7 +30,7 @@ export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
return isVoiceCaptureAllowed(result);
} catch {
return false;
return true;
}
}
@@ -51,6 +51,6 @@ export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
return isCameraCaptureAllowed(result);
} catch {
return false;
return true;
}
}

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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<MobileCapturePermissionResult>;
requestCameraCapturePermissions: () => Promise<MobileCapturePermissionResult>;
} | 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<MobileRuntimePermissionSnapshot | null> {
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<ReturnType<MobileRuntimePermissionRequestDeps['loadMetoyouMobilePlugin']>>
): Promise<boolean> {
if (!plugin?.requestVoiceCapturePermissions) {
return false;
}
try {
const result = await plugin.requestVoiceCapturePermissions();
return isVoiceCaptureAllowed(result);
} catch {
return false;
}
}
async function requestCameraCapturePermission(
plugin: Awaited<ReturnType<MobileRuntimePermissionRequestDeps['loadMetoyouMobilePlugin']>>
): Promise<boolean> {
if (!plugin?.requestCameraCapturePermissions) {
return false;
}
try {
const result = await plugin.requestCameraCapturePermissions();
return isCameraCaptureAllowed(result);
} catch {
return false;
}
}
async function requestLocalNotificationPermission(
localNotifications: Awaited<ReturnType<MobileRuntimePermissionRequestDeps['loadLocalNotifications']>>
): Promise<boolean> {
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<ReturnType<MobileRuntimePermissionRequestDeps['loadPushNotifications']>>
): Promise<boolean> {
if (!pushNotifications) {
return false;
}
const permission = await pushNotifications.checkPermissions();
if (!shouldPromptForNotificationPermission(permission.receive)) {
return true;
}
const requested = await pushNotifications.requestPermissions();
return requested.receive === 'granted';
}

View File

@@ -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<MobileAppLifecycleAdapter> | 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;
}

View File

@@ -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');
});
});

View File

@@ -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<void> | null = null;
initialize(): Promise<void> {
if (this.initialized) {
return Promise.resolve();
}
if (!this.initializePromise) {
this.initializePromise = this.requestPermissions().finally(() => {
this.initialized = true;
});
}
return this.initializePromise;
}
private async requestPermissions(): Promise<void> {
await requestMobileRuntimePermissions({
runtime: this.mobilePlatform.runtime(),
loadMetoyouMobilePlugin,
loadLocalNotifications: loadCapacitorLocalNotificationsPlugin,
loadPushNotifications: loadCapacitorPushNotificationsPlugin
});
}
}