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:
@@ -134,7 +134,9 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
|||||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
| `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)
|
### iOS (APNs)
|
||||||
|
|
||||||
|
|||||||
@@ -78,10 +78,12 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
if (PushNotifications) {
|
if (PushNotifications) {
|
||||||
const permission = await PushNotifications.checkPermissions();
|
const permission = await PushNotifications.checkPermissions();
|
||||||
|
|
||||||
if (permission.receive === 'prompt') {
|
if (permission.receive !== 'granted') {
|
||||||
await PushNotifications.requestPermissions();
|
await PushNotifications.requestPermissions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission(): Promise<boolean> {
|
async requestPermission(): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
|
||||||
|
|
||||||
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
||||||
|
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
||||||
|
|
||||||
export interface MetoyouMobilePlugin {
|
export interface MetoyouMobilePlugin {
|
||||||
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
||||||
@@ -25,7 +24,14 @@ export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | n
|
|||||||
|
|
||||||
if (!metoyouMobilePluginPromise) {
|
if (!metoyouMobilePluginPromise) {
|
||||||
metoyouMobilePluginPromise = import('@capacitor/core')
|
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);
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './logic/platform-detection.rules';
|
export * from './logic/platform-detection.rules';
|
||||||
export * from './logic/call-notification.rules';
|
export * from './logic/call-notification.rules';
|
||||||
|
export * from './services/mobile-runtime-permissions.service';
|
||||||
export * from './services/mobile-platform.service';
|
export * from './services/mobile-platform.service';
|
||||||
export * from './services/mobile-notifications.service';
|
export * from './services/mobile-notifications.service';
|
||||||
export * from './services/mobile-media.service';
|
export * from './services/mobile-media.service';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
|
|||||||
|
|
||||||
return isVoiceCaptureAllowed(result);
|
return isVoiceCaptureAllowed(result);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +51,6 @@ export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
|
|||||||
|
|
||||||
return isCameraCaptureAllowed(result);
|
return isCameraCaptureAllowed(result);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
|||||||
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
||||||
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
|
||||||
|
|
||||||
/** Facade for foreground/background lifecycle events. */
|
/** Facade for foreground/background lifecycle events. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MobileAppLifecycleService {
|
export class MobileAppLifecycleService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
|
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
|
||||||
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
||||||
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
@@ -22,6 +24,7 @@ export class MobileAppLifecycleService {
|
|||||||
|
|
||||||
await adapter.initialize();
|
await adapter.initialize();
|
||||||
this.mobilePlatform.refreshRuntimeDetection();
|
this.mobilePlatform.refreshRuntimeDetection();
|
||||||
|
await this.runtimePermissions.initialize();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user