feat: Add slashcommand api
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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> {}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}"`));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user