chore: Fix app

This commit is contained in:
2026-07-14 00:41:05 +02:00
parent 3e090933fd
commit edc4d935d8
98 changed files with 2878 additions and 155 deletions
@@ -59,4 +59,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Attachment export to public Documents needs legacy storage permissions on Android 10 and below. -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

+2 -2
View File
@@ -13,8 +13,8 @@ const config: CapacitorConfig = {
style: 'DARK'
},
LocalNotifications: {
smallIcon: 'ic_stat_icon_config_sample',
iconColor: '#488AFF',
smallIcon: 'ic_stat_metoyou',
iconColor: '#4A217A',
sound: 'call.wav'
},
PushNotifications: {
+7 -1
View File
@@ -43,7 +43,13 @@
},
"errors": {
"noRecipient": "Direct message conversation has no recipient to call.",
"noCurrentUser": "Cannot use calls without a current user."
"noCurrentUser": "Cannot use calls without a current user.",
"signalingUnavailable": "Not connected to the call server. Check your connection and try again.",
"captureUnsupported": "Voice capture is not available on this device.",
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
}
}
}
+1
View File
@@ -24,6 +24,7 @@
"notifications": {
"incomingCallsChannel": "Incoming calls",
"activeCallsChannel": "Active calls",
"messagesChannel": "Messages",
"answer": "Answer",
"decline": "Decline",
"mute": "Mute",
+8 -1
View File
@@ -127,7 +127,13 @@
},
"errors": {
"noRecipient": "Direct message conversation has no recipient to call.",
"noCurrentUser": "Cannot use calls without a current user."
"noCurrentUser": "Cannot use calls without a current user.",
"signalingUnavailable": "Not connected to the call server. Check your connection and try again.",
"captureUnsupported": "Voice capture is not available on this device.",
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
}
},
"chat": {
@@ -517,6 +523,7 @@
"notifications": {
"incomingCallsChannel": "Incoming calls",
"activeCallsChannel": "Active calls",
"messagesChannel": "Messages",
"answer": "Answer",
"decline": "Decline",
"mute": "Mute",
+6 -1
View File
@@ -29,21 +29,26 @@ infrastructure adapters and UI.
The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md)
- [access-control/README.md](access-control/README.md)
- [attachment/README.md](attachment/README.md)
- [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md)
- [custom-emoji/README.md](custom-emoji/README.md)
- [direct-message/README.md](direct-message/README.md)
- [direct-call/README.md](direct-call/README.md)
- [experimental-media/README.md](experimental-media/README.md)
- [game-activity/README.md](game-activity/README.md)
- [notifications/README.md](notifications/README.md)
- [plugins/README.md](plugins/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md)
- [theme/README.md](theme/README.md)
- [voice-connection/README.md](voice-connection/README.md)
- [voice-session/README.md](voice-session/README.md)
Cross-context wire contracts live in [`agents-docs/features/`](../../agents-docs/features/) — see [`agents-docs/FEATURES.md`](../../agents-docs/FEATURES.md).
## Folder convention
Every domain follows the same internal layout:
@@ -28,7 +28,8 @@ attachment/
├── infrastructure/
│ ├── services/
│ │ ── attachment-storage.service.ts Electron filesystem access (save / read / delete)
│ │ ── attachment-storage.service.ts Electron filesystem access (save / read / delete)
│ │ └── capacitor-attachment-export.service.ts Capacitor "download": copy/write bytes into public Documents
│ └── util/
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
@@ -9,8 +9,15 @@ import {
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
const isCapacitorNativeRuntimeMock = vi.fn(() => false);
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
}));
import { AttachmentDownloadService } from './attachment-download.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
import type { Attachment } from '../../domain/models/attachment.model';
describe('AttachmentDownloadService', () => {
@@ -21,8 +28,11 @@ describe('AttachmentDownloadService', () => {
let documentStub: Document;
let saveExistingFileAs: ReturnType<typeof vi.fn>;
let saveFileAs: ReturnType<typeof vi.fn>;
let exportToDevice: ReturnType<typeof vi.fn>;
beforeEach(() => {
isCapacitorNativeRuntimeMock.mockReturnValue(false);
exportToDevice = vi.fn(async () => true);
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
@@ -53,6 +63,7 @@ describe('AttachmentDownloadService', () => {
providers: [
AttachmentDownloadService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: CapacitorAttachmentExportService, useValue: { exportToDevice } },
{ provide: DOCUMENT, useValue: documentStub }
]
});
@@ -78,6 +89,28 @@ describe('AttachmentDownloadService', () => {
expect(saveFileAs).not.toHaveBeenCalled();
});
it('delegates to the Capacitor export service on a native mobile shell', async () => {
isCapacitorNativeRuntimeMock.mockReturnValue(true);
electronBridge.getApi = vi.fn(() => null);
const service = createService();
const attachment: Attachment = {
id: 'file-3',
messageId: 'message-3',
filename: 'photo.png',
mime: 'image/png',
size: 2048,
available: true,
savedPath: 'metoyou/server/room/files/photo.png'
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
expect(exportToDevice).toHaveBeenCalledWith(attachment);
expect(saveExistingFileAs).not.toHaveBeenCalled();
expect(saveFileAs).not.toHaveBeenCalled();
});
it('does nothing when the attachment is not downloadable yet', async () => {
const service = createService();
const attachment: Attachment = {
@@ -2,12 +2,15 @@ import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
import type { Attachment } from '../../domain/models/attachment.model';
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
@Injectable({ providedIn: 'root' })
export class AttachmentDownloadService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly capacitorExport = inject(CapacitorAttachmentExportService);
private readonly document = inject(DOCUMENT);
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
@@ -15,6 +18,10 @@ export class AttachmentDownloadService {
return false;
}
if (isCapacitorNativeRuntime()) {
return this.capacitorExport.exportToDevice(attachment);
}
const electronApi = this.electronBridge.getApi();
const diskPath = resolveAttachmentDiskPath(attachment);
@@ -0,0 +1,31 @@
import {
describe,
expect,
it
} from 'vitest';
import { buildAttachmentExportFileName } from './attachment-export.rules';
describe('buildAttachmentExportFileName', () => {
it('appends the timestamp before the extension so exports never collide', () => {
expect(buildAttachmentExportFileName('report.pdf', 1_720_900_000_000)).toBe('report-1720900000000.pdf');
});
it('appends the timestamp at the end when there is no extension', () => {
expect(buildAttachmentExportFileName('README', 42)).toBe('README-42');
});
it('keeps dotfiles intact instead of treating the leading dot as an extension', () => {
expect(buildAttachmentExportFileName('.env', 42)).toBe('.env-42');
});
it('strips directory components from the filename', () => {
expect(buildAttachmentExportFileName('../secret/../../etc/passwd.txt', 7)).toBe('passwd-7.txt');
expect(buildAttachmentExportFileName('folder\\file.bin', 7)).toBe('file-7.bin');
});
it('falls back to a generic name when the filename is empty after sanitising', () => {
expect(buildAttachmentExportFileName(' ', 7)).toBe('attachment-7');
expect(buildAttachmentExportFileName('a/b/', 7)).toBe('attachment-7');
});
});
@@ -0,0 +1,23 @@
const FALLBACK_EXPORT_BASE_NAME = 'attachment';
/**
* Build the file name used when exporting an attachment to a user-visible
* directory (e.g. Android `Documents`). The timestamp is appended before the
* extension so repeated exports of the same file never collide - public
* directories on Android 11+ reject overwrites of files the app did not create.
*/
export function buildAttachmentExportFileName(filename: string, timestamp: number): string {
const baseName = stripDirectoryComponents(filename);
const dotIndex = baseName.lastIndexOf('.');
const hasExtension = dotIndex > 0;
const stem = hasExtension ? baseName.slice(0, dotIndex) : baseName;
const extension = hasExtension ? baseName.slice(dotIndex) : '';
return `${stem || FALLBACK_EXPORT_BASE_NAME}-${timestamp}${extension}`;
}
function stripDirectoryComponents(filename: string): string {
const segments = filename.split(/[/\\]/);
return segments[segments.length - 1]?.trim() ?? '';
}
@@ -0,0 +1,121 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const isCapacitorNativeRuntimeMock = vi.fn(() => true);
const loadFilesystemMock = vi.fn();
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
}));
vi.mock('./capacitor-attachment-filesystem.adapter', () => ({
loadCapacitorAttachmentFilesystem: () => loadFilesystemMock()
}));
import { CapacitorAttachmentExportService } from './capacitor-attachment-export.service';
import type { Attachment } from '../../domain/models/attachment.model';
function createFakeAdapter() {
return {
filesystem: {
copy: vi.fn(async () => undefined),
writeFile: vi.fn(async () => ({ uri: 'file:///docs/out' }))
},
directory: 'DATA',
exportDirectory: 'DOCUMENTS',
convertFileSrc: (url: string) => url
};
}
function makeAttachment(overrides: Partial<Attachment>): Attachment {
return {
id: 'file-1',
messageId: 'message-1',
filename: 'photo.png',
mime: 'image/png',
size: 1024,
available: true,
...overrides
};
}
describe('CapacitorAttachmentExportService', () => {
let service: CapacitorAttachmentExportService;
let fakeAdapter: ReturnType<typeof createFakeAdapter>;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_720_900_000_000);
isCapacitorNativeRuntimeMock.mockReturnValue(true);
fakeAdapter = createFakeAdapter();
loadFilesystemMock.mockResolvedValue(fakeAdapter);
service = new CapacitorAttachmentExportService();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('copies a disk-backed attachment from app data into the export directory', async () => {
const attachment = makeAttachment({ savedPath: 'metoyou/server/room/files/photo.png' });
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
expect(fakeAdapter.filesystem.copy).toHaveBeenCalledWith({
from: 'metoyou/server/room/files/photo.png',
directory: 'DATA',
to: 'photo-1720900000000.png',
toDirectory: 'DOCUMENTS'
});
expect(fakeAdapter.filesystem.writeFile).not.toHaveBeenCalled();
});
it('writes an in-memory attachment fetched from its object URL into the export directory', async () => {
const bytes = new TextEncoder().encode('hello');
vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes)));
const attachment = makeAttachment({ objectUrl: 'blob:https://app/abc' });
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalledWith({
path: 'photo-1720900000000.png',
data: btoa('hello'),
directory: 'DOCUMENTS',
recursive: true
});
});
it('falls back to the object URL when copying from disk fails', async () => {
fakeAdapter.filesystem.copy.mockRejectedValue(new Error('copy failed'));
vi.stubGlobal('fetch', vi.fn(async () => new Response(new TextEncoder().encode('x'))));
const attachment = makeAttachment({
savedPath: 'metoyou/server/room/files/photo.png',
objectUrl: 'capacitor://localhost/_capacitor_file_/photo.png'
});
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalled();
});
it('returns false off a native shell', async () => {
isCapacitorNativeRuntimeMock.mockReturnValue(false);
await expect(service.exportToDevice(makeAttachment({ savedPath: 'x' }))).resolves.toBe(false);
});
it('returns false when there is neither a disk path nor an object URL', async () => {
await expect(service.exportToDevice(makeAttachment({}))).resolves.toBe(false);
});
});
@@ -0,0 +1,81 @@
import { Injectable } from '@angular/core';
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
import { resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
import { buildAttachmentExportFileName } from '../../domain/logic/attachment-export.rules';
import type { Attachment } from '../../domain/models/attachment.model';
import { loadCapacitorAttachmentFilesystem } from './capacitor-attachment-filesystem.adapter';
/**
* Exports attachments out of the app-private data directory into the device's
* user-visible `Documents` directory on Capacitor. Anchor-based `download`
* links do nothing in the Android WebView, so "download" on mobile means
* copying the bytes somewhere the user can reach through the Files app.
*/
@Injectable({ providedIn: 'root' })
export class CapacitorAttachmentExportService {
async exportToDevice(attachment: Attachment): Promise<boolean> {
if (!isCapacitorNativeRuntime()) {
return false;
}
const filesystem = await loadCapacitorAttachmentFilesystem();
if (!filesystem) {
return false;
}
const exportPath = buildAttachmentExportFileName(attachment.filename, Date.now());
const diskPath = resolveAttachmentDiskPath(attachment);
if (diskPath) {
try {
await filesystem.filesystem.copy({
from: diskPath,
directory: filesystem.directory,
to: exportPath,
toDirectory: filesystem.exportDirectory
});
return true;
} catch {
/* fall back to the object URL below */
}
}
if (!attachment.objectUrl) {
return false;
}
const base64 = await this.fetchAsBase64(attachment.objectUrl);
if (base64 === null) {
return false;
}
try {
await filesystem.filesystem.writeFile({
path: exportPath,
data: base64,
directory: filesystem.exportDirectory,
recursive: true
});
return true;
} catch {
return false;
}
}
private async fetchAsBase64(objectUrl: string): Promise<string | null> {
try {
const response = await fetch(objectUrl);
const buffer = await response.arrayBuffer();
return encodeUint8ArrayToBase64(new Uint8Array(buffer));
} catch {
return null;
}
}
}
@@ -82,6 +82,7 @@ function createFakeFilesystem() {
adapter: {
filesystem,
directory: 'DATA',
exportDirectory: 'DOCUMENTS',
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
}
};
@@ -10,6 +10,8 @@ type CapacitorCoreModule = typeof import('@capacitor/core');
export interface CapacitorAttachmentFilesystem {
filesystem: CapacitorFilesystemModule['Filesystem'];
directory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
/** User-visible directory (`Documents`) used when exporting attachments out of app storage. */
exportDirectory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
convertFileSrc: (url: string) => string;
}
@@ -42,6 +44,7 @@ async function resolveCapacitorAttachmentFilesystem(): Promise<CapacitorAttachme
return {
filesystem: filesystemModule.Filesystem,
directory: filesystemModule.Directory.Data,
exportDirectory: filesystemModule.Directory.Documents,
convertFileSrc: (url: string) => coreModule.Capacitor.convertFileSrc(url)
};
} catch {
@@ -1,5 +1,5 @@
<div class="h-full grid place-items-center bg-background">
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="h-full grid place-items-center overflow-y-auto bg-background p-4">
<div class="w-full max-w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon
name="lucideLogIn"
@@ -1,5 +1,5 @@
<div class="h-full grid place-items-center bg-background">
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="h-full grid place-items-center overflow-y-auto bg-background p-4">
<div class="w-full max-w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon
name="lucideUserPlus"
+8
View File
@@ -178,3 +178,11 @@ Opening a conversation must land on the newest message even though images, link/
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit `isTyping: false` event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
## Cross-context feature docs
- [`agents-docs/features/messaging.md`](../../../../../agents-docs/features/messaging.md) — server chat + DM transports, sync, delivery states
- [`agents-docs/features/message-integrity.md`](../../../../../agents-docs/features/message-integrity.md) — signed revisions
- [`agents-docs/features/custom-emoji.md`](../../../../../agents-docs/features/custom-emoji.md)
- [`agents-docs/features/klipy-gifs.md`](../../../../../agents-docs/features/klipy-gifs.md)
- [`agents-docs/features/link-preview-media-proxy.md`](../../../../../agents-docs/features/link-preview-media-proxy.md)
@@ -67,7 +67,7 @@
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
@@ -5,7 +5,7 @@
[ariaLabel]="'chat.overlays.closeGalleryAria' | translate"
(dismissed)="closeGallery()"
/>
<div class="pointer-events-none fixed inset-0 z-[101] flex items-center justify-center p-4">
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[101] flex items-center justify-center p-4">
<div
class="pointer-events-auto relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
@@ -111,7 +111,7 @@
[ariaLabel]="'chat.overlays.closePreviewAria' | translate"
(dismissed)="closeLightbox()"
/>
<div class="pointer-events-none fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[110] flex items-center justify-center p-4">
<div
class="lightbox-stage pointer-events-auto relative max-h-[90vh] max-w-[90vw]"
[class.lightbox-chrome-hidden]="!lightboxControlsVisible()"
@@ -0,0 +1,46 @@
# Custom Emoji Domain
User-created image emoji: validation, local asset storage, saved-library membership, P2P sync, and the shared picker consumed by chat reactions and the composer.
**Wire contract (P2P envelopes, `account_sync` relay):** [`agents-docs/features/custom-emoji.md`](../../../../../agents-docs/features/custom-emoji.md)
## Module map
```
custom-emoji/
├── domain/
│ ├── custom-emoji.rules.ts Validation, tokens, chunk limits, shortcut selection
│ └── custom-emoji.rules.spec.ts
├── application/
│ ├── custom-emoji.service.ts Upload, library, known-asset cache, proactive push
│ ├── custom-emoji.service.spec.ts
│ └── custom-emoji-sync.effects.ts NgRx effects: data-channel + account_sync handling
├── feature/
│ └── custom-emoji-picker/ Picker UI, search, shortcut row
└── index.ts Barrel — `CustomEmojiService`, picker component, effects
```
## Public entry points
| Export | Role |
|--------|------|
| `CustomEmojiService` | Upload, save/remove library, resolve tokens, peer summaries |
| `CustomEmojiPickerComponent` | Emoji selector for composer and reactions |
| `CustomEmojiSyncEffects` | Registers with NgRx for inbound sync events |
## NgRx / realtime touchpoints
- `CustomEmojiSyncEffects` listens for P2P `ChatEvent` types and `account_sync` payloads relayed from [`signaling`](../../../../../agents-docs/features/signaling.md).
- Chat domain calls `CustomEmojiService` when sending messages/reactions to push referenced assets to peers.
## UI integration
- **Chat composer**`:name:` aliases rewrite to `:emoji[id](name)` on send; shortcut row shows top seven saved emoji.
- **Message reactions** — custom emoji reactions use the same token format.
- **Context menu** — add/remove from library on rendered custom emoji (`data-custom-emoji` attributes).
## Boundaries
- Does not own chat message persistence or signaling server storage.
- Usage ranking is local per user id; not synced across devices.
- Import from `domains/custom-emoji` barrel only; chat imports the service, not internal paths.
@@ -477,6 +477,56 @@ describe('DirectCallService', () => {
expect(context.voiceSession.endSession).toHaveBeenCalled();
});
it('surfaces a join error when the microphone permission is denied instead of failing silently', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.mobileMedia.ensureVoiceCapturePermissions.mockResolvedValue(false);
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
await context.service.joinCall(session.callId);
});
expect(context.service.joinError()).not.toBeNull();
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
expect(context.service.sessionById(session.callId)?.participants.alice.joined).not.toBe(true);
});
it('surfaces a join error when getUserMedia rejects', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await withStubbedGetUserMedia(vi.fn(async () => {
throw new Error('NotReadableError');
}), async () => {
await context.service.joinCall(session.callId);
});
expect(context.service.joinError()).not.toBeNull();
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
});
it('clears the join error on a successful join', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
await context.service.joinCall(session.callId);
});
expect(context.service.joinError()).toBeNull();
expect(context.voice.setLocalStream).toHaveBeenCalled();
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
});
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
@@ -643,6 +693,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
const voiceSession = {
endSession: vi.fn()
};
const mobileMedia = {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined)
};
const credentialStore = {
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
serverUrl: `https://signal.example/${userId}`,
@@ -732,10 +786,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
},
{
provide: MobileMediaService,
useValue: {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined)
}
useValue: mobileMedia
},
{
provide: RealtimeSessionFacade,
@@ -761,6 +812,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
directCallEvents,
directMessages,
effectScheduler,
mobileMedia,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,
@@ -768,6 +820,42 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
};
}
class FakeMediaStream {
getTracks(): unknown[] {
return [];
}
}
/** Temporarily provide `navigator.mediaDevices.getUserMedia` for the join path under Node. */
async function withStubbedGetUserMedia(
getUserMedia: () => Promise<unknown>,
run: () => Promise<void>
): Promise<void> {
const globalWithNavigator = globalThis as { navigator?: { mediaDevices?: unknown } };
const originalNavigator = globalWithNavigator.navigator;
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
...(originalNavigator ?? {}),
mediaDevices: { getUserMedia }
}
});
try {
await run();
} finally {
if (originalNavigator === undefined) {
delete globalWithNavigator.navigator;
} else {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: originalNavigator
});
}
}
}
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
return {
type: 'direct-call',
@@ -91,6 +91,8 @@ export class DirectCallService {
&& this.hasConnectedParticipant(session)) ?? null;
});
readonly currentSession = signal<DirectCallSession | null>(null);
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
readonly joinError = signal<string | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
@@ -333,6 +335,7 @@ export class DirectCallService {
return;
}
this.joinError.set(null);
this.leaveOtherJoinedCalls(callId);
this.leaveCurrentVoiceTargetForCall(callId);
this.audio.stop(AppSound.Call);
@@ -344,22 +347,36 @@ export class DirectCallService {
const ok = await this.voice.ensureSignalingConnected();
if (!ok || !navigator.mediaDevices?.getUserMedia) {
if (!ok) {
this.joinError.set(this.i18n.instant('call.errors.signalingUnavailable'));
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
this.joinError.set(this.i18n.instant('call.errors.captureUnsupported'));
return;
}
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
if (!voicePermissionsGranted) {
this.joinError.set(this.i18n.instant('call.errors.microphonePermissionDenied'));
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
} catch {
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
return;
}
await this.voice.setLocalStream(stream);
this.voiceActivity.trackLocalMic(meId, stream);
@@ -4,9 +4,9 @@
[dismissable]="false"
/>
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[121] flex items-center justify-center p-4">
<section
class="pointer-events-auto w-full max-w-sm rounded-lg border border-border bg-card shadow-2xl"
class="pointer-events-auto max-h-full w-full max-w-sm overflow-y-auto rounded-lg border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="incoming-call-title"
@@ -61,3 +61,9 @@ Conversation participants keep avatar/profile metadata captured from user cards
## Persistence
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.
## Cross-context feature docs
- [`agents-docs/features/messaging.md`](../../../../../agents-docs/features/messaging.md) — DM delivery states, sync, transports
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — DM WebSocket relay
- [`agents-docs/features/voice-webrtc.md`](../../../../../agents-docs/features/voice-webrtc.md) — private calls
@@ -14,6 +14,7 @@ import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji';
import { NotificationsFacade } from '../../../notifications';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import {
advanceDirectMessageStatus,
@@ -72,6 +73,7 @@ export class DirectMessageService {
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly notifications = inject(NotificationsFacade);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null);
@@ -544,6 +546,15 @@ export class DirectMessageService {
updatedAt: Date.now()
});
if (incomingMessage.kind !== 'system' && !incomingMessage.isDeleted) {
void this.notifications.handleIncomingDirectMessage({
id: incomingMessage.id,
senderName: sender.displayName || sender.username || sender.userId,
content: incomingMessage.content,
conversationVisible: !shouldIncrementUnread
});
}
if (!shouldIncrementUnread) {
await this.markRead(conversationId);
}
@@ -143,7 +143,7 @@
(keydown.space)="closeGifPicker()"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
@@ -0,0 +1,41 @@
# Game Activity Domain
Foreground-window-first game detection, RAWG matching, and P2P now-playing sync.
**Cross-context contract:** [`agents-docs/features/game-activity.md`](../../../../../agents-docs/features/game-activity.md)
## Module map
```
game-activity/
├── domain/
│ ├── game-activity.models.ts Shared-kernel types (re-exported)
│ └── game-activity-time.ts formatGameActivityElapsed()
├── application/
│ ├── game-activity.service.ts Scan loop, match cache, P2P broadcast
│ └── game-activity.service.spec.ts
└── index.ts
```
## Public entry points
| Export | Role |
|--------|------|
| `GameActivityService` | Started from `App` bootstrap; updates user store + broadcasts |
| `formatGameActivityElapsed()` | Profile card / sidebar elapsed time label |
## Dependencies
- `ElectronBridgeService` — process names and foreground candidate (desktop only)
- `ServerDirectoryFacade``POST /api/games/match` base URL
- `RealtimeSessionFacade` — P2P `game-activity` send/receive
## UI consumers
- Profile card components (`formatGameActivityElapsed`, `user.gameActivity`)
- Room side panel member list
## Boundaries
- Does not own RAWG API keys (server configuration).
- Browser/mobile shells do not scan processes locally.
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { NotificationsService } from '../services/notifications.service';
@@ -39,6 +39,12 @@ export class NotificationsFacade {
return this.service.handleIncomingMessage(...args);
}
handleIncomingDirectMessage(
...args: Parameters<NotificationsService['handleIncomingDirectMessage']>
): ReturnType<NotificationsService['handleIncomingDirectMessage']> {
return this.service.handleIncomingDirectMessage(...args);
}
markCurrentChannelReadIfActive(
...args: Parameters<NotificationsService['markCurrentChannelReadIfActive']>
): ReturnType<NotificationsService['markCurrentChannelReadIfActive']> {
@@ -10,7 +10,9 @@ import type {
User
} from '../../../../shared-kernel';
import { NotificationAudioService } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/platform';
import { TimeSyncService } from '../../../../core/services/time-sync.service';
import { MobileAppLifecycleService } from '../../../../infrastructure/mobile/services/mobile-app-lifecycle.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import {
selectActiveChannelId,
@@ -188,6 +190,21 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
play: vi.fn()
}
},
{
provide: PlatformService,
useValue: {
isBrowser: true,
isCapacitor: false,
isElectron: false
}
},
{
provide: MobileAppLifecycleService,
useValue: {
initialize: vi.fn(async () => undefined),
onAppStateChange: vi.fn()
}
},
{
provide: TimeSyncService,
useValue: {
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
@@ -9,7 +9,9 @@ import { Store } from '@ngrx/store';
import type { Message, Room } from '../../../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { AppI18nService } from '../../../../core/i18n';
import { PlatformService } from '../../../../core/platform';
import { TimeSyncService } from '../../../../core/services/time-sync.service';
import { MobileAppLifecycleService } from '../../../../infrastructure/mobile/services/mobile-app-lifecycle.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import {
selectActiveChannelId,
@@ -18,6 +20,7 @@ import {
} from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import {
buildDirectMessageNotificationPayload,
buildNotificationDisplayPayload,
calculateUnreadForRoom,
DEFAULT_TEXT_CHANNEL_ID,
@@ -28,6 +31,7 @@ import {
isRoomMuted,
isMessageVisibleInActiveView,
resolveMessageChannelId,
shouldDeliverDirectMessageNotification,
shouldDeliverNotification
} from '../../domain/logic/notification.logic';
import {
@@ -53,6 +57,8 @@ export class NotificationsService {
private readonly timeSync = inject(TimeSyncService);
private readonly desktopNotifications = inject(DesktopNotificationService);
private readonly storage = inject(NotificationSettingsStorageService);
private readonly platform = inject(PlatformService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
@@ -83,6 +89,7 @@ export class NotificationsService {
this.initialised = true;
this.registerWindowListeners();
this.registerWindowStateListener();
this.registerMobileLifecycleListener();
this.syncRoomCatalog(this.savedRooms());
await this.hydrateUnreadCounts(this.savedRooms());
this.markCurrentChannelReadIfActive();
@@ -234,6 +241,44 @@ export class NotificationsService {
await this.desktopNotifications.showNotification(payload);
}
/** System notification for an incoming direct message; sender/self filtering happens in the DM domain. */
async handleIncomingDirectMessage(input: {
id: string;
senderName: string;
content: string;
conversationVisible: boolean;
}): Promise<void> {
if (!this.initialised || this.isDuplicateMessage(input.id)) {
return;
}
this.rememberMessageId(input.id);
const isWindowActive = this.isWindowActive();
const shouldDeliver = shouldDeliverDirectMessageNotification(this._settings(), {
conversationVisible: input.conversationVisible,
currentUser: this.currentUser() ?? null,
isWindowActive
});
if (!shouldDeliver) {
return;
}
const payload = buildDirectMessageNotificationPayload(
input,
this._settings(),
!isWindowActive,
(key, params) => this.appI18n.instant(key, params)
);
if (this.shouldPlayNotificationSound()) {
this.audio.play(AppSound.Notification);
}
await this.desktopNotifications.showNotification(payload);
}
markCurrentChannelReadIfActive(): void {
if (!this.initialised || !this._windowFocused() || !this._documentVisible()) {
return;
@@ -321,6 +366,28 @@ export class NotificationsService {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
}
/** Android WebView focus/visibility events are unreliable when the app backgrounds; use Capacitor appStateChange instead. */
private registerMobileLifecycleListener(): void {
if (!this.platform.isCapacitor) {
return;
}
void this.mobileLifecycle.initialize().then(() => {
this.mobileLifecycle.onAppStateChange((isActive) => {
this._windowFocused.set(isActive);
this._documentVisible.set(isActive);
this._windowMinimized.set(!isActive);
if (isActive) {
this.markCurrentChannelReadIfActive();
return;
}
this.syncWindowAttention();
});
});
}
private registerWindowStateListener(): void {
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
this._windowFocused.set(state.isFocused);
@@ -1,6 +1,14 @@
import type { Message, Room } from '../../../../shared-kernel';
import type {
Message,
Room,
User
} from '../../../../shared-kernel';
import { createDefaultNotificationSettings } from '../models/notification.model';
import { calculateUnreadForRoom } from './notification.logic';
import {
buildDirectMessageNotificationPayload,
calculateUnreadForRoom,
shouldDeliverDirectMessageNotification
} from './notification.logic';
function createRoom(overrides: Partial<Room> = {}): Room {
return {
@@ -29,6 +37,80 @@ function createMessage(overrides: Partial<Message> = {}): Message {
};
}
describe('shouldDeliverDirectMessageNotification', () => {
const settings = createDefaultNotificationSettings();
const onlineUser = { status: 'online' } as User;
it('delivers when the conversation is not on screen', () => {
expect(shouldDeliverDirectMessageNotification(settings, {
conversationVisible: false,
currentUser: onlineUser,
isWindowActive: true
})).toBe(true);
});
it('delivers when the conversation is on screen but the window is inactive', () => {
expect(shouldDeliverDirectMessageNotification(settings, {
conversationVisible: true,
currentUser: onlineUser,
isWindowActive: false
})).toBe(true);
});
it('suppresses when the conversation is on screen in the active window', () => {
expect(shouldDeliverDirectMessageNotification(settings, {
conversationVisible: true,
currentUser: onlineUser,
isWindowActive: true
})).toBe(false);
});
it('suppresses when notifications are disabled', () => {
expect(shouldDeliverDirectMessageNotification({ ...settings, enabled: false }, {
conversationVisible: false,
currentUser: onlineUser,
isWindowActive: false
})).toBe(false);
});
it('suppresses when the user is busy', () => {
expect(shouldDeliverDirectMessageNotification(settings, {
conversationVisible: false,
currentUser: { status: 'busy' } as User,
isWindowActive: false
})).toBe(false);
});
});
describe('buildDirectMessageNotificationPayload', () => {
const translate = (key: string, params?: Record<string, string | number>) =>
`${key}:${JSON.stringify(params ?? {})}`;
it('uses the sender name as title and the message preview as body', () => {
const payload = buildDirectMessageNotificationPayload(
{ senderName: 'Bob', content: 'hello there' },
createDefaultNotificationSettings(),
true,
translate
);
expect(payload.title).toBe('Bob');
expect(payload.body).toBe('notifications.display.preview:{"sender":"Bob","content":"hello there"}');
expect(payload.requestAttention).toBe(true);
});
it('hides the content when previews are disabled', () => {
const payload = buildDirectMessageNotificationPayload(
{ senderName: 'Bob', content: 'secret' },
{ ...createDefaultNotificationSettings(), showPreview: false },
false,
translate
);
expect(payload.body).toBe('notifications.display.newMessageHidden:{"sender":"Bob"}');
});
});
describe('calculateUnreadForRoom', () => {
it('ignores messages whose channel is not part of the room catalog', () => {
const room = createRoom();
@@ -1,4 +1,8 @@
import type { Message, Room } from '../../../../shared-kernel';
import type {
Message,
Room,
User
} from '../../../../shared-kernel';
import type {
NotificationDeliveryContext,
NotificationDisplayPayload,
@@ -116,6 +120,47 @@ export function buildNotificationDisplayPayload(
};
}
export interface DirectMessageNotificationContext {
conversationVisible: boolean;
currentUser: Pick<User, 'status'> | null;
isWindowActive: boolean;
}
/**
* DMs have no room/channel mute concept; deliver unless the conversation is
* actually on screen in an active window, notifications are off, or the user
* is busy.
*/
export function shouldDeliverDirectMessageNotification(
settings: Pick<NotificationsSettings, 'enabled'>,
context: DirectMessageNotificationContext
): boolean {
if (!settings.enabled) {
return false;
}
if (context.currentUser?.status === 'busy') {
return false;
}
return !(context.conversationVisible && context.isWindowActive);
}
export function buildDirectMessageNotificationPayload(
message: { senderName: string; content: string },
settings: Pick<NotificationsSettings, 'showPreview'>,
requestAttention: boolean,
translate: AppTranslateFn
): NotificationDisplayPayload {
return {
title: message.senderName,
body: settings.showPreview
? formatMessagePreview(message.senderName, message.content, translate)
: translate('notifications.display.newMessageHidden', { sender: message.senderName }),
requestAttention
};
}
export function calculateUnreadForRoom(
room: Room,
messages: Message[],
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -0,0 +1,70 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
import { MobileNotificationsService } from '../../../../infrastructure/mobile/services/mobile-notifications.service';
import { DesktopNotificationService } from './desktop-notification.service';
interface ServiceContextOptions {
electronApi?: { showDesktopNotification?: ReturnType<typeof vi.fn> } | null;
isBrowser?: boolean;
isCapacitor?: boolean;
}
function createService(options: ServiceContextOptions = {}) {
const showMessage = vi.fn(async () => undefined);
const injector = Injector.create({
providers: [
{
provide: ElectronBridgeService,
useValue: {
getApi: vi.fn(() => options.electronApi ?? null)
}
},
{
provide: PlatformService,
useValue: {
isBrowser: options.isBrowser ?? false,
isCapacitor: options.isCapacitor ?? false,
isElectron: false
}
},
{
provide: MobileNotificationsService,
useValue: { showMessage }
}
]
});
return {
service: runInInjectionContext(injector, () => new DesktopNotificationService()),
showMessage
};
}
describe('DesktopNotificationService', () => {
it('routes message notifications to the mobile notifications facade on Capacitor', async () => {
const { service, showMessage } = createService({ isCapacitor: true });
await service.showNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
expect(showMessage).toHaveBeenCalledWith({ title: 'general - Toju HQ', body: 'Alice: hello' });
});
it('prefers the Electron bridge when available', async () => {
const showDesktopNotification = vi.fn(async () => undefined);
const { service, showMessage } = createService({ electronApi: { showDesktopNotification } });
await service.showNotification({ title: 't', body: 'b' });
expect(showDesktopNotification).toHaveBeenCalled();
expect(showMessage).not.toHaveBeenCalled();
});
it('does nothing on non-browser, non-capacitor shells without Electron', async () => {
const { service, showMessage } = createService({ isBrowser: false, isCapacitor: false });
await service.showNotification({ title: 't', body: 'b' });
expect(showMessage).not.toHaveBeenCalled();
});
});
@@ -2,12 +2,14 @@ import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models';
import { PlatformService } from '../../../../core/platform';
import { MobileNotificationsService } from '../../../../infrastructure/mobile/services/mobile-notifications.service';
import type { NotificationDisplayPayload } from '../../domain/models/notification.model';
@Injectable({ providedIn: 'root' })
export class DesktopNotificationService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly platform = inject(PlatformService);
private readonly mobileNotifications = inject(MobileNotificationsService);
async showNotification(payload: NotificationDisplayPayload): Promise<void> {
const api = this.electronBridge.getApi();
@@ -17,6 +19,11 @@ export class DesktopNotificationService {
return;
}
if (this.platform.isCapacitor) {
await this.mobileNotifications.showMessage({ title: payload.title, body: payload.body });
return;
}
if (!this.platform.isBrowser || typeof Notification === 'undefined') {
return;
}
@@ -44,3 +44,8 @@ Desktop plugin preferences that belong to the local user, including capability g
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.
## Cross-context feature docs
- [`agents-docs/features/plugins.md`](../../../../../agents-docs/features/plugins.md)
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — `plugin_event`, `plugin_requirements`
@@ -4,7 +4,7 @@
(dismissed)="cancelled.emit(undefined)"
/>
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
<div class="fixed metoyou-fixed-safe-viewport z-[113] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
role="dialog"
@@ -227,3 +227,12 @@ All endpoint state is persisted to localStorage under two keys:
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.
## Cross-context feature docs
Wire contracts spanning client + server (REST routes, discovery fan-out, invites) are documented in:
- [`agents-docs/features/server-directory.md`](../../../../../agents-docs/features/server-directory.md)
- [`agents-docs/features/server-discovery.md`](../../../../../agents-docs/features/server-discovery.md)
- [`agents-docs/features/invites-join-requests.md`](../../../../../agents-docs/features/invites-join-requests.md)
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — WebSocket `join_server` after REST join
@@ -65,7 +65,7 @@
/>
</button>
@if (!isMobile()) {
@if (showScreenShareButton()) {
<button
(click)="toggleScreenShare()"
type="button"
@@ -25,6 +25,7 @@ import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { ViewportService } from '../../../../core/platform';
import { MobilePlatformService } from '../../../../infrastructure/mobile';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
@@ -63,7 +64,10 @@ export class FloatingVoiceControlsComponent implements OnInit {
private readonly webrtcService = inject(VoiceConnectionFacade);
private readonly screenShareService = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
private readonly mobilePlatform = inject(MobilePlatformService);
readonly isMobile = this.viewport.isMobile;
/** Screen share is not supported in mobile WebViews; hide the control there. */
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);
@@ -79,6 +79,14 @@
[attr.aria-hidden]="isConnected() ? null : 'true'"
>
<div class="overflow-hidden">
@if (mediaError(); as mediaErrorMessage) {
<p
class="mb-2 text-center text-xs text-destructive"
data-testid="voice-controls-media-error"
>
{{ mediaErrorMessage }}
</p>
}
<div
appThemeNode="voiceControlsButtons"
class="flex items-center justify-center gap-2"
@@ -134,23 +142,25 @@
</button>
<!-- Screen Share Toggle -->
<button
type="button"
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
@if (isScreenSharing()) {
<ng-icon
name="lucideMonitorOff"
class="w-5 h-5"
/>
} @else {
<ng-icon
name="lucideMonitor"
class="w-5 h-5"
/>
}
</button>
@if (showScreenShareButton()) {
<button
type="button"
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
@if (isScreenSharing()) {
<ng-icon
name="lucideMonitorOff"
class="w-5 h-5"
/>
} @else {
<ng-icon
name="lucideMonitor"
class="w-5 h-5"
/>
}
</button>
}
<!-- Disconnect -->
<button
@@ -33,7 +33,8 @@ import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { MobileMediaService } from '../../../../infrastructure/mobile';
import { ViewportService } from '../../../../core/platform';
import { MobileMediaService, MobilePlatformService } from '../../../../infrastructure/mobile';
import {
DebugConsoleComponent,
ScreenShareQualityDialogComponent,
@@ -85,6 +86,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private readonly hostEl = inject(ElementRef);
private readonly profileCard = inject(ProfileCardService);
private readonly mobileMedia = inject(MobileMediaService);
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
currentUser = this.store.selectSignal(selectCurrentUser);
@@ -106,6 +109,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
isScreenSharing = this.screenShareService.isScreenSharing;
showSettings = signal(false);
/** Camera/screen-share capture problems surfaced to the user instead of being swallowed. */
mediaError = signal<string | null>(null);
/** Screen share is not supported in mobile WebViews; hide the control there. */
showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
toggleProfileCard(): void {
const user = this.currentUser();
@@ -412,6 +419,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const user = this.currentUser();
this.mediaError.set(null);
if (this.isCameraEnabled()) {
this.webrtcService.disableCamera();
@@ -438,7 +447,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
})
);
}
} catch (_error) {}
} catch (error) {
const errorName = error instanceof Error ? error.name : '';
const isPermissionError = errorName === 'NotAllowedError'
|| (error instanceof Error && error.message.includes('permission'));
this.mediaError.set(this.appI18n.instant(
isPermissionError ? 'call.errors.cameraPermissionDenied' : 'call.errors.cameraUnavailable'
));
}
}
async toggleScreenShare(): Promise<void> {
@@ -85,19 +85,21 @@
/>
</button>
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="screenShareToggled.emit()"
[attr.aria-label]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
[title]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
>
<ng-icon
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="h-5 w-5"
/>
</button>
@if (showScreenShareButton()) {
<button
type="button"
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
[disabled]="!connected()"
(click)="screenShareToggled.emit()"
[attr.aria-label]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
[title]="(screenSharing() ? 'call.stopSharingScreen' : 'call.shareScreen') | translate"
>
<ng-icon
[name]="screenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
class="h-5 w-5"
/>
</button>
}
<button
type="button"
@@ -46,6 +46,7 @@ export class PrivateCallControlsComponent {
readonly cameraEnabled = input.required<boolean>();
readonly screenSharing = input.required<boolean>();
readonly showSpeakerphoneButton = input(false);
readonly showScreenShareButton = input(true);
readonly speakerphoneEnabled = input(false);
readonly joinRequested = output();
@@ -194,6 +194,14 @@
}
<div class="shrink-0 pt-3">
@if (callErrorMessage(); as callError) {
<p
class="mx-auto mb-2 w-full max-w-5xl px-3 text-center text-xs text-destructive"
data-testid="private-call-error"
>
{{ callError }}
</p>
}
<app-private-call-controls
class="mx-auto block w-full max-w-5xl"
[connected]="isConnected()"
@@ -201,6 +209,7 @@
[deafened]="isDeafened()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
[showScreenShareButton]="showScreenShareButton()"
[showSpeakerphoneButton]="showSpeakerphoneButton()"
[speakerphoneEnabled]="speakerphoneEnabled()"
(joinRequested)="join()"
@@ -132,6 +132,10 @@ export class PrivateCallComponent {
readonly isDeafened = this.voice.isDeafened;
readonly isCameraEnabled = this.voice.isCameraEnabled;
readonly isScreenSharing = this.screenShare.isScreenSharing;
readonly joinError = this.calls.joinError;
readonly cameraError = signal<string | null>(null);
readonly callErrorMessage = computed(() => this.joinError() ?? this.cameraError());
readonly showScreenShareButton = computed(() => !this.isMobile() && !this.mobilePlatform.isNativeMobile());
readonly remoteStreamRevision = signal(0);
readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
@@ -381,6 +385,8 @@ export class PrivateCallComponent {
return;
}
this.cameraError.set(null);
if (this.isCameraEnabled()) {
this.voice.disableCamera();
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
@@ -388,11 +394,25 @@ export class PrivateCallComponent {
return;
}
await this.voice.enableCamera();
try {
await this.voice.enableCamera();
} catch (error) {
this.cameraError.set(this.resolveCameraErrorMessage(error));
return;
}
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
this.bumpRemoteStreamRevision();
}
private resolveCameraErrorMessage(error: unknown): string {
const errorName = error instanceof Error ? error.name : '';
const isPermissionError = errorName === 'NotAllowedError'
|| (error instanceof Error && error.message.includes('permission'));
return this.i18n.instant(isPermissionError ? 'call.errors.cameraPermissionDenied' : 'call.errors.cameraUnavailable');
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
@@ -261,7 +261,7 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
[class.hidden]="isMobile()"
[class.hidden]="!showScreenShareButton()"
(click)="toggleScreenShare()"
>
<ng-icon
@@ -44,6 +44,7 @@ import {
ScreenShareStartOptions
} from '../../../domains/screen-share';
import { ViewportService } from '../../../core/platform';
import { MobilePlatformService } from '../../../infrastructure/mobile';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
@@ -94,7 +95,10 @@ export class VoiceWorkspaceComponent {
private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
private readonly mobilePlatform = inject(MobilePlatformService);
readonly isMobile = this.viewport.isMobile;
/** Screen share is not supported in mobile WebViews; hide the control there. */
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
@@ -6,7 +6,7 @@
/>
<div
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
class="fixed metoyou-fixed-safe-viewport z-[121] flex items-center justify-center px-4 pointer-events-none"
>
<div
appThemeNode="highMemoryAlertDialog"
@@ -1,11 +1,13 @@
import translationsEn from '../../../../../../public/i18n/en.json';
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
import type { MessageNotificationPayload } from '../../logic/message-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';
const MESSAGE_CHANNEL_ID = 'toju-messages';
function mobileLabel(key: string): string {
const value = key.split('.').reduce<unknown>((current, part) => {
@@ -47,6 +49,13 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
visibility: 1
});
await LocalNotifications.createChannel({
id: MESSAGE_CHANNEL_ID,
name: mobileLabel('mobile.notifications.messagesChannel'),
importance: 4,
visibility: 1
});
await LocalNotifications.registerActionTypes({
types: [
{
@@ -136,6 +145,37 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
});
}
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
const LocalNotifications = await 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: MESSAGE_CHANNEL_ID,
autoCancel: true,
group: payload.tag,
extra: {
kind: 'message',
tag: payload.tag
}
}
]
});
}
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
@@ -1,4 +1,5 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import type { MessageNotificationPayload } from '../../logic/message-notification.rules';
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
@@ -50,6 +51,21 @@ export class WebMobileNotificationsAdapter implements MobileNotificationAdapter
};
}
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
const granted = await this.requestPermission();
if (!granted) {
return;
}
const notification = new Notification(payload.title, {
body: payload.body,
tag: payload.tag
});
notification.onclick = () => window.focus();
}
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
return;
}
@@ -1,10 +1,12 @@
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
import type { MessageNotificationPayload } from '../logic/message-notification.rules';
import type { RuntimePlatform } from '../logic/platform-detection.rules';
export interface MobileNotificationAdapter {
initialize(): Promise<void>;
requestPermission(): Promise<boolean>;
showCallNotification(payload: CallNotificationPayload): Promise<void>;
showMessageNotification(payload: MessageNotificationPayload): Promise<void>;
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
}
@@ -62,6 +62,16 @@ describe('ensure-mobile-capture-permissions', () => {
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
});
it('defers to WebView capture when the native prompt was dismissed', async () => {
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'prompt' })),
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'prompt' }))
};
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
});
it('blocks capture when the native shell explicitly denies microphone access', async () => {
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { MESSAGE_NOTIFICATION_BASE_ID, buildMessageNotification } from './message-notification.rules';
describe('buildMessageNotification', () => {
it('builds a payload with title, body and a collapse tag', () => {
const payload = buildMessageNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
expect(payload.title).toBe('general - Toju HQ');
expect(payload.body).toBe('Alice: hello');
expect(payload.tag).toBe('toju-message-general - Toju HQ');
});
it('derives a stable numeric id from the tag so newer messages replace the same notification', () => {
const first = buildMessageNotification({ title: 'general - Toju HQ', body: 'first' });
const second = buildMessageNotification({ title: 'general - Toju HQ', body: 'second' });
expect(first.id).toBe(second.id);
expect(Number.isInteger(first.id)).toBe(true);
});
it('uses distinct ids for distinct tags', () => {
const general = buildMessageNotification({ title: 'general - Toju HQ', body: 'x' });
const random = buildMessageNotification({ title: 'random - Toju HQ', body: 'x' });
expect(general.id).not.toBe(random.id);
});
it('keeps ids outside the call-notification id ranges', () => {
const payload = buildMessageNotification({ title: 't', body: 'b' });
expect(payload.id).toBeGreaterThanOrEqual(MESSAGE_NOTIFICATION_BASE_ID);
});
it('honors an explicit tag override', () => {
const payload = buildMessageNotification({ title: 't', body: 'b', tag: 'room-42' });
expect(payload.tag).toBe('toju-message-room-42');
});
});
@@ -0,0 +1,33 @@
export interface MessageNotificationPayload {
id: number;
title: string;
body: string;
tag: string;
}
/** Base id above the incoming (1000+) and active (2000+) call notification ranges. */
export const MESSAGE_NOTIFICATION_BASE_ID = 3000;
const MESSAGE_NOTIFICATION_ID_SPAN = 9973;
/** Build a local notification payload for an incoming chat message; the tag collapses per-channel. */
export function buildMessageNotification(input: { title: string; body: string; tag?: string }): MessageNotificationPayload {
const tag = `toju-message-${input.tag ?? input.title}`;
return {
id: MESSAGE_NOTIFICATION_BASE_ID + hashTag(tag),
title: input.title,
body: input.body,
tag
};
}
function hashTag(tag: string): number {
let hash = 0;
for (let index = 0; index < tag.length; index += 1) {
hash = (hash * 31 + tag.charCodeAt(index)) % MESSAGE_NOTIFICATION_ID_SPAN;
}
return hash;
}
@@ -15,8 +15,10 @@ import {
findMissingLauncherResources,
findStockCapacitorResources,
isBrandLauncherBackgroundColor,
NOTIFICATION_STATUS_ICON_NAME,
readAdaptiveIconBackgroundColor,
REQUIRED_LAUNCHER_ICON_FILES,
REQUIRED_NOTIFICATION_ICON_FILES,
REQUIRED_SPLASH_FILES,
resolveIconPixelSize,
SPLASH_ICON_RATIO
@@ -32,7 +34,11 @@ function sha256OfResource(resRelativePath: string): string {
}
describe('mobile-android-launcher-icon.rules', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const allRequired = [
...REQUIRED_LAUNCHER_ICON_FILES,
...REQUIRED_SPLASH_FILES,
...REQUIRED_NOTIFICATION_ICON_FILES
];
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
@@ -45,6 +51,42 @@ describe('mobile-android-launcher-icon.rules', () => {
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
});
it('ships a notification status-bar icon for every density', () => {
expect(findMissingLauncherResources(presentFiles, REQUIRED_NOTIFICATION_ICON_FILES)).toEqual([]);
});
it('references the notification status icon from the Capacitor config', () => {
const capacitorConfig = readFileSync(resolve(process.cwd(), 'capacitor.config.ts'), 'utf8');
expect(capacitorConfig).toContain(`smallIcon: '${NOTIFICATION_STATUS_ICON_NAME}'`);
});
it('renders the notification status icon as an alpha-only white glyph', async () => {
const iconPath = resolve(RES_DIR, 'drawable-xxxhdpi/ic_stat_metoyou.png');
const { data, info } = await sharp(iconPath).ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let opaquePixels = 0;
let transparentPixels = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
const alpha = data[offset + 3];
if (alpha > 224) {
opaquePixels += 1;
expect(data[offset]).toBeGreaterThan(224);
expect(data[offset + 1]).toBeGreaterThan(224);
expect(data[offset + 2]).toBeGreaterThan(224);
} else if (alpha < 32) {
transparentPixels += 1;
}
}
expect(opaquePixels).toBeGreaterThan(0);
expect(transparentPixels).toBeGreaterThan(0);
});
it('replaces every stock Capacitor placeholder with the brand asset', () => {
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
@@ -49,6 +49,14 @@ export const REQUIRED_LAUNCHER_ICON_FILES: readonly string[] = ANDROID_ICON_DENS
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
);
/** Resource name (no extension) the Capacitor LocalNotifications config must reference as `smallIcon`. */
export const NOTIFICATION_STATUS_ICON_NAME = 'ic_stat_metoyou';
/** res-relative notification status-bar icon files (alpha-only white glyph, one per density). */
export const REQUIRED_NOTIFICATION_ICON_FILES: readonly string[] = ANDROID_ICON_DENSITIES.map(
(density) => `drawable-${density}/${NOTIFICATION_STATUS_ICON_NAME}.png`
);
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
export const REQUIRED_SPLASH_FILES: readonly string[] = [
'drawable/splash.png',
@@ -24,13 +24,19 @@ describe('mobile-media-permission.rules', () => {
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
});
it('requires microphone permission for voice capture', () => {
it('only blocks voice capture on an explicit native denial', () => {
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
// Dismissed dialogs ('prompt') defer to the WebView getUserMedia permission flow.
expect(isVoiceCaptureAllowed({ microphone: 'prompt' })).toBe(true);
expect(isVoiceCaptureAllowed({ microphone: 'prompt-with-rationale' })).toBe(true);
expect(isVoiceCaptureAllowed({})).toBe(true);
});
it('requires camera permission for camera capture', () => {
it('only blocks camera capture on an explicit native denial', () => {
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(false);
expect(isCameraCaptureAllowed({ camera: 'denied' })).toBe(false);
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(true);
expect(isCameraCaptureAllowed({})).toBe(true);
});
});
@@ -17,12 +17,21 @@ export function shouldPreflightMobileCapturePermissions(runtime: RuntimePlatform
return runtime === 'capacitor';
}
/**
* Only an explicit native denial blocks capture. Any other state (granted, a
* dismissed prompt, or an unknown value) defers to the WebView getUserMedia
* permission flow, which re-prompts through Capacitor's WebChromeClient.
*/
function isCaptureBlockedByNativeDenial(state: MobileMediaPermissionState | undefined): boolean {
return state === 'denied';
}
/** Resolve whether voice capture can proceed after a native permission request. */
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
return isMobileCapturePermissionGranted(result.microphone);
return !isCaptureBlockedByNativeDenial(result.microphone);
}
/** Resolve whether camera capture can proceed after a native permission request. */
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
return isMobileCapturePermissionGranted(result.camera);
return !isCaptureBlockedByNativeDenial(result.camera);
}
@@ -0,0 +1,76 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const pluginState = vi.hoisted(() => ({
plugin: null as null | {
startVoiceForegroundService: () => Promise<void>;
stopVoiceForegroundService: () => Promise<void>;
},
isNative: true
}));
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin))
}));
vi.mock('./platform-detection.rules', () => ({
isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative)
}));
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from './mobile-voice-foreground-session';
describe('mobile-voice-foreground-session', () => {
beforeEach(async () => {
pluginState.isNative = true;
pluginState.plugin = {
startVoiceForegroundService: vi.fn(async () => undefined),
stopVoiceForegroundService: vi.fn(async () => undefined)
};
// Reset internal session flag between tests.
await stopMobileVoiceForegroundSession();
vi.clearAllMocks();
});
it('starts the native foreground service on Capacitor shells', async () => {
await startMobileVoiceForegroundSession();
expect(pluginState.plugin?.startVoiceForegroundService).toHaveBeenCalledTimes(1);
});
it('does nothing off Capacitor shells', async () => {
pluginState.isNative = false;
await startMobileVoiceForegroundSession();
expect(pluginState.plugin?.startVoiceForegroundService).not.toHaveBeenCalled();
});
it('stops the native foreground service after a start', async () => {
await startMobileVoiceForegroundSession();
await stopMobileVoiceForegroundSession();
expect(pluginState.plugin?.stopVoiceForegroundService).toHaveBeenCalledTimes(1);
});
it('swallows native bridge failures', async () => {
pluginState.plugin = {
startVoiceForegroundService: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))),
stopVoiceForegroundService: vi.fn(async () => undefined)
};
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
});
it('handles a missing plugin gracefully', async () => {
pluginState.plugin = null;
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
await expect(stopMobileVoiceForegroundSession()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,49 @@
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { isCapacitorNativeRuntime } from './platform-detection.rules';
let sessionActive = false;
/**
* Keep Android microphone capture alive while any voice session (voice channel
* or direct call) is active by running the native foreground service. Without
* it Android kills WebRTC capture shortly after the app backgrounds.
*/
export async function startMobileVoiceForegroundSession(): Promise<void> {
if (!isCapacitorNativeRuntime() || sessionActive) {
return;
}
const plugin = await loadMetoyouMobilePlugin();
if (!plugin?.startVoiceForegroundService) {
return;
}
try {
await plugin.startVoiceForegroundService();
sessionActive = true;
} catch {
// Native bridge unavailable; capture continues while the app stays foregrounded.
}
}
/** Stop the Android voice foreground service once no voice session remains. */
export async function stopMobileVoiceForegroundSession(): Promise<void> {
if (!isCapacitorNativeRuntime() || !sessionActive) {
return;
}
sessionActive = false;
const plugin = await loadMetoyouMobilePlugin();
if (!plugin?.stopVoiceForegroundService) {
return;
}
try {
await plugin.stopVoiceForegroundService();
} catch {
// Service already gone.
}
}
@@ -0,0 +1,94 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { MobileAppLifecycleService } from './mobile-app-lifecycle.service';
import { MobilePlatformService } from './mobile-platform.service';
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
type VisibilityListener = () => void;
interface DocumentStub {
hidden: boolean;
listeners: VisibilityListener[];
addEventListener: (type: string, listener: VisibilityListener) => void;
}
function installDocumentStub(): DocumentStub {
const stub: DocumentStub = {
hidden: false,
listeners: [],
addEventListener(type: string, listener: VisibilityListener) {
if (type === 'visibilitychange') {
stub.listeners.push(listener);
}
}
};
(globalThis as { document?: unknown }).document = stub;
return stub;
}
function createService() {
const injector = Injector.create({
providers: [
{
provide: MobilePlatformService,
useValue: {
refreshRuntimeDetection: vi.fn(),
runtime: vi.fn(() => 'browser')
}
},
{
provide: MobileRuntimePermissionsService,
useValue: {
initialize: vi.fn(async () => undefined)
}
}
]
});
return runInInjectionContext(injector, () => new MobileAppLifecycleService());
}
describe('MobileAppLifecycleService', () => {
let documentStub: DocumentStub;
beforeEach(() => {
documentStub = installDocumentStub();
});
afterEach(() => {
delete (globalThis as { document?: unknown }).document;
});
it('fans app-state changes out to every registered handler', async () => {
const service = createService();
await service.initialize();
const first = vi.fn();
const second = vi.fn();
service.onAppStateChange(first);
service.onAppStateChange(second);
documentStub.hidden = true;
documentStub.listeners.forEach((listener) => listener());
expect(first).toHaveBeenCalledWith(false);
expect(second).toHaveBeenCalledWith(false);
});
it('keeps handlers registered before initialize', async () => {
const service = createService();
const handler = vi.fn();
service.onAppStateChange(handler);
await service.initialize();
documentStub.hidden = false;
documentStub.listeners.forEach((listener) => listener());
expect(handler).toHaveBeenCalledWith(true);
});
});
@@ -12,10 +12,15 @@ import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.se
export class MobileAppLifecycleService {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
private readonly appStateHandlers = new Set<(isActive: boolean) => void>();
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
private initialized = false;
constructor() {
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
}
async initialize(): Promise<void> {
if (this.initialized) {
return;
@@ -30,8 +35,15 @@ export class MobileAppLifecycleService {
this.initialized = true;
}
/** Register an app foreground/background listener; every registered handler is invoked (fan-out). */
onAppStateChange(handler: (isActive: boolean) => void): void {
this.adapter.onAppStateChange(handler);
this.appStateHandlers.add(handler);
}
private dispatchAppStateChange(isActive: boolean): void {
for (const handler of this.appStateHandlers) {
handler(isActive);
}
}
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
@@ -46,6 +58,7 @@ export class MobileAppLifecycleService {
}
).then((adapter) => {
this.adapter = adapter;
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
return adapter;
});
}
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
import { buildMessageNotification } from '../logic/message-notification.rules';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
@@ -44,6 +45,13 @@ export class MobileNotificationsService {
await adapter.showCallNotification(buildInCallNotification(input));
}
async showMessage(input: { title: string; body: string; tag?: string }): Promise<void> {
await this.initialize();
const adapter = await this.ensureAdapter();
await adapter.showMessageNotification(buildMessageNotification(input));
}
async dismissIncomingCall(callId: string): Promise<void> {
const adapter = await this.ensureAdapter();
@@ -6,6 +6,7 @@
*/
import { Subject } from 'rxjs';
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../../mobile/logic/ensure-mobile-capture-permissions';
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from '../../mobile/logic/mobile-voice-foreground-session';
import { ChatEvent } from '../../../shared-kernel';
import { LatencyProfile } from '../realtime.constants';
import { PeerData } from '../realtime.types';
@@ -248,6 +249,7 @@ export class MediaManager {
this.isVoiceActive = true;
this.voiceConnected$.next();
void startMobileVoiceForegroundSession();
return this.localMediaStream;
} catch (error) {
this.logger.error('Failed to getUserMedia', error);
@@ -288,6 +290,7 @@ export class MediaManager {
this.currentVoiceRoomId = undefined;
this.currentVoiceServerId = undefined;
this.allowedVoicePeerIds.clear();
void stopMobileVoiceForegroundSession();
}
/**
@@ -315,6 +318,7 @@ export class MediaManager {
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next();
void startMobileVoiceForegroundSession();
}
/**
@@ -92,7 +92,7 @@
}
@if (showPanel() && isOpen()) {
<div class="pointer-events-none fixed inset-0 z-[79]">
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[79]">
<section
class="pointer-events-auto absolute flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
[class.bottom-20]="!detached()"
@@ -4,9 +4,9 @@
(dismissed)="cancelled.emit(undefined)"
/>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<div class="fixed metoyou-fixed-safe-viewport z-[111] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto w-full max-w-2xl rounded-2xl border border-border bg-card shadow-2xl"
class="pointer-events-auto max-h-full w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
@@ -5,7 +5,7 @@
(dismissed)="cancel()"
/>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<div class="fixed metoyou-fixed-safe-viewport z-[111] flex items-center justify-center p-4 pointer-events-none">
<section
appThemeNode="screenShareSourcePicker"
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"