feat: Android APP V1 - Experimental Alpha
This commit is contained in:
27
toju-app/src/app/infrastructure/mobile/README.md
Normal file
27
toju-app/src/app/infrastructure/mobile/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Mobile infrastructure
|
||||
|
||||
Loosely coupled Capacitor/native bridge for the Angular product client. Domains depend on facades in this folder — never on `@capacitor/*` imports directly.
|
||||
|
||||
## Facades
|
||||
|
||||
| Service | Responsibility |
|
||||
|---------|----------------|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
## Adapters
|
||||
|
||||
- `adapters/web/*` — browser fallbacks (Notification API, hidden file input, Document PiP).
|
||||
- `adapters/capacitor/*` — lazy-loaded Capacitor plugins via `capacitor-plugin-loader.ts`.
|
||||
|
||||
## Rules
|
||||
|
||||
Pure platform/call-notification rules live in `logic/*.rules.ts` and are Vitest-tested without Angular.
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MobileAppLifecycleAdapter } from '../../contracts/mobile.contracts';
|
||||
import { loadCapacitorAppPlugin } from './capacitor-plugin-loader';
|
||||
|
||||
/** Capacitor App plugin lifecycle bridge. */
|
||||
export class CapacitorMobileAppLifecycleAdapter implements MobileAppLifecycleAdapter {
|
||||
private handler: ((isActive: boolean) => void) | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const App = loadCapacitorAppPlugin();
|
||||
|
||||
if (!App) {
|
||||
return;
|
||||
}
|
||||
|
||||
await App.addListener('appStateChange', (state) => {
|
||||
this.handler?.(state.isActive);
|
||||
});
|
||||
}
|
||||
|
||||
onAppStateChange(handler: (isActive: boolean) => void): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
|
||||
import { MetoyouMobile } from './metoyou-mobile.plugin';
|
||||
|
||||
/** iOS CallKit bridge via the MetoyouMobile native plugin. */
|
||||
export class CapacitorMobileCallKitAdapter implements MobileCallKitAdapter {
|
||||
async startActiveCall(callId: string, displayName: string): Promise<void> {
|
||||
try {
|
||||
const result = await MetoyouMobile.startCallKitSession({ callId, displayName });
|
||||
|
||||
if (!result.supported) {
|
||||
console.info('[mobile] CallKit is unavailable on this iOS build');
|
||||
}
|
||||
} catch (error) {
|
||||
console.info('[mobile] CallKit start skipped', error);
|
||||
}
|
||||
}
|
||||
|
||||
async endActiveCall(callId: string): Promise<void> {
|
||||
try {
|
||||
await MetoyouMobile.endCallKitSession({ callId });
|
||||
} catch (error) {
|
||||
console.info('[mobile] CallKit end skipped', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
|
||||
import { loadCapacitorAudioSessionPlugin } from './capacitor-plugin-loader';
|
||||
import { MetoyouMobile } from './metoyou-mobile.plugin';
|
||||
import { WebMobileMediaAdapter } from '../web/web-mobile-media.adapter';
|
||||
|
||||
/** Capacitor media adapter with native speaker routing and background voice session hooks. */
|
||||
export class CapacitorMobileMediaAdapter extends WebMobileMediaAdapter implements MobileMediaAdapter {
|
||||
private backgroundSessionActive = false;
|
||||
|
||||
override async setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await MetoyouMobile.setSpeakerphoneEnabled({ enabled });
|
||||
return;
|
||||
} catch {
|
||||
// Android plugin unavailable in web builds; fall through to iOS audio session.
|
||||
}
|
||||
|
||||
const AudioSession = loadCapacitorAudioSessionPlugin();
|
||||
|
||||
if (!AudioSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await AudioSession.overrideOutput(enabled ? 'speaker' : 'default');
|
||||
}
|
||||
|
||||
override async startBackgroundAudioSession(): Promise<void> {
|
||||
if (this.backgroundSessionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.backgroundSessionActive = true;
|
||||
|
||||
try {
|
||||
await MetoyouMobile.startVoiceForegroundService();
|
||||
} catch (error) {
|
||||
console.info('[mobile] background voice foreground service unavailable', error);
|
||||
}
|
||||
}
|
||||
|
||||
override async stopBackgroundAudioSession(): Promise<void> {
|
||||
if (!this.backgroundSessionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.backgroundSessionActive = false;
|
||||
|
||||
try {
|
||||
await MetoyouMobile.stopVoiceForegroundService();
|
||||
} catch (error) {
|
||||
console.info('[mobile] failed to stop background voice session', error);
|
||||
}
|
||||
}
|
||||
|
||||
override isScreenShareSupported(): boolean {
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
|
||||
if (/iPhone|iPad|iPod/i.test(userAgent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!navigator.mediaDevices?.getDisplayMedia;
|
||||
}
|
||||
|
||||
override isPictureInPictureSupported(): boolean {
|
||||
return super.isPictureInPictureSupported();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
||||
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
|
||||
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
||||
import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from './capacitor-plugin-loader';
|
||||
|
||||
const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call';
|
||||
const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call';
|
||||
|
||||
/** Capacitor local + push notification bridge with action buttons for in-call controls. */
|
||||
export class CapacitorMobileNotificationsAdapter implements MobileNotificationAdapter {
|
||||
private actionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null;
|
||||
private listenersRegistered = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
||||
const PushNotifications = loadCapacitorPushNotificationsPlugin();
|
||||
|
||||
if (!LocalNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
await LocalNotifications.createChannel({
|
||||
id: INCOMING_CALL_CHANNEL_ID,
|
||||
name: 'Incoming calls',
|
||||
importance: 5,
|
||||
visibility: 1,
|
||||
sound: 'call.wav'
|
||||
});
|
||||
|
||||
await LocalNotifications.createChannel({
|
||||
id: ACTIVE_CALL_CHANNEL_ID,
|
||||
name: 'Active calls',
|
||||
importance: 4,
|
||||
visibility: 1
|
||||
});
|
||||
|
||||
await LocalNotifications.registerActionTypes({
|
||||
types: [
|
||||
{
|
||||
id: 'INCOMING_CALL_ACTIONS',
|
||||
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
|
||||
},
|
||||
{
|
||||
id: 'ACTIVE_CALL_ACTIONS',
|
||||
actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!this.listenersRegistered) {
|
||||
await LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
|
||||
const callId = event.notification.extra?.callId as string | undefined;
|
||||
const intent = resolveCallNotificationAction(event.actionId);
|
||||
|
||||
if (!callId || !intent || !this.actionHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionHandler({ callId, intent });
|
||||
});
|
||||
|
||||
this.listenersRegistered = true;
|
||||
}
|
||||
|
||||
if (PushNotifications) {
|
||||
const permission = await PushNotifications.checkPermissions();
|
||||
|
||||
if (permission.receive === 'prompt') {
|
||||
await PushNotifications.requestPermissions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
||||
|
||||
if (!LocalNotifications) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await LocalNotifications.checkPermissions();
|
||||
|
||||
if (permission.display === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requested = await LocalNotifications.requestPermissions();
|
||||
|
||||
return requested.display === 'granted';
|
||||
}
|
||||
|
||||
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
|
||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
||||
|
||||
if (!LocalNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
const granted = await this.requestPermission();
|
||||
|
||||
if (!granted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
id: payload.id,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
channelId: payload.kind === 'incoming' ? INCOMING_CALL_CHANNEL_ID : ACTIVE_CALL_CHANNEL_ID,
|
||||
ongoing: payload.kind === 'active',
|
||||
autoCancel: payload.kind === 'incoming',
|
||||
extra: {
|
||||
callId: payload.callId,
|
||||
kind: payload.kind
|
||||
},
|
||||
actionTypeId: payload.kind === 'active' ? 'ACTIVE_CALL_ACTIONS' : 'INCOMING_CALL_ACTIONS'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
|
||||
const LocalNotifications = loadCapacitorLocalNotificationsPlugin();
|
||||
|
||||
if (!LocalNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await LocalNotifications.getDeliveredNotifications();
|
||||
const matching = notifications.notifications.filter((notification) => {
|
||||
const extraCallId = notification.extra?.callId as string | undefined;
|
||||
const extraKind = notification.extra?.kind as CallNotificationPayload['kind'] | undefined;
|
||||
|
||||
return extraCallId === callId && extraKind === kind;
|
||||
});
|
||||
|
||||
if (matching.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await LocalNotifications.removeDeliveredNotifications({
|
||||
notifications: matching
|
||||
});
|
||||
}
|
||||
|
||||
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
||||
this.actionHandler = handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MobilePersistenceAdapter } from '../../contracts/mobile.contracts';
|
||||
import { MobileSqliteConnectionService } from '../../services/mobile-sqlite-connection.service';
|
||||
|
||||
/**
|
||||
* Capacitor SQLite persistence adapter.
|
||||
*
|
||||
* Initializes native SQLite with schema mirrored from Electron TypeORM entities.
|
||||
* Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells.
|
||||
*/
|
||||
export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter {
|
||||
private initialized = false;
|
||||
|
||||
constructor(private readonly connection: MobileSqliteConnectionService) {}
|
||||
|
||||
get isNativeSqlite(): boolean {
|
||||
return this.connection.isAvailable;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await this.connection.initialize();
|
||||
|
||||
if (!store?.isAvailable) {
|
||||
console.warn('[mobile] SQLite plugin unavailable on this Capacitor shell');
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
console.info('[mobile] native SQLite persistence initialized');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
|
||||
import { MetoyouMobile } from './metoyou-mobile.plugin';
|
||||
import { WebMobilePictureInPictureAdapter } from '../web/web-mobile-picture-in-picture.adapter';
|
||||
|
||||
/** Capacitor PiP adapter with Document PiP first and native Android PiP fallback. */
|
||||
export class CapacitorMobilePictureInPictureAdapter extends WebMobilePictureInPictureAdapter implements MobilePictureInPictureAdapter {
|
||||
private nativeSupported: boolean | null = null;
|
||||
|
||||
override isSupported(): boolean {
|
||||
if (super.isSupported()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.nativeSupported === true;
|
||||
}
|
||||
|
||||
override async enter(videoElement: HTMLVideoElement): Promise<void> {
|
||||
if (super.isSupported()) {
|
||||
await super.enter(videoElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await MetoyouMobile.enterNativePictureInPicture();
|
||||
|
||||
this.nativeSupported = result.supported;
|
||||
|
||||
if (!result.supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoElement.paused) {
|
||||
await videoElement.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
override async exit(): Promise<void> {
|
||||
if (document.pictureInPictureElement) {
|
||||
await super.exit();
|
||||
}
|
||||
|
||||
await MetoyouMobile.exitNativePictureInPicture().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
const capacitorState = vi.hoisted(() => ({
|
||||
isNativePlatform: true,
|
||||
isPluginAvailable: true,
|
||||
platform: 'android'
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
Capacitor: {
|
||||
isNativePlatform: () => capacitorState.isNativePlatform,
|
||||
isPluginAvailable: (name: string) => capacitorState.isPluginAvailable && name.length > 0,
|
||||
getPlatform: () => capacitorState.platform
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/app', () => ({
|
||||
App: {
|
||||
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/local-notifications', () => ({
|
||||
LocalNotifications: {
|
||||
checkPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { App } from '@capacitor/app';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { loadCapacitorAppPlugin, loadCapacitorLocalNotificationsPlugin } from './capacitor-plugin-loader';
|
||||
|
||||
describe('capacitor-plugin-loader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capacitorState.isNativePlatform = true;
|
||||
capacitorState.isPluginAvailable = true;
|
||||
capacitorState.platform = 'android';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns registered plugin instances synchronously without wrapping them in a Promise', () => {
|
||||
const appPlugin = loadCapacitorAppPlugin();
|
||||
const notificationsPlugin = loadCapacitorLocalNotificationsPlugin();
|
||||
|
||||
expect(appPlugin).toBe(App);
|
||||
expect(notificationsPlugin).toBe(LocalNotifications);
|
||||
expect(appPlugin).not.toBeInstanceOf(Promise);
|
||||
expect(notificationsPlugin).not.toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it('returns null when the plugin is unavailable on the active native shell', () => {
|
||||
capacitorState.isPluginAvailable = false;
|
||||
|
||||
expect(loadCapacitorAppPlugin()).toBeNull();
|
||||
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on non-native shells', () => {
|
||||
capacitorState.isNativePlatform = false;
|
||||
|
||||
expect(loadCapacitorAppPlugin()).toBeNull();
|
||||
expect(loadCapacitorLocalNotificationsPlugin()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { App } from '@capacitor/app';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Device } from '@capacitor/device';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import { AudioSession } from '@capgo/capacitor-audio-session';
|
||||
|
||||
function resolveCapacitorPlugin<T>(pluginName: string, plugin: T): T | null {
|
||||
if (typeof window === 'undefined' || !Capacitor.isNativePlatform()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Capacitor.isPluginAvailable(pluginName)) {
|
||||
console.warn(`[mobile] Capacitor plugin "${pluginName}" is not implemented on ${Capacitor.getPlatform()}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor App plugin on native shells; returns null on web/electron or when unavailable. */
|
||||
export function loadCapacitorAppPlugin(): typeof App | null {
|
||||
return resolveCapacitorPlugin('App', App);
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor LocalNotifications plugin on native shells. */
|
||||
export function loadCapacitorLocalNotificationsPlugin(): typeof LocalNotifications | null {
|
||||
return resolveCapacitorPlugin('LocalNotifications', LocalNotifications);
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor PushNotifications plugin on native shells. */
|
||||
export function loadCapacitorPushNotificationsPlugin(): typeof PushNotifications | null {
|
||||
return resolveCapacitorPlugin('PushNotifications', PushNotifications);
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor Device plugin on native shells. */
|
||||
export function loadCapacitorDevicePlugin(): typeof Device | null {
|
||||
return resolveCapacitorPlugin('Device', Device);
|
||||
}
|
||||
|
||||
/** Resolve the Capacitor AudioSession plugin on native shells. */
|
||||
export function loadCapacitorAudioSessionPlugin(): typeof AudioSession | null {
|
||||
return resolveCapacitorPlugin('AudioSession', AudioSession);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
MOBILE_SQLITE_DATABASE_NAME,
|
||||
MOBILE_SQLITE_SCHEMA_VERSION,
|
||||
resolveMobileSqliteMigrationStatements
|
||||
} from '../../logic/mobile-sqlite-schema.rules';
|
||||
import { executeMobileSqliteStatements } from '../../logic/mobile-sqlite-execute.rules';
|
||||
|
||||
const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version';
|
||||
|
||||
export interface MobileSqliteStore {
|
||||
readonly isAvailable: boolean;
|
||||
initialize(): Promise<void>;
|
||||
run(statement: string, values?: unknown[]): Promise<void>;
|
||||
query<T>(statement: string, values?: unknown[]): Promise<T[]>;
|
||||
}
|
||||
|
||||
const schemaInitializationFailures = new Set<string>();
|
||||
|
||||
/** Lazy-loaded @capacitor-community/sqlite connection for native mobile shells. */
|
||||
export async function createCapacitorSqliteStore(
|
||||
databaseName: string = MOBILE_SQLITE_DATABASE_NAME
|
||||
): Promise<MobileSqliteStore | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const sqliteModule = await import('@capacitor-community/sqlite');
|
||||
|
||||
type SqliteDbConnection = import('@capacitor-community/sqlite').SQLiteDBConnection;
|
||||
|
||||
const sqliteConnection = new sqliteModule.SQLiteConnection(sqliteModule.CapacitorSQLite);
|
||||
|
||||
let database: SqliteDbConnection | null = null;
|
||||
|
||||
return {
|
||||
get isAvailable() {
|
||||
return database !== null && !schemaInitializationFailures.has(databaseName);
|
||||
},
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (schemaInitializationFailures.has(databaseName)) {
|
||||
throw new Error(`Mobile SQLite schema initialization failed for "${databaseName}".`);
|
||||
}
|
||||
|
||||
await sqliteConnection.checkConnectionsConsistency();
|
||||
const connectionState = await sqliteConnection.isConnection(databaseName, false);
|
||||
|
||||
database = connectionState.result
|
||||
? await sqliteConnection.retrieveConnection(databaseName, false)
|
||||
: await sqliteConnection.createConnection(
|
||||
databaseName,
|
||||
false,
|
||||
'no-encryption',
|
||||
MOBILE_SQLITE_SCHEMA_VERSION,
|
||||
false
|
||||
);
|
||||
|
||||
await database.open();
|
||||
|
||||
let storedVersion = 0;
|
||||
|
||||
try {
|
||||
const versionRows = await database.query(`SELECT value FROM meta WHERE key = '${META_SCHEMA_VERSION_KEY}' LIMIT 1`);
|
||||
|
||||
storedVersion = Number(versionRows.values?.[0]?.value ?? 0);
|
||||
} catch {
|
||||
storedVersion = 0;
|
||||
}
|
||||
|
||||
const statements = resolveMobileSqliteMigrationStatements(storedVersion);
|
||||
|
||||
if (statements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeDatabase = database;
|
||||
|
||||
if (!activeDatabase) {
|
||||
throw new Error('Mobile SQLite store is not initialized.');
|
||||
}
|
||||
|
||||
await executeMobileSqliteStatements(
|
||||
(statement) => activeDatabase.execute(statement),
|
||||
statements
|
||||
);
|
||||
} catch (error) {
|
||||
schemaInitializationFailures.add(databaseName);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async run(statement: string, values: unknown[] = []): Promise<void> {
|
||||
if (!database) {
|
||||
throw new Error('Mobile SQLite store is not initialized.');
|
||||
}
|
||||
|
||||
await database.run(statement, values);
|
||||
},
|
||||
|
||||
async query<T>(statement: string, values: unknown[] = []): Promise<T[]> {
|
||||
if (!database) {
|
||||
throw new Error('Mobile SQLite store is not initialized.');
|
||||
}
|
||||
|
||||
const result = await database.query(statement, values);
|
||||
|
||||
return (result.values ?? []) as T[];
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
export interface MetoyouMobilePlugin {
|
||||
setSpeakerphoneEnabled(options: { enabled: boolean }): Promise<void>;
|
||||
startVoiceForegroundService(): Promise<void>;
|
||||
stopVoiceForegroundService(): Promise<void>;
|
||||
enterNativePictureInPicture(): Promise<{ supported: boolean }>;
|
||||
exitNativePictureInPicture(): Promise<void>;
|
||||
startCallKitSession(options: { callId: string; displayName: string }): Promise<{ supported: boolean }>;
|
||||
endCallKitSession(options: { callId: string }): Promise<void>;
|
||||
isRemotePushConfigured(): Promise<{ configured: boolean }>;
|
||||
}
|
||||
|
||||
export const MetoyouMobile = registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MobileAppLifecycleAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
/** Visibility API fallback for browser runtimes. */
|
||||
export class WebMobileAppLifecycleAdapter implements MobileAppLifecycleAdapter {
|
||||
private handler: ((isActive: boolean) => void) | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.handler?.(!document.hidden);
|
||||
});
|
||||
}
|
||||
|
||||
onAppStateChange(handler: (isActive: boolean) => void): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MobileCallKitAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
/** Web shells do not expose CallKit. */
|
||||
export class WebMobileCallKitAdapter implements MobileCallKitAdapter {
|
||||
async startActiveCall(): Promise<void> {}
|
||||
|
||||
async endActiveCall(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { MobileMediaAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
/** Web fallback for mobile media affordances. */
|
||||
export class WebMobileMediaAdapter implements MobileMediaAdapter {
|
||||
async pickAttachments(): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = 'image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.doc,.docx,.xls,.xlsx,.ppt,.pptx';
|
||||
input.style.display = 'none';
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
|
||||
input.remove();
|
||||
resolve(files);
|
||||
}, { once: true });
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
async setSpeakerphoneEnabled(_enabled: boolean): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async startBackgroundAudioSession(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async stopBackgroundAudioSession(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
isScreenShareSupported(): boolean {
|
||||
return typeof navigator !== 'undefined'
|
||||
&& !!navigator.mediaDevices?.getDisplayMedia
|
||||
&& !/iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
isPictureInPictureSupported(): boolean {
|
||||
return typeof document !== 'undefined'
|
||||
&& 'pictureInPictureEnabled' in document
|
||||
&& document.pictureInPictureEnabled === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
||||
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
|
||||
|
||||
/** Browser Notification API fallback for web and Capacitor dev shells. */
|
||||
export class WebMobileNotificationsAdapter implements MobileNotificationAdapter {
|
||||
private actionHandler: CallActionHandler | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
return permission === 'granted';
|
||||
}
|
||||
|
||||
async showCallNotification(payload: CallNotificationPayload): Promise<void> {
|
||||
const granted = await this.requestPermission();
|
||||
|
||||
if (!granted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification(payload.title, {
|
||||
body: payload.body,
|
||||
tag: `toju-call-${payload.callId}-${payload.kind}`
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
this.actionHandler?.({
|
||||
callId: payload.callId,
|
||||
intent: payload.kind === 'incoming' ? 'answer' : 'toggle-mute'
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
onActionSelected(handler: CallActionHandler): void {
|
||||
this.actionHandler = handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MobilePersistenceAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
/** Web persistence marker - IndexedDB remains the active store via DatabaseService. */
|
||||
export class WebMobilePersistenceAdapter implements MobilePersistenceAdapter {
|
||||
readonly isNativeSqlite = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MobilePictureInPictureAdapter } from '../../contracts/mobile.contracts';
|
||||
|
||||
/** Document Picture-in-Picture API adapter for supported browsers. */
|
||||
export class WebMobilePictureInPictureAdapter implements MobilePictureInPictureAdapter {
|
||||
isSupported(): boolean {
|
||||
return typeof document !== 'undefined'
|
||||
&& 'pictureInPictureEnabled' in document
|
||||
&& document.pictureInPictureEnabled === true;
|
||||
}
|
||||
|
||||
async enter(videoElement: HTMLVideoElement): Promise<void> {
|
||||
if (!this.isSupported() || document.pictureInPictureElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
await videoElement.requestPictureInPicture();
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
if (!document.pictureInPictureElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.exitPictureInPicture();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
|
||||
import type { RuntimePlatform } from '../logic/platform-detection.rules';
|
||||
|
||||
export interface MobileNotificationAdapter {
|
||||
initialize(): Promise<void>;
|
||||
requestPermission(): Promise<boolean>;
|
||||
showCallNotification(payload: CallNotificationPayload): Promise<void>;
|
||||
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
|
||||
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
|
||||
}
|
||||
|
||||
export interface MobileMediaAdapter {
|
||||
pickAttachments(): Promise<File[]>;
|
||||
setSpeakerphoneEnabled(enabled: boolean): Promise<void>;
|
||||
startBackgroundAudioSession(): Promise<void>;
|
||||
stopBackgroundAudioSession(): Promise<void>;
|
||||
isScreenShareSupported(): boolean;
|
||||
isPictureInPictureSupported(): boolean;
|
||||
}
|
||||
|
||||
export interface MobilePictureInPictureAdapter {
|
||||
isSupported(): boolean;
|
||||
enter(videoElement: HTMLVideoElement): Promise<void>;
|
||||
exit(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MobilePersistenceAdapter {
|
||||
readonly isNativeSqlite: boolean;
|
||||
initialize(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MobileAppLifecycleAdapter {
|
||||
initialize(): Promise<void>;
|
||||
onAppStateChange(handler: (isActive: boolean) => void): void;
|
||||
}
|
||||
|
||||
export interface MobileCallKitAdapter {
|
||||
startActiveCall(callId: string, displayName: string): Promise<void>;
|
||||
endActiveCall(callId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MobilePlatformSnapshot {
|
||||
runtime: RuntimePlatform;
|
||||
isNativeMobile: boolean;
|
||||
isCapacitor: boolean;
|
||||
}
|
||||
12
toju-app/src/app/infrastructure/mobile/index.ts
Normal file
12
toju-app/src/app/infrastructure/mobile/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './logic/platform-detection.rules';
|
||||
export * from './logic/call-notification.rules';
|
||||
export * from './services/mobile-platform.service';
|
||||
export * from './services/mobile-notifications.service';
|
||||
export * from './services/mobile-media.service';
|
||||
export * from './services/mobile-picture-in-picture.service';
|
||||
export * from './services/mobile-persistence.service';
|
||||
export * from './services/mobile-call-session.service';
|
||||
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';
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildIncomingCallNotification,
|
||||
buildInCallNotification,
|
||||
resolveCallNotificationAction
|
||||
} from './call-notification.rules';
|
||||
|
||||
describe('call-notification.rules', () => {
|
||||
it('builds an incoming call notification payload', () => {
|
||||
expect(buildIncomingCallNotification('Alex', 'call-1')).toMatchObject({
|
||||
title: 'Incoming call',
|
||||
body: 'Alex is calling you',
|
||||
callId: 'call-1',
|
||||
kind: 'incoming',
|
||||
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a persistent in-call notification with action ids', () => {
|
||||
const payload = buildInCallNotification({
|
||||
callId: 'call-2',
|
||||
displayName: 'Team call',
|
||||
isMuted: false
|
||||
});
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
title: 'Team call',
|
||||
body: 'Call in progress',
|
||||
callId: 'call-2',
|
||||
kind: 'active',
|
||||
actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('maps mute action to toggle mute intent', () => {
|
||||
expect(resolveCallNotificationAction('mute')).toBe('toggle-mute');
|
||||
expect(resolveCallNotificationAction('hangup')).toBe('hang-up');
|
||||
expect(resolveCallNotificationAction('answer')).toBe('answer');
|
||||
expect(resolveCallNotificationAction('unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
export type CallNotificationKind = 'incoming' | 'active';
|
||||
|
||||
export type CallNotificationActionId = 'answer' | 'mute' | 'hangup';
|
||||
|
||||
export type CallNotificationActionIntent = 'answer' | 'toggle-mute' | 'hang-up';
|
||||
|
||||
export interface CallNotificationPayload {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
callId: string;
|
||||
kind: CallNotificationKind;
|
||||
actions?: { id: CallNotificationActionId; title: string }[];
|
||||
}
|
||||
|
||||
const INCOMING_CALL_NOTIFICATION_BASE_ID = 1000;
|
||||
const ACTIVE_CALL_NOTIFICATION_BASE_ID = 2000;
|
||||
|
||||
/** Build a local notification payload for an incoming direct call. */
|
||||
export function buildIncomingCallNotification(displayName: string, callId: string): CallNotificationPayload {
|
||||
return {
|
||||
id: INCOMING_CALL_NOTIFICATION_BASE_ID + hashCallId(callId),
|
||||
title: 'Incoming call',
|
||||
body: `${displayName} is calling you`,
|
||||
callId,
|
||||
kind: 'incoming',
|
||||
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a persistent in-call notification payload with quick actions. */
|
||||
export function buildInCallNotification(input: {
|
||||
callId: string;
|
||||
displayName: string;
|
||||
isMuted: boolean;
|
||||
}): CallNotificationPayload {
|
||||
return {
|
||||
id: ACTIVE_CALL_NOTIFICATION_BASE_ID + hashCallId(input.callId),
|
||||
title: input.displayName,
|
||||
body: input.isMuted ? 'Call in progress · muted' : 'Call in progress',
|
||||
callId: input.callId,
|
||||
kind: 'active',
|
||||
actions: [{ id: 'mute', title: input.isMuted ? 'Unmute' : 'Mute' }, { id: 'hangup', title: 'Hang up' }]
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a notification action button id to a call-control intent. */
|
||||
export function resolveCallNotificationAction(actionId: string): CallNotificationActionIntent | null {
|
||||
switch (actionId) {
|
||||
case 'answer':
|
||||
return 'answer';
|
||||
case 'mute':
|
||||
return 'toggle-mute';
|
||||
case 'hangup':
|
||||
return 'hang-up';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hashCallId(callId: string): number {
|
||||
let hash = 0;
|
||||
|
||||
for (let index = 0; index < callId.length; index += 1) {
|
||||
hash = (hash * 31 + callId.charCodeAt(index)) % 997;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildRemotePushSkipMessage,
|
||||
resolveRemotePushSkipReason,
|
||||
shouldRegisterForRemotePush,
|
||||
type RemotePushRegistrationGateInput
|
||||
} from './mobile-push-registration.rules';
|
||||
|
||||
describe('mobile-push-registration.rules', () => {
|
||||
const configuredInput: RemotePushRegistrationGateInput = {
|
||||
hasPushPlugin: true,
|
||||
hasDevicePlugin: true,
|
||||
remotePushConfigured: true
|
||||
};
|
||||
|
||||
it('skips registration when remote push is not configured', () => {
|
||||
expect(
|
||||
shouldRegisterForRemotePush({
|
||||
...configuredInput,
|
||||
remotePushConfigured: false
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips registration when the push plugin is unavailable', () => {
|
||||
expect(
|
||||
shouldRegisterForRemotePush({
|
||||
...configuredInput,
|
||||
hasPushPlugin: false
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips registration when the device plugin is unavailable', () => {
|
||||
expect(
|
||||
shouldRegisterForRemotePush({
|
||||
...configuredInput,
|
||||
hasDevicePlugin: false
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows registration when plugins and remote push are available', () => {
|
||||
expect(shouldRegisterForRemotePush(configuredInput)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports why registration was skipped', () => {
|
||||
expect(
|
||||
resolveRemotePushSkipReason({
|
||||
...configuredInput,
|
||||
remotePushConfigured: false
|
||||
})
|
||||
).toBe('remote-push-not-configured');
|
||||
});
|
||||
|
||||
it('builds a single actionable skip message for missing Firebase setup', () => {
|
||||
expect(buildRemotePushSkipMessage('remote-push-not-configured')).toContain('google-services.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export type RemotePushSkipReason =
|
||||
| 'missing-push-plugin'
|
||||
| 'missing-device-plugin'
|
||||
| 'remote-push-not-configured'
|
||||
| 'remote-push-disabled';
|
||||
|
||||
export interface RemotePushRegistrationGateInput {
|
||||
hasPushPlugin: boolean;
|
||||
hasDevicePlugin: boolean;
|
||||
remotePushConfigured: boolean;
|
||||
remotePushEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Whether the client should call `PushNotifications.register()` on this shell. */
|
||||
export function shouldRegisterForRemotePush(input: RemotePushRegistrationGateInput): boolean {
|
||||
return resolveRemotePushSkipReason(input) === null;
|
||||
}
|
||||
|
||||
/** Resolve the first reason remote push registration should be skipped. */
|
||||
export function resolveRemotePushSkipReason(
|
||||
input: RemotePushRegistrationGateInput
|
||||
): RemotePushSkipReason | null {
|
||||
if (!input.hasPushPlugin) {
|
||||
return 'missing-push-plugin';
|
||||
}
|
||||
|
||||
if (!input.hasDevicePlugin) {
|
||||
return 'missing-device-plugin';
|
||||
}
|
||||
|
||||
if (input.remotePushEnabled === false) {
|
||||
return 'remote-push-disabled';
|
||||
}
|
||||
|
||||
if (!input.remotePushConfigured) {
|
||||
return 'remote-push-not-configured';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** User-facing console message when remote push registration is skipped. */
|
||||
export function buildRemotePushSkipMessage(reason: RemotePushSkipReason): string {
|
||||
switch (reason) {
|
||||
case 'missing-push-plugin':
|
||||
return '[mobile] remote push registration skipped: PushNotifications plugin is unavailable on this shell.';
|
||||
case 'missing-device-plugin':
|
||||
return '[mobile] remote push registration skipped: Device plugin is unavailable on this shell.';
|
||||
case 'remote-push-disabled':
|
||||
return '[mobile] remote push registration skipped: disabled by environment configuration.';
|
||||
case 'remote-push-not-configured':
|
||||
return '[mobile] remote push registration skipped: Firebase/APNs is not configured. '
|
||||
+ 'Add google-services.json (Android) and rebuild to enable push.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from './mobile-push-token.rules';
|
||||
|
||||
describe('mobile-push-token.rules', () => {
|
||||
it('normalizes capacitor runtime platforms', () => {
|
||||
expect(normalizePushPlatform('ios')).toBe('ios');
|
||||
expect(normalizePushPlatform('android')).toBe('android');
|
||||
expect(normalizePushPlatform('web')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a registration payload for the signaling server', () => {
|
||||
expect(buildPushDeviceTokenRegistrationPayload({
|
||||
userId: 'user-1',
|
||||
token: 'abc123',
|
||||
platform: 'android'
|
||||
})).toEqual({
|
||||
userId: 'user-1',
|
||||
token: 'abc123',
|
||||
platform: 'android'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export type MobilePushPlatform = 'ios' | 'android';
|
||||
|
||||
export interface PushDeviceTokenRegistrationInput {
|
||||
userId: string;
|
||||
token: string;
|
||||
platform: MobilePushPlatform;
|
||||
}
|
||||
|
||||
export interface PushDeviceTokenRegistrationPayload {
|
||||
userId: string;
|
||||
token: string;
|
||||
platform: MobilePushPlatform;
|
||||
}
|
||||
|
||||
export function normalizePushPlatform(platform: string): MobilePushPlatform | null {
|
||||
if (platform === 'ios' || platform === 'android') {
|
||||
return platform;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPushDeviceTokenRegistrationPayload(
|
||||
input: PushDeviceTokenRegistrationInput
|
||||
): PushDeviceTokenRegistrationPayload {
|
||||
return {
|
||||
userId: input.userId,
|
||||
token: input.token,
|
||||
platform: input.platform
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules';
|
||||
|
||||
describe('mobile-safe-area.rules', () => {
|
||||
it('builds CSS values with Capacitor and env fallbacks', () => {
|
||||
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
|
||||
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
|
||||
});
|
||||
|
||||
it('sets default safe-area variables on the document root', () => {
|
||||
const properties = new Map<string, string>();
|
||||
const root = {
|
||||
style: {
|
||||
getPropertyValue: (property: string) => properties.get(property) ?? '',
|
||||
setProperty: (property: string, value: string) => {
|
||||
properties.set(property, value);
|
||||
}
|
||||
}
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
applyMobileSafeAreaDefaults(root);
|
||||
|
||||
expect(properties.get('--safe-area-inset-top')).toBe('0px');
|
||||
expect(properties.get('--safe-area-inset-right')).toBe('0px');
|
||||
expect(properties.get('--safe-area-inset-bottom')).toBe('0px');
|
||||
expect(properties.get('--safe-area-inset-left')).toBe('0px');
|
||||
});
|
||||
|
||||
it('ignores null roots instead of throwing', () => {
|
||||
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const SAFE_AREA_SIDES = [
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left'
|
||||
] as const;
|
||||
|
||||
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
|
||||
|
||||
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
|
||||
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
|
||||
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
|
||||
}
|
||||
|
||||
/** Apply default safe-area CSS variables when the document root is available. */
|
||||
export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof document === 'undefined'
|
||||
? null
|
||||
: document.documentElement): void {
|
||||
if (!root?.style) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const side of SAFE_AREA_SIDES) {
|
||||
const property = `--safe-area-inset-${side}`;
|
||||
|
||||
if (!root.style.getPropertyValue(property)) {
|
||||
root.style.setProperty(property, '0px');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { resolveMobileSqliteDatabaseName } from './mobile-sqlite-database-name.rules';
|
||||
|
||||
describe('mobile-sqlite-database-name.rules', () => {
|
||||
it('scopes sqlite files per authenticated user', () => {
|
||||
expect(resolveMobileSqliteDatabaseName('user-123')).toBe('metoyou__user-123');
|
||||
});
|
||||
|
||||
it('uses an anonymous scope before login', () => {
|
||||
expect(resolveMobileSqliteDatabaseName(null)).toBe('metoyou__anonymous');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MOBILE_SQLITE_DATABASE_NAME } from './mobile-sqlite-schema.rules';
|
||||
|
||||
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
|
||||
|
||||
/** Mirrors IndexedDB per-user database scoping for Capacitor SQLite files. */
|
||||
export function resolveMobileSqliteDatabaseName(userId: string | null): string {
|
||||
const scopedUserId = userId?.trim() || ANONYMOUS_DATABASE_SCOPE;
|
||||
|
||||
return `${MOBILE_SQLITE_DATABASE_NAME}__${encodeURIComponent(scopedUserId)}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Runs SQLite DDL/DML statements one at a time - required by @capacitor-community/sqlite `execute()`. */
|
||||
export async function executeMobileSqliteStatements(
|
||||
execute: (statement: string) => Promise<unknown>,
|
||||
statements: readonly string[]
|
||||
): Promise<void> {
|
||||
for (const statement of statements) {
|
||||
const trimmed = statement.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await execute(trimmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
messageToRow,
|
||||
rowToMessage,
|
||||
rowToUser,
|
||||
userToRow
|
||||
} from './mobile-sqlite-row-mapper.rules';
|
||||
|
||||
describe('mobile-sqlite-row-mapper.rules', () => {
|
||||
it('round-trips message fields including JSON metadata', () => {
|
||||
const message = {
|
||||
id: 'm1',
|
||||
roomId: 'r1',
|
||||
channelId: 'general',
|
||||
senderId: 'u1',
|
||||
senderName: 'Alice',
|
||||
content: 'hello',
|
||||
timestamp: 100,
|
||||
editedAt: 101,
|
||||
isDeleted: false,
|
||||
replyToId: 'm0',
|
||||
linkMetadata: [{ url: 'https://example.com', title: 'Example' }],
|
||||
kind: 'user' as const,
|
||||
reactions: []
|
||||
};
|
||||
|
||||
const row = messageToRow(message);
|
||||
const restored = rowToMessage(row, [{ id: 'rx1', messageId: 'm1', oderId: 'u1', userId: 'u1', emoji: '👍', timestamp: 102 }]);
|
||||
|
||||
expect(restored.id).toBe('m1');
|
||||
expect(restored.linkMetadata?.[0]?.title).toBe('Example');
|
||||
expect(restored.reactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('maps booleans to integers for SQLite storage', () => {
|
||||
const user = userToRow({
|
||||
id: 'u1',
|
||||
oderId: 'u1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: 1,
|
||||
isOnline: true,
|
||||
isAdmin: false,
|
||||
isRoomOwner: true,
|
||||
voiceState: { muted: false, deafened: false, speaking: false }
|
||||
});
|
||||
|
||||
expect(user.isOnline).toBe(1);
|
||||
expect(user.isAdmin).toBe(0);
|
||||
expect(user.isRoomOwner).toBe(1);
|
||||
expect(user.voiceState).toContain('muted');
|
||||
|
||||
const restored = rowToUser(user);
|
||||
|
||||
expect(restored.isOnline).toBe(true);
|
||||
expect(restored.voiceState).toEqual({ muted: false, deafened: false, speaking: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
type BanEntry,
|
||||
type Message,
|
||||
type Reaction,
|
||||
type Room,
|
||||
type User
|
||||
} from '../../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../../shared-kernel';
|
||||
|
||||
export interface MessageRow {
|
||||
id: string;
|
||||
roomId: string;
|
||||
ownerUserId?: string | null;
|
||||
channelId?: string | null;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
editedAt?: number | null;
|
||||
isDeleted: number;
|
||||
replyToId?: string | null;
|
||||
linkMetadata?: string | null;
|
||||
kind?: string | null;
|
||||
systemEvent?: string | null;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
oderId?: string | null;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
description?: string | null;
|
||||
profileUpdatedAt?: number | null;
|
||||
avatarUrl?: string | null;
|
||||
avatarHash?: string | null;
|
||||
avatarMime?: string | null;
|
||||
avatarUpdatedAt?: number | null;
|
||||
status?: string | null;
|
||||
role?: string | null;
|
||||
joinedAt?: number | null;
|
||||
peerId?: string | null;
|
||||
isOnline: number;
|
||||
isAdmin: number;
|
||||
isRoomOwner: number;
|
||||
voiceState?: string | null;
|
||||
screenShareState?: string | null;
|
||||
homeSignalServerUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface RoomRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
topic?: string | null;
|
||||
hostId: string;
|
||||
password?: string | null;
|
||||
hasPassword: number;
|
||||
isPrivate: number;
|
||||
createdAt: number;
|
||||
userCount: number;
|
||||
maxUsers?: number | null;
|
||||
icon?: string | null;
|
||||
iconUpdatedAt?: number | null;
|
||||
slowModeInterval: number;
|
||||
sourceId?: string | null;
|
||||
sourceName?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function decodeJson<T>(value: string | null | undefined): T | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function messageToRow(message: Message): MessageRow {
|
||||
return {
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null,
|
||||
linkMetadata: encodeJson(message.linkMetadata),
|
||||
kind: message.kind ?? null,
|
||||
systemEvent: message.systemEvent ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToMessage(row: MessageRow, reactions: Reaction[] = []): Message {
|
||||
const message: Message = {
|
||||
id: row.id,
|
||||
roomId: row.roomId,
|
||||
channelId: row.channelId ?? undefined,
|
||||
senderId: row.senderId,
|
||||
senderName: row.senderName,
|
||||
content: row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
isDeleted: row.isDeleted === 1,
|
||||
replyToId: row.replyToId ?? undefined,
|
||||
linkMetadata: decodeJson(row.linkMetadata),
|
||||
kind: (row.kind as Message['kind']) ?? undefined,
|
||||
systemEvent: (row.systemEvent as Message['systemEvent']) ?? undefined,
|
||||
reactions
|
||||
};
|
||||
|
||||
if (message.content === DELETED_MESSAGE_CONTENT) {
|
||||
return { ...message, reactions: [] };
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function userToRow(user: User): UserRow {
|
||||
return {
|
||||
id: user.id,
|
||||
oderId: user.oderId,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
description: user.description ?? null,
|
||||
profileUpdatedAt: user.profileUpdatedAt ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
avatarHash: user.avatarHash ?? null,
|
||||
avatarMime: user.avatarMime ?? null,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
|
||||
status: user.status,
|
||||
role: user.role,
|
||||
joinedAt: user.joinedAt,
|
||||
peerId: user.peerId ?? null,
|
||||
isOnline: user.isOnline ? 1 : 0,
|
||||
isAdmin: user.isAdmin ? 1 : 0,
|
||||
isRoomOwner: user.isRoomOwner ? 1 : 0,
|
||||
voiceState: encodeJson(user.voiceState),
|
||||
screenShareState: encodeJson(user.screenShareState),
|
||||
homeSignalServerUrl: user.homeSignalServerUrl ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToUser(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
oderId: row.oderId ?? row.id,
|
||||
username: row.username ?? '',
|
||||
displayName: row.displayName ?? '',
|
||||
description: row.description ?? undefined,
|
||||
profileUpdatedAt: row.profileUpdatedAt ?? undefined,
|
||||
avatarUrl: row.avatarUrl ?? undefined,
|
||||
avatarHash: row.avatarHash ?? undefined,
|
||||
avatarMime: row.avatarMime ?? undefined,
|
||||
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
|
||||
status: (row.status as User['status']) ?? 'offline',
|
||||
role: (row.role as User['role']) ?? 'member',
|
||||
joinedAt: row.joinedAt ?? 0,
|
||||
peerId: row.peerId ?? undefined,
|
||||
isOnline: row.isOnline === 1,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
isRoomOwner: row.isRoomOwner === 1,
|
||||
voiceState: decodeJson(row.voiceState),
|
||||
screenShareState: decodeJson(row.screenShareState),
|
||||
homeSignalServerUrl: row.homeSignalServerUrl ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function roomToRow(room: Room): RoomRow {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
slowModeInterval: room.slowModeInterval ?? 0,
|
||||
sourceId: room.sourceId ?? null,
|
||||
sourceName: room.sourceName ?? null,
|
||||
sourceUrl: room.sourceUrl ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToRoom(row: RoomRow): Room {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description ?? undefined,
|
||||
topic: row.topic ?? undefined,
|
||||
hostId: row.hostId,
|
||||
password: row.password ?? undefined,
|
||||
hasPassword: row.hasPassword === 1,
|
||||
isPrivate: row.isPrivate === 1,
|
||||
createdAt: row.createdAt,
|
||||
userCount: row.userCount,
|
||||
maxUsers: row.maxUsers ?? undefined,
|
||||
icon: row.icon ?? undefined,
|
||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||
slowModeInterval: row.slowModeInterval,
|
||||
sourceId: row.sourceId ?? undefined,
|
||||
sourceName: row.sourceName ?? undefined,
|
||||
sourceUrl: row.sourceUrl ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function reactionToValues(reaction: Reaction): unknown[] {
|
||||
return [
|
||||
reaction.id,
|
||||
reaction.messageId,
|
||||
reaction.oderId,
|
||||
reaction.userId,
|
||||
reaction.emoji,
|
||||
reaction.timestamp
|
||||
];
|
||||
}
|
||||
|
||||
export function rowToReaction(row: Reaction): Reaction {
|
||||
return row;
|
||||
}
|
||||
|
||||
export function banToValues(ban: BanEntry): unknown[] {
|
||||
return [
|
||||
ban.oderId,
|
||||
ban.roomId,
|
||||
ban.userId,
|
||||
ban.bannedBy,
|
||||
ban.displayName ?? null,
|
||||
ban.reason ?? null,
|
||||
ban.expiresAt ?? null,
|
||||
ban.timestamp
|
||||
];
|
||||
}
|
||||
|
||||
export function attachmentToValues(attachment: ChatAttachmentMeta): unknown[] {
|
||||
return [
|
||||
attachment.id,
|
||||
attachment.messageId,
|
||||
attachment.filename,
|
||||
attachment.size,
|
||||
attachment.mime,
|
||||
attachment.isImage ? 1 : 0,
|
||||
attachment.uploaderPeerId ?? null,
|
||||
attachment.filePath ?? null,
|
||||
attachment.savedPath ?? null
|
||||
];
|
||||
}
|
||||
|
||||
export function rowToAttachment(row: {
|
||||
id: string;
|
||||
messageId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
isImage: number;
|
||||
uploaderPeerId?: string | null;
|
||||
filePath?: string | null;
|
||||
savedPath?: string | null;
|
||||
}): ChatAttachmentMeta {
|
||||
return {
|
||||
id: row.id,
|
||||
messageId: row.messageId,
|
||||
filename: row.filename,
|
||||
size: row.size,
|
||||
mime: row.mime,
|
||||
isImage: row.isImage === 1,
|
||||
uploaderPeerId: row.uploaderPeerId ?? undefined,
|
||||
filePath: row.filePath ?? undefined,
|
||||
savedPath: row.savedPath ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function customEmojiToValues(emoji: CustomEmoji): unknown[] {
|
||||
return [
|
||||
emoji.id,
|
||||
emoji.name,
|
||||
emoji.creatorUserId,
|
||||
emoji.dataUrl,
|
||||
emoji.hash,
|
||||
emoji.mime,
|
||||
emoji.size,
|
||||
emoji.createdAt,
|
||||
emoji.updatedAt
|
||||
];
|
||||
}
|
||||
|
||||
export function rowToCustomEmoji(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
creatorUserId: string;
|
||||
dataUrl: string;
|
||||
hash: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}): CustomEmoji {
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { executeMobileSqliteStatements } from './mobile-sqlite-execute.rules';
|
||||
import {
|
||||
MOBILE_SQLITE_SCHEMA_VERSION,
|
||||
buildMobileSqliteSchemaStatements,
|
||||
resolveMobileSqliteMigrationStatements
|
||||
} from './mobile-sqlite-schema.rules';
|
||||
|
||||
describe('mobile-sqlite-schema.rules', () => {
|
||||
it('returns one statement per DDL operation for a fresh database', () => {
|
||||
const statements = resolveMobileSqliteMigrationStatements(0);
|
||||
|
||||
expect(statements.length).toBe(buildMobileSqliteSchemaStatements().length);
|
||||
expect(statements.length).toBeGreaterThan(1);
|
||||
|
||||
for (const statement of statements) {
|
||||
expect(statement.trim().length).toBeGreaterThan(0);
|
||||
expect(statement).not.toMatch(/;\s*CREATE/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns no statements when the stored schema version is current', () => {
|
||||
expect(resolveMobileSqliteMigrationStatements(MOBILE_SQLITE_SCHEMA_VERSION)).toEqual([]);
|
||||
});
|
||||
|
||||
it('executes each migration statement separately', async () => {
|
||||
const execute = vi.fn(() => Promise.resolve());
|
||||
const statements = resolveMobileSqliteMigrationStatements(0).slice(0, 3);
|
||||
|
||||
await executeMobileSqliteStatements(execute, statements);
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
expect(execute.mock.calls.map(([statement]) => statement)).toEqual(statements);
|
||||
expect(execute.mock.calls[0]?.[0]).toMatch(/^CREATE TABLE IF NOT EXISTS messages/);
|
||||
expect(execute.mock.calls[1]?.[0]).toMatch(/^CREATE INDEX IF NOT EXISTS idx_messages_room_id/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Native SQLite database name for Capacitor mobile shells. */
|
||||
export const MOBILE_SQLITE_DATABASE_NAME = 'metoyou';
|
||||
|
||||
/** Bump when adding DDL statements; stored in meta table. */
|
||||
export const MOBILE_SQLITE_SCHEMA_VERSION = 2;
|
||||
|
||||
const META_SCHEMA_VERSION_KEY = 'mobile_sqlite_schema_version';
|
||||
|
||||
/** DDL mirrored from Electron TypeORM entities under `electron/entities/`. */
|
||||
export function buildMobileSqliteSchemaStatements(): string[] {
|
||||
return [
|
||||
`CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
roomId TEXT NOT NULL,
|
||||
ownerUserId TEXT,
|
||||
channelId TEXT,
|
||||
senderId TEXT NOT NULL,
|
||||
senderName TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
editedAt INTEGER,
|
||||
isDeleted INTEGER NOT NULL DEFAULT 0,
|
||||
replyToId TEXT,
|
||||
linkMetadata TEXT,
|
||||
kind TEXT,
|
||||
systemEvent TEXT
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_messages_room_id ON messages(roomId)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)',
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
oderId TEXT,
|
||||
username TEXT,
|
||||
displayName TEXT,
|
||||
description TEXT,
|
||||
profileUpdatedAt INTEGER,
|
||||
avatarUrl TEXT,
|
||||
avatarHash TEXT,
|
||||
avatarMime TEXT,
|
||||
avatarUpdatedAt INTEGER,
|
||||
status TEXT,
|
||||
role TEXT,
|
||||
joinedAt INTEGER,
|
||||
peerId TEXT,
|
||||
isOnline INTEGER NOT NULL DEFAULT 0,
|
||||
isAdmin INTEGER NOT NULL DEFAULT 0,
|
||||
isRoomOwner INTEGER NOT NULL DEFAULT 0,
|
||||
voiceState TEXT,
|
||||
screenShareState TEXT,
|
||||
homeSignalServerUrl TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS rooms (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
topic TEXT,
|
||||
hostId TEXT NOT NULL,
|
||||
password TEXT,
|
||||
hasPassword INTEGER NOT NULL DEFAULT 0,
|
||||
isPrivate INTEGER NOT NULL DEFAULT 0,
|
||||
createdAt INTEGER NOT NULL,
|
||||
userCount INTEGER NOT NULL DEFAULT 0,
|
||||
maxUsers INTEGER,
|
||||
icon TEXT,
|
||||
iconUpdatedAt INTEGER,
|
||||
slowModeInterval INTEGER NOT NULL DEFAULT 0,
|
||||
sourceId TEXT,
|
||||
sourceName TEXT,
|
||||
sourceUrl TEXT
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_rooms_created_at ON rooms(createdAt)',
|
||||
`CREATE TABLE IF NOT EXISTS reactions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
messageId TEXT NOT NULL,
|
||||
oderId TEXT,
|
||||
userId TEXT,
|
||||
emoji TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_reactions_message_id ON reactions(messageId)',
|
||||
`CREATE TABLE IF NOT EXISTS bans (
|
||||
oderId TEXT NOT NULL,
|
||||
roomId TEXT NOT NULL,
|
||||
userId TEXT,
|
||||
bannedBy TEXT NOT NULL,
|
||||
displayName TEXT,
|
||||
reason TEXT,
|
||||
expiresAt INTEGER,
|
||||
timestamp INTEGER NOT NULL,
|
||||
PRIMARY KEY (oderId, roomId)
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_bans_room_id ON bans(roomId)',
|
||||
`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
messageId TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
isImage INTEGER NOT NULL DEFAULT 0,
|
||||
uploaderPeerId TEXT,
|
||||
filePath TEXT,
|
||||
savedPath TEXT
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(messageId)',
|
||||
`CREATE TABLE IF NOT EXISTS custom_emojis (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
creatorUserId TEXT NOT NULL,
|
||||
dataUrl TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
updatedAt INTEGER NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_custom_emojis_updated_at ON custom_emojis(updatedAt)',
|
||||
`CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS push_device_tokens (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
userId TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
updatedAt INTEGER NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX IF NOT EXISTS idx_push_device_tokens_user_id ON push_device_tokens(userId)',
|
||||
`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '${MOBILE_SQLITE_SCHEMA_VERSION}')`
|
||||
];
|
||||
}
|
||||
|
||||
const SCHEMA_V2_MESSAGE_COLUMNS = [
|
||||
'ALTER TABLE messages ADD COLUMN linkMetadata TEXT',
|
||||
'ALTER TABLE messages ADD COLUMN kind TEXT',
|
||||
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
|
||||
];
|
||||
|
||||
/** Returns DDL statements that still need to run for the stored schema version. */
|
||||
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
|
||||
if (storedVersion >= MOBILE_SQLITE_SCHEMA_VERSION) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (storedVersion <= 0) {
|
||||
return buildMobileSqliteSchemaStatements();
|
||||
}
|
||||
|
||||
const statements: string[] = [];
|
||||
|
||||
if (storedVersion < 2) {
|
||||
statements.push(...SCHEMA_V2_MESSAGE_COLUMNS);
|
||||
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '2')`);
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
detectRuntimePlatform,
|
||||
isCapacitorNativeRuntime,
|
||||
shouldUseMobileAttachmentPicker,
|
||||
type RuntimePlatform
|
||||
} from './platform-detection.rules';
|
||||
|
||||
describe('platform-detection.rules', () => {
|
||||
describe('detectRuntimePlatform', () => {
|
||||
it('prefers electron when the preload API is present', () => {
|
||||
expect(
|
||||
detectRuntimePlatform({
|
||||
hasElectronApi: true,
|
||||
capacitorIsNative: true
|
||||
})
|
||||
).toBe<RuntimePlatform>('electron');
|
||||
});
|
||||
|
||||
it('detects capacitor when running in a native shell without electron', () => {
|
||||
expect(
|
||||
detectRuntimePlatform({
|
||||
hasElectronApi: false,
|
||||
capacitorIsNative: true
|
||||
})
|
||||
).toBe<RuntimePlatform>('capacitor');
|
||||
});
|
||||
|
||||
it('falls back to browser for web runtimes', () => {
|
||||
expect(
|
||||
detectRuntimePlatform({
|
||||
hasElectronApi: false,
|
||||
capacitorIsNative: false
|
||||
})
|
||||
).toBe<RuntimePlatform>('browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUseMobileAttachmentPicker', () => {
|
||||
it('enables the picker on capacitor native and mobile web viewports', () => {
|
||||
expect(shouldUseMobileAttachmentPicker('capacitor', true)).toBe(true);
|
||||
expect(shouldUseMobileAttachmentPicker('browser', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps drag-and-drop on desktop browser and electron', () => {
|
||||
expect(shouldUseMobileAttachmentPicker('browser', false)).toBe(false);
|
||||
expect(shouldUseMobileAttachmentPicker('electron', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCapacitorNativeRuntime', () => {
|
||||
it('returns false when Capacitor is unavailable', () => {
|
||||
expect(isCapacitorNativeRuntime()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type RuntimePlatform = 'electron' | 'capacitor' | 'browser';
|
||||
|
||||
export interface PlatformDetectionInput {
|
||||
hasElectronApi: boolean;
|
||||
capacitorIsNative?: boolean;
|
||||
}
|
||||
|
||||
type CapacitorWindow = Window & {
|
||||
Capacitor?: {
|
||||
isNativePlatform?: () => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve the active runtime shell used by the product client. */
|
||||
export function detectRuntimePlatform(input: PlatformDetectionInput): RuntimePlatform {
|
||||
if (input.hasElectronApi) {
|
||||
return 'electron';
|
||||
}
|
||||
|
||||
if (input.capacitorIsNative) {
|
||||
return 'capacitor';
|
||||
}
|
||||
|
||||
return 'browser';
|
||||
}
|
||||
|
||||
/** Best-effort detection of a Capacitor native shell without importing Capacitor modules. */
|
||||
export function isCapacitorNativeRuntime(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const capacitor = (window as CapacitorWindow).Capacitor;
|
||||
|
||||
return capacitor?.isNativePlatform?.() === true;
|
||||
}
|
||||
|
||||
/** Whether the chat composer should expose a tap-to-attach control instead of drag-and-drop only. */
|
||||
export function shouldUseMobileAttachmentPicker(runtime: RuntimePlatform, isMobileViewport: boolean): boolean {
|
||||
if (runtime === 'electron') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return runtime === 'capacitor' || isMobileViewport;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
||||
import { CapacitorMobileAppLifecycleAdapter } from '../adapters/capacitor/capacitor-mobile-app-lifecycle.adapter';
|
||||
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
/** Facade for foreground/background lifecycle events. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileAppLifecycleService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly adapter: MobileAppLifecycleAdapter = this.createAdapter();
|
||||
private initialized = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.adapter.initialize();
|
||||
this.mobilePlatform.refreshRuntimeDetection();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
onAppStateChange(handler: (isActive: boolean) => void): void {
|
||||
this.adapter.onAppStateChange(handler);
|
||||
}
|
||||
|
||||
private createAdapter(): MobileAppLifecycleAdapter {
|
||||
return this.mobilePlatform.isCapacitor()
|
||||
? new CapacitorMobileAppLifecycleAdapter()
|
||||
: new WebMobileAppLifecycleAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
Injectable,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
||||
import { MobileAppLifecycleService } from './mobile-app-lifecycle.service';
|
||||
import { MobileCallKitService } from './mobile-callkit.service';
|
||||
import { MobileMediaService } from './mobile-media.service';
|
||||
import { MobileNotificationsService } from './mobile-notifications.service';
|
||||
import { MobilePictureInPictureService } from './mobile-picture-in-picture.service';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
export interface ActiveCallSessionState {
|
||||
callId: string;
|
||||
displayName: string;
|
||||
isMuted: boolean;
|
||||
focusedStreamVideo?: HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
/** Coordinates in-call notifications, background audio, and stream pop-out for mobile shells. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileCallSessionService {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly notifications = inject(MobileNotificationsService);
|
||||
private readonly media = inject(MobileMediaService);
|
||||
private readonly pictureInPicture = inject(MobilePictureInPictureService);
|
||||
private readonly lifecycle = inject(MobileAppLifecycleService);
|
||||
private readonly callKit = inject(MobileCallKitService);
|
||||
|
||||
private activeSession: ActiveCallSessionState | null = null;
|
||||
private actionHandler: ((intent: CallNotificationActionIntent, callId: string) => void) | null = null;
|
||||
private wired = false;
|
||||
|
||||
initialize(): void {
|
||||
if (this.wired) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wired = true;
|
||||
|
||||
void this.notifications.initialize();
|
||||
void this.lifecycle.initialize();
|
||||
|
||||
this.notifications.onCallAction(({ callId, intent }) => {
|
||||
void this.router.navigate(['/call', callId]);
|
||||
this.actionHandler?.(intent, callId);
|
||||
});
|
||||
|
||||
this.lifecycle.onAppStateChange((isActive) => {
|
||||
void this.handleAppStateChange(isActive);
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.activeSession = null;
|
||||
this.actionHandler = null;
|
||||
});
|
||||
}
|
||||
|
||||
onCallControlAction(handler: (intent: CallNotificationActionIntent, callId: string) => void): void {
|
||||
this.actionHandler = handler;
|
||||
}
|
||||
|
||||
async notifyIncomingCall(displayName: string, callId: string): Promise<void> {
|
||||
if (!this.shouldHandleMobileCalls()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.notifications.showIncomingCall(displayName, callId);
|
||||
}
|
||||
|
||||
async startActiveCall(session: ActiveCallSessionState): Promise<void> {
|
||||
if (!this.shouldHandleMobileCalls()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeSession = session;
|
||||
await this.media.startBackgroundAudioSession();
|
||||
await this.callKit.startActiveCall(session.callId, session.displayName);
|
||||
await this.notifications.dismissIncomingCall(session.callId);
|
||||
await this.notifications.showActiveCall(session);
|
||||
}
|
||||
|
||||
async updateActiveCall(session: ActiveCallSessionState): Promise<void> {
|
||||
if (!this.shouldHandleMobileCalls()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeSession = session;
|
||||
await this.notifications.showActiveCall(session);
|
||||
}
|
||||
|
||||
async endActiveCall(callId: string): Promise<void> {
|
||||
await this.notifications.dismissIncomingCall(callId);
|
||||
await this.notifications.dismissActiveCall(callId);
|
||||
await this.callKit.endActiveCall(callId);
|
||||
await this.media.stopBackgroundAudioSession();
|
||||
await this.pictureInPicture.exit();
|
||||
|
||||
if (this.activeSession?.callId === callId) {
|
||||
this.activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedStreamVideo(videoElement: HTMLVideoElement | null): void {
|
||||
if (!this.activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeSession = {
|
||||
...this.activeSession,
|
||||
focusedStreamVideo: videoElement
|
||||
};
|
||||
}
|
||||
|
||||
private shouldHandleMobileCalls(): boolean {
|
||||
return this.mobilePlatform.isNativeMobile();
|
||||
}
|
||||
|
||||
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
||||
if (!this.activeSession || isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = this.activeSession.focusedStreamVideo;
|
||||
|
||||
if (video && this.pictureInPicture.isSupported()) {
|
||||
await this.pictureInPicture.enter(video);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import type { MobileCallKitAdapter } from '../contracts/mobile.contracts';
|
||||
import { CapacitorMobileCallKitAdapter } from '../adapters/capacitor/capacitor-mobile-callkit.adapter';
|
||||
import { WebMobileCallKitAdapter } from '../adapters/web/web-mobile-callkit.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
/** Facade for iOS CallKit active-call reporting. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileCallKitService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly adapter: MobileCallKitAdapter = this.createAdapter();
|
||||
|
||||
startActiveCall(callId: string, displayName: string): Promise<void> {
|
||||
if (!this.mobilePlatform.isCapacitor()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.adapter.startActiveCall(callId, displayName);
|
||||
}
|
||||
|
||||
endActiveCall(callId: string): Promise<void> {
|
||||
if (!this.mobilePlatform.isCapacitor()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.adapter.endActiveCall(callId);
|
||||
}
|
||||
|
||||
private createAdapter(): MobileCallKitAdapter {
|
||||
return this.mobilePlatform.isCapacitor()
|
||||
? new CapacitorMobileCallKitAdapter()
|
||||
: new WebMobileCallKitAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
|
||||
import type { MobileMediaAdapter } from '../contracts/mobile.contracts';
|
||||
import { CapacitorMobileMediaAdapter } from '../adapters/capacitor/capacitor-mobile-media.adapter';
|
||||
import { WebMobileMediaAdapter } from '../adapters/web/web-mobile-media.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
/** Facade for mobile media affordances: attachments, speakerphone, background audio, capture limits. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileMediaService {
|
||||
readonly isScreenShareSupported = computed(() => this.adapter.isScreenShareSupported());
|
||||
readonly isPictureInPictureSupported = computed(() => this.adapter.isPictureInPictureSupported());
|
||||
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly adapter: MobileMediaAdapter = this.createAdapter();
|
||||
|
||||
pickAttachments(): Promise<File[]> {
|
||||
return this.adapter.pickAttachments();
|
||||
}
|
||||
|
||||
setSpeakerphoneEnabled(enabled: boolean): Promise<void> {
|
||||
return this.adapter.setSpeakerphoneEnabled(enabled);
|
||||
}
|
||||
|
||||
startBackgroundAudioSession(): Promise<void> {
|
||||
return this.adapter.startBackgroundAudioSession();
|
||||
}
|
||||
|
||||
stopBackgroundAudioSession(): Promise<void> {
|
||||
return this.adapter.stopBackgroundAudioSession();
|
||||
}
|
||||
|
||||
private createAdapter(): MobileMediaAdapter {
|
||||
return this.mobilePlatform.isCapacitor()
|
||||
? new CapacitorMobileMediaAdapter()
|
||||
: new WebMobileMediaAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
||||
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
|
||||
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
|
||||
import { CapacitorMobileNotificationsAdapter } from '../adapters/capacitor/capacitor-mobile-notifications.adapter';
|
||||
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
import { MobilePushRegistrationService } from './mobile-push-registration.service';
|
||||
|
||||
/** Facade for push/local notifications with platform-specific adapters. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileNotificationsService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly pushRegistration = inject(MobilePushRegistrationService);
|
||||
private readonly adapter: MobileNotificationAdapter = this.createAdapter();
|
||||
private initialized = false;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.adapter.initialize();
|
||||
this.pushRegistration.initialize();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async showIncomingCall(displayName: string, callId: string): Promise<void> {
|
||||
await this.initialize();
|
||||
await this.adapter.showCallNotification(buildIncomingCallNotification(displayName, callId));
|
||||
}
|
||||
|
||||
async showActiveCall(input: { callId: string; displayName: string; isMuted: boolean }): Promise<void> {
|
||||
await this.initialize();
|
||||
await this.adapter.showCallNotification(buildInCallNotification(input));
|
||||
}
|
||||
|
||||
async dismissIncomingCall(callId: string): Promise<void> {
|
||||
await this.adapter.dismissCallNotification(callId, 'incoming');
|
||||
}
|
||||
|
||||
async dismissActiveCall(callId: string): Promise<void> {
|
||||
await this.adapter.dismissCallNotification(callId, 'active');
|
||||
}
|
||||
|
||||
onCallAction(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
||||
this.adapter.onActionSelected(handler);
|
||||
}
|
||||
|
||||
private createAdapter(): MobileNotificationAdapter {
|
||||
return this.mobilePlatform.isCapacitor()
|
||||
? new CapacitorMobileNotificationsAdapter()
|
||||
: new WebMobileNotificationsAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import type { MobilePersistenceAdapter } from '../contracts/mobile.contracts';
|
||||
import { CapacitorMobilePersistenceAdapter } from '../adapters/capacitor/capacitor-mobile-persistence.adapter';
|
||||
import { WebMobilePersistenceAdapter } from '../adapters/web/web-mobile-persistence.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
import { MobileSqliteConnectionService } from './mobile-sqlite-connection.service';
|
||||
|
||||
/** Facade for native SQLite persistence on mobile shells. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobilePersistenceService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
|
||||
private readonly adapter: MobilePersistenceAdapter = this.createAdapter();
|
||||
|
||||
get isNativeSqlite(): boolean {
|
||||
return this.adapter.isNativeSqlite;
|
||||
}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
return this.adapter.initialize();
|
||||
}
|
||||
|
||||
private createAdapter(): MobilePersistenceAdapter {
|
||||
return this.mobilePlatform.isCapacitor()
|
||||
? new CapacitorMobilePersistenceAdapter(this.sqliteConnection)
|
||||
: new WebMobilePersistenceAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import type { MobilePictureInPictureAdapter } from '../contracts/mobile.contracts';
|
||||
import { CapacitorMobilePictureInPictureAdapter } from '../adapters/capacitor/capacitor-mobile-picture-in-picture.adapter';
|
||||
import { WebMobilePictureInPictureAdapter } from '../adapters/web/web-mobile-picture-in-picture.adapter';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
/** Facade for stream pop-out while the app is backgrounded. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobilePictureInPictureService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly adapter: MobilePictureInPictureAdapter = this.createAdapter();
|
||||
|
||||
isSupported(): boolean {
|
||||
return this.adapter.isSupported();
|
||||
}
|
||||
|
||||
enter(videoElement: HTMLVideoElement): Promise<void> {
|
||||
return this.adapter.enter(videoElement);
|
||||
}
|
||||
|
||||
exit(): Promise<void> {
|
||||
return this.adapter.exit();
|
||||
}
|
||||
|
||||
private createAdapter(): MobilePictureInPictureAdapter {
|
||||
return this.mobilePlatform.isCapacitor()
|
||||
? new CapacitorMobilePictureInPictureAdapter()
|
||||
: new WebMobilePictureInPictureAdapter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
function createService(options: { isMobile: boolean }): MobilePlatformService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
MobilePlatformService,
|
||||
{
|
||||
provide: ElectronBridgeService,
|
||||
useValue: { isAvailable: false }
|
||||
},
|
||||
{
|
||||
provide: ViewportService,
|
||||
useValue: {
|
||||
isMobile: () => options.isMobile
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(MobilePlatformService));
|
||||
}
|
||||
|
||||
describe('MobilePlatformService', () => {
|
||||
it('reports browser runtime and hides attachment button on desktop viewport', () => {
|
||||
const service = createService({ isMobile: false });
|
||||
|
||||
expect(service.runtime()).toBe('browser');
|
||||
expect(service.shouldShowAttachmentButton()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables attachment button on mobile viewport in browser runtime', () => {
|
||||
const service = createService({ isMobile: true });
|
||||
|
||||
expect(service.shouldShowAttachmentButton()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import {
|
||||
detectRuntimePlatform,
|
||||
isCapacitorNativeRuntime,
|
||||
shouldUseMobileAttachmentPicker,
|
||||
type RuntimePlatform
|
||||
} from '../logic/platform-detection.rules';
|
||||
import type { MobilePlatformSnapshot } from '../contracts/mobile.contracts';
|
||||
|
||||
/** Detects runtime shell and exposes mobile capability flags to Angular domains. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobilePlatformService {
|
||||
readonly runtime = computed(() => this.runtimeSignal());
|
||||
readonly isCapacitor = computed(() => this.runtimeSignal() === 'capacitor');
|
||||
readonly isNativeMobile = computed(() => this.isCapacitor());
|
||||
readonly shouldShowAttachmentButton = computed(() =>
|
||||
shouldUseMobileAttachmentPicker(this.runtimeSignal(), this.viewport.isMobile())
|
||||
);
|
||||
readonly snapshot = computed<MobilePlatformSnapshot>(() => ({
|
||||
runtime: this.runtimeSignal(),
|
||||
isNativeMobile: this.isCapacitor(),
|
||||
isCapacitor: this.isCapacitor()
|
||||
}));
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly runtimeSignal = signal<RuntimePlatform>(
|
||||
detectRuntimePlatform({
|
||||
hasElectronApi: this.electronBridge.isAvailable,
|
||||
capacitorIsNative: isCapacitorNativeRuntime()
|
||||
})
|
||||
);
|
||||
|
||||
/** Re-evaluate runtime detection after Capacitor bootstraps on device. */
|
||||
refreshRuntimeDetection(): void {
|
||||
this.runtimeSignal.set(
|
||||
detectRuntimePlatform({
|
||||
hasElectronApi: this.electronBridge.isAvailable,
|
||||
capacitorIsNative: isCapacitorNativeRuntime()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import '@angular/compiler';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
const pushState = vi.hoisted(() => ({
|
||||
register: vi.fn(() => Promise.resolve()),
|
||||
checkPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })),
|
||||
requestPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' })),
|
||||
addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() }))
|
||||
}));
|
||||
const deviceState = vi.hoisted(() => ({
|
||||
getInfo: vi.fn(() => Promise.resolve({ platform: 'android' }))
|
||||
}));
|
||||
const mobilePlatformState = vi.hoisted(() => ({
|
||||
isCapacitor: true
|
||||
}));
|
||||
const remotePushState = vi.hoisted(() => ({
|
||||
configured: true
|
||||
}));
|
||||
|
||||
vi.mock('../adapters/capacitor/capacitor-plugin-loader', () => ({
|
||||
loadCapacitorPushNotificationsPlugin: () => pushState,
|
||||
loadCapacitorDevicePlugin: () => deviceState
|
||||
}));
|
||||
|
||||
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
|
||||
MetoyouMobile: {
|
||||
isRemotePushConfigured: vi.fn(() => Promise.resolve({ configured: remotePushState.configured }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
import { MobilePushRegistrationService } from './mobile-push-registration.service';
|
||||
|
||||
function createService(): MobilePushRegistrationService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
MobilePushRegistrationService,
|
||||
{
|
||||
provide: MobilePlatformService,
|
||||
useValue: {
|
||||
isCapacitor: () => mobilePlatformState.isCapacitor
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(MobilePushRegistrationService));
|
||||
}
|
||||
|
||||
describe('MobilePushRegistrationService', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mobilePlatformState.isCapacitor = true;
|
||||
remotePushState.configured = true;
|
||||
pushState.register.mockClear();
|
||||
pushState.addListener.mockClear();
|
||||
vi.mocked(MetoyouMobile.isRemotePushConfigured).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('skips PushNotifications.register when remote push is unavailable', async () => {
|
||||
remotePushState.configured = false;
|
||||
|
||||
const service = createService();
|
||||
|
||||
service.initialize();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('google-services.json')
|
||||
);
|
||||
});
|
||||
|
||||
expect(pushState.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers for remote push when Firebase/APNs is configured', async () => {
|
||||
const service = createService();
|
||||
|
||||
service.initialize();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pushState.register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(MetoyouMobile.isRemotePushConfigured).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not wire listeners on non-capacitor shells', () => {
|
||||
mobilePlatformState.isCapacitor = false;
|
||||
|
||||
const service = createService();
|
||||
|
||||
service.initialize();
|
||||
|
||||
expect(pushState.register).not.toHaveBeenCalled();
|
||||
expect(MetoyouMobile.isRemotePushConfigured).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { getStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||
import { buildPushDeviceTokenRegistrationPayload, normalizePushPlatform } from '../logic/mobile-push-token.rules';
|
||||
import { buildRemotePushSkipMessage, resolveRemotePushSkipReason } from '../logic/mobile-push-registration.rules';
|
||||
import { loadCapacitorDevicePlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
|
||||
import { MetoyouMobile } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||
import { MobilePlatformService } from './mobile-platform.service';
|
||||
|
||||
/** Registers FCM/APNs device tokens with the signaling server on Capacitor shells. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobilePushRegistrationService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private wired = false;
|
||||
private latestToken: string | null = null;
|
||||
|
||||
initialize(): void {
|
||||
if (this.wired || !this.mobilePlatform.isCapacitor()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wired = true;
|
||||
void this.registerPushListeners();
|
||||
}
|
||||
|
||||
async registerCurrentToken(): Promise<void> {
|
||||
if (!this.latestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.persistToken(this.latestToken);
|
||||
}
|
||||
|
||||
private async registerPushListeners(): Promise<void> {
|
||||
const PushNotifications = loadCapacitorPushNotificationsPlugin();
|
||||
const Device = loadCapacitorDevicePlugin();
|
||||
const remotePushConfigured = await this.isRemotePushConfigured();
|
||||
const skipReason = resolveRemotePushSkipReason({
|
||||
hasPushPlugin: !!PushNotifications,
|
||||
hasDevicePlugin: !!Device,
|
||||
remotePushConfigured
|
||||
});
|
||||
|
||||
if (skipReason) {
|
||||
console.warn(buildRemotePushSkipMessage(skipReason));
|
||||
return;
|
||||
}
|
||||
|
||||
const pushNotifications = PushNotifications;
|
||||
|
||||
if (!pushNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await pushNotifications.checkPermissions();
|
||||
|
||||
if (permission.receive === 'prompt') {
|
||||
await pushNotifications.requestPermissions();
|
||||
}
|
||||
|
||||
await pushNotifications.addListener('registration', (event) => {
|
||||
this.latestToken = event.value;
|
||||
void this.persistToken(event.value);
|
||||
});
|
||||
|
||||
await pushNotifications.addListener('registrationError', (error) => {
|
||||
console.warn('[mobile] push registration failed', error);
|
||||
});
|
||||
|
||||
await pushNotifications.register();
|
||||
} catch (error) {
|
||||
console.warn('[mobile] remote push registration skipped after failure', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async isRemotePushConfigured(): Promise<boolean> {
|
||||
try {
|
||||
const result = await MetoyouMobile.isRemotePushConfigured();
|
||||
|
||||
return result.configured === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistToken(token: string): Promise<void> {
|
||||
const userId = getStoredCurrentUserId();
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const Device = loadCapacitorDevicePlugin();
|
||||
const deviceInfo = Device ? await Device.getInfo() : null;
|
||||
const platform = normalizePushPlatform(deviceInfo?.platform ?? '');
|
||||
|
||||
if (!platform) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPushDeviceTokenRegistrationPayload({
|
||||
userId,
|
||||
token,
|
||||
platform
|
||||
});
|
||||
const serverUrl = this.resolveSignalingServerUrl();
|
||||
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${serverUrl}/api/users/device-tokens`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[mobile] failed to persist push device token', error);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSignalingServerUrl(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configured = window.localStorage.getItem('metoyou.signalServerUrl');
|
||||
|
||||
if (configured) {
|
||||
return configured.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
return `${window.location.origin}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { createCapacitorSqliteStore, type MobileSqliteStore } from '../adapters/capacitor/capacitor-sqlite.store';
|
||||
import { resolveMobileSqliteDatabaseName } from '../logic/mobile-sqlite-database-name.rules';
|
||||
import { getStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||
|
||||
/** Shared native SQLite connection used by mobile persistence and DatabaseService. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileSqliteConnectionService {
|
||||
private store: MobileSqliteStore | null = null;
|
||||
private activeDatabaseName: string | null = null;
|
||||
private initializationPromise: Promise<MobileSqliteStore | null> | null = null;
|
||||
private initializationFailed = false;
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.store?.isAvailable === true;
|
||||
}
|
||||
|
||||
async initialize(): Promise<MobileSqliteStore | null> {
|
||||
if (this.initializationFailed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = this.openStore()
|
||||
.catch((error) => {
|
||||
this.initializationFailed = true;
|
||||
console.error('[mobile] SQLite initialization failed', error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializationPromise = null;
|
||||
});
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
async getStore(): Promise<MobileSqliteStore> {
|
||||
const store = await this.initialize();
|
||||
|
||||
if (!store?.isAvailable) {
|
||||
throw new Error('Native SQLite store is unavailable on this shell.');
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
private async openStore(): Promise<MobileSqliteStore | null> {
|
||||
const databaseName = resolveMobileSqliteDatabaseName(getStoredCurrentUserId());
|
||||
|
||||
if (this.store && this.activeDatabaseName === databaseName && this.store.isAvailable) {
|
||||
return this.store;
|
||||
}
|
||||
|
||||
this.store = await createCapacitorSqliteStore(databaseName);
|
||||
|
||||
if (!this.store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.store.initialize();
|
||||
this.activeDatabaseName = databaseName;
|
||||
|
||||
return this.store;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user