From e6b62001d39a009ae31a709299049e7d5fce4e57 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 12 Jun 2024 13:36:02 -0700 Subject: [PATCH] Import/export gift badges, other fields --- protos/Backups.proto | 15 +- protos/SignalStorage.proto | 4 +- ts/services/backups/export.ts | 81 ++++++- ts/services/backups/import.ts | 128 ++++++++++- ts/services/storageRecordOps.ts | 46 ++++ ts/test-electron/backup/attachments_test.ts | 1 + ts/test-electron/backup/bubble_test.ts | 232 ++++++++++++++++++++ ts/test-electron/backup/helpers.ts | 59 ++--- ts/test-electron/backup/non_bubble_test.ts | 22 +- ts/types/Storage.d.ts | 3 + 10 files changed, 536 insertions(+), 55 deletions(-) diff --git a/protos/Backups.proto b/protos/Backups.proto index bfc08e4c9901..41d0490e49ec 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -349,6 +349,7 @@ message ChatItem { RemoteDeletedMessage remoteDeletedMessage = 14; ChatUpdateMessage updateMessage = 15; PaymentNotification paymentNotification = 16; + GiftBadge giftBadge = 17; } } @@ -439,6 +440,18 @@ message PaymentNotification { } +message GiftBadge { + enum State { + UNOPENED = 0; + OPENED = 1; + REDEEMED = 2; + FAILED = 3; + } + + bytes receiptCredentialPresentation = 1; + State state = 2; +} + message ContactAttachment { message Name { optional string givenName = 1; @@ -1114,4 +1127,4 @@ message ChatStyle { // Bubble setting is automatically determined based on the wallpaper setting. AutomaticBubbleColor autoBubbleColor = 6; } -} \ No newline at end of file +} diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 1bcaa76d6b49..49875a25339c 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -195,6 +195,7 @@ message AccountRecord { optional bytes subscriberId = 21; optional string subscriberCurrencyCode = 22; optional bool displayBadgesOnProfile = 23; + optional bool donorSubscriptionManuallyCancelled = 24; optional bool keepMutedChatsArchived = 25; optional bool hasSetMyStoriesPrivacy = 26; optional bool hasViewedOnboardingStory = 27; @@ -202,12 +203,13 @@ message AccountRecord { optional bool storiesDisabled = 29; optional OptionalBool storyViewReceiptsEnabled = 30; reserved 31; // hasReadOnboardingStory - reserved 32; // hasSeenGroupStoryEducationSheet + optional bool hasSeenGroupStoryEducationSheet = 32; optional string username = 33; optional bool hasCompletedUsernameOnboarding = 34; optional UsernameLink usernameLink = 35; optional bytes backupsSubscriberId = 36; optional string backupsSubscriberCurrencyCode = 37; + optional bool backupsSubscriptionManuallyCancelled = 38; } message StoryDistributionListRecord { diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index ba4bcb8c00bd..3c4aeb8e61dc 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -12,6 +12,7 @@ import { Backups, SignalService } from '../../protobuf'; import Data from '../../sql/Client'; import type { PageMessagesCursorType } from '../../sql/Interface'; import * as log from '../../logging/log'; +import { GiftBadgeStates } from '../../components/conversation/Message'; import { StorySendMode, MY_STORY_ID } from '../../types/Stories'; import { isPniString, @@ -478,12 +479,20 @@ export class BackupExportStream extends Readable { ? { subscriberId: backupsSubscriberId, currencyCode: storage.get('backupsSubscriberCurrencyCode'), + manuallyCancelled: storage.get( + 'backupsSubscriptionManuallyCancelled', + false + ), } : null, donationSubscriberData: Bytes.isNotEmpty(subscriberId) ? { subscriberId, currencyCode: storage.get('subscriberCurrencyCode'), + manuallyCancelled: storage.get( + 'donorSubscriptionManuallyCancelled', + false + ), } : null, accountSettings: { @@ -507,6 +516,9 @@ export class BackupExportStream extends Readable { hasCompletedUsernameOnboarding: storage.get( 'hasCompletedUsernameOnboarding' ), + hasSeenGroupStoryEducationSheet: storage.get( + 'hasSeenGroupStoryEducationSheet' + ), phoneNumberSharingMode, }, }; @@ -849,6 +861,31 @@ export class BackupExportStream extends Readable { sticker: stickerProto, reactions: this.getMessageReactions(message), }; + } else if (isGiftBadge(message)) { + const { giftBadge } = message; + strictAssert(giftBadge != null, 'Message must have gift badge'); + + let state: Backups.GiftBadge.State; + switch (giftBadge.state) { + case GiftBadgeStates.Unopened: + state = Backups.GiftBadge.State.UNOPENED; + break; + case GiftBadgeStates.Opened: + state = Backups.GiftBadge.State.OPENED; + break; + case GiftBadgeStates.Redeemed: + state = Backups.GiftBadge.State.REDEEMED; + break; + default: + throw missingCaseError(giftBadge.state); + } + + result.giftBadge = { + receiptCredentialPresentation: Bytes.fromBase64( + giftBadge.receiptCredentialPresentation + ), + state, + }; } else { result.standardMessage = await this.toStandardMessage( message, @@ -1187,10 +1224,6 @@ export class BackupExportStream extends Readable { return { kind: NonBubbleResultKind.Drop }; } - if (isGiftBadge(message)) { - // TODO (DESKTOP-6964): reuse quote's handling - } - if (isGroupUpdate(message)) { // GV1 is deprecated. return { kind: NonBubbleResultKind.Drop }; @@ -1837,7 +1870,11 @@ export class BackupExportStream extends Readable { }: Pick): | Array | undefined { - return reactions?.map(reaction => { + if (reactions == null) { + return undefined; + } + + return reactions?.map((reaction, sortOrder) => { return { emoji: reaction.emoji, authorId: this.getOrPushPrivateRecipient({ @@ -1847,6 +1884,7 @@ export class BackupExportStream extends Readable { receivedTimestamp: getSafeLongFromTimestamp( reaction.receivedAtDate ?? reaction.timestamp ), + sortOrder: Long.fromNumber(sortOrder), }; }); } @@ -1856,12 +1894,14 @@ export class BackupExportStream extends Readable { editMessageReceivedAtMs, serverTimestamp, readStatus, + unidentifiedDeliveryReceived, }: Pick< MessageAttributesType, | 'received_at_ms' | 'editMessageReceivedAtMs' | 'serverTimestamp' | 'readStatus' + | 'unidentifiedDeliveryReceived' >): Backups.ChatItem.IIncomingMessageDetails { const dateReceived = editMessageReceivedAtMs || receivedAtMs; return { @@ -1872,6 +1912,7 @@ export class BackupExportStream extends Readable { ? getSafeLongFromTimestamp(serverTimestamp) : null, read: readStatus === ReadStatus.Read, + sealedSender: unidentifiedDeliveryReceived === true, }; } @@ -1879,10 +1920,22 @@ export class BackupExportStream extends Readable { sentAt: number, { sendStateByConversationId = {}, - }: Pick + unidentifiedDeliveries = [], + errors = [], + }: Pick< + MessageAttributesType, + 'sendStateByConversationId' | 'unidentifiedDeliveries' | 'errors' + > ): Backups.ChatItem.IOutgoingMessageDetails { const BackupSendStatus = Backups.SendStatus.Status; + const sealedSenderServiceIds = new Set(unidentifiedDeliveries); + const errorMap = new Map( + errors.map(({ serviceId, name }) => { + return [serviceId, name]; + }) + ); + const sendStatus = new Array(); for (const [id, entry] of Object.entries(sendStateByConversationId)) { const target = window.ConversationController.get(id); @@ -1915,6 +1968,19 @@ export class BackupExportStream extends Readable { throw missingCaseError(entry.status); } + const { serviceId } = target.attributes; + let networkFailure = false; + let identityKeyMismatch = false; + let sealedSender = false; + if (serviceId) { + const errorName = errorMap.get(serviceId); + if (errorName !== undefined) { + identityKeyMismatch = errorName === 'OutgoingIdentityKeyError'; + networkFailure = !identityKeyMismatch; + } + sealedSender = sealedSenderServiceIds.has(serviceId); + } + sendStatus.push({ recipientId: this.getOrPushPrivateRecipient(target.attributes), lastStatusUpdateTimestamp: @@ -1922,6 +1988,9 @@ export class BackupExportStream extends Readable { ? getSafeLongFromTimestamp(entry.updatedAt) : null, deliveryStatus, + networkFailure, + identityKeyMismatch, + sealedSender, }); } return { diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 16b1d0a1e4bb..85e2f1af1ea2 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { Aci, Pni } from '@signalapp/libsignal-client'; +import { ReceiptCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import { v4 as generateUuid } from 'uuid'; import pMap from 'p-map'; import { Writable } from 'stream'; @@ -11,6 +12,7 @@ import { Backups, SignalService } from '../../protobuf'; import Data from '../../sql/Client'; import type { StoryDistributionWithMembersType } from '../../sql/Interface'; import * as log from '../../logging/log'; +import { GiftBadgeStates } from '../../components/conversation/Message'; import { StorySendMode } from '../../types/Stories'; import type { ServiceIdString, AciString } from '../../types/ServiceId'; import { fromAciObject, fromPniObject } from '../../types/ServiceId'; @@ -27,6 +29,7 @@ import { } from '../../types/Stickers'; import type { ConversationAttributesType, + CustomError, MessageAttributesType, MessageReactionType, EditHistoryType, @@ -34,7 +37,7 @@ import type { } from '../../model-types.d'; import { assertDev, strictAssert } from '../../util/assert'; import { getTimestampFromLong } from '../../util/timestampLongUtils'; -import { DurationInSeconds } from '../../util/durations'; +import { DurationInSeconds, SECOND } from '../../util/durations'; import { dropNull } from '../../util/dropNull'; import { deriveGroupID, @@ -468,22 +471,36 @@ export class BackupImportStream extends Writable { await storage.put('avatarUrl', avatarUrlPath); } if (donationSubscriberData != null) { - const { subscriberId, currencyCode } = donationSubscriberData; + const { subscriberId, currencyCode, manuallyCancelled } = + donationSubscriberData; if (Bytes.isNotEmpty(subscriberId)) { await storage.put('subscriberId', subscriberId); } if (currencyCode != null) { await storage.put('subscriberCurrencyCode', currencyCode); } + if (manuallyCancelled != null) { + await storage.put( + 'donorSubscriptionManuallyCancelled', + manuallyCancelled + ); + } } if (backupsSubscriberData != null) { - const { subscriberId, currencyCode } = backupsSubscriberData; + const { subscriberId, currencyCode, manuallyCancelled } = + backupsSubscriberData; if (Bytes.isNotEmpty(subscriberId)) { await storage.put('backupsSubscriberId', subscriberId); } if (currencyCode != null) { await storage.put('backupsSubscriberCurrencyCode', currencyCode); } + if (manuallyCancelled != null) { + await storage.put( + 'backupsSubscriptionManuallyCancelled', + manuallyCancelled + ); + } } await storage.put( @@ -503,6 +520,12 @@ export class BackupImportStream extends Writable { 'preferContactAvatars', accountSettings?.preferContactAvatars === true ); + if (accountSettings?.universalExpireTimer) { + await storage.put( + 'universalExpireTimer', + accountSettings.universalExpireTimer + ); + } await storage.put( 'displayBadgesOnProfile', accountSettings?.displayBadgesOnProfile === true @@ -532,8 +555,8 @@ export class BackupImportStream extends Writable { accountSettings?.hasCompletedUsernameOnboarding === true ); await storage.put( - 'preferredReactionEmoji', - accountSettings?.preferredReactionEmoji || [] + 'hasSeenGroupStoryEducationSheet', + accountSettings?.hasSeenGroupStoryEducationSheet === true ); await storage.put( 'preferredReactionEmoji', @@ -618,6 +641,7 @@ export class BackupImportStream extends Writable { profileName: dropNull(contact.profileGivenName), profileFamilyName: dropNull(contact.profileFamilyName), hideStory: contact.hideStory === true, + username: dropNull(contact.username), }; if (contact.notRegistered) { @@ -871,8 +895,6 @@ export class BackupImportStream extends Writable { } if (item.standardMessage) { - // TODO (DESKTOP-6964): gift badge - attributes = { ...attributes, ...(await this.fromStandardMessage(item.standardMessage, chatConvo.id)), @@ -961,6 +983,8 @@ export class BackupImportStream extends Writable { const BackupSendStatus = Backups.SendStatus.Status; + const unidentifiedDeliveries = new Array(); + const errors = new Array(); for (const status of outgoing.sendStatus ?? []) { strictAssert( status.recipientId, @@ -997,6 +1021,28 @@ export class BackupImportStream extends Writable { break; } + if (target.serviceId) { + if (status.sealedSender) { + unidentifiedDeliveries.push(target.serviceId); + } + + if (status.identityKeyMismatch) { + errors.push({ + serviceId: target.serviceId, + name: 'OutgoingIdentityKeyError', + // See: ts/textsecure/Errors + message: `The identity of ${target.serviceId} has changed.`, + }); + } else if (status.networkFailure) { + errors.push({ + serviceId: target.serviceId, + name: 'OutgoingMessageError', + // See: ts/textsecure/Errors + message: 'no http error', + }); + } + } + sendStateByConversationId[target.id] = { status: sendStatus, updatedAt: @@ -1011,6 +1057,10 @@ export class BackupImportStream extends Writable { patch: { sendStateByConversationId, received_at_ms: timestamp, + unidentifiedDeliveries: unidentifiedDeliveries.length + ? unidentifiedDeliveries + : undefined, + errors: errors.length ? errors : undefined, }, newActiveAt: timestamp, }; @@ -1018,12 +1068,15 @@ export class BackupImportStream extends Writable { if (incoming) { const receivedAtMs = incoming.dateReceived?.toNumber() ?? Date.now(); + const unidentifiedDeliveryReceived = incoming.sealedSender === true; + if (incoming.read) { return { patch: { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, received_at_ms: receivedAtMs, + unidentifiedDeliveryReceived, }, newActiveAt: receivedAtMs, }; @@ -1034,6 +1087,7 @@ export class BackupImportStream extends Writable { readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, received_at_ms: receivedAtMs, + unidentifiedDeliveryReceived, }, newActiveAt: receivedAtMs, unread: true, @@ -1203,8 +1257,15 @@ export class BackupImportStream extends Writable { if (!reactions?.length) { return undefined; } - return reactions.map( - ({ emoji, authorId, sentTimestamp, receivedTimestamp }) => { + return reactions + .slice() + .sort((a, b) => { + if (a.sortOrder && b.sortOrder) { + return a.sortOrder.comp(b.sortOrder); + } + return 0; + }) + .map(({ emoji, authorId, sentTimestamp, receivedTimestamp }) => { strictAssert(emoji != null, 'reaction must have an emoji'); strictAssert(authorId != null, 'reaction must have authorId'); strictAssert( @@ -1229,8 +1290,7 @@ export class BackupImportStream extends Writable { receivedAtDate: getTimestampFromLong(receivedTimestamp), timestamp: getTimestampFromLong(sentTimestamp), }; - } - ); + }); } private async fromNonBubbleChatItem( @@ -1401,6 +1461,52 @@ export class BackupImportStream extends Writable { additionalMessages: [], }; } + if (chatItem.giftBadge) { + const { giftBadge } = chatItem; + strictAssert( + Bytes.isNotEmpty(giftBadge.receiptCredentialPresentation), + 'Gift badge must have a presentation' + ); + + let state: GiftBadgeStates; + switch (giftBadge.state) { + case Backups.GiftBadge.State.OPENED: + state = GiftBadgeStates.Opened; + break; + + case Backups.GiftBadge.State.FAILED: + case Backups.GiftBadge.State.REDEEMED: + state = GiftBadgeStates.Redeemed; + break; + + case Backups.GiftBadge.State.UNOPENED: + state = GiftBadgeStates.Unopened; + break; + + default: + state = GiftBadgeStates.Unopened; + break; + } + + const receipt = new ReceiptCredentialPresentation( + Buffer.from(giftBadge.receiptCredentialPresentation) + ); + + return { + message: { + giftBadge: { + receiptCredentialPresentation: Bytes.toBase64( + giftBadge.receiptCredentialPresentation + ), + expiration: Number(receipt.getReceiptExpirationTime()) * SECOND, + id: undefined, + level: Number(receipt.getReceiptLevel()), + state, + }, + }, + additionalMessages: [], + }; + } if (chatItem.updateMessage) { return this.fromChatItemUpdateMessage(chatItem.updateMessage, options); } diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 3b195b34ce1b..83834490921f 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -397,6 +397,13 @@ export function toAccountRecord( if (typeof subscriberCurrencyCode === 'string') { accountRecord.subscriberCurrencyCode = subscriberCurrencyCode; } + const donorSubscriptionManuallyCancelled = window.storage.get( + 'donorSubscriptionManuallyCancelled' + ); + if (typeof donorSubscriptionManuallyCancelled === 'boolean') { + accountRecord.donorSubscriptionManuallyCancelled = + donorSubscriptionManuallyCancelled; + } const backupsSubscriberId = window.storage.get('backupsSubscriberId'); if (Bytes.isNotEmpty(backupsSubscriberId)) { accountRecord.backupsSubscriberId = backupsSubscriberId; @@ -407,6 +414,13 @@ export function toAccountRecord( if (typeof backupsSubscriberCurrencyCode === 'string') { accountRecord.backupsSubscriberCurrencyCode = backupsSubscriberCurrencyCode; } + const backupsSubscriptionManuallyCancelled = window.storage.get( + 'backupsSubscriptionManuallyCancelled' + ); + if (typeof backupsSubscriptionManuallyCancelled === 'boolean') { + accountRecord.backupsSubscriptionManuallyCancelled = + backupsSubscriptionManuallyCancelled; + } const displayBadgesOnProfile = window.storage.get('displayBadgesOnProfile'); if (displayBadgesOnProfile !== undefined) { accountRecord.displayBadgesOnProfile = displayBadgesOnProfile; @@ -436,6 +450,14 @@ export function toAccountRecord( hasCompletedUsernameOnboarding; } + const hasSeenGroupStoryEducationSheet = window.storage.get( + 'hasSeenGroupStoryEducationSheet' + ); + if (hasSeenGroupStoryEducationSheet !== undefined) { + accountRecord.hasSeenGroupStoryEducationSheet = + hasSeenGroupStoryEducationSheet; + } + const hasStoriesDisabled = window.storage.get('hasStoriesDisabled'); accountRecord.storiesDisabled = hasStoriesDisabled === true; @@ -1235,11 +1257,14 @@ export async function mergeAccountRecord( preferredReactionEmoji: rawPreferredReactionEmoji, subscriberId, subscriberCurrencyCode, + donorSubscriptionManuallyCancelled, backupsSubscriberId, backupsSubscriberCurrencyCode, + backupsSubscriptionManuallyCancelled, displayBadgesOnProfile, keepMutedChatsArchived, hasCompletedUsernameOnboarding, + hasSeenGroupStoryEducationSheet, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, storiesDisabled, @@ -1448,6 +1473,12 @@ export async function mergeAccountRecord( if (typeof subscriberCurrencyCode === 'string') { await window.storage.put('subscriberCurrencyCode', subscriberCurrencyCode); } + if (donorSubscriptionManuallyCancelled != null) { + await window.storage.put( + 'donorSubscriptionManuallyCancelled', + donorSubscriptionManuallyCancelled + ); + } if (Bytes.isNotEmpty(backupsSubscriberId)) { await window.storage.put('backupsSubscriberId', backupsSubscriberId); } @@ -1457,6 +1488,12 @@ export async function mergeAccountRecord( backupsSubscriberCurrencyCode ); } + if (backupsSubscriptionManuallyCancelled != null) { + await window.storage.put( + 'backupsSubscriptionManuallyCancelled', + backupsSubscriptionManuallyCancelled + ); + } await window.storage.put( 'displayBadgesOnProfile', Boolean(displayBadgesOnProfile) @@ -1490,6 +1527,15 @@ export async function mergeAccountRecord( hasCompletedUsernameOnboardingBool ); } + { + const hasCompletedUsernameOnboardingBool = Boolean( + hasSeenGroupStoryEducationSheet + ); + await window.storage.put( + 'hasSeenGroupStoryEducationSheet', + hasCompletedUsernameOnboardingBool + ); + } { const hasStoriesDisabled = Boolean(storiesDisabled); await window.storage.put('hasStoriesDisabled', hasStoriesDisabled); diff --git a/ts/test-electron/backup/attachments_test.ts b/ts/test-electron/backup/attachments_test.ts index 9a1368b3ad21..a59bdd597e09 100644 --- a/ts/test-electron/backup/attachments_test.ts +++ b/ts/test-electron/backup/attachments_test.ts @@ -118,6 +118,7 @@ describe('backup/attachments', () => { timestamp, readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, + unidentifiedDeliveryReceived: true, ...overrides, }; } diff --git a/ts/test-electron/backup/bubble_test.ts b/ts/test-electron/backup/bubble_test.ts index a063df8cff59..9197d8d13792 100644 --- a/ts/test-electron/backup/bubble_test.ts +++ b/ts/test-electron/backup/bubble_test.ts @@ -5,6 +5,7 @@ import { v4 as generateGuid } from 'uuid'; import { SendStatus } from '../../messages/MessageSendState'; import type { ConversationModel } from '../../models/conversations'; +import { GiftBadgeStates } from '../../components/conversation/Message'; import Data from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; @@ -15,6 +16,15 @@ import { setupBasics, symmetricRoundtripHarness, OUR_ACI } from './helpers'; const CONTACT_A = generateAci(); +const BADGE_RECEIPT = + 'AEpyZxbRBT+T5PQw9Wcx1QE2aFvL7LoLir9V4UF09Kk9qiP4SpIlHdlWHrAICy6F' + + '6WdbdCj45fY6cadDKbBmkw+abohRTJnItrFhyKurnA5X+mZHZv4OvS+aZFmAYS6J' + + 'W+hpkbI+Fk7Gu3mEix7Pgz1I2EwGFlUBpm7/nuD5A0cKLrUJAMM142fnOEervePV' + + 'bf0c6Sw5X5aCsBw9J+dxFUGAAAAAAAAAAMH58UUeUj2oH1jfqc0Hb2RUtdA3ee8X' + + '0Pp83WT8njwFw5rNGSHeKqOvBZzfAhMGJoiz7l1XfIfsPIreaFb/tA9aq2bOAdDl' + + '5OYlxxl6DnjQ3+g3k9ycpl0elkaQnPW2Ai7yjeJ/96K1qssR2a/2b7xi10dmTRGg' + + 'gebhZnroYYgIgK22ZgAAAABkAAAAAAAAAD9j4f77Xo2Ox5tVyrV2DUo='; + describe('backup/bubble messages', () => { let contactA: ConversationModel; @@ -48,6 +58,7 @@ describe('backup/bubble messages', () => { body: 'd', readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, editMessageTimestamp: 5, editMessageReceivedAtMs: 5, editHistory: [ @@ -89,6 +100,7 @@ describe('backup/bubble messages', () => { status: SendStatus.Delivered, }, }, + unidentifiedDeliveries: [CONTACT_A], timestamp: 3, editMessageTimestamp: 5, editMessageReceivedAtMs: 5, @@ -131,4 +143,224 @@ describe('backup/bubble messages', () => { }, ]); }); + + it.skip('roundtrips unopened gift badge', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 3, + giftBadge: { + id: undefined, + level: 100, + expiration: 1723248000000, + receiptCredentialPresentation: BADGE_RECEIPT, + state: GiftBadgeStates.Opened, + }, + }, + ]); + }); + + it.skip('roundtrips opened gift badge', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 3, + giftBadge: { + id: undefined, + level: 100, + expiration: 1723248000000, + receiptCredentialPresentation: BADGE_RECEIPT, + state: GiftBadgeStates.Opened, + }, + }, + ]); + }); + + it.skip('roundtrips gift badge quote', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 3, + giftBadge: { + id: undefined, + level: 100, + expiration: 1723248000000, + receiptCredentialPresentation: BADGE_RECEIPT, + state: GiftBadgeStates.Opened, + }, + }, + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 4, + received_at_ms: 4, + sent_at: 4, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 4, + quote: { + authorAci: CONTACT_A, + attachments: [], + id: 3, + isViewOnce: false, + isGiftBadge: true, + messageId: '', + referencedMessageNotFound: false, + }, + }, + ]); + }); + + it('roundtrips sealed/unsealed incoming message', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: false, + timestamp: 3, + body: 'unsealed', + }, + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 4, + received_at_ms: 4, + sent_at: 4, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 4, + body: 'sealed', + }, + ]); + }); + + it('roundtrips sealed/unsealed outgoing message', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'outgoing', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: OUR_ACI, + sendStateByConversationId: { + [contactA.id]: { + status: SendStatus.Delivered, + }, + }, + unidentifiedDeliveries: undefined, + timestamp: 3, + body: 'unsealed', + }, + { + conversationId: contactA.id, + id: generateGuid(), + type: 'outgoing', + received_at: 4, + received_at_ms: 4, + sent_at: 4, + sourceServiceId: OUR_ACI, + sendStateByConversationId: { + [contactA.id]: { + status: SendStatus.Delivered, + }, + }, + unidentifiedDeliveries: [CONTACT_A], + timestamp: 4, + body: 'sealed', + }, + ]); + }); + + it('roundtrips messages with send errors', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'outgoing', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: OUR_ACI, + sendStateByConversationId: { + [contactA.id]: { + status: SendStatus.Delivered, + }, + }, + errors: [ + { + serviceId: CONTACT_A, + name: 'OutgoingIdentityKeyError', + message: `The identity of ${CONTACT_A} has changed.`, + }, + ], + timestamp: 3, + body: 'body', + }, + { + conversationId: contactA.id, + id: generateGuid(), + type: 'outgoing', + received_at: 4, + received_at_ms: 4, + sent_at: 4, + sourceServiceId: OUR_ACI, + sendStateByConversationId: { + [contactA.id]: { + status: SendStatus.Delivered, + }, + }, + errors: [ + { + serviceId: CONTACT_A, + name: 'OutgoingMessageError', + message: 'no http error', + }, + ], + timestamp: 4, + body: 'body', + }, + ]); + }); }); diff --git a/ts/test-electron/backup/helpers.ts b/ts/test-electron/backup/helpers.ts index 5ea83d99318f..0dc87d0b1ab5 100644 --- a/ts/test-electron/backup/helpers.ts +++ b/ts/test-electron/backup/helpers.ts @@ -93,36 +93,39 @@ function sortAndNormalize( return result; } - return { - ...rest, - conversationId: mapConvoId(conversationId), - reactions: reactions?.map(({ fromId, ...restOfReaction }) => { - return { - from: mapConvoId(fromId), - ...restOfReaction, - }; - }), - changedId: mapConvoId(changedId), - key_changed: mapConvoId(keyChanged), - verifiedChanged: mapConvoId(verifiedChanged), - sendStateByConverationId: mapSendState(sendStateByConversationId), - editHistory: editHistory?.map(history => { - const { - sendStateByConversationId: historySendState, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - received_at: _receivedAtHistory, - ...restOfHistory - } = history; + // Get rid of unserializable `undefined` values. + return JSON.parse( + JSON.stringify({ + ...rest, + conversationId: mapConvoId(conversationId), + reactions: reactions?.map(({ fromId, ...restOfReaction }) => { + return { + from: mapConvoId(fromId), + ...restOfReaction, + }; + }), + changedId: mapConvoId(changedId), + key_changed: mapConvoId(keyChanged), + verifiedChanged: mapConvoId(verifiedChanged), + sendStateByConverationId: mapSendState(sendStateByConversationId), + editHistory: editHistory?.map(history => { + const { + sendStateByConversationId: historySendState, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + received_at: _receivedAtHistory, + ...restOfHistory + } = history; - return { - ...restOfHistory, - sendStateByConversationId: mapSendState(historySendState), - }; - }), + return { + ...restOfHistory, + sendStateByConversationId: mapSendState(historySendState), + }; + }), - // Not an original property, but useful - isUnsupported: isUnsupportedMessage(message), - }; + // Not an original property, but useful + isUnsupported: isUnsupportedMessage(message), + }) + ); }); } diff --git a/ts/test-electron/backup/non_bubble_test.ts b/ts/test-electron/backup/non_bubble_test.ts index c9bf2f690ad0..889b0890bd47 100644 --- a/ts/test-electron/backup/non_bubble_test.ts +++ b/ts/test-electron/backup/non_bubble_test.ts @@ -66,6 +66,7 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, flags: Proto.DataMessage.Flags.END_SESSION, }, ]); @@ -204,6 +205,7 @@ describe('backup/non-bubble messages', () => { timestamp: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, }, ]); }); @@ -224,12 +226,12 @@ describe('backup/non-bubble messages', () => { timestamp: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, }, ]); }); - // TODO: DESKTOP-7122 - it.skip('roundtrips bare payments notification', async () => { + it('roundtrips bare payments notification', async () => { await symmetricRoundtripHarness([ { conversationId: contactA.id, @@ -243,6 +245,7 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, payment: { kind: PaymentEventKind.Notification, note: 'note with text', @@ -251,8 +254,7 @@ describe('backup/non-bubble messages', () => { ]); }); - // TODO: DESKTOP-7122 - it.skip('roundtrips full payments notification', async () => { + it('roundtrips full payments notification', async () => { await symmetricRoundtripHarness([ { conversationId: contactA.id, @@ -266,6 +268,7 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, payment: { kind: PaymentEventKind.Notification, note: 'note with text', @@ -297,6 +300,7 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, contact: [ { name: { @@ -339,6 +343,7 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, // TODO (DESKTOP-6845): properly handle data FilePointer sticker: { emoji: '👍', @@ -373,6 +378,7 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, isErased: true, }, ]); @@ -475,8 +481,7 @@ describe('backup/non-bubble messages', () => { ]); }); - // TODO: DESKTOP-7122 - it.skip('roundtrips unsupported message', async () => { + it('roundtrips unsupported message', async () => { await symmetricRoundtripHarness([ { conversationId: contactA.id, @@ -490,8 +495,9 @@ describe('backup/non-bubble messages', () => { timestamp: 1, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, - supportedVersionAtReceive: 1, - requiredProtocolVersion: 2, + unidentifiedDeliveryReceived: true, + supportedVersionAtReceive: 5, + requiredProtocolVersion: 6, }, ]); }); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index c1fcb5f49bfd..35a76cfad193 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -72,6 +72,7 @@ export type StorageAccessType = { hasCompletedUsernameOnboarding: boolean; hasCompletedUsernameLinkOnboarding: boolean; hasCompletedSafetyNumberOnboarding: boolean; + hasSeenGroupStoryEducationSheet: boolean; hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; storyViewReceiptsEnabled: boolean; @@ -154,8 +155,10 @@ export type StorageAccessType = { areWeASubscriber: boolean; subscriberId: Uint8Array; subscriberCurrencyCode: string; + donorSubscriptionManuallyCancelled: boolean; backupsSubscriberId: Uint8Array; backupsSubscriberCurrencyCode: string; + backupsSubscriptionManuallyCancelled: boolean; displayBadgesOnProfile: boolean; keepMutedChatsArchived: boolean; usernameLastIntegrityCheck: number;