Stickers in storage service

This commit is contained in:
Fedor Indutny 2022-08-03 10:10:49 -07:00 committed by GitHub
parent d8a7e99c81
commit b47a906211
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1216 additions and 80 deletions

View File

@ -40,6 +40,7 @@ message ManifestRecord {
GROUPV2 = 3; GROUPV2 = 3;
ACCOUNT = 4; ACCOUNT = 4;
STORY_DISTRIBUTION_LIST = 5; STORY_DISTRIBUTION_LIST = 5;
STICKER_PACK = 6;
} }
optional bytes raw = 1; optional bytes raw = 1;
@ -59,6 +60,7 @@ message StorageRecord {
GroupV2Record groupV2 = 3; GroupV2Record groupV2 = 3;
AccountRecord account = 4; AccountRecord account = 4;
StoryDistributionListRecord storyDistributionList = 5; StoryDistributionListRecord storyDistributionList = 5;
StickerPackRecord stickerPack = 6;
} }
} }
@ -158,3 +160,24 @@ message StoryDistributionListRecord {
optional bool allowsReplies = 5; optional bool allowsReplies = 5;
optional bool isBlockList = 6; 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
}

View File

@ -21,11 +21,13 @@ import {
mergeGroupV1Record, mergeGroupV1Record,
mergeGroupV2Record, mergeGroupV2Record,
mergeStoryDistributionListRecord, mergeStoryDistributionListRecord,
mergeStickerPackRecord,
toAccountRecord, toAccountRecord,
toContactRecord, toContactRecord,
toGroupV1Record, toGroupV1Record,
toGroupV2Record, toGroupV2Record,
toStoryDistributionListRecord, toStoryDistributionListRecord,
toStickerPackRecord,
} from './storageRecordOps'; } from './storageRecordOps';
import type { MergeResultType } from './storageRecordOps'; import type { MergeResultType } from './storageRecordOps';
import { MAX_READ_KEYS } from './storageConstants'; import { MAX_READ_KEYS } from './storageConstants';
@ -52,7 +54,12 @@ import type {
UnknownRecord, UnknownRecord,
} from '../types/StorageService.d'; } from '../types/StorageService.d';
import MessageSender from '../textsecure/SendMessage'; 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'; import { MY_STORIES_ID } from '../types/Stories';
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier; type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
@ -322,11 +329,15 @@ async function generateManifest(
} }
} }
const storyDistributionLists = const {
await dataInterface.getAllStoryDistributionsWithMembers(); storyDistributionLists,
installedStickerPacks,
uninstalledStickerPacks,
} = await getNonConversationRecords();
log.info( log.info(
`storageService.upload(${version}): adding storyDistributionLists=${storyDistributionLists.length}` `storageService.upload(${version}): ` +
`adding storyDistributionLists=${storyDistributionLists.length}`
); );
storyDistributionLists.forEach(storyDistributionList => { storyDistributionLists.forEach(storyDistributionList => {
@ -355,6 +366,81 @@ async function generateManifest(
} }
}); });
log.info(
`storageService.upload(${version}): ` +
`adding uninstalled stickerPacks=${uninstalledStickerPacks.length}`
);
const uninstalledStickerPackIds = new Set<string>();
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<UnknownRecord> = ( const unknownRecordsArray: ReadonlyArray<UnknownRecord> = (
window.storage.get('storage-service-unknown-records') || [] window.storage.get('storage-service-unknown-records') || []
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
@ -858,6 +944,15 @@ async function mergeRecord(
storageVersion, storageVersion,
storageRecord.storyDistributionList storageRecord.storyDistributionList
); );
} else if (
itemType === ITEM_TYPE.STICKER_PACK &&
storageRecord.stickerPack
) {
mergeResult = await mergeStickerPackRecord(
storageID,
storageVersion,
storageRecord.stickerPack
);
} else { } else {
isUnsupported = true; isUnsupported = true;
log.warn( log.warn(
@ -914,6 +1009,31 @@ async function mergeRecord(
}; };
} }
type NonConversationRecordsResultType = Readonly<{
installedStickerPacks: ReadonlyArray<StickerPackType>;
uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>;
storyDistributionLists: ReadonlyArray<StoryDistributionWithMembersType>;
}>;
// TODO: DESKTOP-3929
async function getNonConversationRecords(): Promise<NonConversationRecordsResultType> {
const [
storyDistributionLists,
uninstalledStickerPacks,
installedStickerPacks,
] = await Promise.all([
dataInterface.getAllStoryDistributionsWithMembers(),
dataInterface.getUninstalledStickerPacks(),
dataInterface.getInstalledStickerPacks(),
]);
return {
storyDistributionLists,
uninstalledStickerPacks,
installedStickerPacks,
};
}
async function processManifest( async function processManifest(
manifest: Proto.IManifestRecord, manifest: Proto.IManifestRecord,
version: number version: number
@ -930,6 +1050,7 @@ async function processManifest(
const remoteKeys = new Set(remoteKeysTypeMap.keys()); const remoteKeys = new Set(remoteKeysTypeMap.keys());
const localVersions = new Map<string, number | undefined>(); const localVersions = new Map<string, number | undefined>();
let localRecordCount = 0;
const conversations = window.getConversations(); const conversations = window.getConversations();
conversations.forEach((conversation: ConversationModel) => { conversations.forEach((conversation: ConversationModel) => {
@ -938,6 +1059,33 @@ async function processManifest(
localVersions.set(storageID, conversation.get('storageVersion')); 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<UnknownRecord> = const unknownRecordsArray: ReadonlyArray<UnknownRecord> =
window.storage.get('storage-service-unknown-records') || []; window.storage.get('storage-service-unknown-records') || [];
@ -973,7 +1121,7 @@ async function processManifest(
); );
log.info( log.info(
`storageService.process(${version}): localRecords=${conversations.length} ` + `storageService.process(${version}): localRecords=${localRecordCount} ` +
`localKeys=${localVersions.size} unknownKeys=${stillUnknown.length} ` + `localKeys=${localVersions.size} unknownKeys=${stillUnknown.length} ` +
`remoteKeys=${remoteKeys.size}` `remoteKeys=${remoteKeys.size}`
); );
@ -1025,33 +1173,96 @@ async function processManifest(
} }
}); });
// Check to make sure we have a "My Stories" distribution list set up // Refetch various records post-merge
const myStories = await dataInterface.getStoryDistributionWithMembers( {
MY_STORIES_ID const {
); storyDistributionLists,
installedStickerPacks,
uninstalledStickerPacks,
} = await getNonConversationRecords();
if (!myStories) { uninstalledStickerPacks.forEach(stickerPack => {
const storyDistribution: StoryDistributionWithMembersType = { const { storageID, storageVersion } = stickerPack;
allowsReplies: true, if (!storageID || remoteKeys.has(storageID)) {
id: MY_STORIES_ID, return;
isBlockList: true, }
members: [],
name: MY_STORIES_ID,
senderKeyInfo: undefined,
storageNeedsSync: true,
};
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; installedStickerPacks.forEach(stickerPack => {
window.reduxActions.storyDistributionLists.createDistributionList( const { storageID, storageVersion } = stickerPack;
storyDistribution.name, if (!storageID || remoteKeys.has(storageID)) {
storyDistribution.members, return;
storyDistribution, }
shouldSave
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( log.info(

View File

@ -45,7 +45,11 @@ import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import { MY_STORIES_ID } from '../types/Stories'; 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'; import dataInterface from '../sql/Client';
type RecordClass = type RecordClass =
@ -411,6 +415,31 @@ export function toStoryDistributionListRecord(
return storyDistributionListRecord; 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; type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record;
function applyMessageRequestState( function applyMessageRequestState(
@ -1355,3 +1384,118 @@ export async function mergeStoryDistributionListRecord(
oldStorageVersion, oldStorageVersion,
}; };
} }
export async function mergeStickerPackRecord(
storageID: string,
storageVersion: number,
stickerPackRecord: Proto.IStickerPackRecord
): Promise<MergeResultType> {
if (!stickerPackRecord.packId || Bytes.isEmpty(stickerPackRecord.packId)) {
throw new Error(`No stickerPackRecord identifier for ${storageID}`);
}
const details: Array<string> = [];
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,
};
}

View File

@ -84,6 +84,7 @@ import type {
SignedPreKeyType, SignedPreKeyType,
StoredSignedPreKeyType, StoredSignedPreKeyType,
StickerPackStatusType, StickerPackStatusType,
StickerPackInfoType,
StickerPackType, StickerPackType,
StickerType, StickerType,
StoryDistributionMemberType, StoryDistributionMemberType,
@ -92,6 +93,7 @@ import type {
StoryReadType, StoryReadType,
UnprocessedType, UnprocessedType,
UnprocessedUpdateType, UnprocessedUpdateType,
UninstalledStickerPackType,
} from './Interface'; } from './Interface';
import Server from './Server'; import Server from './Server';
import { isCorruptionError } from './errors'; import { isCorruptionError } from './errors';
@ -277,6 +279,7 @@ const dataInterface: ClientInterface = {
createOrUpdateStickerPack, createOrUpdateStickerPack,
updateStickerPackStatus, updateStickerPackStatus,
updateStickerPackInfo,
createOrUpdateSticker, createOrUpdateSticker,
updateStickerLastUsed, updateStickerLastUsed,
addStickerPackReference, addStickerPackReference,
@ -284,6 +287,13 @@ const dataInterface: ClientInterface = {
getStickerCount, getStickerCount,
deleteStickerPack, deleteStickerPack,
getAllStickerPacks, getAllStickerPacks,
addUninstalledStickerPack,
removeUninstalledStickerPack,
getInstalledStickerPacks,
getUninstalledStickerPacks,
installStickerPack,
uninstallStickerPack,
getStickerPackInfo,
getAllStickers, getAllStickers,
getRecentStickers, getRecentStickers,
clearAllErrorStickerPackAttempts, clearAllErrorStickerPackAttempts,
@ -1601,6 +1611,9 @@ async function updateStickerPackStatus(
): Promise<void> { ): Promise<void> {
await channels.updateStickerPackStatus(packId, status, options); await channels.updateStickerPackStatus(packId, status, options);
} }
async function updateStickerPackInfo(info: StickerPackInfoType): Promise<void> {
await channels.updateStickerPackInfo(info);
}
async function createOrUpdateSticker(sticker: StickerType): Promise<void> { async function createOrUpdateSticker(sticker: StickerType): Promise<void> {
await channels.createOrUpdateSticker(sticker); await channels.createOrUpdateSticker(sticker);
} }
@ -1609,7 +1622,7 @@ async function updateStickerLastUsed(
stickerId: number, stickerId: number,
timestamp: number timestamp: number
): Promise<void> { ): Promise<void> {
await channels.updateStickerLastUsed(packId, stickerId, timestamp); return channels.updateStickerLastUsed(packId, stickerId, timestamp);
} }
async function addStickerPackReference( async function addStickerPackReference(
messageId: string, messageId: string,
@ -1624,15 +1637,46 @@ async function deleteStickerPackReference(
return channels.deleteStickerPackReference(messageId, packId); return channels.deleteStickerPackReference(messageId, packId);
} }
async function deleteStickerPack(packId: string): Promise<Array<string>> { async function deleteStickerPack(packId: string): Promise<Array<string>> {
const paths = await channels.deleteStickerPack(packId); return channels.deleteStickerPack(packId);
return paths;
} }
async function getAllStickerPacks(): Promise<Array<StickerPackType>> { async function getAllStickerPacks(): Promise<Array<StickerPackType>> {
const packs = await channels.getAllStickerPacks(); const packs = await channels.getAllStickerPacks();
return packs; return packs;
} }
async function addUninstalledStickerPack(
pack: UninstalledStickerPackType
): Promise<void> {
return channels.addUninstalledStickerPack(pack);
}
async function removeUninstalledStickerPack(packId: string): Promise<void> {
return channels.removeUninstalledStickerPack(packId);
}
async function getInstalledStickerPacks(): Promise<Array<StickerPackType>> {
return channels.getInstalledStickerPacks();
}
async function getUninstalledStickerPacks(): Promise<
Array<UninstalledStickerPackType>
> {
return channels.getUninstalledStickerPacks();
}
async function installStickerPack(
packId: string,
timestamp: number
): Promise<void> {
return channels.installStickerPack(packId, timestamp);
}
async function uninstallStickerPack(
packId: string,
timestamp: number
): Promise<void> {
return channels.uninstallStickerPack(packId, timestamp);
}
async function getStickerPackInfo(
packId: string
): Promise<StickerPackInfoType | undefined> {
return channels.getStickerPackInfo(packId);
}
async function getAllStickers(): Promise<Array<StickerType>> { async function getAllStickers(): Promise<Array<StickerType>> {
const stickers = await channels.getAllStickers(); const stickers = await channels.getAllStickers();

View File

@ -202,22 +202,49 @@ export const StickerPackStatuses = [
export type StickerPackStatusType = typeof StickerPackStatuses[number]; 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; id: string;
key: string; key: string;
attemptedStatus?: 'downloaded' | 'installed' | 'ephemeral'; uninstalledAt?: undefined;
author: string; position?: number | null;
coverStickerId: number; }> &
createdAt: number; StorageServiceFieldsType;
downloadAttempts: number;
installedAt?: number; export type UninstalledStickerPackType = Readonly<{
lastUsed?: number; id: string;
status: StickerPackStatusType; key?: undefined;
stickerCount: number;
stickers: Record<string, StickerType>; uninstalledAt: number;
title: string; 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<string, StickerType>;
title: string;
}>;
export type UnprocessedType = { export type UnprocessedType = {
id: string; id: string;
@ -267,12 +294,8 @@ export type StoryDistributionType = Readonly<{
allowsReplies: boolean; allowsReplies: boolean;
isBlockList: boolean; isBlockList: boolean;
senderKeyInfo: SenderKeyInfoType | undefined; senderKeyInfo: SenderKeyInfoType | undefined;
}> &
storageID?: string; StorageServiceFieldsType;
storageVersion?: number;
storageUnknownFields?: Uint8Array | null;
storageNeedsSync: boolean;
}>;
export type StoryDistributionMemberType = Readonly<{ export type StoryDistributionMemberType = Readonly<{
listId: UUIDStringType; listId: UUIDStringType;
uuid: UUIDStringType; uuid: UUIDStringType;
@ -543,6 +566,7 @@ export type DataInterface = {
status: StickerPackStatusType, status: StickerPackStatusType,
options?: { timestamp: number } options?: { timestamp: number }
) => Promise<void>; ) => Promise<void>;
updateStickerPackInfo: (info: StickerPackInfoType) => Promise<void>;
createOrUpdateSticker: (sticker: StickerType) => Promise<void>; createOrUpdateSticker: (sticker: StickerType) => Promise<void>;
updateStickerLastUsed: ( updateStickerLastUsed: (
packId: string, packId: string,
@ -557,6 +581,17 @@ export type DataInterface = {
getStickerCount: () => Promise<number>; getStickerCount: () => Promise<number>;
deleteStickerPack: (packId: string) => Promise<Array<string>>; deleteStickerPack: (packId: string) => Promise<Array<string>>;
getAllStickerPacks: () => Promise<Array<StickerPackType>>; getAllStickerPacks: () => Promise<Array<StickerPackType>>;
addUninstalledStickerPack: (
pack: UninstalledStickerPackType
) => Promise<void>;
removeUninstalledStickerPack: (packId: string) => Promise<void>;
getInstalledStickerPacks: () => Promise<Array<StickerPackType>>;
getUninstalledStickerPacks: () => Promise<Array<UninstalledStickerPackType>>;
installStickerPack: (packId: string, timestamp: number) => Promise<void>;
uninstallStickerPack: (packId: string, timestamp: number) => Promise<void>;
getStickerPackInfo: (
packId: string
) => Promise<StickerPackInfoType | undefined>;
getAllStickers: () => Promise<Array<StickerType>>; getAllStickers: () => Promise<Array<StickerType>>;
getRecentStickers: (options?: { getRecentStickers: (options?: {
limit?: number; limit?: number;

View File

@ -81,6 +81,7 @@ import type {
GetUnreadByConversationAndMarkReadResultType, GetUnreadByConversationAndMarkReadResultType,
IdentityKeyIdType, IdentityKeyIdType,
StoredIdentityKeyType, StoredIdentityKeyType,
InstalledStickerPackType,
ItemKeyType, ItemKeyType,
StoredItemType, StoredItemType,
ConversationMessageStatsType, ConversationMessageStatsType,
@ -104,6 +105,7 @@ import type {
SessionType, SessionType,
SignedPreKeyIdType, SignedPreKeyIdType,
StoredSignedPreKeyType, StoredSignedPreKeyType,
StickerPackInfoType,
StickerPackStatusType, StickerPackStatusType,
StickerPackType, StickerPackType,
StickerType, StickerType,
@ -111,6 +113,7 @@ import type {
StoryDistributionType, StoryDistributionType,
StoryDistributionWithMembersType, StoryDistributionWithMembersType,
StoryReadType, StoryReadType,
UninstalledStickerPackType,
UnprocessedType, UnprocessedType,
UnprocessedUpdateType, UnprocessedUpdateType,
} from './Interface'; } from './Interface';
@ -268,6 +271,7 @@ const dataInterface: ServerInterface = {
createOrUpdateStickerPack, createOrUpdateStickerPack,
updateStickerPackStatus, updateStickerPackStatus,
updateStickerPackInfo,
createOrUpdateSticker, createOrUpdateSticker,
updateStickerLastUsed, updateStickerLastUsed,
addStickerPackReference, addStickerPackReference,
@ -275,6 +279,13 @@ const dataInterface: ServerInterface = {
getStickerCount, getStickerCount,
deleteStickerPack, deleteStickerPack,
getAllStickerPacks, getAllStickerPacks,
addUninstalledStickerPack,
removeUninstalledStickerPack,
getInstalledStickerPacks,
getUninstalledStickerPacks,
installStickerPack,
uninstallStickerPack,
getStickerPackInfo,
getAllStickers, getAllStickers,
getRecentStickers, getRecentStickers,
clearAllErrorStickerPackAttempts, clearAllErrorStickerPackAttempts,
@ -3446,6 +3457,10 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
status, status,
stickerCount, stickerCount,
title, title,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync,
} = pack; } = pack;
if (!id) { if (!id) {
throw new Error( throw new Error(
@ -3453,7 +3468,22 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
); );
} }
const rows = db let { position } = pack;
// Assign default position
if (!isNumber(position)) {
position = db
.prepare<EmptyQuery>(
`
SELECT IFNULL(MAX(position) + 1, 0)
FROM sticker_packs
`
)
.pluck()
.get();
}
const row = db
.prepare<Query>( .prepare<Query>(
` `
SELECT id SELECT id
@ -3461,7 +3491,7 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
WHERE id = $id; WHERE id = $id;
` `
) )
.all({ id }); .get({ id });
const payload = { const payload = {
attemptedStatus: attemptedStatus ?? null, attemptedStatus: attemptedStatus ?? null,
author, author,
@ -3475,9 +3505,14 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
status, status,
stickerCount, stickerCount,
title, 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<Query>( db.prepare<Query>(
` `
UPDATE sticker_packs SET UPDATE sticker_packs SET
@ -3491,7 +3526,12 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
lastUsed = $lastUsed, lastUsed = $lastUsed,
status = $status, status = $status,
stickerCount = $stickerCount, stickerCount = $stickerCount,
title = $title title = $title,
position = $position,
storageID = $storageID,
storageVersion = $storageVersion,
storageUnknownFields = $storageUnknownFields,
storageNeedsSync = $storageNeedsSync
WHERE id = $id; WHERE id = $id;
` `
).run(payload); ).run(payload);
@ -3513,7 +3553,12 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
lastUsed, lastUsed,
status, status,
stickerCount, stickerCount,
title title,
position,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync
) values ( ) values (
$attemptedStatus, $attemptedStatus,
$author, $author,
@ -3526,16 +3571,21 @@ async function createOrUpdateStickerPack(pack: StickerPackType): Promise<void> {
$lastUsed, $lastUsed,
$status, $status,
$stickerCount, $stickerCount,
$title $title,
$position,
$storageID,
$storageVersion,
$storageUnknownFields,
$storageNeedsSync
) )
` `
).run(payload); ).run(payload);
} }
async function updateStickerPackStatus( function updateStickerPackStatusSync(
id: string, id: string,
status: StickerPackStatusType, status: StickerPackStatusType,
options?: { timestamp: number } options?: { timestamp: number }
): Promise<void> { ): void {
const db = getInstance(); const db = getInstance();
const timestamp = options ? options.timestamp || Date.now() : Date.now(); const timestamp = options ? options.timestamp || Date.now() : Date.now();
const installedAt = status === 'installed' ? timestamp : null; const installedAt = status === 'installed' ? timestamp : null;
@ -3552,6 +3602,61 @@ async function updateStickerPackStatus(
installedAt, installedAt,
}); });
} }
async function updateStickerPackStatus(
id: string,
status: StickerPackStatusType,
options?: { timestamp: number }
): Promise<void> {
return updateStickerPackStatusSync(id, status, options);
}
async function updateStickerPackInfo({
id,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync,
uninstalledAt,
}: StickerPackInfoType): Promise<void> {
const db = getInstance();
if (uninstalledAt) {
db.prepare<Query>(
`
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<Query>(
`
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<void> { async function clearAllErrorStickerPackAttempts(): Promise<void> {
const db = getInstance(); const db = getInstance();
@ -3823,13 +3928,160 @@ async function getAllStickerPacks(): Promise<Array<StickerPackType>> {
.prepare<EmptyQuery>( .prepare<EmptyQuery>(
` `
SELECT * FROM sticker_packs SELECT * FROM sticker_packs
ORDER BY installedAt DESC, createdAt DESC ORDER BY position ASC, id ASC
` `
) )
.all(); .all();
return rows || []; return rows || [];
} }
function addUninstalledStickerPackSync(pack: UninstalledStickerPackType): void {
const db = getInstance();
db.prepare<Query>(
`
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<void> {
return addUninstalledStickerPackSync(pack);
}
function removeUninstalledStickerPackSync(packId: string): void {
const db = getInstance();
db.prepare<Query>(
'DELETE FROM uninstalled_sticker_packs WHERE id IS $id'
).run({ id: packId });
}
async function removeUninstalledStickerPack(packId: string): Promise<void> {
return removeUninstalledStickerPackSync(packId);
}
async function getUninstalledStickerPacks(): Promise<
Array<UninstalledStickerPackType>
> {
const db = getInstance();
const rows = db
.prepare<EmptyQuery>(
'SELECT * FROM uninstalled_sticker_packs ORDER BY id ASC'
)
.all();
return rows || [];
}
async function getInstalledStickerPacks(): Promise<Array<StickerPackType>> {
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<EmptyQuery>(
`
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<StickerPackInfoType | undefined> {
const db = getInstance();
return db.transaction(() => {
const uninstalled = db
.prepare<Query>(
`
SELECT * FROM uninstalled_sticker_packs
WHERE id IS $packId
`
)
.get({ packId });
if (uninstalled) {
return uninstalled as UninstalledStickerPackType;
}
const installed = db
.prepare<Query>(
`
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<void> {
const db = getInstance();
return db.transaction(() => {
const status = 'installed';
updateStickerPackStatusSync(packId, status, { timestamp });
removeUninstalledStickerPackSync(packId);
})();
}
async function uninstallStickerPack(
packId: string,
timestamp: number
): Promise<void> {
const db = getInstance();
return db.transaction(() => {
const status = 'downloaded';
updateStickerPackStatusSync(packId, status);
db.prepare<Query>(
`
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<Array<StickerType>> { async function getAllStickers(): Promise<Array<StickerType>> {
const db = getInstance(); const db = getInstance();

View File

@ -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!');
}

View File

@ -40,6 +40,7 @@ import updateToSchemaVersion61 from './61-distribution-list-storage';
import updateToSchemaVersion62 from './62-add-urgent-to-send-log'; import updateToSchemaVersion62 from './62-add-urgent-to-send-log';
import updateToSchemaVersion63 from './63-add-urgent-to-unprocessed'; import updateToSchemaVersion63 from './63-add-urgent-to-unprocessed';
import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys'; import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys';
import updateToSchemaVersion65 from './65-add-storage-id-to-stickers';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -1943,6 +1944,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion62, updateToSchemaVersion62,
updateToSchemaVersion63, updateToSchemaVersion63,
updateToSchemaVersion64, updateToSchemaVersion64,
updateToSchemaVersion65,
]; ];
export function updateSchema(db: Database, logger: LoggerType): void { export function updateSchema(db: Database, logger: LoggerType): void {

View File

@ -6,7 +6,9 @@ import { isNumber, last } from 'lodash';
export type EmptyQuery = []; export type EmptyQuery = [];
export type ArrayQuery = Array<Array<null | number | bigint | string>>; export type ArrayQuery = Array<Array<null | number | bigint | string>>;
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 JSONRows = Array<{ readonly json: string }>;
export type TableType = export type TableType =

View File

@ -14,13 +14,13 @@ import {
downloadStickerPack as externalDownloadStickerPack, downloadStickerPack as externalDownloadStickerPack,
maybeDeletePack, maybeDeletePack,
} from '../../types/Stickers'; } from '../../types/Stickers';
import { storageServiceUploadJob } from '../../services/storage';
import { sendStickerPackSync } from '../../shims/textsecure'; import { sendStickerPackSync } from '../../shims/textsecure';
import { trigger } from '../../shims/events'; import { trigger } from '../../shims/events';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
const { getRecentStickers, updateStickerLastUsed, updateStickerPackStatus } = const { getRecentStickers, updateStickerLastUsed } = dataInterface;
dataInterface;
// State // State
@ -204,7 +204,7 @@ function downloadStickerPack(
function installStickerPack( function installStickerPack(
packId: string, packId: string,
packKey: string, packKey: string,
options: { fromSync: boolean } | null = null options: { fromSync?: boolean; fromStorageService?: boolean } = {}
): InstallStickerPackAction { ): InstallStickerPackAction {
return { return {
type: 'stickers/INSTALL_STICKER_PACK', type: 'stickers/INSTALL_STICKER_PACK',
@ -214,25 +214,28 @@ function installStickerPack(
async function doInstallStickerPack( async function doInstallStickerPack(
packId: string, packId: string,
packKey: string, packKey: string,
options: { fromSync: boolean } | null options: { fromSync?: boolean; fromStorageService?: boolean } = {}
): Promise<InstallStickerPackPayloadType> { ): Promise<InstallStickerPackPayloadType> {
const { fromSync } = options || { fromSync: false }; const { fromSync = false, fromStorageService = false } = options;
const status = 'installed';
const timestamp = Date.now(); 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 // Kick this off, but don't wait for it
sendStickerPackSync(packId, packKey, true); sendStickerPackSync(packId, packKey, true);
} }
if (!fromStorageService) {
storageServiceUploadJob();
}
const recentStickers = await getRecentStickers(); const recentStickers = await getRecentStickers();
return { return {
packId, packId,
fromSync, fromSync,
status, status: 'installed',
installedAt: timestamp, installedAt: timestamp,
recentStickers: recentStickers.map(item => ({ recentStickers: recentStickers.map(item => ({
packId: item.packId, packId: item.packId,
@ -243,7 +246,7 @@ async function doInstallStickerPack(
function uninstallStickerPack( function uninstallStickerPack(
packId: string, packId: string,
packKey: string, packKey: string,
options: { fromSync: boolean } | null = null options: { fromSync?: boolean; fromStorageService?: boolean } = {}
): UninstallStickerPackAction { ): UninstallStickerPackAction {
return { return {
type: 'stickers/UNINSTALL_STICKER_PACK', type: 'stickers/UNINSTALL_STICKER_PACK',
@ -253,27 +256,31 @@ function uninstallStickerPack(
async function doUninstallStickerPack( async function doUninstallStickerPack(
packId: string, packId: string,
packKey: string, packKey: string,
options: { fromSync: boolean } | null options: { fromSync?: boolean; fromStorageService?: boolean } = {}
): Promise<UninstallStickerPackPayloadType> { ): Promise<UninstallStickerPackPayloadType> {
const { fromSync } = options || { fromSync: false }; const { fromSync = false, fromStorageService = false } = options;
const status = 'downloaded'; const timestamp = Date.now();
await updateStickerPackStatus(packId, status); await dataInterface.uninstallStickerPack(packId, timestamp);
// If there are no more references, it should be removed // If there are no more references, it should be removed
await maybeDeletePack(packId); await maybeDeletePack(packId);
if (!fromSync) { if (!fromSync && !fromStorageService) {
// Kick this off, but don't wait for it // Kick this off, but don't wait for it
sendStickerPackSync(packId, packKey, false); sendStickerPackSync(packId, packKey, false);
} }
if (!fromStorageService) {
storageServiceUploadJob();
}
const recentStickers = await getRecentStickers(); const recentStickers = await getRecentStickers();
return { return {
packId, packId,
fromSync, fromSync,
status, status: 'downloaded',
installedAt: undefined, installedAt: undefined,
recentStickers: recentStickers.map(item => ({ recentStickers: recentStickers.map(item => ({
packId: item.packId, packId: item.packId,
@ -313,7 +320,7 @@ function stickerPackUpdated(
function useSticker( function useSticker(
packId: string, packId: string,
stickerId: number, stickerId: number,
time = Date.now() time?: number
): UseStickerAction { ): UseStickerAction {
return { return {
type: 'stickers/USE_STICKER', type: 'stickers/USE_STICKER',

View File

@ -61,6 +61,7 @@ export async function initStorage(
state = state.updateAccount({ state = state.updateAccount({
profileKey: phone.profileKey.serialize(), profileKey: phone.profileKey.serialize(),
e164: phone.device.number, e164: phone.device.number,
givenName: phone.profileName,
}); });
state = state state = state
@ -76,6 +77,7 @@ export async function initStorage(
identityKey: contact.publicKey.serialize(), identityKey: contact.publicKey.serialize(),
profileKey: contact.profileKey.serialize(), profileKey: contact.profileKey.serialize(),
givenName: contact.profileName,
}); });
} }

View File

@ -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<StickerPackType> = [
{
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);
});
});

View File

@ -2357,4 +2357,36 @@ describe('SQL migrations test', () => {
assert.strictEqual(payload.urgent, 1); 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 },
]
);
});
});
}); });

View File

@ -96,6 +96,8 @@ const STICKER_PACK_DEFAULTS: StickerPackType = {
stickerCount: 0, stickerCount: 0,
stickers: {}, stickers: {},
title: '', title: '',
storageNeedsSync: false,
}; };
const VALID_PACK_ID_REGEXP = /^[0-9a-f]{32}$/i; const VALID_PACK_ID_REGEXP = /^[0-9a-f]{32}$/i;
@ -529,6 +531,7 @@ export async function downloadEphemeralPack(
export type DownloadStickerPackOptions = Readonly<{ export type DownloadStickerPackOptions = Readonly<{
messageId?: string; messageId?: string;
fromSync?: boolean; fromSync?: boolean;
fromStorageService?: boolean;
finalStatus?: StickerPackStatusType; finalStatus?: StickerPackStatusType;
suppressError?: boolean; suppressError?: boolean;
}>; }>;
@ -558,6 +561,7 @@ async function doDownloadStickerPack(
finalStatus = 'downloaded', finalStatus = 'downloaded',
messageId, messageId,
fromSync = false, fromSync = false,
fromStorageService = false,
suppressError = false, suppressError = false,
}: DownloadStickerPackOptions }: DownloadStickerPackOptions
): Promise<void> { ): Promise<void> {
@ -668,6 +672,7 @@ async function doDownloadStickerPack(
status: 'pending', status: 'pending',
createdAt: Date.now(), createdAt: Date.now(),
stickers: {}, stickers: {},
storageNeedsSync: !fromStorageService,
...pick(proto, ['title', 'author']), ...pick(proto, ['title', 'author']),
}; };
await Data.createOrUpdateStickerPack(pack); await Data.createOrUpdateStickerPack(pack);
@ -748,7 +753,10 @@ async function doDownloadStickerPack(
} }
if (finalStatus === 'installed') { if (finalStatus === 'installed') {
await installStickerPack(packId, packKey, { fromSync }); await installStickerPack(packId, packKey, {
fromSync,
fromStorageService,
});
} else { } else {
// Mark the pack as complete // Mark the pack as complete
await Data.updateStickerPackStatus(packId, finalStatus); 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. // The override; doesn't honor our ref-counting scheme - just deletes it all.
export async function deletePack(packId: string): Promise<void> { async function deletePack(packId: string): Promise<void> {
const isBlessed = Boolean(BLESSED_PACKS[packId]); const isBlessed = Boolean(BLESSED_PACKS[packId]);
if (isBlessed) { if (isBlessed) {
return; return;