diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2b9e17d8c..f98ae496c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1633,6 +1633,10 @@ "messageformat": "Signal Call", "description": "Default title for Signal call links." }, + "icu:calling__call-link-delete-failed": { + "messageformat": "Can't delete link. Check your connection and try again.", + "description": "Default title for Signal call links." + }, "icu:calling__join-request-denied": { "messageformat": "Your request to join this call has been denied.", "description": "Error message when a request to join a call link call was rejected by a call admin." @@ -7298,7 +7302,11 @@ "description": "Calls Tab > Confirm Clear Call History Dialog > Title" }, "icu:CallsTab__ConfirmClearCallHistory__Body": { - "messageformat": "This will permanently delete all call history", + "messageformat": "This will permanently delete all call history.", + "description": "Calls Tab > Confirm Clear Call History Dialog > Body Text" + }, + "icu:CallsTab__ConfirmClearCallHistory__Body--call-links": { + "messageformat": "This will permanently delete all call history. Call links you’ve created will no longer work for people who have them. ", "description": "Calls Tab > Confirm Clear Call History Dialog > Body Text" }, "icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": { @@ -7309,6 +7317,14 @@ "messageformat": "Call history cleared", "description": "Calls Tab > Clear Call History > Toast" }, + "icu:CallsTab__ClearCallHistoryError--call-links": { + "messageformat": "Not all call links could be deleted. Check your connection and try again.", + "description": "Calls Tab > Clear Call History > Error modal when we failed to delete call links." + }, + "icu:CallsTab__ClearCallHistoryError": { + "messageformat": "Not all call history could be cleared. Check your connection and try again.", + "description": "Calls Tab > Clear Call History > Generic error modal" + }, "icu:CallsTab__EmptyStateText--with-icon-2": { "messageformat": "Click to start a new voice or video call.", "description": "Calls Tab > When no call is selected > Empty state > Call to action text" diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index 152637389..59e3cf778 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -49,6 +49,7 @@ type CallsTabProps = Readonly<{ getCallLink: (id: string) => CallLinkType | undefined; getConversation: (id: string) => ConversationType | void; hangUpActiveCall: (reason: string) => void; + hasAnyAdminCallLinks: boolean; hasFailedStorySends: boolean; hasPendingUpdate: boolean; i18n: LocalizerType; @@ -105,6 +106,7 @@ export function CallsTab({ getCallLink, getConversation, hangUpActiveCall, + hasAnyAdminCallLinks, hasFailedStorySends, hasPendingUpdate, i18n, @@ -370,7 +372,9 @@ export function CallsTab({ }, ]} > - {i18n('icu:CallsTab__ConfirmClearCallHistory__Body')} + {hasAnyAdminCallLinks + ? i18n('icu:CallsTab__ConfirmClearCallHistory__Body--call-links') + : i18n('icu:CallsTab__ConfirmClearCallHistory__Body')} )} diff --git a/ts/components/ErrorModal.tsx b/ts/components/ErrorModal.tsx index c24243d9e..cd964508c 100644 --- a/ts/components/ErrorModal.tsx +++ b/ts/components/ErrorModal.tsx @@ -10,7 +10,7 @@ import { Button, ButtonVariant } from './Button'; export type PropsType = { buttonVariant?: ButtonVariant; description?: string; - title?: string; + title?: string | null; onClose: () => void; i18n: LocalizerType; @@ -40,7 +40,7 @@ export function ErrorModal(props: PropsType): JSX.Element { modalName="ErrorModal" i18n={i18n} onClose={onClose} - title={title || i18n('icu:ErrorModal--title')} + title={title == null ? undefined : i18n('icu:ErrorModal--title')} modalFooter={footer} >
diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 0f5a3b235..b7f14bb1e 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -53,12 +53,16 @@ export type PropsType = { renderEditNicknameAndNoteModal: () => JSX.Element; // ErrorModal errorModalProps: - | { buttonVariant?: ButtonVariant; description?: string; title?: string } + | { + buttonVariant?: ButtonVariant; + description?: string; + title?: string | null; + } | undefined; renderErrorModal: (opts: { buttonVariant?: ButtonVariant; description?: string; - title?: string; + title?: string | null; }) => JSX.Element; // DeleteMessageModal deleteMessagesProps: DeleteMessagesPropsType | undefined; diff --git a/ts/jobs/CallLinkDeleteManager.ts b/ts/jobs/CallLinkFinalizeDeleteManager.ts similarity index 72% rename from ts/jobs/CallLinkDeleteManager.ts rename to ts/jobs/CallLinkFinalizeDeleteManager.ts index f567581eb..9dd5f1608 100644 --- a/ts/jobs/CallLinkDeleteManager.ts +++ b/ts/jobs/CallLinkFinalizeDeleteManager.ts @@ -9,8 +9,6 @@ import { type JobManagerJobResultType, type JobManagerJobType, } from './JobManager'; -import { calling } from '../services/calling'; -import { callLinkFromRecord } from '../util/callLinksRingrtc'; // Type for adding a new job export type NewCallLinkDeleteJobType = { @@ -28,7 +26,7 @@ export type CallLinkDeleteJobType = CoreCallLinkDeleteJobType & const MAX_CONCURRENT_JOBS = 5; const DEFAULT_RETRY_CONFIG = { - maxAttempts: Infinity, + maxAttempts: 10, backoffConfig: { // 1 min, 5 min, 25 min, (max) 1 day multiplier: 5, @@ -37,19 +35,24 @@ const DEFAULT_RETRY_CONFIG = { }, }; -type CallLinkDeleteManagerParamsType = +type CallLinkFinalizeDeleteManagerParamsType = JobManagerParamsType; function getJobId(job: CoreCallLinkDeleteJobType): string { return job.roomId; } -export class CallLinkDeleteManager extends JobManager { +// The purpose of this job is to finalize local DB delete of call links and +// associated call history, after we confirm storage sync. +// It does *not* delete the call link from the server -- this should be done +// synchronously and prior to running this job, so we can show confirmation +// or error to the user. +export class CallLinkFinalizeDeleteManager extends JobManager { jobs: Map = new Map(); - private static _instance: CallLinkDeleteManager | undefined; - override logPrefix = 'CallLinkDeleteManager'; + private static _instance: CallLinkFinalizeDeleteManager | undefined; + override logPrefix = 'CallLinkFinalizeDeleteManager'; - static defaultParams: CallLinkDeleteManagerParamsType = { + static defaultParams: CallLinkFinalizeDeleteManagerParamsType = { markAllJobsInactive: () => Promise.resolve(), getNextJobs, saveJob, @@ -61,7 +64,7 @@ export class CallLinkDeleteManager extends JobManager maxConcurrentJobs: MAX_CONCURRENT_JOBS, }; - constructor(params: CallLinkDeleteManagerParamsType) { + constructor(params: CallLinkFinalizeDeleteManagerParamsType) { super({ ...params, getNextJobs: ({ limit, timestamp }) => @@ -103,40 +106,43 @@ export class CallLinkDeleteManager extends JobManager roomIds.forEach(roomId => this.addJob({ roomId }, options)); } - static get instance(): CallLinkDeleteManager { - if (!CallLinkDeleteManager._instance) { - CallLinkDeleteManager._instance = new CallLinkDeleteManager( - CallLinkDeleteManager.defaultParams - ); + static get instance(): CallLinkFinalizeDeleteManager { + if (!CallLinkFinalizeDeleteManager._instance) { + CallLinkFinalizeDeleteManager._instance = + new CallLinkFinalizeDeleteManager( + CallLinkFinalizeDeleteManager.defaultParams + ); } - return CallLinkDeleteManager._instance; + return CallLinkFinalizeDeleteManager._instance; } static async start(): Promise { - await CallLinkDeleteManager.instance.enqueueAllDeletedCallLinks(); - await CallLinkDeleteManager.instance.start(); + await CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks(); + await CallLinkFinalizeDeleteManager.instance.start(); } static async stop(): Promise { - return CallLinkDeleteManager._instance?.stop(); + return CallLinkFinalizeDeleteManager._instance?.stop(); } static async addJob( newJob: CoreCallLinkDeleteJobType, options?: { delay: number } ): Promise { - return CallLinkDeleteManager.instance.addJob(newJob, options); + return CallLinkFinalizeDeleteManager.instance.addJob(newJob, options); } static async enqueueAllDeletedCallLinks(options?: { delay: number; }): Promise { - return CallLinkDeleteManager.instance.enqueueAllDeletedCallLinks(options); + return CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks( + options + ); } } async function getNextJobs( - this: CallLinkDeleteManager, + this: CallLinkFinalizeDeleteManager, { limit, timestamp, @@ -162,7 +168,7 @@ async function getNextJobs( } async function saveJob( - this: CallLinkDeleteManager, + this: CallLinkFinalizeDeleteManager, job: CallLinkDeleteJobType ): Promise { const { roomId } = job; @@ -170,14 +176,10 @@ async function saveJob( } async function removeJob( - this: CallLinkDeleteManager, + this: CallLinkFinalizeDeleteManager, job: CallLinkDeleteJobType ): Promise { - const logId = `CallLinkDeleteJobType/removeJob/${getJobId(job)}`; - const { roomId } = job; - await DataWriter.finalizeDeleteCallLink(job.roomId); - log.info(`${logId}: Finalized local delete`); - this.jobs.delete(roomId); + this.jobs.delete(job.roomId); } async function runJob( @@ -191,10 +193,8 @@ async function runJob( log.warn(`${logId}: Call link gone from DB`); return { status: 'finished' }; } - if (callLinkRecord.adminKey == null) { - log.error( - `${logId}: No admin key available, deletion on server not possible. Giving up.` - ); + if (callLinkRecord.deleted !== 1) { + log.error(`${logId}: Call link not marked deleted. Giving up.`); return { status: 'finished' }; } @@ -204,10 +204,7 @@ async function runJob( return { status: 'retry' }; } - // Delete link on calling server. May 200 or 404 and both are OK. - // Errs if call link is active or network is unavailable. - const callLink = callLinkFromRecord(callLinkRecord); - await calling.deleteCallLink(callLink); - log.info(`${logId}: Deleted call link on server`); + await DataWriter.finalizeDeleteCallLink(job.roomId); + log.info(`${logId}: Finalized local delete`); return { status: 'finished' }; } diff --git a/ts/jobs/callLinkRefreshJobQueue.ts b/ts/jobs/callLinkRefreshJobQueue.ts index 00ab01792..be76c891e 100644 --- a/ts/jobs/callLinkRefreshJobQueue.ts +++ b/ts/jobs/callLinkRefreshJobQueue.ts @@ -97,10 +97,7 @@ export class CallLinkRefreshJobQueue extends JobQueue { `${logId}: Call link not found on server and deleteLocallyIfMissingOnCallingServer; deleting local call link` ); // This will leave a storage service record, and it's up to primary to delete it - await DataWriter.beginDeleteCallLink(roomId, { - storageNeedsSync: false, - }); - await DataWriter.finalizeDeleteCallLink(roomId); + await DataWriter.deleteCallLinkAndHistory(roomId); window.reduxActions.calling.handleCallLinkDelete({ roomId }); } else { log.info(`${logId}: Call link not found on server, ignoring`); diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index e1a9396b3..5e56741eb 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -3,7 +3,7 @@ import type { WebAPIType } from '../textsecure/WebAPI'; import { drop } from '../util/drop'; -import { CallLinkDeleteManager } from './CallLinkDeleteManager'; +import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager'; import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue'; import { conversationJobQueue } from './conversationJobQueue'; @@ -43,7 +43,7 @@ export function initializeAllJobQueues({ drop(removeStorageKeyJobQueue.streamJobs()); drop(reportSpamJobQueue.streamJobs()); drop(callLinkRefreshJobQueue.streamJobs()); - drop(CallLinkDeleteManager.start()); + drop(CallLinkFinalizeDeleteManager.start()); } export async function shutdownAllJobQueues(): Promise { @@ -57,6 +57,6 @@ export async function shutdownAllJobQueues(): Promise { viewOnceOpenJobQueue.shutdown(), removeStorageKeyJobQueue.shutdown(), reportSpamJobQueue.shutdown(), - CallLinkDeleteManager.stop(), + CallLinkFinalizeDeleteManager.stop(), ]); } diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 6a48dda24..4fc4fa7a6 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -2075,12 +2075,8 @@ export async function mergeCallLinkRecord( // Another device deleted the link and uploaded to storage, and we learned about it log.info(`${logId}: Discovered deleted call link, deleting locally`); details.push('deleting locally'); - await DataWriter.beginDeleteCallLink(roomId, { - storageNeedsSync: false, - deletedAt, - }); // No need to delete via RingRTC as we assume the originating device did that already - await DataWriter.finalizeDeleteCallLink(roomId); + await DataWriter.deleteCallLinkAndHistory(roomId); window.reduxActions.calling.handleCallLinkDelete({ roomId }); } else if (!deletedAt && localCallLinkDbRecord.deleted === 1) { // Not deleted in storage, but we've marked it as deleted locally. diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index ad69d1fe6..2a5e8a403 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -44,7 +44,6 @@ import type { import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; -import type { DeleteCallLinkOptions } from './server/callLinks'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -584,6 +583,7 @@ type ReadableInterface = { getAllCallLinks: () => ReadonlyArray; getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined; getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined; + getAllAdminCallLinks(): ReadonlyArray; getAllCallLinkRecordsWithAdminKey(): ReadonlyArray; getAllMarkedDeletedCallLinkRoomIds(): ReadonlyArray; getMessagesBetween: ( @@ -813,8 +813,10 @@ type WritableInterface = { roomId: string, callLinkState: CallLinkStateType ): CallLinkType; - beginDeleteAllCallLinks(): void; - beginDeleteCallLink(roomId: string, options: DeleteCallLinkOptions): void; + beginDeleteAllCallLinks(): boolean; + beginDeleteCallLink(roomId: string): boolean; + deleteCallHistoryByRoomId(roomid: string): void; + deleteCallLinkAndHistory(roomId: string): void; finalizeDeleteCallLink(roomId: string): void; _removeAllCallLinks(): void; deleteCallLinkFromSync(roomId: string): void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 16d29d499..03733ca3f 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -180,6 +180,9 @@ import { updateCallLinkAdminKeyByRoomId, updateCallLinkState, beginDeleteAllCallLinks, + deleteCallHistoryByRoomId, + deleteCallLinkAndHistory, + getAllAdminCallLinks, getAllCallLinkRecordsWithAdminKey, getAllMarkedDeletedCallLinkRoomIds, finalizeDeleteCallLink, @@ -313,6 +316,7 @@ export const DataReader: ServerReadableInterface = { getAllCallLinks, getCallLinkByRoomId, getCallLinkRecordByRoomId, + getAllAdminCallLinks, getAllCallLinkRecordsWithAdminKey, getAllMarkedDeletedCallLinkRoomIds, getMessagesBetween, @@ -451,6 +455,8 @@ export const DataWriter: ServerWritableInterface = { updateCallLinkState, beginDeleteAllCallLinks, beginDeleteCallLink, + deleteCallHistoryByRoomId, + deleteCallLinkAndHistory, finalizeDeleteCallLink, _removeAllCallLinks, deleteCallLinkFromSync, @@ -3543,6 +3549,14 @@ function _removeAllCallHistory(db: WritableDB): void { db.prepare(query).run(params); } +/** + * Deletes call history by marking it deleted. Tombstoning is needed in case sync messages + * come in around the same time, to prevent reappearance of deleted call history. + * Limitation: History for admin call links is skipped. Admin call links need to be + * deleted on the calling server first, before we can clear local history. + * + * @returns ReadonlyArray: message ids of call history messages + */ function clearCallHistory( db: WritableDB, target: CallLogEventTarget @@ -3555,17 +3569,33 @@ function clearCallHistory( } const { timestamp } = callHistory; + // Admin call links are deleted separately after server confirmation + const [selectAdminCallLinksQuery, selectAdminCallLinksParams] = sql` + SELECT roomId + FROM callLinks + WHERE callLinks.adminKey IS NOT NULL; + `; + + const adminCallLinkIds: ReadonlyArray = db + .prepare(selectAdminCallLinksQuery) + .pluck() + .all(selectAdminCallLinksParams); + const adminCallLinkIdsFragment = sqlJoin(adminCallLinkIds); + const [selectCallsQuery, selectCallsParams] = sql` SELECT callsHistory.callId FROM callsHistory WHERE - -- Prior calls - (callsHistory.timestamp <= ${timestamp}) - -- Unused call links - OR ( - callsHistory.mode IS ${CALL_MODE_ADHOC} AND - callsHistory.status IS ${CALL_STATUS_PENDING} - ); + ( + -- Prior calls + (callsHistory.timestamp <= ${timestamp}) + -- Unused call links + OR ( + callsHistory.mode IS ${CALL_MODE_ADHOC} AND + callsHistory.status IS ${CALL_STATUS_PENDING} + ) + ) AND + callsHistory.peerId NOT IN (${adminCallLinkIdsFragment}); `; const deletedCallIds: ReadonlyArray = db diff --git a/ts/sql/migrations/1230-call-links-admin-key-index.ts b/ts/sql/migrations/1230-call-links-admin-key-index.ts new file mode 100644 index 000000000..3dc883901 --- /dev/null +++ b/ts/sql/migrations/1230-call-links-admin-key-index.ts @@ -0,0 +1,28 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Database } from '@signalapp/better-sqlite3'; +import type { LoggerType } from '../../types/Logging'; + +export const version = 1230; + +export function updateToSchemaVersion1230( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1230) { + return; + } + + db.transaction(() => { + db.exec(` + DROP INDEX IF EXISTS callLinks_adminKey; + + CREATE INDEX callLinks_adminKey + ON callLinks (adminKey); + `); + + db.pragma('user_version = 1230'); + })(); + logger.info('updateToSchemaVersion1230: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 8c5f95ac8..8bee9e34f 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -98,10 +98,11 @@ import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source import { updateToSchemaVersion1190 } from './1190-call-links-storage'; import { updateToSchemaVersion1200 } from './1200-attachment-download-source-index'; import { updateToSchemaVersion1210 } from './1210-call-history-started-id'; +import { updateToSchemaVersion1220 } from './1220-blob-sessions'; import { - updateToSchemaVersion1220, + updateToSchemaVersion1230, version as MAX_VERSION, -} from './1220-blob-sessions'; +} from './1230-call-links-admin-key-index'; function updateToSchemaVersion1( currentVersion: number, @@ -2069,6 +2070,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1200, updateToSchemaVersion1210, updateToSchemaVersion1220, + updateToSchemaVersion1230, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/callLinks.ts b/ts/sql/server/callLinks.ts index cb7d1e68e..f9917df15 100644 --- a/ts/sql/server/callLinks.ts +++ b/ts/sql/server/callLinks.ts @@ -20,7 +20,7 @@ import type { ReadableDB, WritableDB } from '../Interface'; import { prepare } from '../Server'; import { sql } from '../util'; import { strictAssert } from '../../util/assert'; -import { CallStatusValue } from '../../types/CallDisposition'; +import { CallStatusValue, DirectCallStatus } from '../../types/CallDisposition'; import { parseStrict, parseUnknown } from '../../util/schemas'; export function callLinkExists(db: ReadableDB, roomId: string): boolean { @@ -190,7 +190,10 @@ function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void { ); } -function deleteCallHistoryByRoomId(db: WritableDB, roomId: string) { +export function deleteCallHistoryByRoomId( + db: WritableDB, + roomId: string +): void { const [ markCallHistoryDeleteByPeerIdQuery, markCallHistoryDeleteByPeerIdParams, @@ -222,17 +225,14 @@ export function deleteCallLinkFromSync(db: WritableDB, roomId: string): void { })(); } -export type DeleteCallLinkOptions = { - storageNeedsSync: boolean; - deletedAt?: number; -}; - -export function beginDeleteCallLink( - db: WritableDB, - roomId: string, - options: DeleteCallLinkOptions -): void { - db.transaction(() => { +/** + * Deletes a non-admin call link from the local database, or if it's an admin call link, + * then marks it for deletion and storage sync. + * + * @returns boolean: True if storage sync is needed; False if not + */ +export function beginDeleteCallLink(db: WritableDB, roomId: string): boolean { + return db.transaction(() => { // If adminKey is null, then we should delete the call link const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql` DELETE FROM callLinks @@ -244,35 +244,62 @@ export function beginDeleteCallLink( .prepare(deleteNonAdminCallLinksQuery) .run(deleteNonAdminCallLinksParams); - // Skip this query if the call is already deleted - if (result.changes === 0) { - const { storageNeedsSync } = options; - const deletedAt = options.deletedAt ?? new Date().getTime(); - - // If the admin key is not null, we should mark it for deletion - const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = - sql` - UPDATE callLinks - SET - deleted = 1, - deletedAt = ${deletedAt}, - storageNeedsSync = ${storageNeedsSync ? 1 : 0} - WHERE adminKey IS NOT NULL - AND roomId = ${roomId}; - `; - - db.prepare(markAdminCallLinksDeletedQuery).run( - markAdminCallLinksDeletedParams - ); + // If we successfully deleted the call link, then it was a non-admin call link + // and we're done + if (result.changes !== 0) { + return false; } - deleteCallHistoryByRoomId(db, roomId); + const deletedAt = new Date().getTime(); + + // If the admin key is not null, we should mark it for deletion + const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = + sql` + UPDATE callLinks + SET + deleted = 1, + deletedAt = ${deletedAt}, + storageNeedsSync = 1 + WHERE adminKey IS NOT NULL + AND deleted IS NOT 1 + AND roomId = ${roomId}; + `; + + const deleteAdminLinkResult = db + .prepare(markAdminCallLinksDeletedQuery) + .run(markAdminCallLinksDeletedParams); + return deleteAdminLinkResult.changes > 0; })(); } -export function beginDeleteAllCallLinks(db: WritableDB): void { - const deletedAt = new Date().getTime(); +export function deleteCallLinkAndHistory(db: WritableDB, roomId: string): void { db.transaction(() => { + const [deleteCallLinkQuery, deleteCallLinkParams] = sql` + DELETE FROM callLinks + WHERE roomId = ${roomId}; + `; + db.prepare(deleteCallLinkQuery).run(deleteCallLinkParams); + + const [deleteCallHistoryQuery, clearCallHistoryParams] = sql` + UPDATE callsHistory + SET + status = ${DirectCallStatus.Deleted}, + timestamp = ${Date.now()} + WHERE peerId = ${roomId}; + `; + db.prepare(deleteCallHistoryQuery).run(clearCallHistoryParams); + })(); +} + +/** + * Deletes all non-admin call link from the local database, and marks all admin call links + * for deletion and storage sync. + * + * @returns boolean: True if storage sync is needed; False if not + */ +export function beginDeleteAllCallLinks(db: WritableDB): boolean { + const deletedAt = new Date().getTime(); + return db.transaction(() => { const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = sql` UPDATE callLinks @@ -280,12 +307,13 @@ export function beginDeleteAllCallLinks(db: WritableDB): void { deleted = 1, deletedAt = ${deletedAt}, storageNeedsSync = 1 - WHERE adminKey IS NOT NULL; + WHERE adminKey IS NOT NULL + AND deleted IS NOT 1; `; - db.prepare(markAdminCallLinksDeletedQuery).run( - markAdminCallLinksDeletedParams - ); + const markAdminCallLinksDeletedResult = db + .prepare(markAdminCallLinksDeletedQuery) + .run(markAdminCallLinksDeletedParams); const [deleteNonAdminCallLinksQuery] = sql` DELETE FROM callLinks @@ -293,6 +321,9 @@ export function beginDeleteAllCallLinks(db: WritableDB): void { `; db.prepare(deleteNonAdminCallLinksQuery).run(); + + // If admin call links were marked deleted, then storage will need sync + return markAdminCallLinksDeletedResult.changes > 0; })(); } @@ -311,6 +342,14 @@ export function getAllCallLinkRecordsWithAdminKey( .map((item: unknown) => parseUnknown(callLinkRecordSchema, item)); } +export function getAllAdminCallLinks( + db: ReadableDB +): ReadonlyArray { + return getAllCallLinkRecordsWithAdminKey(db).map((record: CallLinkRecord) => + callLinkFromRecord(record) + ); +} + export function getAllMarkedDeletedCallLinkRoomIds( db: ReadableDB ): ReadonlyArray { diff --git a/ts/state/ducks/callHistory.ts b/ts/state/ducks/callHistory.ts index 032d00cd5..8cb159047 100644 --- a/ts/state/ducks/callHistory.ts +++ b/ts/state/ducks/callHistory.ts @@ -15,7 +15,10 @@ import type { ToastActionType } from './toast'; import { showToast } from './toast'; import { DataReader, DataWriter } from '../../sql/Client'; import { ToastType } from '../../types/Toast'; -import type { CallHistoryDetails } from '../../types/CallDisposition'; +import { + ClearCallHistoryResult, + type CallHistoryDetails, +} from '../../types/CallDisposition'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; import { drop } from '../../util/drop'; @@ -29,6 +32,11 @@ import { loadCallHistory, } from '../../services/callHistoryLoader'; import { makeLookup } from '../../util/makeLookup'; +import { missingCaseError } from '../../util/missingCaseError'; +import { getIntl } from '../selectors/user'; +import { ButtonVariant } from '../../components/Button'; +import type { ShowErrorModalActionType } from './globalModals'; +import { SHOW_ERROR_MODAL } from './globalModals'; export type CallHistoryState = ReadonlyDeep<{ // This informs the app that underlying call history data has changed. @@ -191,14 +199,42 @@ function clearAllCallHistory(): ThunkAction< void, RootStateType, unknown, - CallHistoryReset | ToastActionType + CallHistoryReset | ToastActionType | ShowErrorModalActionType > { return async (dispatch, getState) => { try { const latestCall = getCallHistoryLatestCall(getState()); - if (latestCall != null) { - await clearCallHistoryDataAndSync(latestCall); + if (latestCall == null) { + return; + } + + const result = await clearCallHistoryDataAndSync(latestCall); + if (result === ClearCallHistoryResult.Success) { dispatch(showToast({ toastType: ToastType.CallHistoryCleared })); + } else if (result === ClearCallHistoryResult.Error) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: null, + description: i18n('icu:CallsTab__ClearCallHistoryError'), + buttonVariant: ButtonVariant.Primary, + }, + }); + } else if (result === ClearCallHistoryResult.ErrorDeletingCallLinks) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: null, + description: i18n( + 'icu:CallsTab__ClearCallHistoryError--call-links' + ), + buttonVariant: ButtonVariant.Primary, + }, + }); + } else { + throw missingCaseError(result); } } catch (error) { log.error('Error clearing call history', Errors.toLogFormat(error)); diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 9b560a407..97163afbe 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -36,10 +36,11 @@ import type { PresentedSource, PresentableSource, } from '../../types/Calling'; -import type { - CallLinkRestrictions, - CallLinkStateType, - CallLinkType, +import { + isCallLinkAdmin, + type CallLinkRestrictions, + type CallLinkStateType, + type CallLinkType, } from '../../types/CallLink'; import { CALLING_REACTIONS_LIFETIME, @@ -110,7 +111,7 @@ import { getPresentingSource, } from '../selectors/calling'; import { storageServiceUploadJob } from '../../services/storage'; -import { CallLinkDeleteManager } from '../../jobs/CallLinkDeleteManager'; +import { CallLinkFinalizeDeleteManager } from '../../jobs/CallLinkFinalizeDeleteManager'; import { callLinkRefreshJobQueue } from '../../jobs/callLinkRefreshJobQueue'; // State @@ -2125,13 +2126,50 @@ function createCallLink( function deleteCallLink( roomId: string -): ThunkAction { - return async dispatch => { - await DataWriter.beginDeleteCallLink(roomId, { storageNeedsSync: true }); - storageServiceUploadJob({ reason: 'deleteCallLink' }); - // Wait for storage service sync before finalizing delete - drop(CallLinkDeleteManager.addJob({ roomId }, { delay: 10000 })); - dispatch(handleCallLinkDelete({ roomId })); +): ThunkAction< + void, + RootStateType, + unknown, + HandleCallLinkDeleteActionType | ShowErrorModalActionType +> { + return async (dispatch, getState) => { + const callLink = await DataReader.getCallLinkByRoomId(roomId); + if (!callLink) { + return; + } + + const isStorageSyncNeeded = await DataWriter.beginDeleteCallLink(roomId); + if (isStorageSyncNeeded) { + storageServiceUploadJob({ reason: 'deleteCallLink' }); + } + try { + if (isCallLinkAdmin(callLink)) { + // This throws if call link is active or network is unavailable. + await calling.deleteCallLink(callLink); + // Wait for storage service sync before finalizing delete. + drop( + CallLinkFinalizeDeleteManager.addJob( + { roomId: callLink.roomId }, + { delay: 10000 } + ) + ); + } + + await DataWriter.deleteCallHistoryByRoomId(callLink.roomId); + dispatch(handleCallLinkDelete({ roomId })); + } catch (error) { + log.warn('clearCallHistory: Failed to delete call link', error); + + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: null, + description: i18n('icu:calling__call-link-delete-failed'), + buttonVariant: ButtonVariant.Primary, + }, + }); + } }; } @@ -3985,5 +4023,14 @@ export function reducer( }; } + if (action.type === HANDLE_CALL_LINK_DELETE) { + const { roomId } = action.payload; + + return { + ...state, + callLinks: omit(state.callLinks, roomId), + }; + } + return state; } diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index a9b75a544..a5b4e1f10 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -103,7 +103,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{ errorModalProps?: { buttonVariant?: ButtonVariant; description?: string; - title?: string; + title?: string | null; }; forwardMessagesProps?: ForwardMessagesPropsType; gv2MigrationProps?: MigrateToGV2PropsType; @@ -338,7 +338,7 @@ export type ShowErrorModalActionType = ReadonlyDeep<{ payload: { buttonVariant?: ButtonVariant; description?: string; - title?: string; + title?: string | null; }; }>; diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index e14a6bf3f..ccdb6b465 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -16,7 +16,7 @@ import type { import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers'; import type { PresentedSource } from '../../types/Calling'; import { CallMode } from '../../types/CallDisposition'; -import type { CallLinkType } from '../../types/CallLink'; +import { isCallLinkAdmin, type CallLinkType } from '../../types/CallLink'; import { getUserACI } from './user'; import { getOwn } from '../../util/getOwn'; import type { AciString } from '../../types/ServiceId'; @@ -96,6 +96,11 @@ export const getAllCallLinks = createSelector( (lookup): Array => Object.values(lookup) ); +export const getHasAnyAdminCallLinks = createSelector( + getAllCallLinks, + (callLinks): boolean => callLinks.some(callLink => isCallLinkAdmin(callLink)) +); + export type CallSelectorType = ( conversationId: string ) => CallStateType | undefined; diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index db1f06759..fd36c9872 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -32,6 +32,7 @@ import { getAllCallLinks, getCallSelector, getCallLinkSelector, + getHasAnyAdminCallLinks, } from '../selectors/calling'; import { useCallHistoryActions } from '../ducks/callHistory'; import { getCallHistoryEdition } from '../selectors/callHistory'; @@ -151,6 +152,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { const getAdhocCall = useSelector(getAdhocCallSelector); const getCall = useSelector(getCallSelector); const getCallLink = useSelector(getCallLinkSelector); + const hasAnyAdminCallLinks = useSelector(getHasAnyAdminCallLinks); const activeCall = useSelector(getActiveCallState); const callHistoryEdition = useSelector(getCallHistoryEdition); @@ -242,6 +244,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { callHistoryEdition={callHistoryEdition} canCreateCallLinks={canCreateCallLinks} hangUpActiveCall={hangUpActiveCall} + hasAnyAdminCallLinks={hasAnyAdminCallLinks} hasFailedStorySends={hasFailedStorySends} hasPendingUpdate={hasPendingUpdate} i18n={i18n} diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index c660c67d1..8f802ca52 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -174,7 +174,7 @@ export const SmartGlobalModalContainer = memo( }: { buttonVariant?: ButtonVariant; description?: string; - title?: string; + title?: string | null; }) => ( { }, }; + const stateWithCallLink: CallingStateType = { + ...getEmptyState(), + callLinks: { + [FAKE_CALL_LINK.roomId]: FAKE_CALL_LINK, + }, + }; + + const stateWithAdminCallLink: CallingStateType = { + ...getEmptyState(), + callLinks: { + [FAKE_CALL_LINK_WITH_ADMIN_KEY.roomId]: FAKE_CALL_LINK_WITH_ADMIN_KEY, + }, + }; + describe('getCallsByConversation', () => { it('returns state.calling.callsByConversation', () => { assert.deepEqual(getCallsByConversation(getEmptyRootState()), {}); @@ -217,4 +236,22 @@ describe('state/selectors/calling', () => { assert.isTrue(isInCall(getCallingState(stateWithActiveDirectCall))); }); }); + + describe('getHasAnyAdminCallLinks', () => { + it('returns true with admin call links', () => { + assert.isTrue( + getHasAnyAdminCallLinks(getCallingState(stateWithAdminCallLink)) + ); + }); + + it('returns false with only non-admin call links', () => { + assert.isFalse( + getHasAnyAdminCallLinks(getCallingState(stateWithCallLink)) + ); + }); + + it('returns false without any call links', () => { + assert.isFalse(getHasAnyAdminCallLinks(getEmptyRootState())); + }); + }); }); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index bfb51c97d..11c858caa 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -99,6 +99,7 @@ import { } from '../types/CallDisposition'; import { getBytesForPeerId, + getCallIdForProto, getProtoForCallHistory, } from '../util/callDisposition'; import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types'; @@ -1612,7 +1613,7 @@ export default class MessageSender { type: Proto.SyncMessage.CallLogEvent.Type.CLEAR, timestamp: Long.fromNumber(latestCall.timestamp), peerId: getBytesForPeerId(latestCall), - callId: Long.fromString(latestCall.callId), + callId: getCallIdForProto(latestCall), }); const syncMessage = MessageSender.createSyncMessage(); diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts index f3d328505..f96b4fbe5 100644 --- a/ts/types/CallDisposition.ts +++ b/ts/types/CallDisposition.ts @@ -186,6 +186,12 @@ export type CallHistoryPagination = Readonly<{ limit: number; }>; +export enum ClearCallHistoryResult { + Success = 'Success', + Error = 'Error', + ErrorDeletingCallLinks = 'ErrorDeletingCallLinks', +} + const ringerIdSchema = z.union([aciSchema, z.string(), z.null()]); const callModeSchema = z.nativeEnum(CallMode); diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 6bd7633b1..51fc43541 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -35,6 +35,7 @@ import { CallStatusValue, callLogEventNormalizeSchema, CallLogEvent, + ClearCallHistoryResult, } from '../types/CallDisposition'; import type { AciString } from '../types/ServiceId'; import { isAciString } from './isAciString'; @@ -67,8 +68,9 @@ import type { ConversationModel } from '../models/conversations'; import { drop } from './drop'; import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync'; import { storageServiceUploadJob } from '../services/storage'; -import { CallLinkDeleteManager } from '../jobs/CallLinkDeleteManager'; +import { CallLinkFinalizeDeleteManager } from '../jobs/CallLinkFinalizeDeleteManager'; import { parsePartial, parseStrict } from './schemas'; +import { calling } from '../services/calling'; // utils // ----- @@ -347,6 +349,23 @@ export function getBytesForPeerId(callHistory: CallHistoryDetails): Uint8Array { return peerId; } +export function getCallIdForProto( + callHistory: CallHistoryDetails +): Long.Long | undefined { + try { + return Long.fromString(callHistory.callId); + } catch (error) { + // When CallHistory is a placeholder record for call links, then the history item's + // callId is invalid. We will ignore it and only send the timestamp. + if (callHistory.mode === CallMode.Adhoc) { + return undefined; + } + + // For other calls, we expect a valid callId. + throw error; + } +} + export function getProtoForCallHistory( callHistory: CallHistoryDetails ): Proto.SyncMessage.ICallEvent | null { @@ -361,7 +380,7 @@ export function getProtoForCallHistory( return new Proto.SyncMessage.CallEvent({ peerId: getBytesForPeerId(callHistory), - callId: Long.fromString(callHistory.callId), + callId: getCallIdForProto(callHistory), type: typeToProto[callHistory.type], direction: directionToProto[callHistory.direction], event, @@ -1343,24 +1362,63 @@ export function updateDeletedMessages(messageIds: ReadonlyArray): void { export async function clearCallHistoryDataAndSync( latestCall: CallHistoryDetails -): Promise { +): Promise { try { log.info( `clearCallHistory: Clearing call history before (${latestCall.callId}, ${latestCall.timestamp})` ); + // This skips call history for admin call links. const messageIds = await DataWriter.clearCallHistory(latestCall); - await DataWriter.beginDeleteAllCallLinks(); - storageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' }); - // Wait for storage sync before finalizing delete - drop(CallLinkDeleteManager.enqueueAllDeletedCallLinks({ delay: 10000 })); + const isStorageSyncNeeded = await DataWriter.beginDeleteAllCallLinks(); + if (isStorageSyncNeeded) { + storageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' }); + } updateDeletedMessages(messageIds); log.info('clearCallHistory: Queueing sync message'); await singleProtoJobQueue.add( MessageSender.getClearCallHistoryMessage(latestCall) ); + + const adminCallLinks = await DataReader.getAllAdminCallLinks(); + const callLinkCount = adminCallLinks.length; + if (callLinkCount > 0) { + log.info(`clearCallHistory: Deleting ${callLinkCount} admin call links`); + let successCount = 0; + let failCount = 0; + for (const callLink of adminCallLinks) { + try { + // This throws if call link is active or network is unavailable. + // eslint-disable-next-line no-await-in-loop + await calling.deleteCallLink(callLink); + // eslint-disable-next-line no-await-in-loop + await DataWriter.deleteCallHistoryByRoomId(callLink.roomId); + // Wait for storage service sync before finalizing delete. + drop( + CallLinkFinalizeDeleteManager.addJob( + { roomId: callLink.roomId }, + { delay: 10000 } + ) + ); + successCount += 1; + } catch (error) { + log.warn('clearCallHistory: Failed to delete admin call link', error); + failCount += 1; + } + } + log.info( + `clearCallHistory: Deleted admin call links, success=${successCount} failed=${failCount}` + ); + + if (failCount > 0) { + return ClearCallHistoryResult.ErrorDeletingCallLinks; + } + } } catch (error) { log.error('clearCallHistory: Failed to clear call history', error); + return ClearCallHistoryResult.Error; } + + return ClearCallHistoryResult.Success; } export async function markAllCallHistoryReadAndSync(