From b47a906211de34739bfe82f26c0d2a91f6ee282f Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 3 Aug 2022 10:10:49 -0700 Subject: [PATCH] Stickers in storage service --- ...ack-ae8fedafda4768fd3384d4b3b9db963d-0.bin | Bin 0 -> 25536 bytes ...rpack-ae8fedafda4768fd3384d4b3b9db963d.bin | Bin 0 -> 96 bytes ...ack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin | Bin 0 -> 14176 bytes ...rpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin | Bin 0 -> 96 bytes protos/SignalStorage.proto | 23 ++ ts/services/storage.ts | 265 +++++++++++++-- ts/services/storageRecordOps.ts | 146 +++++++- ts/sql/Client.ts | 52 ++- ts/sql/Interface.ts | 73 ++-- ts/sql/Server.ts | 270 ++++++++++++++- .../65-add-storage-id-to-stickers.ts | 62 ++++ ts/sql/migrations/index.ts | 2 + ts/sql/util.ts | 4 +- ts/state/ducks/stickers.ts | 41 ++- ts/test-mock/storage/fixtures.ts | 2 + ts/test-mock/storage/sticker_test.ts | 312 ++++++++++++++++++ ts/test-node/sql_migrations_test.ts | 32 ++ ts/types/Stickers.ts | 12 +- 18 files changed, 1216 insertions(+), 80 deletions(-) create mode 100644 fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d-0.bin create mode 100644 fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d.bin create mode 100644 fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin create mode 100644 fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin create mode 100644 ts/sql/migrations/65-add-storage-id-to-stickers.ts create mode 100644 ts/test-mock/storage/sticker_test.ts diff --git a/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d-0.bin b/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d-0.bin new file mode 100644 index 0000000000000000000000000000000000000000..307346bbffbdae7afbff2e6b600e0df75cee0c69 GIT binary patch literal 25536 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

GN3&tGG)MhQ z01welk>0ec?lu_rwkrO`&zD}@U>m39dIeLl7GmgC<@ZzUzturQX6sP`q``ls&fI$ zgz!t2X#!z18-8#?PwJ#^5FrUWn0QD@nh%WZA`Jo60vQlRt+>O2w9p+{oU>T zL85O_`OqIw<<%N6SAbsI?D~?hxsohB;>ma?L9qtzf2y8sxvt2~&m*bRnNi`BOy~!@ zHk|d0F4L}KWJ|385aZSXL~N`_~!3pg&p}213eTR7`Uz(Q2=PY zjqd@z6wl!tHV*#sd6&O&K0nQCa{HcBeBsK_xwY}9?xz~8>(;ePl)*eq(mVBctmFm{#uC3ScD=Vzv_Gr~r zyQbW!4t=LTwnX4 TLw4~n6-Qld3Bb{bt*mMh1v$B` literal 0 HcmV?d00001 diff --git a/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d.bin b/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d.bin new file mode 100644 index 0000000000000000000000000000000000000000..493763dc3b88fdd8714813549b2b585734358292 GIT binary patch literal 96 zcmV-m0H6OzIo2B1E)PmrYBj4fRDX#8CBI(>Bpcokc8HUgv-A=vnMkHRK28Gv(83ph z{H}NqgnjGkV$eCMhQDcKO1`+F1ndo27KPTW=h50aPyaEE0GfS(DAX__`eM5-xBK|( CYAve( literal 0 HcmV?d00001 diff --git a/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin b/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin new file mode 100644 index 0000000000000000000000000000000000000000..efd38e0596040449b8cdfd8322c0e7bc389d60d2 GIT binary patch literal 14176 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 literal 0 HcmV?d00001 diff --git a/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin b/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin new file mode 100644 index 0000000000000000000000000000000000000000..84777c9026bd4541033fb286bed18f153f8fb143 GIT binary patch literal 96 zcmV-m0H6QbyR*Ka#~6ZsQ4qtOK4FDh+6Bb$Ppq)ps*jwaCLk(LV~K5$;$L2zm*@*RO_PUB { @@ -355,6 +366,81 @@ async function generateManifest( } }); + log.info( + `storageService.upload(${version}): ` + + `adding uninstalled stickerPacks=${uninstalledStickerPacks.length}` + ); + + const uninstalledStickerPackIds = new Set(); + + uninstalledStickerPacks.forEach(stickerPack => { + const storageRecord = new Proto.StorageRecord(); + storageRecord.stickerPack = toStickerPackRecord(stickerPack); + + uninstalledStickerPackIds.add(stickerPack.id); + + const { isNewItem, storageID } = processStorageRecord({ + currentStorageID: stickerPack.storageID, + currentStorageVersion: stickerPack.storageVersion, + identifierType: ITEM_TYPE.STICKER_PACK, + storageNeedsSync: stickerPack.storageNeedsSync, + storageRecord, + }); + + if (isNewItem) { + postUploadUpdateFunctions.push(() => { + dataInterface.addUninstalledStickerPack({ + ...stickerPack, + storageID, + storageVersion: version, + storageNeedsSync: false, + }); + }); + } + }); + + log.info( + `storageService.upload(${version}): ` + + `adding installed stickerPacks=${installedStickerPacks.length}` + ); + + installedStickerPacks.forEach(stickerPack => { + if (uninstalledStickerPackIds.has(stickerPack.id)) { + log.error( + `storageService.upload(${version}): ` + + `sticker pack ${stickerPack.id} is both installed and uninstalled` + ); + window.reduxActions.stickers.uninstallStickerPack( + stickerPack.id, + stickerPack.key, + { fromSync: true } + ); + return; + } + + const storageRecord = new Proto.StorageRecord(); + storageRecord.stickerPack = toStickerPackRecord(stickerPack); + + const { isNewItem, storageID } = processStorageRecord({ + currentStorageID: stickerPack.storageID, + currentStorageVersion: stickerPack.storageVersion, + identifierType: ITEM_TYPE.STICKER_PACK, + storageNeedsSync: stickerPack.storageNeedsSync, + storageRecord, + }); + + if (isNewItem) { + postUploadUpdateFunctions.push(() => { + dataInterface.createOrUpdateStickerPack({ + ...stickerPack, + storageID, + storageVersion: version, + storageNeedsSync: false, + }); + }); + } + }); + const unknownRecordsArray: ReadonlyArray = ( window.storage.get('storage-service-unknown-records') || [] ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); @@ -858,6 +944,15 @@ async function mergeRecord( storageVersion, storageRecord.storyDistributionList ); + } else if ( + itemType === ITEM_TYPE.STICKER_PACK && + storageRecord.stickerPack + ) { + mergeResult = await mergeStickerPackRecord( + storageID, + storageVersion, + storageRecord.stickerPack + ); } else { isUnsupported = true; log.warn( @@ -914,6 +1009,31 @@ async function mergeRecord( }; } +type NonConversationRecordsResultType = Readonly<{ + installedStickerPacks: ReadonlyArray; + uninstalledStickerPacks: ReadonlyArray; + storyDistributionLists: ReadonlyArray; +}>; + +// TODO: DESKTOP-3929 +async function getNonConversationRecords(): Promise { + const [ + storyDistributionLists, + uninstalledStickerPacks, + installedStickerPacks, + ] = await Promise.all([ + dataInterface.getAllStoryDistributionsWithMembers(), + dataInterface.getUninstalledStickerPacks(), + dataInterface.getInstalledStickerPacks(), + ]); + + return { + storyDistributionLists, + uninstalledStickerPacks, + installedStickerPacks, + }; +} + async function processManifest( manifest: Proto.IManifestRecord, version: number @@ -930,6 +1050,7 @@ async function processManifest( const remoteKeys = new Set(remoteKeysTypeMap.keys()); const localVersions = new Map(); + let localRecordCount = 0; const conversations = window.getConversations(); conversations.forEach((conversation: ConversationModel) => { @@ -938,6 +1059,33 @@ async function processManifest( localVersions.set(storageID, conversation.get('storageVersion')); } }); + localRecordCount += conversations.length; + + { + const { + storyDistributionLists, + installedStickerPacks, + uninstalledStickerPacks, + } = await getNonConversationRecords(); + + const collectLocalKeysFromFields = ({ + storageID, + storageVersion, + }: StorageServiceFieldsType): void => { + if (storageID) { + localVersions.set(storageID, storageVersion); + } + }; + + storyDistributionLists.forEach(collectLocalKeysFromFields); + localRecordCount += storyDistributionLists.length; + + uninstalledStickerPacks.forEach(collectLocalKeysFromFields); + localRecordCount += uninstalledStickerPacks.length; + + installedStickerPacks.forEach(collectLocalKeysFromFields); + localRecordCount += installedStickerPacks.length; + } const unknownRecordsArray: ReadonlyArray = window.storage.get('storage-service-unknown-records') || []; @@ -973,7 +1121,7 @@ async function processManifest( ); log.info( - `storageService.process(${version}): localRecords=${conversations.length} ` + + `storageService.process(${version}): localRecords=${localRecordCount} ` + `localKeys=${localVersions.size} unknownKeys=${stillUnknown.length} ` + `remoteKeys=${remoteKeys.size}` ); @@ -1025,33 +1173,96 @@ async function processManifest( } }); - // Check to make sure we have a "My Stories" distribution list set up - const myStories = await dataInterface.getStoryDistributionWithMembers( - MY_STORIES_ID - ); + // Refetch various records post-merge + { + const { + storyDistributionLists, + installedStickerPacks, + uninstalledStickerPacks, + } = await getNonConversationRecords(); - if (!myStories) { - const storyDistribution: StoryDistributionWithMembersType = { - allowsReplies: true, - id: MY_STORIES_ID, - isBlockList: true, - members: [], - name: MY_STORIES_ID, - senderKeyInfo: undefined, - storageNeedsSync: true, - }; + uninstalledStickerPacks.forEach(stickerPack => { + const { storageID, storageVersion } = stickerPack; + if (!storageID || remoteKeys.has(storageID)) { + return; + } - await dataInterface.createNewStoryDistribution(storyDistribution); + const missingKey = redactStorageID(storageID, storageVersion); + log.info( + `storageService.process(${version}): localKey=${missingKey} was not ` + + 'in remote manifest' + ); + dataInterface.addUninstalledStickerPack({ + ...stickerPack, + storageID: undefined, + storageVersion: undefined, + }); + }); - const shouldSave = false; - window.reduxActions.storyDistributionLists.createDistributionList( - storyDistribution.name, - storyDistribution.members, - storyDistribution, - shouldSave + installedStickerPacks.forEach(stickerPack => { + const { storageID, storageVersion } = stickerPack; + if (!storageID || remoteKeys.has(storageID)) { + return; + } + + const missingKey = redactStorageID(storageID, storageVersion); + log.info( + `storageService.process(${version}): localKey=${missingKey} was not ` + + 'in remote manifest' + ); + dataInterface.createOrUpdateStickerPack({ + ...stickerPack, + storageID: undefined, + storageVersion: undefined, + }); + }); + + storyDistributionLists.forEach(storyDistributionList => { + const { storageID, storageVersion } = storyDistributionList; + if (!storageID || remoteKeys.has(storageID)) { + return; + } + + const missingKey = redactStorageID(storageID, storageVersion); + log.info( + `storageService.process(${version}): localKey=${missingKey} was not ` + + 'in remote manifest' + ); + dataInterface.modifyStoryDistribution({ + ...storyDistributionList, + storageID: undefined, + storageVersion: undefined, + }); + }); + + // Check to make sure we have a "My Stories" distribution list set up + const myStories = storyDistributionLists.find( + ({ id }) => id === MY_STORIES_ID ); - conflictCount += 1; + if (!myStories) { + const storyDistribution: StoryDistributionWithMembersType = { + allowsReplies: true, + id: MY_STORIES_ID, + isBlockList: true, + members: [], + name: MY_STORIES_ID, + senderKeyInfo: undefined, + storageNeedsSync: true, + }; + + await dataInterface.createNewStoryDistribution(storyDistribution); + + const shouldSave = false; + window.reduxActions.storyDistributionLists.createDistributionList( + storyDistribution.name, + storyDistribution.members, + storyDistribution, + shouldSave + ); + + conflictCount += 1; + } } log.info( diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index f0418fbef..4e41df887 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -45,7 +45,11 @@ import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { UUIDStringType } from '../types/UUID'; import { MY_STORIES_ID } from '../types/Stories'; -import type { StoryDistributionWithMembersType } from '../sql/Interface'; +import * as Stickers from '../types/Stickers'; +import type { + StoryDistributionWithMembersType, + StickerPackInfoType, +} from '../sql/Interface'; import dataInterface from '../sql/Client'; type RecordClass = @@ -411,6 +415,31 @@ export function toStoryDistributionListRecord( return storyDistributionListRecord; } +export function toStickerPackRecord( + stickerPack: StickerPackInfoType +): Proto.StickerPackRecord { + const stickerPackRecord = new Proto.StickerPackRecord(); + + stickerPackRecord.packId = Bytes.fromHex(stickerPack.id); + + if (stickerPack.uninstalledAt !== undefined) { + stickerPackRecord.deletedAtTimestamp = Long.fromNumber( + stickerPack.uninstalledAt + ); + } else { + stickerPackRecord.packKey = Bytes.fromBase64(stickerPack.key); + if (stickerPack.position) { + stickerPackRecord.position = stickerPack.position; + } + } + + if (stickerPack.storageUnknownFields) { + stickerPackRecord.__unknownFields = [stickerPack.storageUnknownFields]; + } + + return stickerPackRecord; +} + type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record; function applyMessageRequestState( @@ -1355,3 +1384,118 @@ export async function mergeStoryDistributionListRecord( oldStorageVersion, }; } + +export async function mergeStickerPackRecord( + storageID: string, + storageVersion: number, + stickerPackRecord: Proto.IStickerPackRecord +): Promise { + if (!stickerPackRecord.packId || Bytes.isEmpty(stickerPackRecord.packId)) { + throw new Error(`No stickerPackRecord identifier for ${storageID}`); + } + + const details: Array = []; + const id = Bytes.toHex(stickerPackRecord.packId); + + const localStickerPack = await dataInterface.getStickerPackInfo(id); + + if (stickerPackRecord.__unknownFields) { + details.push('adding unknown fields'); + } + const storageUnknownFields = stickerPackRecord.__unknownFields + ? Bytes.concatenate(stickerPackRecord.__unknownFields) + : null; + + let stickerPack: StickerPackInfoType; + if (stickerPackRecord.deletedAtTimestamp?.toNumber()) { + stickerPack = { + id, + uninstalledAt: stickerPackRecord.deletedAtTimestamp.toNumber(), + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync: false, + }; + } else { + if ( + !stickerPackRecord.packKey || + Bytes.isEmpty(stickerPackRecord.packKey) + ) { + throw new Error(`No stickerPackRecord key for ${storageID}`); + } + + stickerPack = { + id, + key: Bytes.toBase64(stickerPackRecord.packKey), + position: + 'position' in stickerPackRecord + ? stickerPackRecord.position + : localStickerPack?.position ?? undefined, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync: false, + }; + } + + const oldStorageID = localStickerPack?.storageID; + const oldStorageVersion = localStickerPack?.storageVersion; + + const needsToClearUnknownFields = + !stickerPack.storageUnknownFields && localStickerPack?.storageUnknownFields; + + if (needsToClearUnknownFields) { + details.push('clearing unknown fields'); + } + + const { hasConflict, details: conflictDetails } = doRecordsConflict( + toStickerPackRecord(stickerPack), + stickerPackRecord + ); + + const wasUninstalled = Boolean(localStickerPack?.uninstalledAt); + const isUninstalled = Boolean(stickerPack.uninstalledAt); + + details.push( + `wasUninstalled=${wasUninstalled}`, + `isUninstalled=${isUninstalled}`, + `oldPosition=${localStickerPack?.position ?? '?'}`, + `newPosition=${stickerPack.position ?? '?'}` + ); + + if ((!localStickerPack || !wasUninstalled) && isUninstalled) { + assert(localStickerPack?.key, 'Installed sticker pack has no key'); + window.reduxActions.stickers.uninstallStickerPack( + localStickerPack.id, + localStickerPack.key, + { fromStorageService: true } + ); + } else if ((!localStickerPack || wasUninstalled) && !isUninstalled) { + assert(stickerPack.key, 'Sticker pack does not have key'); + + const status = Stickers.getStickerPackStatus(stickerPack.id); + if (status === 'downloaded') { + window.reduxActions.stickers.installStickerPack( + stickerPack.id, + stickerPack.key, + { + fromStorageService: true, + } + ); + } else { + Stickers.downloadStickerPack(stickerPack.id, stickerPack.key, { + finalStatus: 'installed', + fromStorageService: true, + }); + } + } + + await dataInterface.updateStickerPackInfo(stickerPack); + + return { + details: [...details, ...conflictDetails], + hasConflict, + oldStorageID, + oldStorageVersion, + }; +} diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 2b79ae7e2..4a0563af4 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -84,6 +84,7 @@ import type { SignedPreKeyType, StoredSignedPreKeyType, StickerPackStatusType, + StickerPackInfoType, StickerPackType, StickerType, StoryDistributionMemberType, @@ -92,6 +93,7 @@ import type { StoryReadType, UnprocessedType, UnprocessedUpdateType, + UninstalledStickerPackType, } from './Interface'; import Server from './Server'; import { isCorruptionError } from './errors'; @@ -277,6 +279,7 @@ const dataInterface: ClientInterface = { createOrUpdateStickerPack, updateStickerPackStatus, + updateStickerPackInfo, createOrUpdateSticker, updateStickerLastUsed, addStickerPackReference, @@ -284,6 +287,13 @@ const dataInterface: ClientInterface = { getStickerCount, deleteStickerPack, getAllStickerPacks, + addUninstalledStickerPack, + removeUninstalledStickerPack, + getInstalledStickerPacks, + getUninstalledStickerPacks, + installStickerPack, + uninstallStickerPack, + getStickerPackInfo, getAllStickers, getRecentStickers, clearAllErrorStickerPackAttempts, @@ -1601,6 +1611,9 @@ async function updateStickerPackStatus( ): Promise { await channels.updateStickerPackStatus(packId, status, options); } +async function updateStickerPackInfo(info: StickerPackInfoType): Promise { + await channels.updateStickerPackInfo(info); +} async function createOrUpdateSticker(sticker: StickerType): Promise { await channels.createOrUpdateSticker(sticker); } @@ -1609,7 +1622,7 @@ async function updateStickerLastUsed( stickerId: number, timestamp: number ): Promise { - await channels.updateStickerLastUsed(packId, stickerId, timestamp); + return channels.updateStickerLastUsed(packId, stickerId, timestamp); } async function addStickerPackReference( messageId: string, @@ -1624,15 +1637,46 @@ async function deleteStickerPackReference( return channels.deleteStickerPackReference(messageId, packId); } async function deleteStickerPack(packId: string): Promise> { - const paths = await channels.deleteStickerPack(packId); - - return paths; + return channels.deleteStickerPack(packId); } async function getAllStickerPacks(): Promise> { const packs = await channels.getAllStickerPacks(); return packs; } +async function addUninstalledStickerPack( + pack: UninstalledStickerPackType +): Promise { + return channels.addUninstalledStickerPack(pack); +} +async function removeUninstalledStickerPack(packId: string): Promise { + return channels.removeUninstalledStickerPack(packId); +} +async function getInstalledStickerPacks(): Promise> { + return channels.getInstalledStickerPacks(); +} +async function getUninstalledStickerPacks(): Promise< + Array +> { + return channels.getUninstalledStickerPacks(); +} +async function installStickerPack( + packId: string, + timestamp: number +): Promise { + return channels.installStickerPack(packId, timestamp); +} +async function uninstallStickerPack( + packId: string, + timestamp: number +): Promise { + return channels.uninstallStickerPack(packId, timestamp); +} +async function getStickerPackInfo( + packId: string +): Promise { + return channels.getStickerPackInfo(packId); +} async function getAllStickers(): Promise> { const stickers = await channels.getAllStickers(); diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index a8f2ad460..3fe6b09ee 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -202,22 +202,49 @@ export const StickerPackStatuses = [ export type StickerPackStatusType = typeof StickerPackStatuses[number]; -export type StickerPackType = Readonly<{ +export type StorageServiceFieldsType = Readonly<{ + storageID?: string; + storageVersion?: number; + storageUnknownFields?: Uint8Array | null; + storageNeedsSync: boolean; +}>; + +export type InstalledStickerPackType = Readonly<{ id: string; key: string; - attemptedStatus?: 'downloaded' | 'installed' | 'ephemeral'; - author: string; - coverStickerId: number; - createdAt: number; - downloadAttempts: number; - installedAt?: number; - lastUsed?: number; - status: StickerPackStatusType; - stickerCount: number; - stickers: Record; - title: string; -}>; + uninstalledAt?: undefined; + position?: number | null; +}> & + StorageServiceFieldsType; + +export type UninstalledStickerPackType = Readonly<{ + id: string; + key?: undefined; + + uninstalledAt: number; + position?: undefined; +}> & + StorageServiceFieldsType; + +export type StickerPackInfoType = + | InstalledStickerPackType + | UninstalledStickerPackType; + +export type StickerPackType = InstalledStickerPackType & + Readonly<{ + attemptedStatus?: 'downloaded' | 'installed' | 'ephemeral'; + author: string; + coverStickerId: number; + createdAt: number; + downloadAttempts: number; + installedAt?: number; + lastUsed?: number; + status: StickerPackStatusType; + stickerCount: number; + stickers: Record; + title: string; + }>; export type UnprocessedType = { id: string; @@ -267,12 +294,8 @@ export type StoryDistributionType = Readonly<{ allowsReplies: boolean; isBlockList: boolean; senderKeyInfo: SenderKeyInfoType | undefined; - - storageID?: string; - storageVersion?: number; - storageUnknownFields?: Uint8Array | null; - storageNeedsSync: boolean; -}>; +}> & + StorageServiceFieldsType; export type StoryDistributionMemberType = Readonly<{ listId: UUIDStringType; uuid: UUIDStringType; @@ -543,6 +566,7 @@ export type DataInterface = { status: StickerPackStatusType, options?: { timestamp: number } ) => Promise; + updateStickerPackInfo: (info: StickerPackInfoType) => Promise; createOrUpdateSticker: (sticker: StickerType) => Promise; updateStickerLastUsed: ( packId: string, @@ -557,6 +581,17 @@ export type DataInterface = { getStickerCount: () => Promise; deleteStickerPack: (packId: string) => Promise>; getAllStickerPacks: () => Promise>; + addUninstalledStickerPack: ( + pack: UninstalledStickerPackType + ) => Promise; + removeUninstalledStickerPack: (packId: string) => Promise; + getInstalledStickerPacks: () => Promise>; + getUninstalledStickerPacks: () => Promise>; + installStickerPack: (packId: string, timestamp: number) => Promise; + uninstallStickerPack: (packId: string, timestamp: number) => Promise; + getStickerPackInfo: ( + packId: string + ) => Promise; getAllStickers: () => Promise>; getRecentStickers: (options?: { limit?: number; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 85ab7eba9..176ef953e 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -81,6 +81,7 @@ import type { GetUnreadByConversationAndMarkReadResultType, IdentityKeyIdType, StoredIdentityKeyType, + InstalledStickerPackType, ItemKeyType, StoredItemType, ConversationMessageStatsType, @@ -104,6 +105,7 @@ import type { SessionType, SignedPreKeyIdType, StoredSignedPreKeyType, + StickerPackInfoType, StickerPackStatusType, StickerPackType, StickerType, @@ -111,6 +113,7 @@ import type { StoryDistributionType, StoryDistributionWithMembersType, StoryReadType, + UninstalledStickerPackType, UnprocessedType, UnprocessedUpdateType, } from './Interface'; @@ -268,6 +271,7 @@ const dataInterface: ServerInterface = { createOrUpdateStickerPack, updateStickerPackStatus, + updateStickerPackInfo, createOrUpdateSticker, updateStickerLastUsed, addStickerPackReference, @@ -275,6 +279,13 @@ const dataInterface: ServerInterface = { getStickerCount, deleteStickerPack, getAllStickerPacks, + addUninstalledStickerPack, + removeUninstalledStickerPack, + getInstalledStickerPacks, + getUninstalledStickerPacks, + installStickerPack, + uninstallStickerPack, + getStickerPackInfo, getAllStickers, getRecentStickers, clearAllErrorStickerPackAttempts, @@ -3446,6 +3457,10 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { status, stickerCount, title, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync, } = pack; if (!id) { throw new Error( @@ -3453,7 +3468,22 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { ); } - const rows = db + let { position } = pack; + + // Assign default position + if (!isNumber(position)) { + position = db + .prepare( + ` + SELECT IFNULL(MAX(position) + 1, 0) + FROM sticker_packs + ` + ) + .pluck() + .get(); + } + + const row = db .prepare( ` SELECT id @@ -3461,7 +3491,7 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { WHERE id = $id; ` ) - .all({ id }); + .get({ id }); const payload = { attemptedStatus: attemptedStatus ?? null, author, @@ -3475,9 +3505,14 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { status, stickerCount, title, + position: position ?? 0, + storageID: storageID ?? null, + storageVersion: storageVersion ?? null, + storageUnknownFields: storageUnknownFields ?? null, + storageNeedsSync: storageNeedsSync ? 1 : 0, }; - if (rows && rows.length) { + if (row) { db.prepare( ` UPDATE sticker_packs SET @@ -3491,7 +3526,12 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { lastUsed = $lastUsed, status = $status, stickerCount = $stickerCount, - title = $title + title = $title, + position = $position, + storageID = $storageID, + storageVersion = $storageVersion, + storageUnknownFields = $storageUnknownFields, + storageNeedsSync = $storageNeedsSync WHERE id = $id; ` ).run(payload); @@ -3513,7 +3553,12 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { lastUsed, status, stickerCount, - title + title, + position, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync ) values ( $attemptedStatus, $author, @@ -3526,16 +3571,21 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise { $lastUsed, $status, $stickerCount, - $title + $title, + $position, + $storageID, + $storageVersion, + $storageUnknownFields, + $storageNeedsSync ) ` ).run(payload); } -async function updateStickerPackStatus( +function updateStickerPackStatusSync( id: string, status: StickerPackStatusType, options?: { timestamp: number } -): Promise { +): void { const db = getInstance(); const timestamp = options ? options.timestamp || Date.now() : Date.now(); const installedAt = status === 'installed' ? timestamp : null; @@ -3552,6 +3602,61 @@ async function updateStickerPackStatus( installedAt, }); } +async function updateStickerPackStatus( + id: string, + status: StickerPackStatusType, + options?: { timestamp: number } +): Promise { + return updateStickerPackStatusSync(id, status, options); +} +async function updateStickerPackInfo({ + id, + storageID, + storageVersion, + storageUnknownFields, + storageNeedsSync, + uninstalledAt, +}: StickerPackInfoType): Promise { + const db = getInstance(); + + if (uninstalledAt) { + db.prepare( + ` + UPDATE uninstalled_sticker_packs + SET + storageID = $storageID, + storageVersion = $storageVersion, + storageUnknownFields = $storageUnknownFields, + storageNeedsSync = $storageNeedsSync + WHERE id = $id; + ` + ).run({ + id, + storageID: storageID ?? null, + storageVersion: storageVersion ?? null, + storageUnknownFields: storageUnknownFields ?? null, + storageNeedsSync: storageNeedsSync ? 1 : 0, + }); + } else { + db.prepare( + ` + UPDATE sticker_packs + SET + storageID = $storageID, + storageVersion = $storageVersion, + storageUnknownFields = $storageUnknownFields, + storageNeedsSync = $storageNeedsSync + WHERE id = $id; + ` + ).run({ + id, + storageID: storageID ?? null, + storageVersion: storageVersion ?? null, + storageUnknownFields: storageUnknownFields ?? null, + storageNeedsSync: storageNeedsSync ? 1 : 0, + }); + } +} async function clearAllErrorStickerPackAttempts(): Promise { const db = getInstance(); @@ -3823,13 +3928,160 @@ async function getAllStickerPacks(): Promise> { .prepare( ` SELECT * FROM sticker_packs - ORDER BY installedAt DESC, createdAt DESC + ORDER BY position ASC, id ASC ` ) .all(); return rows || []; } +function addUninstalledStickerPackSync(pack: UninstalledStickerPackType): void { + const db = getInstance(); + + db.prepare( + ` + INSERT OR REPLACE INTO uninstalled_sticker_packs + ( + id, uninstalledAt, storageID, storageVersion, storageUnknownFields, + storageNeedsSync + ) + VALUES + ( + $id, $uninstalledAt, $storageID, $storageVersion, $unknownFields, + $storageNeedsSync + ) + ` + ).run({ + id: pack.id, + uninstalledAt: pack.uninstalledAt, + storageID: pack.storageID ?? null, + storageVersion: pack.storageVersion ?? null, + unknownFields: pack.storageUnknownFields ?? null, + storageNeedsSync: pack.storageNeedsSync ? 1 : 0, + }); +} +async function addUninstalledStickerPack( + pack: UninstalledStickerPackType +): Promise { + return addUninstalledStickerPackSync(pack); +} +function removeUninstalledStickerPackSync(packId: string): void { + const db = getInstance(); + + db.prepare( + 'DELETE FROM uninstalled_sticker_packs WHERE id IS $id' + ).run({ id: packId }); +} +async function removeUninstalledStickerPack(packId: string): Promise { + return removeUninstalledStickerPackSync(packId); +} +async function getUninstalledStickerPacks(): Promise< + Array +> { + const db = getInstance(); + + const rows = db + .prepare( + 'SELECT * FROM uninstalled_sticker_packs ORDER BY id ASC' + ) + .all(); + + return rows || []; +} +async function getInstalledStickerPacks(): Promise> { + const db = getInstance(); + + // If sticker pack has a storageID - it is being downloaded and about to be + // installed so we better sync it back to storage service if asked. + const rows = db + .prepare( + ` + SELECT * + FROM sticker_packs + WHERE + status IS "installed" OR + storageID IS NOT NULL + ORDER BY id ASC + ` + ) + .all(); + + return rows || []; +} +async function getStickerPackInfo( + packId: string +): Promise { + const db = getInstance(); + + return db.transaction(() => { + const uninstalled = db + .prepare( + ` + SELECT * FROM uninstalled_sticker_packs + WHERE id IS $packId + ` + ) + .get({ packId }); + if (uninstalled) { + return uninstalled as UninstalledStickerPackType; + } + + const installed = db + .prepare( + ` + SELECT + id, key, position, storageID, storageVersion, storageUnknownFields + FROM sticker_packs + WHERE id IS $packId + ` + ) + .get({ packId }); + if (installed) { + return installed as InstalledStickerPackType; + } + + return undefined; + })(); +} +async function installStickerPack( + packId: string, + timestamp: number +): Promise { + const db = getInstance(); + return db.transaction(() => { + const status = 'installed'; + updateStickerPackStatusSync(packId, status, { timestamp }); + + removeUninstalledStickerPackSync(packId); + })(); +} +async function uninstallStickerPack( + packId: string, + timestamp: number +): Promise { + const db = getInstance(); + return db.transaction(() => { + const status = 'downloaded'; + updateStickerPackStatusSync(packId, status); + + db.prepare( + ` + UPDATE sticker_packs SET + storageID = NULL, + storageVersion = NULL, + storageUnknownFields = NULL, + storageNeedsSync = 0 + WHERE id = $packId; + ` + ).run({ packId }); + + addUninstalledStickerPackSync({ + id: packId, + uninstalledAt: timestamp, + storageNeedsSync: true, + }); + })(); +} async function getAllStickers(): Promise> { const db = getInstance(); diff --git a/ts/sql/migrations/65-add-storage-id-to-stickers.ts b/ts/sql/migrations/65-add-storage-id-to-stickers.ts new file mode 100644 index 000000000..155af5ca1 --- /dev/null +++ b/ts/sql/migrations/65-add-storage-id-to-stickers.ts @@ -0,0 +1,62 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion65( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 65) { + return; + } + + db.transaction(() => { + db.exec( + ` + ALTER TABLE sticker_packs ADD COLUMN position INTEGER DEFAULT 0 NOT NULL; + ALTER TABLE sticker_packs ADD COLUMN storageID STRING; + ALTER TABLE sticker_packs ADD COLUMN storageVersion INTEGER; + ALTER TABLE sticker_packs ADD COLUMN storageUnknownFields BLOB; + ALTER TABLE sticker_packs + ADD COLUMN storageNeedsSync + INTEGER DEFAULT 0 NOT NULL; + + CREATE TABLE uninstalled_sticker_packs ( + id STRING NOT NULL PRIMARY KEY, + uninstalledAt NUMBER NOT NULL, + storageID STRING, + storageVersion NUMBER, + storageUnknownFields BLOB, + storageNeedsSync INTEGER NOT NULL + ); + + -- Set initial position + + UPDATE sticker_packs + SET + position = (row_number - 1), + storageNeedsSync = 1 + FROM ( + SELECT id, row_number() OVER (ORDER BY lastUsed DESC) as row_number + FROM sticker_packs + ) as ordered_pairs + WHERE sticker_packs.id IS ordered_pairs.id; + + -- See: getAllStickerPacks + + CREATE INDEX sticker_packs_by_position_and_id ON sticker_packs ( + position ASC, + id ASC + ); + ` + ); + + db.pragma('user_version = 65'); + })(); + + logger.info('updateToSchemaVersion65: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 0e6e5c7c0..db8dac214 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -40,6 +40,7 @@ import updateToSchemaVersion61 from './61-distribution-list-storage'; import updateToSchemaVersion62 from './62-add-urgent-to-send-log'; import updateToSchemaVersion63 from './63-add-urgent-to-unprocessed'; import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys'; +import updateToSchemaVersion65 from './65-add-storage-id-to-stickers'; function updateToSchemaVersion1( currentVersion: number, @@ -1943,6 +1944,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion62, updateToSchemaVersion63, updateToSchemaVersion64, + updateToSchemaVersion65, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/sql/util.ts b/ts/sql/util.ts index 28008f93f..39bbcb4d0 100644 --- a/ts/sql/util.ts +++ b/ts/sql/util.ts @@ -6,7 +6,9 @@ import { isNumber, last } from 'lodash'; export type EmptyQuery = []; export type ArrayQuery = Array>; -export type Query = { [key: string]: null | number | bigint | string | Buffer }; +export type Query = { + [key: string]: null | number | bigint | string | Uint8Array; +}; export type JSONRows = Array<{ readonly json: string }>; export type TableType = diff --git a/ts/state/ducks/stickers.ts b/ts/state/ducks/stickers.ts index ab06e75e3..c34892136 100644 --- a/ts/state/ducks/stickers.ts +++ b/ts/state/ducks/stickers.ts @@ -14,13 +14,13 @@ import { downloadStickerPack as externalDownloadStickerPack, maybeDeletePack, } from '../../types/Stickers'; +import { storageServiceUploadJob } from '../../services/storage'; import { sendStickerPackSync } from '../../shims/textsecure'; import { trigger } from '../../shims/events'; import type { NoopActionType } from './noop'; -const { getRecentStickers, updateStickerLastUsed, updateStickerPackStatus } = - dataInterface; +const { getRecentStickers, updateStickerLastUsed } = dataInterface; // State @@ -204,7 +204,7 @@ function downloadStickerPack( function installStickerPack( packId: string, packKey: string, - options: { fromSync: boolean } | null = null + options: { fromSync?: boolean; fromStorageService?: boolean } = {} ): InstallStickerPackAction { return { type: 'stickers/INSTALL_STICKER_PACK', @@ -214,25 +214,28 @@ function installStickerPack( async function doInstallStickerPack( packId: string, packKey: string, - options: { fromSync: boolean } | null + options: { fromSync?: boolean; fromStorageService?: boolean } = {} ): Promise { - const { fromSync } = options || { fromSync: false }; + const { fromSync = false, fromStorageService = false } = options; - const status = 'installed'; const timestamp = Date.now(); - await updateStickerPackStatus(packId, status, { timestamp }); + await dataInterface.installStickerPack(packId, timestamp); - if (!fromSync) { + if (!fromSync && !fromStorageService) { // Kick this off, but don't wait for it sendStickerPackSync(packId, packKey, true); } + if (!fromStorageService) { + storageServiceUploadJob(); + } + const recentStickers = await getRecentStickers(); return { packId, fromSync, - status, + status: 'installed', installedAt: timestamp, recentStickers: recentStickers.map(item => ({ packId: item.packId, @@ -243,7 +246,7 @@ async function doInstallStickerPack( function uninstallStickerPack( packId: string, packKey: string, - options: { fromSync: boolean } | null = null + options: { fromSync?: boolean; fromStorageService?: boolean } = {} ): UninstallStickerPackAction { return { type: 'stickers/UNINSTALL_STICKER_PACK', @@ -253,27 +256,31 @@ function uninstallStickerPack( async function doUninstallStickerPack( packId: string, packKey: string, - options: { fromSync: boolean } | null + options: { fromSync?: boolean; fromStorageService?: boolean } = {} ): Promise { - const { fromSync } = options || { fromSync: false }; + const { fromSync = false, fromStorageService = false } = options; - const status = 'downloaded'; - await updateStickerPackStatus(packId, status); + const timestamp = Date.now(); + await dataInterface.uninstallStickerPack(packId, timestamp); // If there are no more references, it should be removed await maybeDeletePack(packId); - if (!fromSync) { + if (!fromSync && !fromStorageService) { // Kick this off, but don't wait for it sendStickerPackSync(packId, packKey, false); } + if (!fromStorageService) { + storageServiceUploadJob(); + } + const recentStickers = await getRecentStickers(); return { packId, fromSync, - status, + status: 'downloaded', installedAt: undefined, recentStickers: recentStickers.map(item => ({ packId: item.packId, @@ -313,7 +320,7 @@ function stickerPackUpdated( function useSticker( packId: string, stickerId: number, - time = Date.now() + time?: number ): UseStickerAction { return { type: 'stickers/USE_STICKER', diff --git a/ts/test-mock/storage/fixtures.ts b/ts/test-mock/storage/fixtures.ts index dd4af9f9f..d8e987a06 100644 --- a/ts/test-mock/storage/fixtures.ts +++ b/ts/test-mock/storage/fixtures.ts @@ -61,6 +61,7 @@ export async function initStorage( state = state.updateAccount({ profileKey: phone.profileKey.serialize(), e164: phone.device.number, + givenName: phone.profileName, }); state = state @@ -76,6 +77,7 @@ export async function initStorage( identityKey: contact.publicKey.serialize(), profileKey: contact.profileKey.serialize(), + givenName: contact.profileName, }); } diff --git a/ts/test-mock/storage/sticker_test.ts b/ts/test-mock/storage/sticker_test.ts new file mode 100644 index 000000000..8a04b02ac --- /dev/null +++ b/ts/test-mock/storage/sticker_test.ts @@ -0,0 +1,312 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { range } from 'lodash'; +import { Proto } from '@signalapp/mock-server'; +import type { StorageStateRecord } from '@signalapp/mock-server'; +import fs from 'fs/promises'; +import path from 'path'; + +import * as durations from '../../util/durations'; +import type { App, Bootstrap } from './fixtures'; +import { initStorage, debug } from './fixtures'; + +const { StickerPackOperation } = Proto.SyncMessage; + +const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures'); +const IdentifierType = Proto.ManifestRecord.Identifier.Type; + +const EMPTY = new Uint8Array(0); + +export type StickerPackType = Readonly<{ + id: Buffer; + key: Buffer; + stickerCount: number; +}>; + +const STICKER_PACKS: ReadonlyArray = [ + { + id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'), + key: Buffer.from( + 'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4', + 'hex' + ), + stickerCount: 1, + }, + { + id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'), + key: Buffer.from( + '53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca', + 'hex' + ), + stickerCount: 1, + }, +]; + +function getStickerPackLink(pack: StickerPackType): string { + return ( + `https://signal.art/addstickers/#pack_id=${pack.id.toString('hex')}&` + + `pack_key=${pack.key.toString('hex')}` + ); +} + +function getStickerPackRecordPredicate( + pack: StickerPackType +): (record: StorageStateRecord) => boolean { + return ({ type, record }: StorageStateRecord): boolean => { + if (type !== IdentifierType.STICKER_PACK) { + return false; + } + return pack.id.equals(record.stickerPack?.packId ?? EMPTY); + }; +} + +describe('storage service', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + + beforeEach(async () => { + ({ bootstrap, app } = await initStorage()); + + const { server } = bootstrap; + + await Promise.all( + STICKER_PACKS.map(async ({ id, stickerCount }) => { + const hexId = id.toString('hex'); + + await server.storeStickerPack({ + id, + manifest: await fs.readFile( + path.join(FIXTURES, `stickerpack-${hexId}.bin`) + ), + stickers: await Promise.all( + range(0, stickerCount).map(async index => + fs.readFile( + path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`) + ) + ) + ), + }); + }) + ); + }); + + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('should install/uninstall stickers', async () => { + const { phone, desktop, contacts } = bootstrap; + const [firstContact] = contacts; + + const window = await app.getWindow(); + + const leftPane = window.locator('.left-pane-wrapper'); + const conversationStack = window.locator('.conversation-stack'); + + debug('sending two sticker pack links'); + await firstContact.sendText( + desktop, + `First sticker pack ${getStickerPackLink(STICKER_PACKS[0])}` + ); + await firstContact.sendText( + desktop, + `Second sticker pack ${getStickerPackLink(STICKER_PACKS[1])}` + ); + + await leftPane + .locator( + '_react=ConversationListItem' + + `[title = ${JSON.stringify(firstContact.profileName)}]` + ) + .click(); + + { + debug('installing first sticker pack via UI'); + const state = await phone.expectStorageState('initial state'); + + await conversationStack + .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) + .click({ noWaitAfter: true }); + await window + .locator( + '.module-sticker-manager__preview-modal__container button >> "Install"' + ) + .click(); + + debug('waiting for sync message'); + const { syncMessage } = await phone.waitForSyncMessage(entry => + Boolean(entry.syncMessage.stickerPackOperation?.length) + ); + const [syncOp] = syncMessage.stickerPackOperation ?? []; + assert.isTrue(STICKER_PACKS[0].id.equals(syncOp?.packId ?? EMPTY)); + assert.isTrue(STICKER_PACKS[0].key.equals(syncOp?.packKey ?? EMPTY)); + assert.strictEqual(syncOp?.type, StickerPackOperation.Type.INSTALL); + + debug('waiting for storage service update'); + const stateAfter = await phone.waitForStorageState({ after: state }); + const stickerPack = stateAfter.findRecord( + getStickerPackRecordPredicate(STICKER_PACKS[0]) + ); + assert.ok( + stickerPack, + 'New storage state should have sticker pack record' + ); + assert.isTrue( + STICKER_PACKS[0].key.equals( + stickerPack?.record.stickerPack?.packKey ?? EMPTY + ), + 'Wrong sticker pack key' + ); + assert.strictEqual( + stickerPack?.record.stickerPack?.position, + 6, + 'Wrong sticker pack position' + ); + } + + { + debug('uninstalling first sticker pack via UI'); + const state = await phone.expectStorageState('initial state'); + + await conversationStack + .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) + .click({ noWaitAfter: true }); + await window + .locator( + '.module-sticker-manager__preview-modal__container button ' + + '>> "Uninstall"' + ) + .click(); + + // Confirm + await window.locator('.module-Modal button >> "Uninstall"').click(); + + debug('waiting for sync message'); + const { syncMessage } = await phone.waitForSyncMessage(entry => + Boolean(entry.syncMessage.stickerPackOperation?.length) + ); + const [syncOp] = syncMessage.stickerPackOperation ?? []; + assert.isTrue(STICKER_PACKS[0].id.equals(syncOp?.packId ?? EMPTY)); + assert.strictEqual(syncOp?.type, StickerPackOperation.Type.REMOVE); + + debug('waiting for storage service update'); + const stateAfter = await phone.waitForStorageState({ after: state }); + const stickerPack = stateAfter.findRecord( + getStickerPackRecordPredicate(STICKER_PACKS[0]) + ); + assert.ok( + stickerPack, + 'New storage state should have sticker pack record' + ); + assert.deepStrictEqual( + stickerPack?.record.stickerPack?.packKey, + EMPTY, + 'Sticker pack key should be removed' + ); + const deletedAt = + stickerPack?.record.stickerPack?.deletedAtTimestamp?.toNumber() ?? 0; + assert.isAbove( + deletedAt, + Date.now() - durations.HOUR, + 'Sticker pack should have deleted at timestamp' + ); + } + + debug('opening sticker picker'); + conversationStack + .locator('.CompositionArea .module-sticker-button__button') + .click(); + + const stickerPicker = conversationStack.locator('.module-sticker-picker'); + + { + debug('installing first sticker pack via storage service'); + const state = await phone.expectStorageState('initial state'); + + await phone.setStorageState( + state.updateRecord( + getStickerPackRecordPredicate(STICKER_PACKS[0]), + record => ({ + ...record, + stickerPack: { + ...record?.stickerPack, + packKey: STICKER_PACKS[0].key, + position: 7, + deletedAtTimestamp: undefined, + }, + }) + ) + ); + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + debug('waiting for sticker pack to become visible'); + stickerPicker + .locator( + 'button.module-sticker-picker__header__button' + + `[key="${STICKER_PACKS[0].id.toString('hex')}"]` + ) + .waitFor(); + } + + { + debug('installing second sticker pack via sync message'); + const state = await phone.expectStorageState('initial state'); + + await phone.sendStickerPackSync({ + type: 'install', + packId: STICKER_PACKS[1].id, + packKey: STICKER_PACKS[1].key, + timestamp: bootstrap.getTimestamp(), + }); + + debug('waiting for sticker pack to become visible'); + stickerPicker + .locator( + 'button.module-sticker-picker__header__button' + + `[key="${STICKER_PACKS[1].id.toString('hex')}"]` + ) + .waitFor(); + + debug('waiting for storage service update'); + const stateAfter = await phone.waitForStorageState({ after: state }); + const stickerPack = stateAfter.findRecord( + getStickerPackRecordPredicate(STICKER_PACKS[1]) + ); + assert.ok( + stickerPack, + 'New storage state should have sticker pack record' + ); + assert.isTrue( + STICKER_PACKS[1].key.equals( + stickerPack?.record.stickerPack?.packKey ?? EMPTY + ), + 'Wrong sticker pack key' + ); + assert.strictEqual( + stickerPack?.record.stickerPack?.position, + 6, + 'Wrong sticker pack position' + ); + } + + debug('Verifying the final manifest version'); + const finalState = await phone.expectStorageState('consistency check'); + + assert.strictEqual(finalState.version, 5); + }); +}); diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index 20baa9182..104e27a14 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -2357,4 +2357,36 @@ describe('SQL migrations test', () => { assert.strictEqual(payload.urgent, 1); }); }); + + describe('updateToSchemaVersion65', () => { + it('initializes sticker pack positions', () => { + updateToVersion(64); + + db.exec( + ` + INSERT INTO sticker_packs + (id, key, lastUsed) + VALUES + ("a", "key-1", 1), + ("b", "key-2", 2), + ("c", "key-3", 3); + ` + ); + + updateToVersion(65); + + assert.deepStrictEqual( + db + .prepare( + 'SELECT id, position FROM sticker_packs ORDER BY position DESC' + ) + .all(), + [ + { id: 'a', position: 2 }, + { id: 'b', position: 1 }, + { id: 'c', position: 0 }, + ] + ); + }); + }); }); diff --git a/ts/types/Stickers.ts b/ts/types/Stickers.ts index e8d5cec6f..d6286a11b 100644 --- a/ts/types/Stickers.ts +++ b/ts/types/Stickers.ts @@ -96,6 +96,8 @@ const STICKER_PACK_DEFAULTS: StickerPackType = { stickerCount: 0, stickers: {}, title: '', + + storageNeedsSync: false, }; const VALID_PACK_ID_REGEXP = /^[0-9a-f]{32}$/i; @@ -529,6 +531,7 @@ export async function downloadEphemeralPack( export type DownloadStickerPackOptions = Readonly<{ messageId?: string; fromSync?: boolean; + fromStorageService?: boolean; finalStatus?: StickerPackStatusType; suppressError?: boolean; }>; @@ -558,6 +561,7 @@ async function doDownloadStickerPack( finalStatus = 'downloaded', messageId, fromSync = false, + fromStorageService = false, suppressError = false, }: DownloadStickerPackOptions ): Promise { @@ -668,6 +672,7 @@ async function doDownloadStickerPack( status: 'pending', createdAt: Date.now(), stickers: {}, + storageNeedsSync: !fromStorageService, ...pick(proto, ['title', 'author']), }; await Data.createOrUpdateStickerPack(pack); @@ -748,7 +753,10 @@ async function doDownloadStickerPack( } if (finalStatus === 'installed') { - await installStickerPack(packId, packKey, { fromSync }); + await installStickerPack(packId, packKey, { + fromSync, + fromStorageService, + }); } else { // Mark the pack as complete await Data.updateStickerPackStatus(packId, finalStatus); @@ -888,7 +896,7 @@ export async function deletePackReference( } // The override; doesn't honor our ref-counting scheme - just deletes it all. -export async function deletePack(packId: string): Promise { +async function deletePack(packId: string): Promise { const isBlessed = Boolean(BLESSED_PACKS[packId]); if (isBlessed) { return;