Use storage service for call links

This commit is contained in:
ayumi-signal 2024-09-04 11:06:06 -07:00 committed by GitHub
parent 50447b7686
commit 5a75246e42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 583 additions and 50 deletions

View file

@ -622,7 +622,7 @@ message SyncMessage {
message CallLinkUpdate { message CallLinkUpdate {
enum Type { enum Type {
UPDATE = 0; UPDATE = 0;
DELETE = 1; reserved 1; // was DELETE, superseded by storage service
} }
optional bytes rootKey = 1; optional bytes rootKey = 1;

View file

@ -47,6 +47,7 @@ message ManifestRecord {
ACCOUNT = 4; ACCOUNT = 4;
STORY_DISTRIBUTION_LIST = 5; STORY_DISTRIBUTION_LIST = 5;
STICKER_PACK = 6; STICKER_PACK = 6;
CALL_LINK = 7;
} }
optional bytes raw = 1; optional bytes raw = 1;
@ -67,6 +68,7 @@ message StorageRecord {
AccountRecord account = 4; AccountRecord account = 4;
StoryDistributionListRecord storyDistributionList = 5; StoryDistributionListRecord storyDistributionList = 5;
StickerPackRecord stickerPack = 6; StickerPackRecord stickerPack = 6;
CallLinkRecord callLink = 7;
} }
} }
@ -241,3 +243,10 @@ message StickerPackRecord {
// non-zero - `packKey` and `position` should // non-zero - `packKey` and `position` should
// be unset // be unset
} }
message CallLinkRecord {
optional bytes rootKey = 1; // 16 bytes
optional bytes adminPasskey = 2; // Non-empty when the current user is an admin
optional uint64 deletedAtTimestampMs = 3; // When present and non-zero, `adminPasskey`
// should be cleared
}

View file

@ -54,6 +54,7 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
restrictions: CallLinkRestrictions.None, restrictions: CallLinkRestrictions.None,
revoked: false, revoked: false,
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000, expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
storageNeedsSync: false,
...overrideProps, ...overrideProps,
}; };
} }

View file

@ -1074,6 +1074,7 @@ export class BackupImportStream extends Writable {
restrictions: fromCallLinkRestrictionsProto(restrictions), restrictions: fromCallLinkRestrictionsProto(restrictions),
revoked: false, revoked: false,
expiration: expirationMs?.toNumber() || null, expiration: expirationMs?.toNumber() || null,
storageNeedsSync: false,
}; };
this.recipientIdToCallLink.set(recipientId, callLink); this.recipientIdToCallLink.set(recipientId, callLink);

View file

@ -153,10 +153,7 @@ import {
import type { CallLinkType, CallLinkStateType } from '../types/CallLink'; import type { CallLinkType, CallLinkStateType } from '../types/CallLink';
import { CallLinkRestrictions } from '../types/CallLink'; import { CallLinkRestrictions } from '../types/CallLink';
import { getConversationIdForLogging } from '../util/idForLogging'; import { getConversationIdForLogging } from '../util/idForLogging';
import { import { sendCallLinkUpdateSync } from '../util/sendCallLinkUpdateSync';
sendCallLinkDeleteSync,
sendCallLinkUpdateSync,
} from '../util/sendCallLinkUpdateSync';
import { createIdenticon } from '../util/createIdenticon'; import { createIdenticon } from '../util/createIdenticon';
import { getColorForCallLink } from '../util/getColorForCallLink'; import { getColorForCallLink } from '../util/getColorForCallLink';
@ -677,6 +674,7 @@ export class CallingClass {
roomId: roomIdHex, roomId: roomIdHex,
rootKey: rootKey.toString(), rootKey: rootKey.toString(),
adminKey: adminKey.toString('base64'), adminKey: adminKey.toString('base64'),
storageNeedsSync: true,
...state, ...state,
}; };
@ -716,8 +714,6 @@ export class CallingClass {
log.error(`${logId}: ${message}`); log.error(`${logId}: ${message}`);
throw new Error(message); throw new Error(message);
} }
drop(sendCallLinkDeleteSync(callLink));
} }
async updateCallLinkName( async updateCallLinkName(

View file

@ -29,6 +29,8 @@ import {
toGroupV2Record, toGroupV2Record,
toStoryDistributionListRecord, toStoryDistributionListRecord,
toStickerPackRecord, toStickerPackRecord,
toCallLinkRecord,
mergeCallLinkRecord,
} from './storageRecordOps'; } from './storageRecordOps';
import type { MergeResultType } from './storageRecordOps'; import type { MergeResultType } from './storageRecordOps';
import { MAX_READ_KEYS } from './storageConstants'; import { MAX_READ_KEYS } from './storageConstants';
@ -68,6 +70,8 @@ import { MY_STORY_ID } from '../types/Stories';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { isSignalConversation } from '../util/isSignalConversation'; import { isSignalConversation } from '../util/isSignalConversation';
import { redactExtendedStorageID, redactStorageID } from '../util/privacy'; import { redactExtendedStorageID, redactStorageID } from '../util/privacy';
import type { CallLinkRecord } from '../types/CallLink';
import { callLinkFromRecord } from '../util/callLinksRingrtc';
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier; type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
@ -90,6 +94,7 @@ const validRecordTypes = new Set([
4, // ACCOUNT 4, // ACCOUNT
5, // STORY_DISTRIBUTION_LIST 5, // STORY_DISTRIBUTION_LIST
6, // STICKER_PACK 6, // STICKER_PACK
7, // CALL_LINK
]); ]);
const backOff = new BackOff([ const backOff = new BackOff([
@ -326,6 +331,7 @@ async function generateManifest(
} }
const { const {
callLinkDbRecords,
storyDistributionLists, storyDistributionLists,
installedStickerPacks, installedStickerPacks,
uninstalledStickerPacks, uninstalledStickerPacks,
@ -460,6 +466,57 @@ async function generateManifest(
} }
}); });
log.info(
`storageService.upload(${version}): ` +
`adding callLinks=${callLinkDbRecords.length}`
);
for (const callLinkDbRecord of callLinkDbRecords) {
const { roomId } = callLinkDbRecord;
if (callLinkDbRecord.adminKey == null || callLinkDbRecord.rootKey == null) {
log.warn(
`storageService.upload(${version}): ` +
`call link ${roomId} has empty rootKey`
);
continue;
}
const storageRecord = new Proto.StorageRecord();
storageRecord.callLink = toCallLinkRecord(callLinkDbRecord);
const callLink = callLinkFromRecord(callLinkDbRecord);
const { isNewItem, storageID } = processStorageRecord({
currentStorageID: callLink.storageID,
currentStorageVersion: callLink.storageVersion,
identifierType: ITEM_TYPE.CALL_LINK,
storageNeedsSync: callLink.storageNeedsSync,
storageRecord,
});
const storageFields = {
storageID,
storageVersion: version,
storageNeedsSync: false,
};
if (isNewItem) {
postUploadUpdateFunctions.push(async () => {
const freshCallLink = await DataReader.getCallLinkByRoomId(roomId);
if (freshCallLink == null) {
log.warn(
`storageService.upload(${version}): ` +
`call link ${roomId} removed locally from DB while we were uploading to storage`
);
return;
}
const callLinkToSave = { ...freshCallLink, ...storageFields };
await DataWriter.updateCallLink(callLinkToSave);
window.reduxActions.calling.handleCallLinkUpdateLocal(callLinkToSave);
});
}
}
const unknownRecordsArray: ReadonlyArray<UnknownRecord> = ( const unknownRecordsArray: ReadonlyArray<UnknownRecord> = (
window.storage.get('storage-service-unknown-records') || [] window.storage.get('storage-service-unknown-records') || []
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
@ -1023,6 +1080,12 @@ async function mergeRecord(
storageVersion, storageVersion,
storageRecord.stickerPack storageRecord.stickerPack
); );
} else if (itemType === ITEM_TYPE.CALL_LINK && storageRecord.callLink) {
mergeResult = await mergeCallLinkRecord(
storageID,
storageVersion,
storageRecord.callLink
);
} else { } else {
isUnsupported = true; isUnsupported = true;
log.warn( log.warn(
@ -1077,6 +1140,7 @@ async function mergeRecord(
} }
type NonConversationRecordsResultType = Readonly<{ type NonConversationRecordsResultType = Readonly<{
callLinkDbRecords: ReadonlyArray<CallLinkRecord>;
installedStickerPacks: ReadonlyArray<StickerPackType>; installedStickerPacks: ReadonlyArray<StickerPackType>;
uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>; uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>;
storyDistributionLists: ReadonlyArray<StoryDistributionWithMembersType>; storyDistributionLists: ReadonlyArray<StoryDistributionWithMembersType>;
@ -1085,16 +1149,19 @@ type NonConversationRecordsResultType = Readonly<{
// TODO: DESKTOP-3929 // TODO: DESKTOP-3929
async function getNonConversationRecords(): Promise<NonConversationRecordsResultType> { async function getNonConversationRecords(): Promise<NonConversationRecordsResultType> {
const [ const [
callLinkDbRecords,
storyDistributionLists, storyDistributionLists,
uninstalledStickerPacks, uninstalledStickerPacks,
installedStickerPacks, installedStickerPacks,
] = await Promise.all([ ] = await Promise.all([
DataReader.getAllCallLinkRecordsWithAdminKey(),
DataReader.getAllStoryDistributionsWithMembers(), DataReader.getAllStoryDistributionsWithMembers(),
DataReader.getUninstalledStickerPacks(), DataReader.getUninstalledStickerPacks(),
DataReader.getInstalledStickerPacks(), DataReader.getInstalledStickerPacks(),
]); ]);
return { return {
callLinkDbRecords,
storyDistributionLists, storyDistributionLists,
uninstalledStickerPacks, uninstalledStickerPacks,
installedStickerPacks, installedStickerPacks,
@ -1130,6 +1197,7 @@ async function processManifest(
{ {
const { const {
callLinkDbRecords,
storyDistributionLists, storyDistributionLists,
installedStickerPacks, installedStickerPacks,
uninstalledStickerPacks, uninstalledStickerPacks,
@ -1144,6 +1212,11 @@ async function processManifest(
} }
}; };
callLinkDbRecords.forEach(dbRecord =>
collectLocalKeysFromFields(callLinkFromRecord(dbRecord))
);
localRecordCount += callLinkDbRecords.length;
storyDistributionLists.forEach(collectLocalKeysFromFields); storyDistributionLists.forEach(collectLocalKeysFromFields);
localRecordCount += storyDistributionLists.length; localRecordCount += storyDistributionLists.length;
@ -1264,6 +1337,7 @@ async function processManifest(
// Refetch various records post-merge // Refetch various records post-merge
{ {
const { const {
callLinkDbRecords,
storyDistributionLists, storyDistributionLists,
installedStickerPacks, installedStickerPacks,
uninstalledStickerPacks, uninstalledStickerPacks,
@ -1352,6 +1426,30 @@ async function processManifest(
conflictCount += 1; conflictCount += 1;
} }
callLinkDbRecords.forEach(callLinkDbRecord => {
const { storageID, storageVersion } = callLinkDbRecord;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(
storageID,
storageVersion || undefined
);
log.info(
`storageService.process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
);
const callLink = callLinkFromRecord(callLinkDbRecord);
drop(
DataWriter.updateCallLink({
...callLink,
storageID: undefined,
storageVersion: undefined,
})
);
});
} }
log.info( log.info(

View file

@ -4,6 +4,7 @@
import { isEqual, isNumber } from 'lodash'; import { isEqual, isNumber } from 'lodash';
import Long from 'long'; import Long from 'long';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
import { deriveMasterKeyFromGroupV1 } from '../Crypto'; import { deriveMasterKeyFromGroupV1 } from '../Crypto';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
@ -65,6 +66,18 @@ import { findAndDeleteOnboardingStoryIfExists } from '../util/findAndDeleteOnboa
import { downloadOnboardingStory } from '../util/downloadOnboardingStory'; import { downloadOnboardingStory } from '../util/downloadOnboardingStory';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { redactExtendedStorageID } from '../util/privacy'; import { redactExtendedStorageID } from '../util/privacy';
import type { CallLinkRecord } from '../types/CallLink';
import {
callLinkFromRecord,
fromRootKeyBytes,
getRoomIdFromRootKey,
} from '../util/callLinksRingrtc';
import {
CALL_LINK_DELETED_STORAGE_RECORD_TTL,
fromAdminKeyBytes,
toCallHistoryFromUnusedCallLink,
} from '../util/callLinks';
import { isOlderThan } from '../util/timestamp';
const MY_STORY_BYTES = uuidToBytes(MY_STORY_ID); const MY_STORY_BYTES = uuidToBytes(MY_STORY_ID);
@ -603,6 +616,35 @@ export function toStickerPackRecord(
return stickerPackRecord; return stickerPackRecord;
} }
// callLinkDbRecord exposes additional fields not available on CallLinkType
export function toCallLinkRecord(
callLinkDbRecord: CallLinkRecord
): Proto.CallLinkRecord {
strictAssert(callLinkDbRecord.rootKey, 'toCallLinkRecord: no rootKey');
const callLinkRecord = new Proto.CallLinkRecord();
callLinkRecord.rootKey = callLinkDbRecord.rootKey;
if (callLinkDbRecord.deleted === 1) {
// adminKey is intentionally omitted for deleted call links.
callLinkRecord.deletedAtTimestampMs = Long.fromNumber(
callLinkDbRecord.deletedAt || new Date().getTime()
);
} else {
strictAssert(
callLinkDbRecord.adminKey,
'toCallLinkRecord: no adminPasskey'
);
callLinkRecord.adminPasskey = callLinkDbRecord.adminKey;
}
if (callLinkDbRecord.storageUnknownFields) {
callLinkRecord.$unknownFields = [callLinkDbRecord.storageUnknownFields];
}
return callLinkRecord;
}
type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record; type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record;
function applyMessageRequestState( function applyMessageRequestState(
@ -1906,3 +1948,152 @@ export async function mergeStickerPackRecord(
oldStorageVersion, oldStorageVersion,
}; };
} }
export async function mergeCallLinkRecord(
storageID: string,
storageVersion: number,
callLinkRecord: Proto.ICallLinkRecord
): Promise<MergeResultType> {
const redactedStorageID = redactExtendedStorageID({
storageID,
storageVersion,
});
// callLinkRecords must have rootKey
if (!callLinkRecord.rootKey) {
return { hasConflict: false, shouldDrop: true, details: ['no rootKey'] };
}
const details: Array<string> = [];
const rootKeyString = fromRootKeyBytes(callLinkRecord.rootKey);
const adminKeyString = callLinkRecord.adminPasskey
? fromAdminKeyBytes(callLinkRecord.adminPasskey)
: null;
const callLinkRootKey = CallLinkRootKey.parse(rootKeyString);
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const logId = `mergeCallLinkRecord(${redactedStorageID}, ${roomId})`;
const localCallLinkDbRecord =
await DataReader.getCallLinkRecordByRoomId(roomId);
const deletedAt: number | null =
callLinkRecord.deletedAtTimestampMs != null
? getTimestampFromLong(callLinkRecord.deletedAtTimestampMs)
: null;
const shouldDrop =
deletedAt != null &&
isOlderThan(deletedAt, CALL_LINK_DELETED_STORAGE_RECORD_TTL);
if (shouldDrop) {
details.push('expired deleted call link; scheduling for removal');
}
const callLinkDbRecord: CallLinkRecord = {
roomId,
rootKey: callLinkRecord.rootKey,
adminKey: callLinkRecord.adminPasskey ?? null,
name: localCallLinkDbRecord?.name ?? '',
restrictions: localCallLinkDbRecord?.restrictions ?? 0,
expiration: localCallLinkDbRecord?.expiration ?? null,
revoked: localCallLinkDbRecord?.revoked === 1 ? 1 : 0,
deleted: deletedAt ? 1 : 0,
deletedAt,
storageID,
storageVersion,
storageUnknownFields: callLinkRecord.$unknownFields
? Bytes.concatenate(callLinkRecord.$unknownFields)
: null,
storageNeedsSync: localCallLinkDbRecord?.storageNeedsSync === 1 ? 1 : 0,
};
if (!localCallLinkDbRecord) {
if (deletedAt) {
log.info(
`${logId}: Found deleted call link with no matching local record, skipping`
);
} else {
log.info(`${logId}: Discovered new call link, creating locally`);
details.push('creating call link');
// Create CallLink and call history item
const callLink = callLinkFromRecord(callLinkDbRecord);
const callHistory = toCallHistoryFromUnusedCallLink(callLink);
await Promise.all([
DataWriter.insertCallLink(callLink),
DataWriter.saveCallHistory(callHistory),
]);
// Refresh call link state via RingRTC and update in redux
window.reduxActions.calling.handleCallLinkUpdate({
rootKey: rootKeyString,
adminKey: adminKeyString,
});
window.reduxActions.callHistory.addCallHistory(callHistory);
}
return {
details,
hasConflict: false,
shouldDrop,
};
}
const oldStorageID = localCallLinkDbRecord.storageID || undefined;
const oldStorageVersion = localCallLinkDbRecord.storageVersion || undefined;
const needsToClearUnknownFields =
!callLinkRecord.$unknownFields &&
localCallLinkDbRecord.storageUnknownFields;
if (needsToClearUnknownFields) {
details.push('clearing unknown fields');
}
const isBadRemoteData = Boolean(deletedAt && adminKeyString);
if (isBadRemoteData) {
log.warn(
`${logId}: Found bad remote data: deletedAtTimestampMs and adminPasskey were both present. Assuming deleted.`
);
}
const { hasConflict, details: conflictDetails } = doRecordsConflict(
toCallLinkRecord(callLinkDbRecord),
callLinkRecord
);
// First update local record
details.push('updated');
const callLink = callLinkFromRecord(callLinkDbRecord);
await DataWriter.updateCallLink(callLink);
// Deleted in storage but we have it locally: Delete locally too and update redux
if (deletedAt && localCallLinkDbRecord.deleted !== 1) {
// 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);
window.reduxActions.calling.handleCallLinkDelete({ roomId });
} else if (!deletedAt && localCallLinkDbRecord.deleted === 1) {
// Not deleted in storage, but we've marked it as deleted locally.
// Skip doing anything, we will update things locally after sync.
log.warn(`${logId}: Found call link, but it was marked deleted locally.`);
} else {
window.reduxActions.calling.handleCallLinkUpdate({
rootKey: rootKeyString,
adminKey: adminKeyString,
});
}
return {
details: [...details, ...conflictDetails],
hasConflict,
shouldDrop,
oldStorageID,
oldStorageVersion,
};
}

View file

@ -31,12 +31,17 @@ import type {
CallHistoryPagination, CallHistoryPagination,
CallLogEventTarget, CallLogEventTarget,
} from '../types/CallDisposition'; } from '../types/CallDisposition';
import type { CallLinkStateType, CallLinkType } from '../types/CallLink'; import type {
CallLinkRecord,
CallLinkStateType,
CallLinkType,
} from '../types/CallLink';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements'; import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements';
import type { SyncTaskType } from '../util/syncTasks'; import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import type { DeleteCallLinkOptions } from './server/callLinks';
export type ReadableDB = Database & { __readable_db: never }; export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never };
@ -568,6 +573,8 @@ type ReadableInterface = {
callLinkExists(roomId: string): boolean; callLinkExists(roomId: string): boolean;
getAllCallLinks: () => ReadonlyArray<CallLinkType>; getAllCallLinks: () => ReadonlyArray<CallLinkType>;
getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined; getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined;
getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined;
getAllCallLinkRecordsWithAdminKey(): ReadonlyArray<CallLinkRecord>;
getAllMarkedDeletedCallLinks(): ReadonlyArray<CallLinkType>; getAllMarkedDeletedCallLinks(): ReadonlyArray<CallLinkType>;
getMessagesBetween: ( getMessagesBetween: (
conversationId: string, conversationId: string,
@ -799,13 +806,14 @@ type WritableInterface = {
markCallHistoryMissed(callIds: ReadonlyArray<string>): void; markCallHistoryMissed(callIds: ReadonlyArray<string>): void;
getRecentStaleRingsAndMarkOlderMissed(): ReadonlyArray<MaybeStaleCallHistory>; getRecentStaleRingsAndMarkOlderMissed(): ReadonlyArray<MaybeStaleCallHistory>;
insertCallLink(callLink: CallLinkType): void; insertCallLink(callLink: CallLinkType): void;
updateCallLink(callLink: CallLinkType): void;
updateCallLinkAdminKeyByRoomId(roomId: string, adminKey: string): void; updateCallLinkAdminKeyByRoomId(roomId: string, adminKey: string): void;
updateCallLinkState( updateCallLinkState(
roomId: string, roomId: string,
callLinkState: CallLinkStateType callLinkState: CallLinkStateType
): CallLinkType; ): CallLinkType;
beginDeleteAllCallLinks(): void; beginDeleteAllCallLinks(): void;
beginDeleteCallLink(roomId: string): void; beginDeleteCallLink(roomId: string, options: DeleteCallLinkOptions): void;
finalizeDeleteCallLink(roomId: string): void; finalizeDeleteCallLink(roomId: string): void;
_removeAllCallLinks(): void; _removeAllCallLinks(): void;
deleteCallLinkFromSync(roomId: string): void; deleteCallLinkFromSync(roomId: string): void;

View file

@ -174,10 +174,13 @@ import {
callLinkExists, callLinkExists,
getAllCallLinks, getAllCallLinks,
getCallLinkByRoomId, getCallLinkByRoomId,
getCallLinkRecordByRoomId,
insertCallLink, insertCallLink,
updateCallLink,
updateCallLinkAdminKeyByRoomId, updateCallLinkAdminKeyByRoomId,
updateCallLinkState, updateCallLinkState,
beginDeleteAllCallLinks, beginDeleteAllCallLinks,
getAllCallLinkRecordsWithAdminKey,
getAllMarkedDeletedCallLinks, getAllMarkedDeletedCallLinks,
finalizeDeleteCallLink, finalizeDeleteCallLink,
beginDeleteCallLink, beginDeleteCallLink,
@ -304,6 +307,8 @@ export const DataReader: ServerReadableInterface = {
callLinkExists, callLinkExists,
getAllCallLinks, getAllCallLinks,
getCallLinkByRoomId, getCallLinkByRoomId,
getCallLinkRecordByRoomId,
getAllCallLinkRecordsWithAdminKey,
getAllMarkedDeletedCallLinks, getAllMarkedDeletedCallLinks,
getMessagesBetween, getMessagesBetween,
getNearbyMessageFromDeletedSet, getNearbyMessageFromDeletedSet,
@ -439,6 +444,7 @@ export const DataWriter: ServerWritableInterface = {
saveCallHistory, saveCallHistory,
markCallHistoryMissed, markCallHistoryMissed,
insertCallLink, insertCallLink,
updateCallLink,
updateCallLinkAdminKeyByRoomId, updateCallLinkAdminKeyByRoomId,
updateCallLinkState, updateCallLinkState,
beginDeleteAllCallLinks, beginDeleteAllCallLinks,
@ -6445,6 +6451,14 @@ function eraseStorageServiceState(db: WritableDB): void {
storageVersion = null, storageVersion = null,
storageUnknownFields = null, storageUnknownFields = null,
storageNeedsSync = 0; storageNeedsSync = 0;
-- Call links
UPDATE callLinks
SET
storageID = null,
storageVersion = null,
storageUnknownFields = null,
storageNeedsSync = 0;
`); `);
} }

View file

@ -0,0 +1,38 @@
// 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 = 1190;
export function updateToSchemaVersion1190(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 1190) {
return;
}
db.transaction(() => {
db.exec(`
ALTER TABLE callLinks ADD COLUMN storageID TEXT;
ALTER TABLE callLinks ADD COLUMN storageVersion INTEGER;
ALTER TABLE callLinks ADD COLUMN storageUnknownFields BLOB;
ALTER TABLE callLinks ADD COLUMN storageNeedsSync INTEGER NOT NULL DEFAULT 0;
ALTER TABLE callLinks ADD COLUMN deletedAt INTEGER;
`);
db.prepare(
`
UPDATE callLinks
SET deletedAt = $deletedAt
WHERE deleted = 1;
`
).run({
deletedAt: new Date().getTime(),
});
db.pragma('user_version = 1190');
})();
logger.info('updateToSchemaVersion1190: success!');
}

View file

@ -94,10 +94,11 @@ import { updateToSchemaVersion1140 } from './1140-call-links-deleted-column';
import { updateToSchemaVersion1150 } from './1150-expire-timer-version'; import { updateToSchemaVersion1150 } from './1150-expire-timer-version';
import { updateToSchemaVersion1160 } from './1160-optimize-calls-unread-count'; import { updateToSchemaVersion1160 } from './1160-optimize-calls-unread-count';
import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-index'; import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-index';
import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source';
import { import {
updateToSchemaVersion1180, updateToSchemaVersion1190,
version as MAX_VERSION, version as MAX_VERSION,
} from './1180-add-attachment-download-source'; } from './1190-call-links-storage';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -2060,6 +2061,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1160, updateToSchemaVersion1160,
updateToSchemaVersion1170, updateToSchemaVersion1170,
updateToSchemaVersion1180, updateToSchemaVersion1180,
updateToSchemaVersion1190,
]; ];
export class DBVersionFromFutureError extends Error { export class DBVersionFromFutureError extends Error {

View file

@ -2,7 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { CallLinkRootKey } from '@signalapp/ringrtc'; import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkStateType, CallLinkType } from '../../types/CallLink'; import type {
CallLinkRecord,
CallLinkStateType,
CallLinkType,
} from '../../types/CallLink';
import { import {
callLinkRestrictionsSchema, callLinkRestrictionsSchema,
callLinkRecordSchema, callLinkRecordSchema,
@ -31,6 +35,19 @@ export function getCallLinkByRoomId(
db: ReadableDB, db: ReadableDB,
roomId: string roomId: string
): CallLinkType | undefined { ): CallLinkType | undefined {
const callLinkRecord = getCallLinkRecordByRoomId(db, roomId);
if (!callLinkRecord) {
return undefined;
}
return callLinkFromRecord(callLinkRecord);
}
// When you need to access all the fields (such as deleted and storage fields)
export function getCallLinkRecordByRoomId(
db: ReadableDB,
roomId: string
): CallLinkRecord | undefined {
const row = prepare(db, 'SELECT * FROM callLinks WHERE roomId = $roomId').get( const row = prepare(db, 'SELECT * FROM callLinks WHERE roomId = $roomId').get(
{ {
roomId, roomId,
@ -41,7 +58,7 @@ export function getCallLinkByRoomId(
return undefined; return undefined;
} }
return callLinkFromRecord(callLinkRecordSchema.parse(row)); return callLinkRecordSchema.parse(row);
} }
export function getAllCallLinks(db: ReadableDB): ReadonlyArray<CallLinkType> { export function getAllCallLinks(db: ReadableDB): ReadonlyArray<CallLinkType> {
@ -69,7 +86,11 @@ function _insertCallLink(db: WritableDB, callLink: CallLinkType): void {
name, name,
restrictions, restrictions,
revoked, revoked,
expiration expiration,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync
) VALUES ( ) VALUES (
$roomId, $roomId,
$rootKey, $rootKey,
@ -77,7 +98,11 @@ function _insertCallLink(db: WritableDB, callLink: CallLinkType): void {
$name, $name,
$restrictions, $restrictions,
$revoked, $revoked,
$expiration $expiration,
$storageID,
$storageVersion,
$storageUnknownFields,
$storageNeedsSync
) )
` `
).run(data); ).run(data);
@ -87,6 +112,30 @@ export function insertCallLink(db: WritableDB, callLink: CallLinkType): void {
_insertCallLink(db, callLink); _insertCallLink(db, callLink);
} }
export function updateCallLink(db: WritableDB, callLink: CallLinkType): void {
const { roomId, rootKey } = callLink;
assertRoomIdMatchesRootKey(roomId, rootKey);
const data = callLinkToRecord(callLink);
// Do not write roomId or rootKey since they should never change
db.prepare(
`
UPDATE callLinks
SET
adminKey = $adminKey,
name = $name,
restrictions = $restrictions,
revoked = $revoked,
expiration = $expiration,
storageID = $storageID,
storageVersion = $storageVersion,
storageUnknownFields = $storageUnknownFields,
storageNeedsSync = $storageNeedsSync
WHERE roomId = $roomId
`
).run(data);
}
export function updateCallLinkState( export function updateCallLinkState(
db: WritableDB, db: WritableDB,
roomId: string, roomId: string,
@ -167,7 +216,16 @@ export function deleteCallLinkFromSync(db: WritableDB, roomId: string): void {
})(); })();
} }
export function beginDeleteCallLink(db: WritableDB, roomId: string): void { export type DeleteCallLinkOptions = {
storageNeedsSync: boolean;
deletedAt?: number;
};
export function beginDeleteCallLink(
db: WritableDB,
roomId: string,
options: DeleteCallLinkOptions
): void {
db.transaction(() => { db.transaction(() => {
// If adminKey is null, then we should delete the call link // If adminKey is null, then we should delete the call link
const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql` const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql`
@ -182,11 +240,17 @@ export function beginDeleteCallLink(db: WritableDB, roomId: string): void {
// Skip this query if the call is already deleted // Skip this query if the call is already deleted
if (result.changes === 0) { 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 // If the admin key is not null, we should mark it for deletion
const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
sql` sql`
UPDATE callLinks UPDATE callLinks
SET deleted = 1 SET
deleted = 1,
deletedAt = ${deletedAt},
storageNeedsSync = ${storageNeedsSync ? 1 : 0}
WHERE adminKey IS NOT NULL WHERE adminKey IS NOT NULL
AND roomId = ${roomId}; AND roomId = ${roomId};
`; `;
@ -201,14 +265,21 @@ export function beginDeleteCallLink(db: WritableDB, roomId: string): void {
} }
export function beginDeleteAllCallLinks(db: WritableDB): void { export function beginDeleteAllCallLinks(db: WritableDB): void {
const deletedAt = new Date().getTime();
db.transaction(() => { db.transaction(() => {
const [markAdminCallLinksDeletedQuery] = sql` const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
sql`
UPDATE callLinks UPDATE callLinks
SET deleted = 1 SET
deleted = 1,
deletedAt = ${deletedAt},
storageNeedsSync = 1
WHERE adminKey IS NOT NULL; WHERE adminKey IS NOT NULL;
`; `;
db.prepare(markAdminCallLinksDeletedQuery).run(); db.prepare(markAdminCallLinksDeletedQuery).run(
markAdminCallLinksDeletedParams
);
const [deleteNonAdminCallLinksQuery] = sql` const [deleteNonAdminCallLinksQuery] = sql`
DELETE FROM callLinks DELETE FROM callLinks
@ -219,6 +290,21 @@ export function beginDeleteAllCallLinks(db: WritableDB): void {
})(); })();
} }
// When you need to access the deleted field
export function getAllCallLinkRecordsWithAdminKey(
db: ReadableDB
): ReadonlyArray<CallLinkRecord> {
const [query] = sql`
SELECT * FROM callLinks
WHERE adminKey IS NOT NULL
AND rootKey IS NOT NULL;
`;
return db
.prepare(query)
.all()
.map(item => callLinkRecordSchema.parse(item));
}
export function getAllMarkedDeletedCallLinks( export function getAllMarkedDeletedCallLinks(
db: ReadableDB db: ReadableDB
): ReadonlyArray<CallLinkType> { ): ReadonlyArray<CallLinkType> {
@ -231,9 +317,13 @@ export function getAllMarkedDeletedCallLinks(
.map(item => callLinkFromRecord(callLinkRecordSchema.parse(item))); .map(item => callLinkFromRecord(callLinkRecordSchema.parse(item)));
} }
// TODO: Run this after uploading storage records, maybe periodically on startup
export function finalizeDeleteCallLink(db: WritableDB, roomId: string): void { export function finalizeDeleteCallLink(db: WritableDB, roomId: string): void {
const [query, params] = sql` const [query, params] = sql`
DELETE FROM callLinks WHERE roomId = ${roomId} AND deleted = 1; DELETE FROM callLinks
WHERE roomId = ${roomId}
AND deleted = 1
AND storageNeedsSync = 0;
`; `;
db.prepare(query).run(params); db.prepare(query).run(params);
} }

View file

@ -6,7 +6,7 @@ import {
hasScreenCapturePermission, hasScreenCapturePermission,
openSystemPreferences, openSystemPreferences,
} from 'mac-screen-capture-permissions'; } from 'mac-screen-capture-permissions';
import { omit, pick } from 'lodash'; import { omit } from 'lodash';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import { import {
CallLinkRootKey, CallLinkRootKey,
@ -98,6 +98,7 @@ import type { CallHistoryDetails } from '../../types/CallDisposition';
import type { StartCallData } from '../../components/ConfirmLeaveCallModal'; import type { StartCallData } from '../../components/ConfirmLeaveCallModal';
import { callLinksDeleteJobQueue } from '../../jobs/callLinksDeleteJobQueue'; import { callLinksDeleteJobQueue } from '../../jobs/callLinksDeleteJobQueue';
import { getCallLinksByRoomId } from '../selectors/calling'; import { getCallLinksByRoomId } from '../selectors/calling';
import { storageServiceUploadJob } from '../../services/storage';
// State // State
@ -1425,25 +1426,31 @@ function handleCallLinkUpdate(
const logId = `handleCallLinkUpdate(${roomId})`; const logId = `handleCallLinkUpdate(${roomId})`;
const freshCallLinkState = await calling.readCallLink(callLinkRootKey); const freshCallLinkState = await calling.readCallLink(callLinkRootKey);
const existingCallLink = await DataReader.getCallLinkByRoomId(roomId);
// Only give up when server confirms the call link is gone. If we fail to fetch // Only give up when server confirms the call link is gone. If we fail to fetch
// state due to unexpected errors, continue to save rootKey and adminKey. // state due to unexpected errors, continue to save rootKey and adminKey.
if (freshCallLinkState == null) { if (freshCallLinkState == null) {
log.info(`${logId}: Call link not found, ignoring`); log.info(`${logId}: Call link not found on server`);
if (!existingCallLink) {
return;
}
// If the call link is gone remotely (for example if it expired on the server),
// then delete local call link.
log.info(`${logId}: Deleting existing call link`);
await DataWriter.beginDeleteCallLink(roomId, {
storageNeedsSync: true,
});
storageServiceUploadJob();
handleCallLinkDelete({ roomId });
return; return;
} }
const existingCallLink = await DataReader.getCallLinkByRoomId(roomId);
const existingCallLinkState = pick(existingCallLink, [
'name',
'restrictions',
'expiration',
'revoked',
]);
const callLink: CallLinkType = { const callLink: CallLinkType = {
...CALL_LINK_DEFAULT_STATE, ...CALL_LINK_DEFAULT_STATE,
...existingCallLinkState, storageNeedsSync: false,
...existingCallLink,
...freshCallLinkState, ...freshCallLinkState,
roomId, roomId,
rootKey, rootKey,
@ -1482,6 +1489,17 @@ function handleCallLinkUpdate(
}; };
} }
function handleCallLinkUpdateLocal(
callLink: CallLinkType
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return dispatch => {
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
};
}
function handleCallLinkDelete( function handleCallLinkDelete(
payload: HandleCallLinkDeleteType payload: HandleCallLinkDeleteType
): ThunkAction<void, RootStateType, unknown, HandleCallLinkDeleteActionType> { ): ThunkAction<void, RootStateType, unknown, HandleCallLinkDeleteActionType> {
@ -1990,6 +2008,9 @@ function createCallLink(
DataWriter.insertCallLink(callLink), DataWriter.insertCallLink(callLink),
DataWriter.saveCallHistory(callHistory), DataWriter.saveCallHistory(callHistory),
]); ]);
storageServiceUploadJob();
dispatch({ dispatch({
type: HANDLE_CALL_LINK_UPDATE, type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink }, payload: { callLink },
@ -2004,7 +2025,8 @@ function deleteCallLink(
roomId: string roomId: string
): ThunkAction<void, RootStateType, unknown, HandleCallLinkDeleteActionType> { ): ThunkAction<void, RootStateType, unknown, HandleCallLinkDeleteActionType> {
return async dispatch => { return async dispatch => {
await DataWriter.beginDeleteCallLink(roomId); await DataWriter.beginDeleteCallLink(roomId, { storageNeedsSync: true });
storageServiceUploadJob();
await callLinksDeleteJobQueue.add({ source: 'deleteCallLink' }); await callLinksDeleteJobQueue.add({ source: 'deleteCallLink' });
dispatch(handleCallLinkDelete({ roomId })); dispatch(handleCallLinkDelete({ roomId }));
}; };
@ -2171,6 +2193,7 @@ const _startCallLinkLobby = async ({
restrictions, restrictions,
revoked, revoked,
expiration, expiration,
storageNeedsSync: false,
}); });
log.info('startCallLinkLobby: Saved new call link', roomId); log.info('startCallLinkLobby: Saved new call link', roomId);
} }
@ -2446,6 +2469,7 @@ export const actions = {
groupCallStateChange, groupCallStateChange,
hangUpActiveCall, hangUpActiveCall,
handleCallLinkUpdate, handleCallLinkUpdate,
handleCallLinkUpdateLocal,
handleCallLinkDelete, handleCallLinkDelete,
joinedAdhocCall, joinedAdhocCall,
leaveCurrentCallAndStartCallingLobby, leaveCurrentCallAndStartCallingLobby,
@ -2670,6 +2694,7 @@ export function reducer(
callLinks[conversationId]?.rootKey ?? callLinks[conversationId]?.rootKey ??
action.payload.callLinkRootKey, action.payload.callLinkRootKey,
adminKey: callLinks[conversationId]?.adminKey, adminKey: callLinks[conversationId]?.adminKey,
storageNeedsSync: false,
}, },
} }
: callLinks, : callLinks,

View file

@ -13,6 +13,10 @@ export const FAKE_CALL_LINK: CallLinkType = {
revoked: false, revoked: false,
roomId: 'd517b48dd118bee24068d4938886c8abe192706d84936d52594a9157189d2759', roomId: 'd517b48dd118bee24068d4938886c8abe192706d84936d52594a9157189d2759',
rootKey: 'dxbb-xfqz-xkgp-nmrx-bpqn-ptkb-spdt-pdgt', rootKey: 'dxbb-xfqz-xkgp-nmrx-bpqn-ptkb-spdt-pdgt',
storageID: undefined,
storageVersion: undefined,
storageUnknownFields: undefined,
storageNeedsSync: false,
}; };
// Please set expiration // Please set expiration
@ -24,6 +28,10 @@ export const FAKE_CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
revoked: false, revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4', roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg', rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
storageID: undefined,
storageVersion: undefined,
storageUnknownFields: undefined,
storageNeedsSync: false,
}; };
export function getCallLinkState(callLink: CallLinkType): CallLinkStateType { export function getCallLinkState(callLink: CallLinkType): CallLinkStateType {

View file

@ -73,6 +73,10 @@ describe('backup/calling', () => {
restrictions: CallLinkRestrictions.AdminApproval, restrictions: CallLinkRestrictions.AdminApproval,
revoked: false, revoked: false,
expiration: null, expiration: null,
storageID: undefined,
storageVersion: undefined,
storageUnknownFields: undefined,
storageNeedsSync: false,
}; };
await DataWriter.insertCallLink(callLink); await DataWriter.insertCallLink(callLink);
@ -201,6 +205,10 @@ describe('backup/calling', () => {
restrictions: CallLinkRestrictions.AdminApproval, restrictions: CallLinkRestrictions.AdminApproval,
revoked: false, revoked: false,
expiration: null, expiration: null,
storageID: undefined,
storageVersion: undefined,
storageUnknownFields: undefined,
storageNeedsSync: false,
}; };
await DataWriter.insertCallLink(callLinkNoAdmin); await DataWriter.insertCallLink(callLinkNoAdmin);

View file

@ -1368,6 +1368,10 @@ describe('calling duck', () => {
roomId, roomId,
rootKey, rootKey,
adminKey, adminKey,
storageID: undefined,
storageVersion: undefined,
storageUnknownFields: undefined,
storageNeedsSync: false,
}, },
}, },
}); });
@ -1388,6 +1392,10 @@ describe('calling duck', () => {
roomId, roomId,
rootKey, rootKey,
adminKey: 'banana', adminKey: 'banana',
storageID: undefined,
storageVersion: undefined,
storageUnknownFields: undefined,
storageNeedsSync: false,
}, },
}, },
}); });

View file

@ -115,4 +115,27 @@ describe('Proto#$unknownFields', () => {
assert.strictEqual(decoded.c, 42); assert.strictEqual(decoded.c, 42);
assert.strictEqual(Buffer.from(decoded.d).toString(), 'ohai'); assert.strictEqual(Buffer.from(decoded.d).toString(), 'ohai');
}); });
it('should not set unknown fields if all fields were known', () => {
const encoded = Partial.encode({
a: 'hello',
c: 42,
}).finish();
const decoded = Full.decode(encoded);
assert.strictEqual(decoded.a, 'hello');
assert.strictEqual(decoded.c, 42);
assert.isUndefined(decoded.$unknownFields);
const encodedWithEmptyArray = Partial.encode({
a: 'hi',
c: 69,
$unkownFields: [],
}).finish();
const decodedWithEmptyArray = Full.decode(encodedWithEmptyArray);
assert.strictEqual(decodedWithEmptyArray.a, 'hi');
assert.strictEqual(decodedWithEmptyArray.c, 69);
assert.isUndefined(decodedWithEmptyArray.$unknownFields);
});
}); });

View file

@ -3557,10 +3557,6 @@ export default class MessageReceiver
callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.UPDATE callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.UPDATE
) { ) {
callLinkUpdateSyncType = CallLinkUpdateSyncType.Update; callLinkUpdateSyncType = CallLinkUpdateSyncType.Update;
} else if (
callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.DELETE
) {
callLinkUpdateSyncType = CallLinkUpdateSyncType.Delete;
} else { } else {
throw new Error( throw new Error(
`MessageReceiver.handleCallLinkUpdate: unknown type ${callLinkUpdate.type}` `MessageReceiver.handleCallLinkUpdate: unknown type ${callLinkUpdate.type}`

View file

@ -5,6 +5,7 @@ import { z } from 'zod';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import { safeParseInteger } from '../util/numbers'; import { safeParseInteger } from '../util/numbers';
import { byteLength } from '../Bytes'; import { byteLength } from '../Bytes';
import type { StorageServiceFieldsType } from '../sql/Interface';
export enum CallLinkUpdateSyncType { export enum CallLinkUpdateSyncType {
Update = 'Update', Update = 'Update',
@ -61,7 +62,8 @@ export type CallLinkType = Readonly<{
// Guaranteed from RingRTC readCallLink, but locally may be null immediately after // Guaranteed from RingRTC readCallLink, but locally may be null immediately after
// CallLinkUpdate sync and before readCallLink // CallLinkUpdate sync and before readCallLink
expiration: number | null; expiration: number | null;
}>; }> &
StorageServiceFieldsType;
export type CallLinkStateType = Pick< export type CallLinkStateType = Pick<
CallLinkType, CallLinkType,
@ -86,6 +88,12 @@ export type CallLinkRecord = Readonly<{
restrictions: number; restrictions: number;
expiration: number | null; expiration: number | null;
revoked: 1 | 0; // sqlite's version of boolean revoked: 1 | 0; // sqlite's version of boolean
deleted?: 1 | 0;
deletedAt?: number | null;
storageID: string | null;
storageVersion: number | null;
storageUnknownFields: Uint8Array | null;
storageNeedsSync: 1 | 0;
}>; }>;
export const callLinkRecordSchema = z.object({ export const callLinkRecordSchema = z.object({
@ -98,6 +106,12 @@ export const callLinkRecordSchema = z.object({
restrictions: callLinkRestrictionsSchema, restrictions: callLinkRestrictionsSchema,
expiration: z.number().int().nullable(), expiration: z.number().int().nullable(),
revoked: z.union([z.literal(1), z.literal(0)]), revoked: z.union([z.literal(1), z.literal(0)]),
deleted: z.union([z.literal(1), z.literal(0)]).optional(),
deletedAt: z.number().int().nullable().optional(),
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<CallLinkRecord>; }) satisfies z.ZodType<CallLinkRecord>;
export function isCallLinkAdmin(callLink: CallLinkType): boolean { export function isCallLinkAdmin(callLink: CallLinkType): boolean {

View file

@ -67,6 +67,7 @@ import type { ConversationModel } from '../models/conversations';
import { drop } from './drop'; import { drop } from './drop';
import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync'; import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync';
import { callLinksDeleteJobQueue } from '../jobs/callLinksDeleteJobQueue'; import { callLinksDeleteJobQueue } from '../jobs/callLinksDeleteJobQueue';
import { storageServiceUploadJob } from '../services/storage';
// utils // utils
// ----- // -----
@ -1303,6 +1304,7 @@ export async function clearCallHistoryDataAndSync(
); );
const messageIds = await DataWriter.clearCallHistory(latestCall); const messageIds = await DataWriter.clearCallHistory(latestCall);
await DataWriter.beginDeleteAllCallLinks(); await DataWriter.beginDeleteAllCallLinks();
storageServiceUploadJob();
updateDeletedMessages(messageIds); updateDeletedMessages(messageIds);
log.info('clearCallHistory: Queueing sync message'); log.info('clearCallHistory: Queueing sync message');
await singleProtoJobQueue.add( await singleProtoJobQueue.add(

View file

@ -15,14 +15,18 @@ import {
type CallHistoryDetails, type CallHistoryDetails,
CallMode, CallMode,
} from '../types/CallDisposition'; } from '../types/CallDisposition';
import { DAY } from './durations';
export const CALL_LINK_DEFAULT_STATE = { export const CALL_LINK_DEFAULT_STATE: Partial<CallLinkType> = {
name: '', name: '',
restrictions: CallLinkRestrictions.Unknown, restrictions: CallLinkRestrictions.Unknown,
revoked: false, revoked: false,
expiration: null, expiration: null,
storageNeedsSync: false,
}; };
export const CALL_LINK_DELETED_STORAGE_RECORD_TTL = 30 * DAY;
export function getKeyFromCallLink(callLink: string): string { export function getKeyFromCallLink(callLink: string): string {
const url = new URL(callLink); const url = new URL(callLink);
if (url == null) { if (url == null) {

View file

@ -136,6 +136,10 @@ export function callLinkFromRecord(record: CallLinkRecord): CallLinkType {
restrictions: toCallLinkRestrictions(record.restrictions), restrictions: toCallLinkRestrictions(record.restrictions),
revoked: record.revoked === 1, revoked: record.revoked === 1,
expiration: record.expiration, expiration: record.expiration,
storageID: record.storageID || undefined,
storageVersion: record.storageVersion || undefined,
storageUnknownFields: record.storageUnknownFields || undefined,
storageNeedsSync: record.storageNeedsSync === 1,
}; };
} }
@ -156,5 +160,9 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
restrictions: callLink.restrictions, restrictions: callLink.restrictions,
revoked: callLink.revoked ? 1 : 0, revoked: callLink.revoked ? 1 : 0,
expiration: callLink.expiration, expiration: callLink.expiration,
storageID: callLink.storageID || null,
storageVersion: callLink.storageVersion || null,
storageUnknownFields: callLink.storageUnknownFields || null,
storageNeedsSync: callLink.storageNeedsSync ? 1 : 0,
}); });
} }

View file

@ -21,15 +21,6 @@ export async function sendCallLinkUpdateSync(
return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Update); return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Update);
} }
/**
* Underlying sync message is CallLinkUpdate with type set to DELETE.
*/
export async function sendCallLinkDeleteSync(
callLink: sendCallLinkUpdateSyncCallLinkType
): Promise<void> {
return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Delete);
}
async function _sendCallLinkUpdateSync( async function _sendCallLinkUpdateSync(
callLink: sendCallLinkUpdateSyncCallLinkType, callLink: sendCallLinkUpdateSyncCallLinkType,
type: CallLinkUpdateSyncType type: CallLinkUpdateSyncType
@ -37,8 +28,6 @@ async function _sendCallLinkUpdateSync(
let protoType: Proto.SyncMessage.CallLinkUpdate.Type; let protoType: Proto.SyncMessage.CallLinkUpdate.Type;
if (type === CallLinkUpdateSyncType.Update) { if (type === CallLinkUpdateSyncType.Update) {
protoType = Proto.SyncMessage.CallLinkUpdate.Type.UPDATE; protoType = Proto.SyncMessage.CallLinkUpdate.Type.UPDATE;
} else if (type === CallLinkUpdateSyncType.Delete) {
protoType = Proto.SyncMessage.CallLinkUpdate.Type.DELETE;
} else { } else {
throw new Error(`sendCallLinkUpdateSync: unknown type ${type}`); throw new Error(`sendCallLinkUpdateSync: unknown type ${type}`);
} }