diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 1e3bc83a85..0680adee37 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -351,7 +351,8 @@ export class CallingClass { reduxInterface.setPresenting(); }); - void this.cleanExpiredGroupCallRingsAndLoop(); + drop(this.cleanExpiredGroupCallRingsAndLoop()); + drop(this.cleanupStaleRingingCalls()); if (process.platform === 'darwin') { drop(this.enumerateMediaDevices()); @@ -593,6 +594,27 @@ export class CallingClass { ); } + public async cleanupStaleRingingCalls(): Promise { + const calls = await dataInterface.getRecentStaleRingsAndMarkOlderMissed(); + + const results = await Promise.all( + calls.map(async call => { + const peekInfo = await this.peekGroupCall(call.peerId); + return { callId: call.callId, peekInfo }; + }) + ); + + const staleCallIds = results + .filter(result => { + return result.peekInfo == null; + }) + .map(result => { + return result.callId; + }); + + await dataInterface.markCallHistoryMissed(staleCallIds); + } + public async peekGroupCall(conversationId: string): Promise { // This can be undefined in two cases: // diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index c6834eb7d8..61506122b7 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -19,7 +19,10 @@ import type { BadgeType } from '../badges/types'; import type { LoggerType } from '../types/Logging'; import type { ReadStatus } from '../messages/MessageReadStatus'; import type { RawBodyRange } from '../types/BodyRange'; -import type { GetMessagesBetweenOptions } from './Server'; +import type { + GetMessagesBetweenOptions, + MaybeStaleCallHistory, +} from './Server'; import type { MessageTimestamps } from '../state/ducks/conversations'; import type { CallHistoryDetails, @@ -665,6 +668,10 @@ export type DataInterface = { conversationId: string, eraId: string ) => Promise; + markCallHistoryMissed(callIds: ReadonlyArray): Promise; + getRecentStaleRingsAndMarkOlderMissed(): Promise< + ReadonlyArray + >; migrateConversationMessages: ( obsoleteId: string, currentId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 16a047fb5c..928dfb2b1b 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -26,6 +26,7 @@ import { map, mapValues, omit, + partition, pick, } from 'lodash'; @@ -157,6 +158,8 @@ import { CallHistoryFilterStatus, callHistoryDetailsSchema, CallDirection, + GroupCallStatus, + CallType, } from '../types/CallDisposition'; type ConversationRow = Readonly<{ @@ -316,6 +319,8 @@ const dataInterface: ServerInterface = { getCallHistoryGroups, saveCallHistory, hasGroupCallHistoryMessage, + markCallHistoryMissed, + getRecentStaleRingsAndMarkOlderMissed, migrateConversationMessages, getMessagesBetween, getNearbyMessageFromDeletedSet, @@ -3802,6 +3807,65 @@ async function hasGroupCallHistoryMessage( return exists !== 0; } +function _markCallHistoryMissed(db: Database, callIds: ReadonlyArray) { + batchMultiVarQuery(db, callIds, batch => { + const [updateQuery, updateParams] = sql` + UPDATE callsHistory + SET status = ${sqlConstant(GroupCallStatus.Missed)} + WHERE callId IN (${sqlJoin(batch)}) + `; + return db.prepare(updateQuery).run(updateParams); + }); +} + +async function markCallHistoryMissed( + callIds: ReadonlyArray +): Promise { + const db = await getWritableInstance(); + return db.transaction(() => _markCallHistoryMissed(db, callIds))(); +} + +export type MaybeStaleCallHistory = Readonly< + Pick +>; + +async function getRecentStaleRingsAndMarkOlderMissed(): Promise< + ReadonlyArray +> { + const db = await getWritableInstance(); + return db.transaction(() => { + const [selectQuery, selectParams] = sql` + SELECT callId, peerId FROM callsHistory + WHERE + type = ${sqlConstant(CallType.Group)} AND + status = ${sqlConstant(GroupCallStatus.Ringing)} + ORDER BY timestamp DESC + `; + + const ringingCalls = db.prepare(selectQuery).all(selectParams); + + const seen = new Set(); + const [latestCalls, pastCalls] = partition(ringingCalls, result => { + if (seen.size >= 10) { + return false; + } + if (seen.has(result.peerId)) { + return false; + } + seen.add(result.peerId); + return true; + }); + + _markCallHistoryMissed( + db, + pastCalls.map(result => result.callId) + ); + + // These are returned so we can peek them. + return latestCalls; + })(); +} + async function migrateConversationMessages( obsoleteId: string, currentId: string diff --git a/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts b/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts new file mode 100644 index 0000000000..389b2d6e16 --- /dev/null +++ b/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts @@ -0,0 +1,105 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as generateUuid } from 'uuid'; + +import { times } from 'lodash'; +import dataInterface from '../../sql/Client'; + +import { CallMode } from '../../types/Calling'; +import { generateAci } from '../../types/ServiceId'; +import type { CallHistoryDetails } from '../../types/CallDisposition'; +import { + CallDirection, + CallType, + GroupCallStatus, +} from '../../types/CallDisposition'; +import type { MaybeStaleCallHistory } from '../../sql/Server'; + +const { + removeAll, + getRecentStaleRingsAndMarkOlderMissed, + saveCallHistory, + getAllCallHistory, +} = dataInterface; + +describe('sql/getRecentStaleRingsAndMarkOlderMissed', () => { + beforeEach(async () => { + await removeAll(); + }); + + const now = Date.now(); + let offset = 0; + + async function makeCall( + peerId: string, + callId: string, + status: GroupCallStatus + ) { + const timestamp = now + offset; + offset += 1; + const call: CallHistoryDetails = { + callId, + peerId, + ringerId: generateAci(), + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Incoming, + timestamp, + status, + }; + await saveCallHistory(call); + return call; + } + + function toMissed(call: CallHistoryDetails) { + return { ...call, status: GroupCallStatus.Missed }; + } + + function toMaybeStale(call: CallHistoryDetails): MaybeStaleCallHistory { + return { callId: call.callId, peerId: call.peerId }; + } + + it('should mark every call but the latest with the same peer as missed', async () => { + const peer1 = generateUuid(); + const peer2 = generateUuid(); + const call1 = await makeCall(peer1, '1', GroupCallStatus.Ringing); + const call2 = await makeCall(peer1, '2', GroupCallStatus.Ringing); + const call3 = await makeCall(peer2, '3', GroupCallStatus.Ringing); + const call4 = await makeCall(peer2, '4', GroupCallStatus.Ringing); + const callsToCheck = await getRecentStaleRingsAndMarkOlderMissed(); + const callHistory = await getAllCallHistory(); + assert.deepEqual(callHistory, [ + toMissed(call1), + call2, // latest peer1 + toMissed(call3), + call4, // latest peer2 + ]); + assert.deepEqual(callsToCheck, [ + // in order of timestamp + toMaybeStale(call4), + toMaybeStale(call2), + ]); + }); + + it('should mark every ringing call after the first 10 as missed', async () => { + const calls = await Promise.all( + times(15, async i => { + return makeCall(generateUuid(), String(i), GroupCallStatus.Ringing); + }) + ); + + const callsToCheck = await getRecentStaleRingsAndMarkOlderMissed(); + const callHistory = await getAllCallHistory(); + assert.deepEqual(callHistory, [ + // first 10 are not missed + ...calls.slice(0, -10).map(toMissed), + ...calls.slice(-10), + ]); + assert.deepEqual( + callsToCheck, + calls.slice(-10).map(toMaybeStale).reverse() + ); + }); +});