Implement backup support for chat folders

This commit is contained in:
Jamie
2025-11-10 15:27:32 -08:00
committed by GitHub
parent d328b45a28
commit 3dbab74378
6 changed files with 142 additions and 32 deletions

View File

@@ -58,6 +58,10 @@ import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/Pre
import { CurrentChatFolders } from '../types/CurrentChatFolders.std.js'; import { CurrentChatFolders } from '../types/CurrentChatFolders.std.js';
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js'; import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js';
import type { NotificationProfileIdString } from '../types/NotificationProfile.std.js'; import type { NotificationProfileIdString } from '../types/NotificationProfile.std.js';
import type {
ExportResultType,
LocalBackupExportResultType,
} from '../services/backups/types.std.js';
import { BackupLevel } from '../services/backups/types.std.js'; import { BackupLevel } from '../services/backups/types.std.js';
const { shuffle } = lodash; const { shuffle } = lodash;
@@ -112,13 +116,14 @@ const availableSpeakers = [
}, },
]; ];
const validateBackupResult = { const validateBackupResult: ExportResultType = {
totalBytes: 100, totalBytes: 100,
duration: 10000, duration: 10000,
stats: { stats: {
adHocCalls: 1, adHocCalls: 1,
callLinks: 2, callLinks: 2,
conversations: 3, conversations: 3,
chatFolders: 3,
chats: 4, chats: 4,
distributionLists: 5, distributionLists: 5,
messages: 6, messages: 6,
@@ -129,7 +134,7 @@ const validateBackupResult = {
}, },
}; };
const exportLocalBackupResult = { const exportLocalBackupResult: LocalBackupExportResultType = {
...validateBackupResult, ...validateBackupResult,
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169', snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
}; };

View File

@@ -120,6 +120,7 @@ import type {
AboutMe, AboutMe,
BackupExportOptions, BackupExportOptions,
LocalChatStyle, LocalChatStyle,
StatsType,
} from './types.std.js'; } from './types.std.js';
import { messageHasPaymentEvent } from '../../messages/payments.std.js'; import { messageHasPaymentEvent } from '../../messages/payments.std.js';
import { import {
@@ -177,6 +178,7 @@ import {
} from '../../util/Settings.preload.js'; } from '../../util/Settings.preload.js';
import { KIBIBYTE } from '../../types/AttachmentSize.std.js'; import { KIBIBYTE } from '../../types/AttachmentSize.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js';
import { ChatFolderType } from '../../types/ChatFolder.std.js';
const { isNumber } = lodash; const { isNumber } = lodash;
@@ -242,19 +244,6 @@ type NonBubbleResultType = Readonly<
} }
>; >;
export type StatsType = {
adHocCalls: number;
callLinks: number;
conversations: number;
chats: number;
distributionLists: number;
messages: number;
notificationProfiles: number;
skippedMessages: number;
stickerPacks: number;
fixedDirectMessages: number;
};
export class BackupExportStream extends Readable { export class BackupExportStream extends Readable {
// Shared between all methods for consistency. // Shared between all methods for consistency.
#now = Date.now(); #now = Date.now();
@@ -269,6 +258,7 @@ export class BackupExportStream extends Readable {
adHocCalls: 0, adHocCalls: 0,
callLinks: 0, callLinks: 0,
conversations: 0, conversations: 0,
chatFolders: 0,
chats: 0, chats: 0,
distributionLists: 0, distributionLists: 0,
messages: 0, messages: 0,
@@ -728,6 +718,42 @@ export class BackupExportStream extends Readable {
this.#stats.notificationProfiles += 1; this.#stats.notificationProfiles += 1;
} }
const currentChatFolders = await DataReader.getCurrentChatFolders();
for (const chatFolder of currentChatFolders) {
let folderType: Backups.ChatFolder.FolderType;
if (chatFolder.folderType === ChatFolderType.ALL) {
folderType = Backups.ChatFolder.FolderType.ALL;
} else if (chatFolder.folderType === ChatFolderType.CUSTOM) {
folderType = Backups.ChatFolder.FolderType.CUSTOM;
} else {
log.warn('backups: Dropping chat folder; unknown folder type');
continue;
}
this.#pushFrame({
chatFolder: {
id: uuidToBytes(chatFolder.id),
name: chatFolder.name,
folderType,
showOnlyUnread: chatFolder.showOnlyUnread,
showMutedChats: chatFolder.showMutedChats,
includeAllIndividualChats: chatFolder.includeAllIndividualChats,
includeAllGroupChats: chatFolder.includeAllGroupChats,
includedRecipientIds: chatFolder.includedConversationIds.map(id => {
return this.#getOrPushPrivateRecipient({ id });
}),
excludedRecipientIds: chatFolder.excludedConversationIds.map(id => {
return this.#getOrPushPrivateRecipient({ id });
}),
},
});
// eslint-disable-next-line no-await-in-loop
await this.#flush();
this.#stats.chatFolders += 1;
}
let cursor: PageMessagesCursorType | undefined; let cursor: PageMessagesCursorType | undefined;
const callHistory = await DataReader.getAllCallHistory(); const callHistory = await DataReader.getAllCallHistory();

View File

@@ -164,6 +164,8 @@ import {
import { normalizeNotificationProfileId } from '../../types/NotificationProfile-node.node.js'; import { normalizeNotificationProfileId } from '../../types/NotificationProfile-node.node.js';
import { updateBackupMediaDownloadProgress } from '../../util/updateBackupMediaDownloadProgress.preload.js'; import { updateBackupMediaDownloadProgress } from '../../util/updateBackupMediaDownloadProgress.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js';
import { ChatFolderType } from '../../types/ChatFolder.std.js';
import type { ChatFolderId, ChatFolder } from '../../types/ChatFolder.std.js';
const { isNumber } = lodash; const { isNumber } = lodash;
@@ -543,9 +545,7 @@ export class BackupImportStream extends Writable {
} else if (frame.notificationProfile) { } else if (frame.notificationProfile) {
await this.#fromNotificationProfile(frame.notificationProfile); await this.#fromNotificationProfile(frame.notificationProfile);
} else if (frame.chatFolder) { } else if (frame.chatFolder) {
log.warn( await this.#fromChatFolder(frame.chatFolder);
`${this.#logId}: Received currently unsupported feature: chat folder. Dropping.`
);
} else { } else {
log.warn( log.warn(
`${this.#logId}: unknown unsupported frame item ${frame.item}` `${this.#logId}: unknown unsupported frame item ${frame.item}`
@@ -3634,6 +3634,68 @@ export class BackupImportStream extends Writable {
await DataWriter.createNotificationProfile(profile); await DataWriter.createNotificationProfile(profile);
} }
#chatFolderPositionCursor = 0;
async #fromChatFolder(proto: Backups.IChatFolder): Promise<void> {
if (proto.id == null || proto.id.length === 0) {
log.warn('Dropping chat folder; it was missing an id');
return;
}
const id = bytesToUuid(proto.id) as ChatFolderId | undefined;
if (id == null) {
log.warn('Dropping chat folder; invalid uuid bytes');
return;
}
let folderType: ChatFolderType;
if (proto.folderType === Backups.ChatFolder.FolderType.ALL) {
folderType = ChatFolderType.ALL;
} else if (proto.folderType === Backups.ChatFolder.FolderType.CUSTOM) {
folderType = ChatFolderType.CUSTOM;
} else {
log.warn('Dropping chat folder; unknown folder type');
return;
}
const position = this.#chatFolderPositionCursor;
this.#chatFolderPositionCursor += 1;
const includedRecipientIds = proto.includedRecipientIds ?? [];
const excludedRecipientIds = proto.excludedRecipientIds ?? [];
const chatFolder: ChatFolder = {
id,
name: proto.name ?? '',
folderType,
showOnlyUnread: proto.showOnlyUnread ?? false,
showMutedChats: proto.showMutedChats ?? false,
includeAllIndividualChats: proto.includeAllIndividualChats ?? false,
includeAllGroupChats: proto.includeAllGroupChats ?? false,
includedConversationIds: includedRecipientIds.map(recipientId => {
const convo = this.#recipientIdToConvo.get(recipientId.toNumber());
strictAssert(convo != null, 'Missing chat folder included recipient');
return convo.id;
}),
excludedConversationIds: excludedRecipientIds.map(recipientId => {
const convo = this.#recipientIdToConvo.get(recipientId.toNumber());
strictAssert(convo != null, 'Missing chat folder included recipient');
return convo.id;
}),
position,
deletedAtTimestampMs: 0,
storageID: null,
storageVersion: null,
storageUnknownFields: null,
storageNeedsSync: false,
};
if (folderType === ChatFolderType.ALL) {
await DataWriter.upsertAllChatsChatFolderFromSync(chatFolder);
} else {
await DataWriter.createChatFolder(chatFolder);
}
}
async #fromCustomChatColors( async #fromCustomChatColors(
customChatColors: customChatColors:
| ReadonlyArray<Backups.ChatStyle.ICustomChatColor> | ReadonlyArray<Backups.ChatStyle.ICustomChatColor>

View File

@@ -55,7 +55,7 @@ import { measureSize } from '../../AttachmentCrypto.node.js';
import { signalProtocolStore } from '../../SignalProtocolStore.preload.js'; import { signalProtocolStore } from '../../SignalProtocolStore.preload.js';
import { isTestOrMockEnvironment } from '../../environment.std.js'; import { isTestOrMockEnvironment } from '../../environment.std.js';
import { runStorageServiceSyncJob } from '../storage.preload.js'; import { runStorageServiceSyncJob } from '../storage.preload.js';
import { BackupExportStream, type StatsType } from './export.preload.js'; import { BackupExportStream } from './export.preload.js';
import { BackupImportStream } from './import.preload.js'; import { BackupImportStream } from './import.preload.js';
import { import {
getBackupId, getBackupId,
@@ -69,7 +69,12 @@ import {
validateBackupStream, validateBackupStream,
ValidationType, ValidationType,
} from './validator.preload.js'; } from './validator.preload.js';
import type { BackupExportOptions, BackupImportOptions } from './types.std.js'; import type {
BackupExportOptions,
BackupImportOptions,
ExportResultType,
LocalBackupExportResultType,
} from './types.std.js';
import { import {
BackupInstallerError, BackupInstallerError,
BackupDownloadFailedError, BackupDownloadFailedError,
@@ -126,16 +131,6 @@ type DoDownloadOptionsType = Readonly<{
) => void; ) => void;
}>; }>;
export type ExportResultType = Readonly<{
totalBytes: number;
duration: number;
stats: Readonly<StatsType>;
}>;
export type LocalBackupExportResultType = ExportResultType & {
snapshotDir: string;
};
export type ValidationResultType = Readonly< export type ValidationResultType = Readonly<
| { | {
result: ExportResultType | LocalBackupExportResultType; result: ExportResultType | LocalBackupExportResultType;

View File

@@ -50,3 +50,27 @@ export type LocalChatStyle = Readonly<{
dimWallpaperInDarkMode: boolean | undefined; dimWallpaperInDarkMode: boolean | undefined;
autoBubbleColor: boolean | undefined; autoBubbleColor: boolean | undefined;
}>; }>;
export type StatsType = {
adHocCalls: number;
callLinks: number;
conversations: number;
chatFolders: number;
chats: number;
distributionLists: number;
messages: number;
notificationProfiles: number;
skippedMessages: number;
stickerPacks: number;
fixedDirectMessages: number;
};
export type ExportResultType = Readonly<{
totalBytes: number;
duration: number;
stats: Readonly<StatsType>;
}>;
export type LocalBackupExportResultType = ExportResultType & {
snapshotDir: string;
};

View File

@@ -80,8 +80,6 @@ describe('backup/integration', () => {
if ( if (
expectedString.includes('ReleaseChannelDonationRequest') || expectedString.includes('ReleaseChannelDonationRequest') ||
// TODO (DESKTOP-8025) roundtrip these frames
fullPath.includes('chat_folder') ||
// TODO (DESKTOP-9209) roundtrip these frames when feature is added // TODO (DESKTOP-9209) roundtrip these frames when feature is added
fullPath.includes('poll_terminate') fullPath.includes('poll_terminate')
) { ) {