feat: Android APP V1 - Experimental Alpha
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user