diff --git a/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d-0.bin b/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d-0.bin new file mode 100644 index 000000000..307346bbf Binary files /dev/null and b/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d-0.bin differ diff --git a/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d.bin b/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d.bin new file mode 100644 index 000000000..493763dc3 Binary files /dev/null and b/fixtures/stickerpack-ae8fedafda4768fd3384d4b3b9db963d.bin differ diff --git a/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin b/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin new file mode 100644 index 000000000..efd38e059 Binary files /dev/null and b/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc-0.bin differ diff --git a/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin b/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin new file mode 100644 index 000000000..84777c902 Binary files /dev/null and b/fixtures/stickerpack-c40ed069cdc2b91eccfccf25e6bcddfc.bin differ diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 205a1476e..7ad5d74ce 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -40,6 +40,7 @@ message ManifestRecord { GROUPV2 = 3; ACCOUNT = 4; STORY_DISTRIBUTION_LIST = 5; + STICKER_PACK = 6; } optional bytes raw = 1; @@ -59,6 +60,7 @@ message StorageRecord { GroupV2Record groupV2 = 3; AccountRecord account = 4; StoryDistributionListRecord storyDistributionList = 5; + StickerPackRecord stickerPack = 6; } } @@ -158,3 +160,24 @@ message StoryDistributionListRecord { optional bool allowsReplies = 5; optional bool isBlockList = 6; } + +message StickerPackRecord { + optional bytes packId = 1; // 16 bytes + optional bytes packKey = 2; // 32 bytes, used to derive the AES-256 key + // aesKey = HKDF( + // input = packKey, + // salt = 32 zero bytes, + // info = "Sticker Pack" + // ) + optional uint32 position = 3; // When displayed sticker packs should be first sorted + // in descending order by zero-based `position` and + // then by ascending `packId` (lexicographically, + // packId can be treated as a hex string). + // When installing a sticker pack the client should find + // the maximum `position` among currently known stickers + // and use `max_position + 1` as the value for the new + // `position`. + optional uint64 deletedAtTimestamp = 4; // Timestamp in milliseconds. When present and + // non-zero - `packKey` and `position` should + // be unset +} diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 0e61a56f3..4216edb08 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -21,11 +21,13 @@ import { mergeGroupV1Record, mergeGroupV2Record, mergeStoryDistributionListRecord, + mergeStickerPackRecord, toAccountRecord, toContactRecord, toGroupV1Record, toGroupV2Record, toStoryDistributionListRecord, + toStickerPackRecord, } from './storageRecordOps'; import type { MergeResultType } from './storageRecordOps'; import { MAX_READ_KEYS } from './storageConstants'; @@ -52,7 +54,12 @@ import type { UnknownRecord, } from '../types/StorageService.d'; import MessageSender from '../textsecure/SendMessage'; -import type { StoryDistributionWithMembersType } from '../sql/Interface'; +import type { + StoryDistributionWithMembersType, + StorageServiceFieldsType, + StickerPackType, + UninstalledStickerPackType, +} from '../sql/Interface'; import { MY_STORIES_ID } from '../types/Stories'; type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier; @@ -322,11 +329,15 @@ async function generateManifest( } } - const storyDistributionLists = - await dataInterface.getAllStoryDistributionsWithMembers(); + const { + storyDistributionLists, + installedStickerPacks, + uninstalledStickerPacks, + } = await getNonConversationRecords(); log.info( - `storageService.upload(${version}): adding storyDistributionLists=${storyDistributionLists.length}` + `storageService.upload(${version}): ` + + `adding storyDistributionLists=${storyDistributionLists.length}` ); storyDistributionLists.forEach(storyDistributionList => { @@ -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;