feat: Add slashcommand api

This commit is contained in:
2026-06-05 17:12:26 +02:00
parent 4070ef6caf
commit 8ecfc9a1fe
101 changed files with 3526 additions and 147 deletions

View File

@@ -9,18 +9,19 @@ Loosely coupled Capacitor/native bridge for the Angular product client. Domains
| `MobilePlatformService` | Runtime detection (`browser` / `capacitor` / `electron`) and mobile UX flags |
| `MobileNotificationsService` | Local/push notifications for calls |
| `MobileCallSessionService` | In-call notification actions, background audio session, stream video hand-off |
| `MobileMediaService` | Attachment picker, speakerphone route, screen-share/PiP capability probes |
| `MobileMediaService` | Attachment picker, speakerphone route, Android/iOS capture permission preflight, screen-share/PiP capability probes |
| `MobilePictureInPictureService` | Stream pop-out while backgrounded |
| `MobilePersistenceService` | Native SQLite schema init (`@capacitor-community/sqlite`) |
| `MobileSqliteConnectionService` | Shared SQLite connection for persistence + `DatabaseService` |
| `MobileCallKitService` | iOS CallKit active-call reporting for background voice |
| `MobilePushRegistrationService` | FCM/APNs token registration with signaling server; skips `PushNotifications.register()` when Firebase/APNs is not configured |
| `MobileAppLifecycleService` | Foreground/background lifecycle |
| `MobileAppUpdateService` | Play Store / App Store update checks via `@capawesome/capacitor-app-update` |
## Adapters
- `adapters/web/*` — browser fallbacks (Notification API, hidden file input, Document PiP).
- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts`.
- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts` (including `AppUpdate` for store update checks).
## Rules

View File

@@ -0,0 +1,103 @@
import { AppUpdateAvailability } from '@capawesome/capacitor-app-update';
import type {
MobileAppUpdateAdapter,
MobileAppUpdateAvailability,
MobileAppUpdateInfo
} from '../../contracts/mobile.contracts';
import { getCapacitorPlatform, loadCapacitorAppUpdatePlugin } from './capacitor-plugin-loader';
function mapAvailability(value: AppUpdateAvailability | undefined): MobileAppUpdateAvailability {
switch (value) {
case AppUpdateAvailability.UPDATE_AVAILABLE:
case AppUpdateAvailability.UPDATE_IN_PROGRESS:
return 'update-available';
case AppUpdateAvailability.UPDATE_NOT_AVAILABLE:
return 'update-not-available';
default:
return 'unknown';
}
}
/** Capacitor App Update plugin bridge for Play Store / App Store checks. */
export class CapacitorMobileAppUpdateAdapter implements MobileAppUpdateAdapter {
readonly isSupported = true;
async getAppUpdateInfo(): Promise<MobileAppUpdateInfo> {
const AppUpdate = await loadCapacitorAppUpdatePlugin();
if (!AppUpdate) {
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
}
const platform = await this.resolvePlatform();
const result = await AppUpdate.getAppUpdateInfo(
platform === 'ios'
? { country: 'US' }
: undefined
);
return {
availableVersion: result.availableVersionName
?? result.availableVersionCode
?? null,
currentVersion: result.currentVersionName
?? result.currentVersionCode
?? '0.0.0',
flexibleUpdateAllowed: result.flexibleUpdateAllowed ?? false,
immediateUpdateAllowed: result.immediateUpdateAllowed ?? false,
platform,
updateAvailability: mapAvailability(result.updateAvailability)
};
}
async openAppStore(): Promise<void> {
const AppUpdate = await loadCapacitorAppUpdatePlugin();
if (!AppUpdate) {
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
}
await AppUpdate.openAppStore();
}
async performImmediateUpdate(): Promise<void> {
const AppUpdate = await loadCapacitorAppUpdatePlugin();
if (!AppUpdate) {
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
}
await AppUpdate.performImmediateUpdate();
}
async startFlexibleUpdate(): Promise<void> {
const AppUpdate = await loadCapacitorAppUpdatePlugin();
if (!AppUpdate) {
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
}
await AppUpdate.startFlexibleUpdate();
}
async completeFlexibleUpdate(): Promise<void> {
const AppUpdate = await loadCapacitorAppUpdatePlugin();
if (!AppUpdate) {
throw new Error('Capacitor AppUpdate plugin is not available on this platform.');
}
await AppUpdate.completeFlexibleUpdate();
}
private async resolvePlatform(): Promise<'android' | 'ios' | null> {
const platform = await getCapacitorPlatform();
if (platform === 'android' || platform === 'ios') {
return platform;
}
return null;
}
}

View File

@@ -42,6 +42,13 @@ async function resolveCapacitorPlugin<T>(
return pluginPromises.get(pluginName) as Promise<T>;
}
/** Resolve the active Capacitor platform id on native shells. */
export async function getCapacitorPlatform(): Promise<string | null> {
const Capacitor = await loadCapacitorCore();
return Capacitor?.getPlatform() ?? null;
}
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
export async function loadCapacitorAppPlugin(): Promise<import('@capacitor/app').AppPlugin | null> {
return resolveCapacitorPlugin('App', async () => {
@@ -86,3 +93,12 @@ export async function loadCapacitorAudioSessionPlugin(): Promise<import('@capgo/
return module.AudioSession;
});
}
/** Resolve the Capacitor App Update plugin on native shells. */
export async function loadCapacitorAppUpdatePlugin(): Promise<import('@capawesome/capacitor-app-update').AppUpdatePlugin | null> {
return resolveCapacitorPlugin('AppUpdate', async () => {
const module = await import('@capawesome/capacitor-app-update');
return module.AppUpdate;
});
}

View File

@@ -1,6 +1,10 @@
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
export interface MetoyouMobilePlugin {
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
requestCameraCapturePermissions(): Promise<MobileCapturePermissionResult>;
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
startVoiceForegroundService(): Promise<void>;
stopVoiceForegroundService(): Promise<void>;

View File

@@ -0,0 +1,25 @@
import type { MobileAppUpdateAdapter } from '../../contracts/mobile.contracts';
/** Browser/electron fallback - store updates are not available outside native shells. */
export class WebMobileAppUpdateAdapter implements MobileAppUpdateAdapter {
readonly isSupported = false;
async getAppUpdateInfo() {
return {
availableVersion: null,
currentVersion: '0.0.0',
flexibleUpdateAllowed: false,
immediateUpdateAllowed: false,
platform: null,
updateAvailability: 'unknown' as const
};
}
async openAppStore(): Promise<void> {}
async performImmediateUpdate(): Promise<void> {}
async startFlexibleUpdate(): Promise<void> {}
async completeFlexibleUpdate(): Promise<void> {}
}

View File

@@ -44,3 +44,23 @@ export interface MobilePlatformSnapshot {
isNativeMobile: boolean;
isCapacitor: boolean;
}
export type MobileAppUpdateAvailability = 'unknown' | 'update-available' | 'update-not-available';
export interface MobileAppUpdateInfo {
availableVersion: string | null;
currentVersion: string;
flexibleUpdateAllowed: boolean;
immediateUpdateAllowed: boolean;
platform: 'android' | 'ios' | null;
updateAvailability: MobileAppUpdateAvailability;
}
export interface MobileAppUpdateAdapter {
readonly isSupported: boolean;
getAppUpdateInfo(): Promise<MobileAppUpdateInfo>;
openAppStore(): Promise<void>;
performImmediateUpdate(): Promise<void>;
startFlexibleUpdate(): Promise<void>;
completeFlexibleUpdate(): Promise<void>;
}

View File

@@ -10,3 +10,5 @@ export * from './services/mobile-app-lifecycle.service';
export * from './services/mobile-push-registration.service';
export * from './services/mobile-callkit.service';
export * from './services/mobile-sqlite-connection.service';
export * from './services/mobile-app-update.service';
export * from './logic/mobile-app-update.rules';

View File

@@ -0,0 +1,56 @@
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import {
isCameraCaptureAllowed,
isVoiceCaptureAllowed,
shouldPreflightMobileCapturePermissions
} from './mobile-media-permission.rules';
import { detectRuntimePlatform, isCapacitorNativeRuntime } from './platform-detection.rules';
function resolveRuntimePlatform(): ReturnType<typeof detectRuntimePlatform> {
return detectRuntimePlatform({
hasElectronApi: false,
capacitorIsNative: isCapacitorNativeRuntime()
});
}
/** Request Android/iOS runtime microphone permissions before WebRTC voice capture. */
export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
if (!shouldPreflightMobileCapturePermissions(resolveRuntimePlatform())) {
return true;
}
const plugin = await loadMetoyouMobilePlugin();
if (!plugin?.requestVoiceCapturePermissions) {
return true;
}
try {
const result = await plugin.requestVoiceCapturePermissions();
return isVoiceCaptureAllowed(result);
} catch {
return false;
}
}
/** Request Android/iOS runtime camera permissions before WebRTC camera capture. */
export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
if (!shouldPreflightMobileCapturePermissions(resolveRuntimePlatform())) {
return true;
}
const plugin = await loadMetoyouMobilePlugin();
if (!plugin?.requestCameraCapturePermissions) {
return true;
}
try {
const result = await plugin.requestCameraCapturePermissions();
return isCameraCaptureAllowed(result);
} catch {
return false;
}
}

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
describe,
expect,
it
} from 'vitest';
import { ANDROID_REQUIRED_MANIFEST_PERMISSIONS, findMissingAndroidManifestPermissions } from './mobile-android-manifest-permissions.rules';
describe('mobile-android-manifest-permissions.rules', () => {
it('requires MODIFY_AUDIO_SETTINGS so Capacitor WebView audio grants succeed', () => {
expect(ANDROID_REQUIRED_MANIFEST_PERMISSIONS).toContain('android.permission.MODIFY_AUDIO_SETTINGS');
});
it('declares every required permission in the Android app manifest', () => {
const manifestPath = resolve(process.cwd(), 'android/app/src/main/AndroidManifest.xml');
const manifestXml = readFileSync(manifestPath, 'utf8');
const missing = findMissingAndroidManifestPermissions(manifestXml);
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,20 @@
/** Android permissions the MetoYou Capacitor shell must declare for voice, camera, and notifications. */
export const ANDROID_REQUIRED_MANIFEST_PERMISSIONS = [
'android.permission.INTERNET',
'android.permission.POST_NOTIFICATIONS',
'android.permission.RECORD_AUDIO',
'android.permission.MODIFY_AUDIO_SETTINGS',
'android.permission.CAMERA',
'android.permission.FOREGROUND_SERVICE',
'android.permission.FOREGROUND_SERVICE_MICROPHONE',
'android.permission.WAKE_LOCK',
'android.permission.BLUETOOTH_CONNECT'
] as const;
/** Return manifest permission names that are missing from the given AndroidManifest.xml source. */
export function findMissingAndroidManifestPermissions(
manifestXml: string,
requiredPermissions: readonly string[] = ANDROID_REQUIRED_MANIFEST_PERMISSIONS
): string[] {
return requiredPermissions.filter((permission) => !manifestXml.includes(`android:name="${permission}"`));
}

View File

@@ -0,0 +1,76 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildMobileUpdateState,
createInitialMobileUpdateState,
getMobileUpdateStatusLabel,
mapUpdateAvailabilityToStatus,
resolveMobileUpdateStatusMessage
} from './mobile-app-update.rules';
import type { MobileAppUpdateInfoSnapshot } from './mobile-app-update.rules';
describe('mobile-app-update.rules', () => {
it('creates an idle unsupported state for non-native shells', () => {
const state = createInitialMobileUpdateState({ isSupported: false });
expect(state.status).toBe('unsupported');
expect(state.isSupported).toBe(false);
expect(state.currentVersion).toBe('0.0.0');
});
it('maps update availability to mobile update statuses', () => {
expect(mapUpdateAvailabilityToStatus('checking')).toBe('checking');
expect(mapUpdateAvailabilityToStatus('update-available')).toBe('update-available');
expect(mapUpdateAvailabilityToStatus('update-not-available')).toBe('up-to-date');
expect(mapUpdateAvailabilityToStatus('unknown')).toBe('idle');
});
it('builds an update-available state from store metadata', () => {
const snapshot: MobileAppUpdateInfoSnapshot = {
availableVersion: '1.2.0',
currentVersion: '1.1.0',
flexibleUpdateAllowed: true,
immediateUpdateAllowed: false,
platform: 'android',
updateAvailability: 'update-available'
};
const state = buildMobileUpdateState(snapshot, {
isSupported: true,
lastCheckedAt: 1_700_000_000_000
});
expect(state.status).toBe('update-available');
expect(state.availableVersion).toBe('1.2.0');
expect(state.currentVersion).toBe('1.1.0');
expect(state.flexibleUpdateAllowed).toBe(true);
expect(state.immediateUpdateAllowed).toBe(false);
expect(state.lastCheckedAt).toBe(1_700_000_000_000);
expect(state.statusMessage).toContain('1.2.0');
});
it('builds an up-to-date state when the store reports no update', () => {
const snapshot: MobileAppUpdateInfoSnapshot = {
availableVersion: null,
currentVersion: '1.1.0',
flexibleUpdateAllowed: false,
immediateUpdateAllowed: false,
platform: 'ios',
updateAvailability: 'update-not-available'
};
const state = buildMobileUpdateState(snapshot, { isSupported: true });
expect(state.status).toBe('up-to-date');
expect(state.statusMessage).toContain('up to date');
});
it('maps errors to a friendly status message', () => {
expect(resolveMobileUpdateStatusMessage('error', new Error('Required app information could not be fetched')))
.toContain('store');
expect(getMobileUpdateStatusLabel('update-available')).toBe('Update available');
});
});

View File

@@ -0,0 +1,151 @@
export type MobileUpdateAvailability = 'unknown' | 'update-available' | 'update-not-available' | 'checking';
export type MobileUpdateStatus =
| 'idle'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'update-available'
| 'unsupported'
| 'error';
export type MobileUpdatePlatform = 'android' | 'ios' | null;
export interface MobileAppUpdateInfoSnapshot {
availableVersion: string | null;
currentVersion: string;
flexibleUpdateAllowed: boolean;
immediateUpdateAllowed: boolean;
platform: MobileUpdatePlatform;
updateAvailability: MobileUpdateAvailability;
}
export interface MobileUpdateState {
availableVersion: string | null;
currentVersion: string;
flexibleUpdateAllowed: boolean;
immediateUpdateAllowed: boolean;
isSupported: boolean;
lastCheckedAt: number | null;
platform: MobileUpdatePlatform;
status: MobileUpdateStatus;
statusMessage: string | null;
}
export function createInitialMobileUpdateState(
options: { isSupported: boolean; currentVersion?: string } = { isSupported: false }
): MobileUpdateState {
return {
availableVersion: null,
currentVersion: options.currentVersion ?? '0.0.0',
flexibleUpdateAllowed: false,
immediateUpdateAllowed: false,
isSupported: options.isSupported,
lastCheckedAt: null,
platform: null,
status: options.isSupported ? 'idle' : 'unsupported',
statusMessage: options.isSupported
? 'Waiting for the first store update check.'
: 'Store updates are only available in the packaged Android or iOS app.'
};
}
export function mapUpdateAvailabilityToStatus(
availability: MobileUpdateAvailability
): MobileUpdateStatus {
switch (availability) {
case 'checking':
return 'checking';
case 'update-available':
return 'update-available';
case 'update-not-available':
return 'up-to-date';
default:
return 'idle';
}
}
export function buildMobileUpdateState(
snapshot: MobileAppUpdateInfoSnapshot,
options: {
isSupported: boolean;
lastCheckedAt?: number | null;
statusOverride?: MobileUpdateStatus;
statusMessage?: string | null;
}
): MobileUpdateState {
const status = options.statusOverride ?? mapUpdateAvailabilityToStatus(snapshot.updateAvailability);
return {
availableVersion: snapshot.availableVersion,
currentVersion: snapshot.currentVersion,
flexibleUpdateAllowed: snapshot.flexibleUpdateAllowed,
immediateUpdateAllowed: snapshot.immediateUpdateAllowed,
isSupported: options.isSupported,
lastCheckedAt: options.lastCheckedAt ?? null,
platform: snapshot.platform,
status,
statusMessage: options.statusMessage ?? resolveMobileUpdateStatusMessage(status, null, snapshot)
};
}
export function resolveMobileUpdateStatusMessage(
status: MobileUpdateStatus,
error: unknown = null,
snapshot: MobileAppUpdateInfoSnapshot | null = null
): string | null {
if (status === 'error') {
const message = error instanceof Error ? error.message : String(error ?? '');
if (message.includes('Required app information could not be fetched')) {
return 'The app store could not return release information. Confirm the app is published and try again.';
}
return message.trim() || 'Unable to check for mobile app updates.';
}
if (status === 'unsupported') {
return 'Store updates are only available in the packaged Android or iOS app.';
}
if (status === 'checking') {
return 'Checking the app store for a newer release...';
}
if (status === 'downloading') {
return 'Downloading the update from the app store...';
}
if (status === 'update-available' && snapshot?.availableVersion) {
return `MetoYou ${snapshot.availableVersion} is available in the app store.`;
}
if (status === 'up-to-date' && snapshot?.currentVersion) {
return `MetoYou ${snapshot.currentVersion} is up to date.`;
}
if (status === 'idle') {
return 'Waiting for the first store update check.';
}
return null;
}
export function getMobileUpdateStatusLabel(status: MobileUpdateStatus): string {
switch (status) {
case 'checking':
return 'Checking';
case 'downloading':
return 'Downloading';
case 'update-available':
return 'Update available';
case 'up-to-date':
return 'Up to date';
case 'unsupported':
return 'Unsupported';
case 'error':
return 'Error';
default:
return 'Idle';
}
}

View File

@@ -0,0 +1,36 @@
import {
describe,
expect,
it
} from 'vitest';
import {
isCameraCaptureAllowed,
isMobileCapturePermissionGranted,
isVoiceCaptureAllowed,
shouldPreflightMobileCapturePermissions
} from './mobile-media-permission.rules';
describe('mobile-media-permission.rules', () => {
it('only preflights capture permissions on Capacitor shells', () => {
expect(shouldPreflightMobileCapturePermissions('capacitor')).toBe(true);
expect(shouldPreflightMobileCapturePermissions('browser')).toBe(false);
expect(shouldPreflightMobileCapturePermissions('electron')).toBe(false);
});
it('treats granted as the only successful native permission state', () => {
expect(isMobileCapturePermissionGranted('granted')).toBe(true);
expect(isMobileCapturePermissionGranted('prompt')).toBe(false);
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
});
it('requires microphone permission for voice capture', () => {
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
});
it('requires camera permission for camera capture', () => {
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(false);
});
});

View File

@@ -0,0 +1,28 @@
import type { RuntimePlatform } from './platform-detection.rules';
export type MobileMediaPermissionState = 'granted' | 'denied' | 'prompt' | string;
export interface MobileCapturePermissionResult {
microphone?: MobileMediaPermissionState;
camera?: MobileMediaPermissionState;
}
/** Whether a Capacitor permission alias was granted by the native shell. */
export function isMobileCapturePermissionGranted(state: MobileMediaPermissionState | undefined): boolean {
return state === 'granted';
}
/** Native Android/iOS shells need an explicit runtime grant before WebRTC capture works reliably. */
export function shouldPreflightMobileCapturePermissions(runtime: RuntimePlatform): boolean {
return runtime === 'capacitor';
}
/** Resolve whether voice capture can proceed after a native permission request. */
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
return isMobileCapturePermissionGranted(result.microphone);
}
/** Resolve whether camera capture can proceed after a native permission request. */
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
return isMobileCapturePermissionGranted(result.camera);
}

View File

@@ -28,7 +28,6 @@ describe('mobile-sqlite-row-mapper.rules', () => {
kind: 'user' as const,
reactions: []
};
const row = messageToRow(message);
const restored = rowToMessage(row, [{ id: 'rx1', messageId: 'm1', oderId: 'u1', userId: 'u1', emoji: '👍', timestamp: 102 }]);

View File

@@ -0,0 +1,48 @@
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import {
describe,
expect,
it
} from 'vitest';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../core/platform/viewport.service';
import { MobileAppUpdateService } from './mobile-app-update.service';
import { MobilePlatformService } from './mobile-platform.service';
function createService(isMobile: boolean): MobileAppUpdateService {
const injector = Injector.create({
providers: [
MobileAppUpdateService,
MobilePlatformService,
{
provide: ElectronBridgeService,
useValue: { isAvailable: false }
},
{
provide: ViewportService,
useValue: {
isMobile: () => isMobile
}
}
]
});
return runInInjectionContext(injector, () => injector.get(MobileAppUpdateService));
}
describe('MobileAppUpdateService', () => {
it('reports unsupported state on browser shells', () => {
const service = createService(false);
expect(service.isCapacitor).toBe(false);
expect(service.state().status).toBe('unsupported');
});
it('exposes capacitor support flag from the mobile platform service', () => {
const service = createService(true);
expect(service.isCapacitor).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
import {
Injectable,
inject,
signal
} from '@angular/core';
import { WebMobileAppUpdateAdapter } from '../adapters/web/web-mobile-app-update.adapter';
import type { MobileAppUpdateAdapter } from '../contracts/mobile.contracts';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import {
buildMobileUpdateState,
createInitialMobileUpdateState,
resolveMobileUpdateStatusMessage,
type MobileUpdateState
} from '../logic/mobile-app-update.rules';
import { MobilePlatformService } from './mobile-platform.service';
const DEFAULT_POLL_INTERVAL_MS = 30 * 60_000;
@Injectable({ providedIn: 'root' })
export class MobileAppUpdateService {
readonly state = signal<MobileUpdateState>(createInitialMobileUpdateState());
private readonly mobilePlatform = inject(MobilePlatformService);
private adapter: MobileAppUpdateAdapter = new WebMobileAppUpdateAdapter();
private adapterReady: Promise<MobileAppUpdateAdapter> | null = null;
private initialized = false;
private pollTimerId: number | null = null;
get isCapacitor(): boolean {
return this.mobilePlatform.isCapacitor();
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
this.initialized = true;
const adapter = await this.ensureAdapter();
this.state.set(createInitialMobileUpdateState({
isSupported: adapter.isSupported
}));
if (!adapter.isSupported) {
return;
}
this.startPolling();
await this.checkForUpdates();
}
async checkForUpdates(): Promise<void> {
const adapter = await this.ensureAdapter();
if (!adapter.isSupported) {
return;
}
this.state.update((current) => ({
...current,
status: 'checking',
statusMessage: resolveMobileUpdateStatusMessage('checking')
}));
try {
const snapshot = await adapter.getAppUpdateInfo();
this.state.set(buildMobileUpdateState(snapshot, {
isSupported: true,
lastCheckedAt: Date.now()
}));
} catch (error) {
this.state.set({
...createInitialMobileUpdateState({ isSupported: true }),
lastCheckedAt: Date.now(),
status: 'error',
statusMessage: resolveMobileUpdateStatusMessage('error', error)
});
}
}
async openAppStore(): Promise<void> {
const adapter = await this.ensureAdapter();
if (!adapter.isSupported) {
return;
}
await adapter.openAppStore();
}
async performImmediateUpdate(): Promise<void> {
const adapter = await this.ensureAdapter();
if (!adapter.isSupported) {
return;
}
this.state.update((current) => ({
...current,
status: 'downloading',
statusMessage: resolveMobileUpdateStatusMessage('downloading')
}));
await adapter.performImmediateUpdate();
}
async startFlexibleUpdate(): Promise<void> {
const adapter = await this.ensureAdapter();
if (!adapter.isSupported) {
return;
}
this.state.update((current) => ({
...current,
status: 'downloading',
statusMessage: resolveMobileUpdateStatusMessage('downloading')
}));
await adapter.startFlexibleUpdate();
}
async completeFlexibleUpdate(): Promise<void> {
const adapter = await this.ensureAdapter();
if (!adapter.isSupported) {
return;
}
await adapter.completeFlexibleUpdate();
}
private ensureAdapter(): Promise<MobileAppUpdateAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(
this.mobilePlatform.runtime(),
this.adapter,
async () => {
const { CapacitorMobileAppUpdateAdapter } = await import('../adapters/capacitor/capacitor-mobile-app-update.adapter');
return new CapacitorMobileAppUpdateAdapter();
}
).then((adapter) => {
this.adapter = adapter;
this.mobilePlatform.refreshRuntimeDetection();
return adapter;
});
}
return this.adapterReady;
}
private startPolling(): void {
if (this.pollTimerId !== null || typeof window === 'undefined') {
return;
}
this.pollTimerId = window.setInterval(() => {
void this.checkForUpdates();
}, DEFAULT_POLL_INTERVAL_MS);
}
}

View File

@@ -5,6 +5,7 @@ import {
} from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../logic/ensure-mobile-capture-permissions';
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -35,6 +36,14 @@ export class MobileMediaService {
return this.ensureAdapter().then((adapter) => adapter.stopBackgroundAudioSession());
}
ensureVoiceCapturePermissions(): Promise<boolean> {
return ensureMobileVoiceCapturePermissions();
}
ensureCameraCapturePermissions(): Promise<boolean> {
return ensureMobileCameraCapturePermissions();
}
private ensureAdapter(): Promise<MobileMediaAdapter> {
if (!this.adapterReady) {
this.adapterReady = resolveMobileAdapter(