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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 LaItgknk+#81v$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 zcmeaUye4%`kKZdI%XGDINPQ=R>i&3k73q5dWgU~JZ~h{rIn!gAt(_m!{|kr28~&`T z5NN4={VM5#<;u4GnJHfTb}V9f!y75qdTs6Vi?^)&{~Pu)%&KkBxMHC6D`~gh_TN7M D>uN2l 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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 b{hu%P^%HMgQtG+8GA_lmVCJ)fi_QQ5h9qS9 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 zcmcb$d-J{p$2Ol { @@ -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;