Support additional sticker states

Co-authored-by: scott@signal.org
Co-authored-by: ken@signal.org
This commit is contained in:
Ken Powers 2019-05-23 18:27:42 -07:00 committed by Scott Nonnenberg
parent 41880cfe66
commit be5d0837f8
35 changed files with 925 additions and 249 deletions

View file

@ -302,22 +302,25 @@
await window.Signal.Data.shutdown();
},
installStickerPack: async (id, key) => {
const status = window.Signal.Stickers.getStickerPackStatus(id);
showStickerPack: async (packId, key) => {
// Kick off the download
window.Signal.Stickers.downloadEphemeralPack(packId, key);
if (status === 'installed') {
return;
}
const props = {
packId,
onClose: async () => {
stickerPreviewModalView.remove();
await window.Signal.Stickers.removeEphemeralPack(packId);
},
};
if (status === 'advertised') {
await window.reduxActions.stickers.installStickerPack(id, key, {
fromSync: true,
});
} else {
await window.Signal.Stickers.downloadStickerPack(id, key, {
finalStatus: 'installed',
});
}
const stickerPreviewModalView = new Whisper.ReactWrapperView({
className: 'sticker-preview-modal-wrapper',
JSX: Signal.State.Roots.createStickerPreviewModal(
window.reduxStore,
props
),
});
},
};
@ -464,6 +467,7 @@
user: {
attachmentsPath: window.baseAttachmentsPath,
stickersPath: window.baseStickersPath,
tempPath: window.baseTempPath,
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
i18n: window.i18n,
@ -1056,7 +1060,7 @@
fromSync: true,
});
} else if (isInstall) {
if (status === 'advertised') {
if (status === 'downloaded') {
window.reduxActions.stickers.installStickerPack(id, key, {
fromSync: true,
});

View file

@ -33,7 +33,7 @@
const {
copyStickerToAttachments,
deletePackReference,
downloadStickerPack,
savePackMetadata,
getStickerPackStatus,
} = window.Signal.Stickers;
const { addStickerPackReference } = window.Signal.Data;
@ -1467,7 +1467,7 @@
const status = getStickerPackStatus(packId);
let data;
if (status && status !== 'pending' && status !== 'error') {
if (status && (status === 'downloaded' || status === 'installed')) {
try {
const copiedSticker = await copyStickerToAttachments(
packId,
@ -1492,8 +1492,8 @@
});
}
if (!status) {
// kick off the download without waiting
downloadStickerPack(packId, packKey, { messageId });
// Save the packId/packKey for future download/install
savePackMetadata(packId, packKey, { messageId });
} else {
await addStickerPackReference(messageId, packId);
}

View file

@ -8,7 +8,7 @@ export function updateStickerLastUsed(
): Promise<void>;
export function updateStickerPackStatus(
packId: string,
status: 'advertised' | 'installed' | 'error' | 'pending',
status: 'known' | 'downloaded' | 'installed' | 'error' | 'pending',
options?: { timestamp: number }
): Promise<void>;

View file

@ -28,6 +28,7 @@ const SQL_CHANNEL_KEY = 'sql-channel';
const ERASE_SQL_KEY = 'erase-sql-key';
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
const ERASE_STICKERS_KEY = 'erase-stickers';
const ERASE_TEMP_KEY = 'erase-temp';
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const _jobs = Object.create(null);
@ -965,6 +966,7 @@ async function removeOtherData() {
callChannel(ERASE_SQL_KEY),
callChannel(ERASE_ATTACHMENTS_KEY),
callChannel(ERASE_STICKERS_KEY),
callChannel(ERASE_TEMP_KEY),
]);
}

View file

@ -129,6 +129,7 @@ function initializeMigrations({
const {
getPath,
getStickersPath,
getTempPath,
createReader,
createAbsolutePathGetter,
createWriterForNew,
@ -161,6 +162,12 @@ function initializeMigrations({
const deleteSticker = Attachments.createDeleter(stickersPath);
const readStickerData = createReader(stickersPath);
const tempPath = getTempPath(userDataPath);
const getAbsoluteTempPath = createAbsolutePathGetter(tempPath);
const writeNewTempData = createWriterForNew(tempPath);
const deleteTempFile = Attachments.createDeleter(tempPath);
const readTempData = createReader(tempPath);
return {
attachmentsPath,
copyIntoAttachmentsDirectory,
@ -170,6 +177,7 @@ function initializeMigrations({
deleteOnDisk,
}),
deleteSticker,
deleteTempFile,
getAbsoluteAttachmentPath,
getAbsoluteStickerPath,
getPlaceholderMigrations,
@ -181,6 +189,7 @@ function initializeMigrations({
loadStickerData,
readAttachmentData,
readStickerData,
readTempData,
run,
processNewAttachment: attachment =>
MessageType.processNewAttachment(attachment, {
@ -200,6 +209,13 @@ function initializeMigrations({
getImageDimensions,
logger,
}),
processNewEphemeralSticker: stickerData =>
MessageType.processNewSticker(stickerData, {
writeNewStickerData: writeNewTempData,
getAbsoluteStickerPath: getAbsoluteTempPath,
getImageDimensions,
logger,
}),
upgradeMessageSchema: (message, options = {}) => {
const { maxVersion } = options;

View file

@ -1 +1,11 @@
export function maybeDeletePack(packId: string): Promise<void>;
export function downloadStickerPack(
packId: string,
packKey: string,
options?: {
finalStatus?: 'installed' | 'downloaded';
messageId?: string;
fromSync?: boolean;
}
): Promise<void>;

View file

@ -2,6 +2,7 @@
textsecure,
Signal,
log,
navigator,
reduxStore,
reduxActions,
URL
@ -9,7 +10,7 @@
const BLESSED_PACKS = {};
const { isNumber, pick, reject, groupBy } = require('lodash');
const { isNumber, pick, reject, groupBy, values } = require('lodash');
const pMap = require('p-map');
const Queue = require('p-queue');
const qs = require('qs');
@ -34,6 +35,7 @@ module.exports = {
deletePack,
deletePackReference,
downloadStickerPack,
downloadEphemeralPack,
getDataFromLink,
getInitialState,
getInstalledStickerPacks,
@ -44,6 +46,8 @@ module.exports = {
maybeDeletePack,
downloadQueuedPacks,
redactPackId,
removeEphemeralPack,
savePackMetadata,
};
let initialState = null;
@ -88,8 +92,8 @@ function getInstalledStickerPacks() {
return [];
}
const values = Object.values(packs);
return values.filter(pack => pack.status === 'installed');
const items = Object.values(packs);
return items.filter(pack => pack.status === 'installed');
}
function downloadQueuedPacks() {
@ -113,7 +117,7 @@ function capturePacksToDownload(existingPackLookup) {
const existing = existingPackLookup[id];
if (
!existing ||
(existing.status !== 'advertised' && existing.status !== 'installed')
(existing.status !== 'downloaded' && existing.status !== 'installed')
) {
toDownload[id] = {
id,
@ -130,6 +134,18 @@ function capturePacksToDownload(existingPackLookup) {
}
const existing = existingPackLookup[id];
// These packs should never end up in the database, but if they do we'll delete them
if (existing.status === 'ephemeral') {
deletePack(id);
return;
}
// We don't automatically download these; not until a user action kicks it off
if (existing.status === 'known') {
return;
}
if (doesPackNeedDownload(existing)) {
toDownload[id] = {
id,
@ -147,14 +163,23 @@ function doesPackNeedDownload(pack) {
return true;
}
const stickerCount = Object.keys(pack.stickers || {}).length;
return (
!pack.status ||
pack.status === 'error' ||
pack.status === 'pending' ||
!pack.stickerCount ||
stickerCount < pack.stickerCount
);
const { status, stickerCount } = pack;
const stickersDownloaded = Object.keys(pack.stickers || {}).length;
if (
(status === 'installed' || status === 'downloaded') &&
stickerCount > 0 &&
stickersDownloaded >= stickerCount
) {
return false;
}
// If we don't understand a pack's status, we'll download it
// If a pack has any other status, we'll download it
// If a pack has zero stickers in it, we'll download it
// If a pack doesn't have enough downloaded stickers, we'll download it
return true;
}
async function getPacksForRedux() {
@ -209,10 +234,15 @@ async function decryptSticker(packKey, ciphertext) {
return plaintext;
}
async function downloadSticker(packId, packKey, proto) {
async function downloadSticker(packId, packKey, proto, options) {
const { ephemeral } = options || {};
const ciphertext = await textsecure.messaging.getSticker(packId, proto.id);
const plaintext = await decryptSticker(packKey, ciphertext);
const sticker = await Signal.Migrations.processNewSticker(plaintext);
const sticker = ephemeral
? await Signal.Migrations.processNewEphemeralSticker(plaintext, options)
: await Signal.Migrations.processNewSticker(plaintext, options);
return {
...pick(proto, ['id', 'emoji']),
@ -221,6 +251,156 @@ async function downloadSticker(packId, packKey, proto) {
};
}
async function savePackMetadata(packId, packKey, options = {}) {
const { messageId } = options;
const existing = getStickerPack(packId);
if (existing) {
return;
}
const { stickerPackAdded } = getReduxStickerActions();
const pack = {
id: packId,
key: packKey,
status: 'known',
};
stickerPackAdded(pack);
await createOrUpdateStickerPack(pack);
if (messageId) {
await addStickerPackReference(messageId, packId);
}
}
async function removeEphemeralPack(packId) {
const existing = getStickerPack(packId);
if (
existing.status !== 'ephemeral' &&
!(existing.status === 'error' && existing.attemptedStatus === 'ephemeral')
) {
return;
}
const { removeStickerPack } = getReduxStickerActions();
removeStickerPack(packId);
const stickers = values(existing.stickers);
const paths = stickers.map(sticker => sticker.path);
await pMap(paths, Signal.Migrations.deleteTempFile, {
concurrency: 3,
});
// Remove it from database in case it made it there
await deleteStickerPack(packId);
}
async function downloadEphemeralPack(packId, packKey) {
const {
stickerAdded,
stickerPackAdded,
stickerPackUpdated,
} = getReduxStickerActions();
const existingPack = getStickerPack(packId);
if (existingPack) {
log.warn(
`Ephemeral download for pack ${redactPackId(
packId
)} requested, we already know about it. Skipping.`
);
return;
}
try {
// Synchronous placeholder to help with race conditions
const placeholder = {
id: packId,
key: packKey,
status: 'ephemeral',
};
stickerPackAdded(placeholder);
const ciphertext = await textsecure.messaging.getStickerPackManifest(
packId
);
const plaintext = await decryptSticker(packKey, ciphertext);
const proto = textsecure.protobuf.StickerPack.decode(plaintext);
const firstStickerProto = proto.stickers ? proto.stickers[0] : null;
const stickerCount = proto.stickers.length;
const coverProto = proto.cover || firstStickerProto;
const coverStickerId = coverProto ? coverProto.id : null;
if (!coverProto || !isNumber(coverStickerId)) {
throw new Error(
`Sticker pack ${redactPackId(
packId
)} is malformed - it has no cover, and no stickers`
);
}
const nonCoverStickers = reject(
proto.stickers,
sticker => !isNumber(sticker.id) || sticker.id === coverStickerId
);
const coverIncludedInList = nonCoverStickers.length < stickerCount;
const pack = {
id: packId,
key: packKey,
coverStickerId,
stickerCount,
status: 'ephemeral',
...pick(proto, ['title', 'author']),
};
stickerPackAdded(pack);
const downloadStickerJob = async stickerProto => {
const stickerInfo = await downloadSticker(packId, packKey, stickerProto, {
ephemeral: true,
});
const sticker = {
...stickerInfo,
isCoverOnly: !coverIncludedInList && stickerInfo.id === coverStickerId,
};
const statusCheck = getStickerPackStatus(packId);
if (statusCheck !== 'ephemeral') {
throw new Error(
`Ephemeral download for pack ${redactPackId(
packId
)} interrupted by status change. Status is now ${statusCheck}.`
);
}
stickerAdded(sticker);
};
// Download the cover first
await downloadStickerJob(coverProto);
// Then the rest
await pMap(nonCoverStickers, downloadStickerJob, { concurrency: 3 });
} catch (error) {
// Because the user could install this pack while we are still downloading this
// ephemeral pack, we don't want to go change its status unless we're still in
// ephemeral mode.
const statusCheck = getStickerPackStatus(packId);
if (statusCheck === 'ephemeral') {
stickerPackUpdated(packId, {
attemptedStatus: 'ephemeral',
status: 'error',
});
}
log.error(
`Ephemeral download error for sticker pack ${redactPackId(packId)}:`,
error && error.stack ? error.stack : error
);
}
}
async function downloadStickerPack(packId, packKey, options = {}) {
// This will ensure that only one download process is in progress at any given time
return downloadQueue.add(async () => {
@ -244,7 +424,12 @@ async function doDownloadStickerPack(packId, packKey, options = {}) {
installStickerPack,
} = getReduxStickerActions();
const finalStatus = options.finalStatus || 'advertised';
const finalStatus = options.finalStatus || 'downloaded';
if (finalStatus !== 'downloaded' && finalStatus !== 'installed') {
throw new Error(
`doDownloadStickerPack: invalid finalStatus of ${finalStatus} requested.`
);
}
const existing = getStickerPack(packId);
if (!doesPackNeedDownload(existing)) {
@ -256,7 +441,10 @@ async function doDownloadStickerPack(packId, packKey, options = {}) {
return;
}
const downloadAttempts = (existing ? existing.downloadAttempts || 0 : 0) + 1;
// We don't count this as an attempt if we're offline
const attemptIncrement = navigator.onLine ? 1 : 0;
const downloadAttempts =
(existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement;
if (downloadAttempts > 3) {
log.warn(
`Refusing to attempt another download for pack ${redactPackId(
@ -280,6 +468,16 @@ async function doDownloadStickerPack(packId, packKey, options = {}) {
let nonCoverStickers;
try {
// Synchronous placeholder to help with race conditions
const placeholder = {
id: packId,
key: packKey,
attemptedStatus: finalStatus,
downloadAttempts,
status: 'pending',
};
stickerPackAdded(placeholder);
const ciphertext = await textsecure.messaging.getStickerPackManifest(
packId
);
@ -307,8 +505,10 @@ async function doDownloadStickerPack(packId, packKey, options = {}) {
coverIncludedInList = nonCoverStickers.length < stickerCount;
// status can be:
// - 'known'
// - 'ephemeral' (should not hit database)
// - 'pending'
// - 'advertised'
// - 'downloaded'
// - 'error'
// - 'installed'
const pack = {
@ -365,6 +565,13 @@ async function doDownloadStickerPack(packId, packKey, options = {}) {
// Then the rest
await pMap(nonCoverStickers, downloadStickerJob, { concurrency: 3 });
// Allow for the user marking this pack as installed in the middle of our download;
// don't overwrite that status.
const existingStatus = getStickerPackStatus(packId);
if (existingStatus === 'installed') {
return;
}
if (finalStatus === 'installed') {
await installStickerPack(packId, packKey, { fromSync });
} else {
@ -380,11 +587,12 @@ async function doDownloadStickerPack(packId, packKey, options = {}) {
error && error.stack ? error.stack : error
);
const errorState = 'error';
await updateStickerPackStatus(packId, errorState);
const errorStatus = 'error';
await updateStickerPackStatus(packId, errorStatus);
if (stickerPackUpdated) {
stickerPackUpdated(packId, {
state: errorState,
attemptedStatus: finalStatus,
status: errorStatus,
});
}
}

View file

@ -1331,15 +1331,19 @@
dialog.focusCancel();
},
showStickerPackPreview(packId) {
showStickerPackPreview(packId, packKey) {
if (!window.ENABLE_STICKER_SEND) {
return;
}
window.Signal.Stickers.downloadEphemeralPack(packId, packKey);
const props = {
packId,
onClose: () => {
onClose: async () => {
this.stickerPreviewModalView.remove();
this.stickerPreviewModalView = null;
await window.Signal.Stickers.removeEphemeralPack(packId);
},
};
@ -1349,9 +1353,6 @@
window.reduxStore,
props
),
onClose: () => {
this.stickerPreviewModalView = null;
},
});
},
@ -1364,8 +1365,8 @@
}
const sticker = message.get('sticker');
if (sticker) {
const { packId } = sticker;
this.showStickerPackPreview(packId);
const { packId, packKey } = sticker;
this.showStickerPackPreview(packId, packKey);
return;
}
@ -1992,17 +1993,25 @@
},
async getStickerPackPreview(url) {
const isPackDownloaded = pack =>
pack && (pack.status === 'downloaded' || pack.status === 'installed');
const isPackValid = pack =>
pack && (pack.status === 'advertised' || pack.status === 'installed');
pack &&
(pack.status === 'ephemeral' ||
pack.status === 'downloaded' ||
pack.status === 'installed');
let id;
let key;
try {
const { id, key } = window.Signal.Stickers.getDataFromLink(url);
({ id, key } = window.Signal.Stickers.getDataFromLink(url));
const keyBytes = window.Signal.Crypto.bytesFromHexString(key);
const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes);
const existing = window.Signal.Stickers.getStickerPack(id);
if (!isPackValid(existing)) {
await window.Signal.Stickers.downloadStickerPack(id, keyBase64);
if (!isPackDownloaded(existing)) {
await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64);
}
const pack = window.Signal.Stickers.getStickerPack(id);
@ -2015,9 +2024,10 @@
const { title, coverStickerId } = pack;
const sticker = pack.stickers[coverStickerId];
const data = await window.Signal.Migrations.readStickerData(
sticker.path
);
const data =
pack.status === 'ephemeral'
? await window.Signal.Migrations.readTempData(sticker.path)
: await window.Signal.Migrations.readStickerData(sticker.path);
return {
title,
@ -2035,6 +2045,10 @@
error && error.stack ? error.stack : error
);
return null;
} finally {
if (id) {
await window.Signal.Stickers.removeEphemeralPack(id);
}
}
},