diff --git a/ts/jobs/callLinkRefreshJobQueue.ts b/ts/jobs/callLinkRefreshJobQueue.ts index 456ec135b607..af418a90975b 100644 --- a/ts/jobs/callLinkRefreshJobQueue.ts +++ b/ts/jobs/callLinkRefreshJobQueue.ts @@ -4,21 +4,23 @@ import * as z from 'zod'; import PQueue from 'p-queue'; import { CallLinkRootKey } from '@signalapp/ringrtc'; +import * as globalLogger from '../logging/log'; import type { LoggerType } from '../types/Logging'; import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff'; -import type { ParsedJob } from './types'; +import type { ParsedJob, StoredJob } from './types'; import type { JOB_STATUS } from './JobQueue'; import { JobQueue } from './JobQueue'; import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; import { DAY, SECOND } from '../util/durations'; import { commonShouldJobContinue } from './helpers/commonShouldJobContinue'; import { DataReader, DataWriter } from '../sql/Client'; -import type { CallLinkType } from '../types/CallLink'; +import type { CallLinkType, PendingCallLinkType } from '../types/CallLink'; import { calling } from '../services/calling'; import { sleeper } from '../util/sleeper'; import { parseUnknown } from '../util/schemas'; import { getRoomIdFromRootKey } from '../util/callLinksRingrtc'; import { toCallHistoryFromUnusedCallLink } from '../util/callLinks'; +import type { StorageServiceFieldsType } from '../sql/Interface'; const MAX_RETRY_TIME = DAY; const MAX_PARALLEL_JOBS = 10; @@ -45,6 +47,8 @@ export type CallLinkRefreshJobData = z.infer< export class CallLinkRefreshJobQueue extends JobQueue { private parallelQueue = new PQueue({ concurrency: MAX_PARALLEL_JOBS }); + private readonly pendingCallLinks = new Map(); + protected override getQueues(): ReadonlySet { return new Set([this.parallelQueue]); } @@ -59,6 +63,97 @@ export class CallLinkRefreshJobQueue extends JobQueue { return parseUnknown(callLinkRefreshJobDataSchema, data); } + // Called for every job; wrap it to save pending storage data + protected override async enqueueStoredJob( + storedJob: Readonly + ): Promise { + let parsedData: CallLinkRefreshJobData | undefined; + try { + parsedData = this.parseData(storedJob.data); + } catch { + // No need to err, it will fail below during super + } + const { + storageID, + storageVersion, + storageUnknownFields, + rootKey, + adminKey, + } = parsedData ?? {}; + if (storageID && storageVersion && rootKey) { + this.pendingCallLinks.set(rootKey, { + rootKey, + adminKey: adminKey ?? null, + storageID: storageID ?? undefined, + storageVersion: storageVersion ?? undefined, + storageUnknownFields, + storageNeedsSync: false, + }); + } + + await super.enqueueStoredJob(storedJob); + + if (rootKey) { + this.pendingCallLinks.delete(rootKey); + } + } + + // Return pending call links with storageIDs and versions. They're pending because + // depending on the refresh result, we will create either CallLinks or DefunctCallLinks, + // and we'll save storageID and version onto those records. + public getPendingAdminCallLinks(): ReadonlyArray { + return Array.from(this.pendingCallLinks.values()).filter( + callLink => callLink.adminKey != null + ); + } + + public hasPendingCallLink(rootKey: string): boolean { + return this.pendingCallLinks.has(rootKey); + } + + // If a new version of storage is uploaded before we get a chance to refresh the + // call link, then we need to refresh pending storage fields so when the job + // completes it will save with the latest storage fields. + public updatePendingCallLinkStorageFields( + rootKey: string, + storageFields: StorageServiceFieldsType + ): void { + const existingStorageFields = this.pendingCallLinks.get(rootKey); + if (!existingStorageFields) { + globalLogger.warn( + 'callLinkRefreshJobQueue.updatePendingCallLinkStorageFields: unknown rootKey' + ); + return; + } + + this.pendingCallLinks.set(rootKey, { + ...existingStorageFields, + ...storageFields, + }); + } + + protected getPendingCallLinkStorageFields( + storageID: string, + jobData: CallLinkRefreshJobData + ): StorageServiceFieldsType | undefined { + const storageFields = this.pendingCallLinks.get(storageID); + if (storageFields) { + return { + storageID: storageFields.storageID, + storageVersion: storageFields.storageVersion, + storageUnknownFields: storageFields.storageUnknownFields, + storageNeedsSync: storageFields.storageNeedsSync, + }; + } + + return { + storageID: jobData.storageID ?? undefined, + storageVersion: jobData.storageVersion ?? undefined, + storageUnknownFields: jobData.storageUnknownFields ?? undefined, + storageNeedsSync: false, + }; + } + protected async run( { data, @@ -102,16 +197,18 @@ export class CallLinkRefreshJobQueue extends JobQueue { window.reduxActions.calling.handleCallLinkUpdateLocal(callLink); } else { log.info(`${logId}: Creating new call link`); - const { adminKey, storageID, storageVersion, storageUnknownFields } = - data; + const { adminKey } = data; + // Refresh the latest storage fields, since they may have changed. + const storageFields = this.getPendingCallLinkStorageFields( + rootKey, + data + ); const callLink: CallLinkType = { ...freshCallLinkState, roomId, rootKey, adminKey: adminKey ?? null, - storageID: storageID ?? undefined, - storageVersion: storageVersion ?? undefined, - storageUnknownFields, + ...storageFields, storageNeedsSync: false, }; @@ -130,10 +227,17 @@ export class CallLinkRefreshJobQueue extends JobQueue { log.info( `${logId}: Call link not found on server but absent locally, saving DefunctCallLink` ); + // Refresh the latest storage fields, since they may have changed. + const storageFields = this.getPendingCallLinkStorageFields( + rootKey, + data + ); await DataWriter.insertDefunctCallLink({ roomId, rootKey, adminKey: data.adminKey ?? null, + ...storageFields, + storageNeedsSync: false, }); } else { log.info( diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 955bc5688a26..68a84641f3da 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -31,6 +31,7 @@ import { toStickerPackRecord, toCallLinkRecord, mergeCallLinkRecord, + toDefunctOrPendingCallLinkRecord, } from './storageRecordOps'; import type { MergeResultType } from './storageRecordOps'; import { MAX_READ_KEYS } from './storageConstants'; @@ -71,8 +72,16 @@ import { MY_STORY_ID } from '../types/Stories'; import { isNotNil } from '../util/isNotNil'; import { isSignalConversation } from '../util/isSignalConversation'; import { redactExtendedStorageID, redactStorageID } from '../util/privacy'; -import type { CallLinkRecord } from '../types/CallLink'; -import { callLinkFromRecord } from '../util/callLinksRingrtc'; +import type { + CallLinkRecord, + DefunctCallLinkType, + PendingCallLinkType, +} from '../types/CallLink'; +import { + callLinkFromRecord, + getRoomIdFromRootKeyString, +} from '../util/callLinksRingrtc'; +import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier; @@ -333,6 +342,8 @@ async function generateManifest( const { callLinkDbRecords, + defunctCallLinks, + pendingCallLinks, storyDistributionLists, installedStickerPacks, uninstalledStickerPacks, @@ -475,6 +486,8 @@ async function generateManifest( `adding callLinks=${callLinkDbRecords.length}` ); + const callLinkRoomIds = new Set(); + for (const callLinkDbRecord of callLinkDbRecords) { const { roomId } = callLinkDbRecord; if (callLinkDbRecord.adminKey == null || callLinkDbRecord.rootKey == null) { @@ -487,8 +500,10 @@ async function generateManifest( const storageRecord = new Proto.StorageRecord(); storageRecord.callLink = toCallLinkRecord(callLinkDbRecord); - const callLink = callLinkFromRecord(callLinkDbRecord); + + callLinkRoomIds.add(callLink.roomId); + const { isNewItem, storageID } = processStorageRecord({ currentStorageID: callLink.storageID, currentStorageVersion: callLink.storageVersion, @@ -521,6 +536,76 @@ async function generateManifest( } } + log.info( + `storageService.upload(${version}): ` + + `adding defunctCallLinks=${defunctCallLinks.length}` + ); + + defunctCallLinks.forEach(defunctCallLink => { + const storageRecord = new Proto.StorageRecord(); + storageRecord.callLink = toDefunctOrPendingCallLinkRecord(defunctCallLink); + + callLinkRoomIds.add(defunctCallLink.roomId); + + const { isNewItem, storageID } = processStorageRecord({ + currentStorageID: defunctCallLink.storageID, + currentStorageVersion: defunctCallLink.storageVersion, + identifierType: ITEM_TYPE.CALL_LINK, + storageNeedsSync: defunctCallLink.storageNeedsSync, + storageRecord, + }); + + if (isNewItem) { + postUploadUpdateFunctions.push(() => { + drop( + DataWriter.updateDefunctCallLink({ + ...defunctCallLink, + storageID, + storageVersion: version, + storageNeedsSync: false, + }) + ); + }); + } + }); + + log.info( + `storageService.upload(${version}): ` + + `adding pendingCallLinks=${pendingCallLinks.length}` + ); + + pendingCallLinks.forEach(pendingCallLink => { + const storageRecord = new Proto.StorageRecord(); + storageRecord.callLink = toDefunctOrPendingCallLinkRecord(pendingCallLink); + + const roomId = getRoomIdFromRootKeyString(pendingCallLink.rootKey); + if (callLinkRoomIds.has(roomId)) { + return; + } + + const { isNewItem, storageID } = processStorageRecord({ + currentStorageID: pendingCallLink.storageID, + currentStorageVersion: pendingCallLink.storageVersion, + identifierType: ITEM_TYPE.CALL_LINK, + storageNeedsSync: pendingCallLink.storageNeedsSync, + storageRecord, + }); + + if (isNewItem) { + postUploadUpdateFunctions.push(() => { + callLinkRefreshJobQueue.updatePendingCallLinkStorageFields( + pendingCallLink.rootKey, + { + ...pendingCallLink, + storageID, + storageVersion: version, + storageNeedsSync: false, + } + ); + }); + } + }); + const unknownRecordsArray: ReadonlyArray = ( window.storage.get('storage-service-unknown-records') || [] ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); @@ -1145,6 +1230,8 @@ async function mergeRecord( type NonConversationRecordsResultType = Readonly<{ callLinkDbRecords: ReadonlyArray; + defunctCallLinks: ReadonlyArray; + pendingCallLinks: ReadonlyArray; installedStickerPacks: ReadonlyArray; uninstalledStickerPacks: ReadonlyArray; storyDistributionLists: ReadonlyArray; @@ -1154,11 +1241,15 @@ type NonConversationRecordsResultType = Readonly<{ async function getNonConversationRecords(): Promise { const [ callLinkDbRecords, + defunctCallLinks, + pendingCallLinks, storyDistributionLists, uninstalledStickerPacks, installedStickerPacks, ] = await Promise.all([ DataReader.getAllCallLinkRecordsWithAdminKey(), + DataReader.getAllDefunctCallLinksWithAdminKey(), + callLinkRefreshJobQueue.getPendingAdminCallLinks(), DataReader.getAllStoryDistributionsWithMembers(), DataReader.getUninstalledStickerPacks(), DataReader.getInstalledStickerPacks(), @@ -1166,6 +1257,8 @@ async function getNonConversationRecords(): Promise { + const { storageID, storageVersion } = defunctCallLink; + if (!storageID || remoteKeys.has(storageID)) { + return; + } + + const missingKey = redactStorageID(storageID, storageVersion); + log.info( + `storageService.process(${version}): localKey=${missingKey} was not ` + + 'in remote manifest' + ); + drop( + DataWriter.updateDefunctCallLink({ + ...defunctCallLink, + storageID: undefined, + storageVersion: undefined, + }) + ); + }); + + pendingCallLinks.forEach(pendingCallLink => { + const { storageID, storageVersion } = pendingCallLink; + if (!storageID || remoteKeys.has(storageID)) { + return; + } + + const missingKey = redactStorageID(storageID, storageVersion); + log.info( + `storageService.process(${version}): localKey=${missingKey} was not ` + + 'in remote manifest' + ); + callLinkRefreshJobQueue.updatePendingCallLinkStorageFields( + pendingCallLink.rootKey, + { + ...pendingCallLink, + storageID: undefined, + storageVersion: undefined, + } + ); + }); } log.info( diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index a2fc98c73d36..882501bcb31c 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -4,7 +4,6 @@ import { isEqual } from 'lodash'; import Long from 'long'; -import { CallLinkRootKey } from '@signalapp/ringrtc'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes'; import { deriveMasterKeyFromGroupV1 } from '../Crypto'; import * as Bytes from '../Bytes'; @@ -66,13 +65,18 @@ import { findAndDeleteOnboardingStoryIfExists } from '../util/findAndDeleteOnboa import { downloadOnboardingStory } from '../util/downloadOnboardingStory'; import { drop } from '../util/drop'; import { redactExtendedStorageID } from '../util/privacy'; -import type { CallLinkRecord } from '../types/CallLink'; +import type { + CallLinkRecord, + DefunctCallLinkType, + PendingCallLinkType, +} from '../types/CallLink'; import { callLinkFromRecord, fromRootKeyBytes, - getRoomIdFromRootKey, + getRoomIdFromRootKeyString, + toRootKeyBytes, } from '../util/callLinksRingrtc'; -import { fromAdminKeyBytes } from '../util/callLinks'; +import { fromAdminKeyBytes, toAdminKeyBytes } from '../util/callLinks'; import { isOlderThan } from '../util/timestamp'; import { getMessageQueueTime } from '../util/getMessageQueueTime'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; @@ -643,6 +647,29 @@ export function toCallLinkRecord( return callLinkRecord; } +export function toDefunctOrPendingCallLinkRecord( + callLink: DefunctCallLinkType | PendingCallLinkType +): Proto.CallLinkRecord { + const rootKey = toRootKeyBytes(callLink.rootKey); + const adminKey = callLink.adminKey + ? toAdminKeyBytes(callLink.adminKey) + : null; + + strictAssert(rootKey, 'toDefunctOrPendingCallLinkRecord: no rootKey'); + strictAssert(adminKey, 'toDefunctOrPendingCallLinkRecord: no adminPasskey'); + + const callLinkRecord = new Proto.CallLinkRecord(); + + callLinkRecord.rootKey = rootKey; + callLinkRecord.adminPasskey = adminKey; + + if (callLink.storageUnknownFields) { + callLinkRecord.$unknownFields = [callLink.storageUnknownFields]; + } + + return callLinkRecord; +} + type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record; function applyMessageRequestState( @@ -1967,8 +1994,7 @@ export async function mergeCallLinkRecord( ? fromAdminKeyBytes(callLinkRecord.adminPasskey) : null; - const callLinkRootKey = CallLinkRootKey.parse(rootKeyString); - const roomId = getRoomIdFromRootKey(callLinkRootKey); + const roomId = getRoomIdFromRootKeyString(rootKeyString); const logId = `mergeCallLinkRecord(${redactedStorageID}, ${roomId})`; const localCallLinkDbRecord = @@ -2012,6 +2038,17 @@ export async function mergeCallLinkRecord( ); } else if (await DataReader.defunctCallLinkExists(roomId)) { details.push('skipping known defunct call link'); + } else if (callLinkRefreshJobQueue.hasPendingCallLink(storageID)) { + details.push('pending call link refresh, updating storage fields'); + callLinkRefreshJobQueue.updatePendingCallLinkStorageFields( + rootKeyString, + { + storageID, + storageVersion, + storageUnknownFields: callLinkDbRecord.storageUnknownFields, + storageNeedsSync: false, + } + ); } else { details.push('new call link, enqueueing call link refresh and create'); diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 36c4abea0343..d3f3726d0480 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -589,6 +589,7 @@ type ReadableInterface = { getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined; getAllAdminCallLinks(): ReadonlyArray; getAllCallLinkRecordsWithAdminKey(): ReadonlyArray; + getAllDefunctCallLinksWithAdminKey(): ReadonlyArray; getAllMarkedDeletedCallLinkRoomIds(): ReadonlyArray; getMessagesBetween: ( conversationId: string, @@ -823,7 +824,8 @@ type WritableInterface = { deleteCallLinkAndHistory(roomId: string): void; finalizeDeleteCallLink(roomId: string): void; _removeAllCallLinks(): void; - insertDefunctCallLink(callLink: DefunctCallLinkType): void; + insertDefunctCallLink(defunctCallLink: DefunctCallLinkType): void; + updateDefunctCallLink(defunctCallLink: DefunctCallLinkType): void; deleteCallLinkFromSync(roomId: string): void; migrateConversationMessages: (obsoleteId: string, currentId: string) => void; saveEditedMessage: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 1d6ed39fa901..ad540c1fa747 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -185,12 +185,14 @@ import { deleteCallLinkAndHistory, getAllAdminCallLinks, getAllCallLinkRecordsWithAdminKey, + getAllDefunctCallLinksWithAdminKey, getAllMarkedDeletedCallLinkRoomIds, finalizeDeleteCallLink, beginDeleteCallLink, deleteCallLinkFromSync, _removeAllCallLinks, insertDefunctCallLink, + updateDefunctCallLink, } from './server/callLinks'; import { replaceAllEndorsementsForGroup, @@ -321,6 +323,7 @@ export const DataReader: ServerReadableInterface = { getCallLinkRecordByRoomId, getAllAdminCallLinks, getAllCallLinkRecordsWithAdminKey, + getAllDefunctCallLinksWithAdminKey, getAllMarkedDeletedCallLinkRoomIds, getMessagesBetween, getNearbyMessageFromDeletedSet, @@ -464,6 +467,7 @@ export const DataWriter: ServerWritableInterface = { _removeAllCallLinks, deleteCallLinkFromSync, insertDefunctCallLink, + updateDefunctCallLink, migrateConversationMessages, saveEditedMessage, saveEditedMessages, diff --git a/ts/sql/migrations/1250-defunct-call-links-storage.ts b/ts/sql/migrations/1250-defunct-call-links-storage.ts new file mode 100644 index 000000000000..c0e63129018c --- /dev/null +++ b/ts/sql/migrations/1250-defunct-call-links-storage.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 = 1250; + +export function updateToSchemaVersion1250( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1250) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE defunctCallLinks ADD COLUMN storageID TEXT; + ALTER TABLE defunctCallLinks ADD COLUMN storageVersion INTEGER; + ALTER TABLE defunctCallLinks ADD COLUMN storageUnknownFields BLOB; + ALTER TABLE defunctCallLinks ADD COLUMN storageNeedsSync INTEGER NOT NULL DEFAULT 0; + `); + + db.pragma('user_version = 1250'); + })(); + logger.info('updateToSchemaVersion1250: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 05d1a773ccde..678600e16ac7 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -100,10 +100,11 @@ import { updateToSchemaVersion1200 } from './1200-attachment-download-source-ind import { updateToSchemaVersion1210 } from './1210-call-history-started-id'; import { updateToSchemaVersion1220 } from './1220-blob-sessions'; import { updateToSchemaVersion1230 } from './1230-call-links-admin-key-index'; +import { updateToSchemaVersion1240 } from './1240-defunct-call-links-table'; import { - updateToSchemaVersion1240, + updateToSchemaVersion1250, version as MAX_VERSION, -} from './1240-defunct-call-links-table'; +} from './1250-defunct-call-links-storage'; function updateToSchemaVersion1( currentVersion: number, @@ -2073,6 +2074,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1220, updateToSchemaVersion1230, updateToSchemaVersion1240, + updateToSchemaVersion1250, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/callLinks.ts b/ts/sql/server/callLinks.ts index b44a6158167b..139bc9e7db40 100644 --- a/ts/sql/server/callLinks.ts +++ b/ts/sql/server/callLinks.ts @@ -11,12 +11,14 @@ import type { import { callLinkRestrictionsSchema, callLinkRecordSchema, + defunctCallLinkRecordSchema, } from '../../types/CallLink'; import { toAdminKeyBytes } from '../../util/callLinks'; import { callLinkToRecord, callLinkFromRecord, - toRootKeyBytes, + defunctCallLinkToRecord, + defunctCallLinkFromRecord, } from '../../util/callLinksRingrtc'; import type { ReadableDB, WritableDB } from '../Interface'; import { prepare } from '../Server'; @@ -388,31 +390,73 @@ export function defunctCallLinkExists(db: ReadableDB, roomId: string): boolean { return db.prepare(query).pluck(true).get(params) === 1; } +export function getAllDefunctCallLinksWithAdminKey( + db: ReadableDB +): ReadonlyArray { + const [query] = sql` + SELECT * + FROM defunctCallLinks + WHERE adminKey IS NOT NULL; + `; + return db + .prepare(query) + .all() + .map((item: unknown) => + defunctCallLinkFromRecord(parseUnknown(defunctCallLinkRecordSchema, item)) + ); +} + export function insertDefunctCallLink( db: WritableDB, - callLink: DefunctCallLinkType + defunctCallLink: DefunctCallLinkType ): void { - const { roomId, rootKey } = callLink; + const { roomId, rootKey } = defunctCallLink; assertRoomIdMatchesRootKey(roomId, rootKey); - const rootKeyData = toRootKeyBytes(callLink.rootKey); - const adminKeyData = callLink.adminKey - ? toAdminKeyBytes(callLink.adminKey) - : null; - + const data = defunctCallLinkToRecord(defunctCallLink); prepare( db, ` INSERT INTO defunctCallLinks ( roomId, rootKey, - adminKey + adminKey, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync ) VALUES ( $roomId, - $rootKeyData, - $adminKeyData + $rootKey, + $adminKey, + $storageID, + $storageVersion, + $storageUnknownFields, + $storageNeedsSync ) ON CONFLICT (roomId) DO NOTHING; ` - ).run({ roomId, rootKeyData, adminKeyData }); + ).run(data); +} + +export function updateDefunctCallLink( + db: WritableDB, + defunctCallLink: DefunctCallLinkType +): void { + const { roomId, rootKey } = defunctCallLink; + assertRoomIdMatchesRootKey(roomId, rootKey); + + const data = defunctCallLinkToRecord(defunctCallLink); + // Do not write roomId or rootKey since they should never change + db.prepare( + ` + UPDATE callLinks + SET + storageID = $storageID, + storageVersion = $storageVersion, + storageUnknownFields = $storageUnknownFields, + storageNeedsSync = $storageNeedsSync + WHERE roomId = $roomId + ` + ).run(data); } diff --git a/ts/types/CallLink.ts b/ts/types/CallLink.ts index 48116d74761d..c2000a353a44 100644 --- a/ts/types/CallLink.ts +++ b/ts/types/CallLink.ts @@ -83,13 +83,41 @@ export type CallLinkConversationType = ReadonlyDeep< } >; +// Call link discovered from sync, waiting to refresh state from the calling server +export type PendingCallLinkType = Readonly<{ + rootKey: string; + adminKey: string | null; +}> & + StorageServiceFieldsType; + // Call links discovered missing after server refresh export type DefunctCallLinkType = Readonly<{ roomId: string; rootKey: string; adminKey: string | null; +}> & + StorageServiceFieldsType; + +export type DefunctCallLinkRecord = Readonly<{ + roomId: string; + rootKey: Uint8Array; + adminKey: Uint8Array | null; + storageID: string | null; + storageVersion: number | null; + storageUnknownFields: Uint8Array | null; + storageNeedsSync: 1 | 0; }>; +export const defunctCallLinkRecordSchema = z.object({ + roomId: z.string(), + rootKey: z.instanceof(Uint8Array), + adminKey: z.instanceof(Uint8Array).nullable(), + storageID: z.string().nullable(), + storageVersion: z.number().int().nullable(), + storageUnknownFields: z.instanceof(Uint8Array).nullable(), + storageNeedsSync: z.union([z.literal(1), z.literal(0)]), +}) satisfies z.ZodType; + // DB Record export type CallLinkRecord = Readonly<{ roomId: string; diff --git a/ts/util/callLinksRingrtc.ts b/ts/util/callLinksRingrtc.ts index 3e950350fb8a..312d944b38fc 100644 --- a/ts/util/callLinksRingrtc.ts +++ b/ts/util/callLinksRingrtc.ts @@ -12,11 +12,14 @@ import type { CallLinkRecord, CallLinkRestrictions, CallLinkType, + DefunctCallLinkRecord, + DefunctCallLinkType, } from '../types/CallLink'; import { type CallLinkStateType, CallLinkNameMaxByteLength, callLinkRecordSchema, + defunctCallLinkRecordSchema, toCallLinkRestrictions, } from '../types/CallLink'; import { unicodeSlice } from './unicodeSlice'; @@ -64,6 +67,11 @@ export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string { return rootKey.deriveRoomId().toString('hex'); } +export function getRoomIdFromRootKeyString(rootKeyString: string): string { + const callLinkRootKey = CallLinkRootKey.parse(rootKeyString); + return getRoomIdFromRootKey(callLinkRootKey); +} + export function getCallLinkRootKeyFromUrlKey(key: string): Uint8Array { // Returns `Buffer` which inherits from `Uint8Array` return CallLinkRootKey.parse(key).bytes; @@ -167,3 +175,45 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord { storageNeedsSync: callLink.storageNeedsSync ? 1 : 0, }); } + +export function defunctCallLinkFromRecord( + record: DefunctCallLinkRecord +): DefunctCallLinkType { + if (record.rootKey == null) { + throw new Error('CallLink.defunctCallLinkFromRecord: rootKey is null'); + } + + const rootKey = fromRootKeyBytes(record.rootKey); + const adminKey = record.adminKey ? fromAdminKeyBytes(record.adminKey) : null; + return { + roomId: record.roomId, + rootKey, + adminKey, + storageID: record.storageID || undefined, + storageVersion: record.storageVersion || undefined, + storageUnknownFields: record.storageUnknownFields || undefined, + storageNeedsSync: record.storageNeedsSync === 1, + }; +} + +export function defunctCallLinkToRecord( + defunctCallLink: DefunctCallLinkType +): DefunctCallLinkRecord { + if (defunctCallLink.rootKey == null) { + throw new Error('CallLink.defunctCallLinkToRecord: rootKey is null'); + } + + const rootKey = toRootKeyBytes(defunctCallLink.rootKey); + const adminKey = defunctCallLink.adminKey + ? toAdminKeyBytes(defunctCallLink.adminKey) + : null; + return parseStrict(defunctCallLinkRecordSchema, { + roomId: defunctCallLink.roomId, + rootKey, + adminKey, + storageID: defunctCallLink.storageID || null, + storageVersion: defunctCallLink.storageVersion || null, + storageUnknownFields: defunctCallLink.storageUnknownFields || null, + storageNeedsSync: defunctCallLink.storageNeedsSync ? 1 : 0, + }); +}