feat: Android APP V1 - Experimental Alpha

This commit is contained in:
2026-06-05 07:40:25 +02:00
parent bf4e6891d1
commit 9a1305f976
179 changed files with 8031 additions and 120 deletions

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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