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

@@ -9,6 +9,10 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
MobileCallSessionService,
MobileNotificationsService
} from '../../../../infrastructure/mobile';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
@@ -547,6 +551,22 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
useValue: {
isMobile: vi.fn(() => false)
}
},
{
provide: MobileNotificationsService,
useValue: {
initialize: vi.fn(async () => undefined),
showIncomingCall: vi.fn(async () => undefined)
}
},
{
provide: MobileCallSessionService,
useValue: {
initialize: vi.fn(),
onCallControlAction: vi.fn(),
startActiveCall: vi.fn(async () => undefined),
endActiveCall: vi.fn(async () => undefined)
}
}
]
});

View File

@@ -10,6 +10,7 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -40,10 +41,14 @@ export class DirectCallService {
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly mobileNotifications = inject(MobileNotificationsService);
private readonly mobileCallSession = inject(MobileCallSessionService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
private readonly declinedCallIds = new Set<string>();
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
@@ -79,6 +84,24 @@ export class DirectCallService {
});
constructor() {
this.mobileCallSession.initialize();
this.mobileCallSession.onCallControlAction((intent, callId) => {
if (intent === 'answer') {
void this.answerIncomingCall(callId);
return;
}
if (intent === 'toggle-mute') {
this.voice.toggleMute(!this.voice.isMuted());
void this.syncActiveCallNotification(callId);
return;
}
if (intent === 'hang-up') {
this.leaveCall(callId);
}
});
this.delivery.directCallEvents$.subscribe((event) => {
if (event.directCall) {
void this.handleIncomingCallEvent(event.directCall);
@@ -110,6 +133,14 @@ export class DirectCallService {
this.mobileOverlayCallId.set(null);
}
});
effect(() => {
if (!this.currentUserId()) {
return;
}
void this.drainPendingIncomingCallPayloads();
});
}
sessionById(callId: string | null | undefined): DirectCallSession | null {
@@ -171,6 +202,13 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
conversation.id,
meParticipant,
[meParticipant, peerParticipant],
session.createdAt
);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.openCallView(session.callId);
@@ -242,6 +280,7 @@ export class DirectCallService {
return;
}
this.declinedCallIds.add(callId);
const meId = this.currentUserId();
const nextSession = meId
? {
@@ -306,6 +345,7 @@ export class DirectCallService {
this.upsertSession(nextSession);
this.currentSession.set(nextSession);
void this.syncActiveCallNotification(callId);
if (notifyPeers) {
this.broadcastCallEvent('join', nextSession);
@@ -342,6 +382,7 @@ export class DirectCallService {
this.upsertSession(nextSession);
this.currentSession.set(null);
void this.mobileCallSession.endActiveCall(session.callId);
}
async inviteUser(callId: string, user: User): Promise<void> {
@@ -384,10 +425,32 @@ export class DirectCallService {
return participant ? participantToUser(participant) : null;
}
private async drainPendingIncomingCallPayloads(): Promise<void> {
if (this.pendingIncomingCallPayloads.length === 0) {
return;
}
const pending = [...this.pendingIncomingCallPayloads];
this.pendingIncomingCallPayloads.length = 0;
for (const payload of pending) {
await this.handleIncomingCallEvent(payload);
}
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const meId = this.currentUserId();
if (!meId || payload.sender.userId === meId) {
if (!meId) {
if (payload.action === 'ring') {
this.pendingIncomingCallPayloads.push(payload);
}
return;
}
if (payload.sender.userId === meId) {
return;
}
@@ -395,6 +458,10 @@ export class DirectCallService {
return;
}
if (payload.action === 'ring' && this.declinedCallIds.has(payload.callId)) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
const incomingSession = this.createSession({
@@ -423,18 +490,7 @@ export class DirectCallService {
}
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
if (this.shouldAlertIncomingCall(session)) {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
if (this.shouldAlertIncomingCall(session)) {
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
}
await this.handleIncomingRingEvent(payload, session);
return;
}
@@ -445,9 +501,36 @@ export class DirectCallService {
this.stopLocalMedia(session);
this.currentSession.set(null);
}
void this.mobileCallSession.endActiveCall(payload.callId);
}
}
private async handleIncomingRingEvent(payload: DirectCallEventPayload, session: DirectCallSession): Promise<void> {
await this.ensureCallConversation(session);
await this.directMessages.recordCallStarted(
session.conversationId,
payload.sender,
Object.values(session.participants).map((participant) => participant.profile),
payload.createdAt
);
const latestSession = this.sessionById(payload.callId) ?? session;
if (this.declinedCallIds.has(payload.callId) || latestSession.status === 'ended') {
this.audio.stop(AppSound.Call);
return;
}
if (this.shouldAlertIncomingCall(latestSession)) {
this.audio.playLoop(AppSound.Call);
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
return;
}
this.audio.stop(AppSound.Call);
}
private async startGroupCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
@@ -872,28 +955,33 @@ export class DirectCallService {
}
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
if (typeof Notification === 'undefined') {
await this.mobileNotifications.showIncomingCall(displayName, callId);
}
private async syncActiveCallNotification(callId: string): Promise<void> {
const session = this.sessionById(callId);
if (!session || !this.isCurrentUserJoined(session)) {
return;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
return;
}
const notification = new Notification('Incoming call', {
body: `${displayName} is calling you`
await this.mobileCallSession.startActiveCall({
callId,
displayName: this.activeCallDisplayName(session),
isMuted: this.voice.isMuted()
});
}
notification.onclick = () => {
window.focus();
void this.router.navigate(['/call', callId]);
};
private activeCallDisplayName(session: DirectCallSession): string {
const remoteNames = this.remoteParticipantIds(session)
.map((participantId) => session.participants[participantId]?.profile.displayName)
.filter((name): name is string => !!name);
if (remoteNames.length > 0) {
return remoteNames.join(', ');
}
return 'Call in progress';
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {