add sending of message for opengroupv2`

This commit is contained in:
Audric Ackermann 2021-04-26 11:56:00 +10:00
parent 35d66d8865
commit f7e163c142
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4
16 changed files with 334 additions and 74 deletions

View File

@ -39,6 +39,10 @@ import { ReadReceiptMessage } from '../session/messages/outgoing/controlMessage/
import { OpenGroup } from '../opengroup/opengroupV1/OpenGroup';
import { OpenGroupUtils } from '../opengroup/utils';
import { ConversationInteraction } from '../interactions';
import { getV2OpenGroupRoom } from '../data/opengroups';
import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { OpenGroupRequestCommonType } from '../opengroup/opengroupV2/ApiUtil';
import { getOpenGroupV2FromConversationId } from '../opengroup/utils/OpenGroupUtils';
export enum ConversationType {
GROUP = 'group',
@ -573,9 +577,16 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
};
}
public toOpenGroup() {
if (!this.isPublic()) {
throw new Error('tried to run toOpenGroup for not public group');
public toOpenGroupV2(): OpenGroupRequestCommonType {
if (!this.isOpenGroupV2()) {
throw new Error('tried to run toOpenGroup for not public group v2');
}
return getOpenGroupV2FromConversationId(this.id);
}
public toOpenGroupV1(): OpenGroup {
if (!this.isOpenGroupV1()) {
throw new Error('tried to run toOpenGroup for not public group v1');
}
return new OpenGroup({
@ -596,8 +607,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
throw new Error('sendMessageJob() sent_at must be set.');
}
if (this.isPublic()) {
const openGroup = this.toOpenGroup();
if (this.isPublic() && !this.isOpenGroupV2()) {
const openGroup = this.toOpenGroupV1();
const openGroupParams = {
body: uploads.body,
@ -611,8 +622,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const openGroupMessage = new OpenGroupMessage(openGroupParams);
// we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToOpenGroup(openGroupMessage);
return;
}
// an OpenGroupV2 message is just a visible message
const chatMessageParams: VisibleMessageParams = {
body: uploads.body,
identifier: id,
@ -624,6 +637,18 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
lokiProfile: UserUtils.getOurProfile(),
};
if (this.isOpenGroupV2()) {
const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams);
const roomInfos = this.toOpenGroupV2();
if (!roomInfos) {
throw new Error('Could not find this room in db');
}
// we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos);
return;
}
const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) {
if (this.isMe()) {

View File

@ -27,6 +27,11 @@ import { isOpenGroupV2 } from '../opengroup/utils/OpenGroupUtils';
import { banUser } from '../opengroup/opengroupV2/OpenGroupAPIV2';
import { getV2OpenGroupRoom } from '../data/opengroups';
import { MessageInteraction } from '../interactions';
import {
uploadAttachmentsV2,
uploadLinkPreviewsV2,
uploadQuoteThumbnailsV2,
} from '../session/utils/AttachmentsV2';
export class MessageModel extends Backbone.Model<MessageAttributes> {
public propsForTimerNotification: any;
public propsForGroupNotification: any;
@ -736,14 +741,35 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const previewWithData = await window.Signal.Migrations.loadPreviewData(this.get('preview'));
const conversation = this.getConversation();
const openGroup =
(conversation && conversation.isPublic() && conversation.toOpenGroup()) || undefined;
let attachmentPromise;
let linkPreviewPromise;
let quotePromise;
const { AttachmentUtils } = Utils;
// we want to go for the v1, if this is an OpenGroupV1 or not an open group at all
if (conversation?.isOpenGroupV2()) {
const openGroupV2 = conversation.toOpenGroupV2();
attachmentPromise = uploadAttachmentsV2(filenameOverridenAttachments, openGroupV2);
linkPreviewPromise = uploadLinkPreviewsV2(previewWithData, openGroupV2);
quotePromise = uploadQuoteThumbnailsV2(openGroupV2, quoteWithData);
} else {
// NOTE: we want to go for the v1 if this is an OpenGroupV1 or not an open group at all
// because there is a fallback invoked on uploadV1() for attachments for not open groups attachments
const openGroupV1 = conversation?.toOpenGroupV1();
attachmentPromise = AttachmentUtils.uploadAttachmentsV1(
filenameOverridenAttachments,
openGroupV1
);
linkPreviewPromise = AttachmentUtils.uploadLinkPreviewsV1(previewWithData, openGroupV1);
quotePromise = AttachmentUtils.uploadQuoteThumbnailsV1(quoteWithData, openGroupV1);
}
const [attachments, preview, quote] = await Promise.all([
AttachmentUtils.uploadAttachments(filenameOverridenAttachments, openGroup),
AttachmentUtils.uploadLinkPreviews(previewWithData, openGroup),
AttachmentUtils.uploadQuoteThumbnails(quoteWithData, openGroup),
attachmentPromise,
linkPreviewPromise,
quotePromise,
]);
return {

View File

@ -1,13 +1,6 @@
import _ from 'underscore';
import { getV2OpenGroupRoomByRoomId } from '../../data/opengroups';
import { getSodium } from '../../session/crypto';
import { PubKey } from '../../session/types';
import {
fromBase64ToArray,
fromBase64ToArrayBuffer,
fromHex,
fromHexToArray,
} from '../../session/utils/String';
import { fromBase64ToArrayBuffer, fromHex } from '../../session/utils/String';
import { OpenGroupMessageV2 } from './OpenGroupMessageV2';
export const defaultServer = 'https://sessionopengroup.com';
@ -80,6 +73,7 @@ export const setCachedModerators = (
cachedModerators.set(serverUrl, new Map());
allRoomsMods = cachedModerators.get(serverUrl);
}
// tslint:disable: no-non-null-assertion
if (!allRoomsMods!.get(roomId)) {
allRoomsMods!.set(roomId, new Set());
}

View File

@ -335,40 +335,38 @@ export const getMessages = async ({
return validMessages;
};
/**
* Send the specified message to the specified room.
* If an error happens, this function throws it
*
*/
export const postMessage = async (
message: OpenGroupMessageV2,
room: OpenGroupRequestCommonType
) => {
try {
const signedMessage = await message.sign();
const json = signedMessage.toJson();
const signedMessage = await message.sign();
const json = signedMessage.toJson();
const request: OpenGroupV2Request = {
method: 'POST',
room: room.roomId,
server: room.serverUrl,
queryParams: json,
isAuthRequired: true,
endpoint: 'messages',
};
const result = await sendOpenGroupV2Request(request);
const statusCode = parseStatusCodeFromOnionRequest(result);
const request: OpenGroupV2Request = {
method: 'POST',
room: room.roomId,
server: room.serverUrl,
queryParams: json,
isAuthRequired: true,
endpoint: 'messages',
};
const result = await sendOpenGroupV2Request(request);
const statusCode = parseStatusCodeFromOnionRequest(result);
if (statusCode !== 200) {
window.log.warn(`Could not postMessage, status code: ${statusCode}`);
return null;
}
const rawMessage = (result as any)?.result?.message;
if (!rawMessage) {
window.log.warn('postMessage parsing failed');
return null;
}
// this will throw if the json is not valid
return OpenGroupMessageV2.fromJson(rawMessage);
} catch (e) {
window.log.error('Failed to post message to open group v2', e);
return null;
if (statusCode !== 200) {
throw new Error(`Could not postMessage, status code: ${statusCode}`);
}
const rawMessage = (result as any)?.result?.message;
if (!rawMessage) {
throw new Error('postMessage parsing failed');
}
// this will throw if the json is not valid
return OpenGroupMessageV2.fromJson(rawMessage);
};
/** Those functions are related to moderators management */

View File

@ -145,7 +145,6 @@ async function sendOpenGroupV2RequestCompactPoll(
const roomPollValidResults = results.filter(ret => ret.statusCode === 200);
if (roomWithTokensToRefresh) {
console.warn('roomWithTokensToRefresh', roomWithTokensToRefresh);
await Promise.all(
roomWithTokensToRefresh.map(async roomId => {
const roomDetails = await getV2OpenGroupRoomByRoomId({

View File

@ -1,6 +1,7 @@
import { default as insecureNodeFetch } from 'node-fetch';
import { OpenGroupV2Room } from '../../data/opengroups';
import { sendViaOnion } from '../../session/onions/onionSend';
import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil';
const protocolRegex = new RegExp('(https?://)?');
@ -163,6 +164,24 @@ export function getOpenGroupV2ConversationId(serverUrl: string, roomId: string)
return `${openGroupPrefix}${roomId}@${serverUrl}`;
}
/**
* No sql access. Just plain string logic
*/
export function getOpenGroupV2FromConversationId(
conversationId: string
): OpenGroupRequestCommonType {
if (isOpenGroupV2(conversationId)) {
const atIndex = conversationId.indexOf('@');
const roomId = conversationId.slice(openGroupPrefix.length, atIndex);
const serverUrl = conversationId.slice(atIndex + 1);
return {
serverUrl,
roomId,
};
}
throw new Error('Not a v2 open group convo id');
}
/**
* Check if this conversation id corresponds to an OpenGroupV1 conversation.
* No access to database are made. Only regex matches

View File

@ -10,6 +10,9 @@ interface OpenGroupMessageParams extends MessageParams {
quote?: Quote;
}
/**
* This class is only used for OpenGroup v1 (deprecated)
*/
export class OpenGroupMessage extends Message {
public readonly group: OpenGroup;
public readonly body?: string;

View File

@ -0,0 +1,3 @@
import { VisibleMessage } from './VisibleMessage';
export class OpenGroupVisibleMessage extends VisibleMessage {}

View File

@ -18,6 +18,9 @@ import { ClosedGroupRemovedMembersMessage } from '../messages/outgoing/controlMe
import { ClosedGroupVisibleMessage } from '../messages/outgoing/visibleMessage/ClosedGroupVisibleMessage';
import { SyncMessageType } from '../utils/syncUtils';
import { OpenGroupRequestCommonType } from '../../opengroup/opengroupV2/ApiUtil';
import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
type ClosedGroupMessageType =
| ClosedGroupVisibleMessage
| ClosedGroupAddedMembersMessage
@ -51,7 +54,7 @@ export class MessageQueue {
}
/**
* This function is synced. It will wait for the message to be delivered to the open
* DEPRECATED This function is synced. It will wait for the message to be delivered to the open
* group to return.
* So there is no need for a sendCb callback
*
@ -79,6 +82,34 @@ export class MessageQueue {
}
}
/**
* This function is synced. It will wait for the message to be delivered to the open
* group to return.
* So there is no need for a sendCb callback
*
*/
public async sendToOpenGroupV2(
message: OpenGroupVisibleMessage,
roomInfos: OpenGroupRequestCommonType
) {
// No queue needed for Open Groups v2; send directly
const error = new Error('Failed to send message to open group.');
try {
const { sentTimestamp, serverId } = await MessageSender.sendToOpenGroupV2(message, roomInfos);
if (!serverId) {
throw new Error(`Invalid serverId returned by server: ${serverId}`);
}
void MessageSentHandler.handlePublicMessageSentSuccess(message, {
serverId: serverId,
serverTimestamp: sentTimestamp,
});
} catch (e) {
window?.log?.warn(`Failed to send message to open group: ${roomInfos}`, e);
void MessageSentHandler.handleMessageSentFailure(message, error);
}
}
/**
*
* @param sentCb currently only called for medium groups sent message

View File

@ -7,6 +7,13 @@ import { MessageEncrypter } from '../crypto';
import pRetry from 'p-retry';
import { PubKey } from '../types';
import { UserUtils } from '../utils';
import { VisibleMessage } from '../messages/outgoing/visibleMessage/VisibleMessage';
import { OpenGroupRequestCommonType } from '../../opengroup/opengroupV2/ApiUtil';
import { postMessage } from '../../opengroup/opengroupV2/OpenGroupAPIV2';
import { OpenGroupMessageV2 } from '../../opengroup/opengroupV2/OpenGroupMessageV2';
import { padPlainTextBuffer } from '../crypto/MessageEncrypter';
import { fromUInt8ArrayToBase64 } from '../utils/String';
import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
// ================ Regular ================
@ -99,7 +106,7 @@ function wrapEnvelope(envelope: SignalService.Envelope): Uint8Array {
// ================ Open Group ================
/**
* Send a message to an open group.
* Deprecated Send a message to an open group v2.
* @param message The open group message.
*/
export async function sendToOpenGroup(
@ -132,3 +139,24 @@ export async function sendToOpenGroup(
timestamp
);
}
/**
* Deprecated Send a message to an open group v2.
* @param message The open group message.
*/
export async function sendToOpenGroupV2(
rawMessage: OpenGroupVisibleMessage,
roomInfos: OpenGroupRequestCommonType
): Promise<OpenGroupMessageV2> {
const paddedBody = padPlainTextBuffer(rawMessage.plainTextBuffer());
const v2Message = new OpenGroupMessageV2({
sentTimestamp: Date.now(),
sender: UserUtils.getOurPubKeyStrFromCache(),
base64EncodedData: fromUInt8ArrayToBase64(paddedBody),
// the signature is added in the postMessage())
});
// postMessage throws
const sentMessage = await postMessage(v2Message, roomInfos);
return sentMessage;
}

View File

@ -1,16 +1,16 @@
import _ from 'lodash';
import { getMessageById } from '../../data/data';
import { SignalService } from '../../protobuf';
import { ConversationController } from '../conversations';
import { MessageController } from '../messages';
import { OpenGroupMessage } from '../messages/outgoing';
import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { EncryptionType, RawMessage } from '../types';
import { UserUtils } from '../utils';
// tslint:disable-next-line no-unnecessary-class
export class MessageSentHandler {
public static async handlePublicMessageSentSuccess(
sentMessage: OpenGroupMessage,
sentMessage: OpenGroupMessage | OpenGroupVisibleMessage,
result: { serverId: number; serverTimestamp: number }
) {
const { serverId, serverTimestamp } = result;
@ -131,7 +131,7 @@ export class MessageSentHandler {
}
public static async handleMessageSentFailure(
sentMessage: RawMessage | OpenGroupMessage,
sentMessage: RawMessage | OpenGroupMessage | OpenGroupVisibleMessage,
error: any
) {
const fetchedMessage = await MessageSentHandler.fetchHandleMessageSentData(sentMessage);
@ -143,7 +143,10 @@ export class MessageSentHandler {
await fetchedMessage.saveErrors(error);
}
if (!(sentMessage instanceof OpenGroupMessage)) {
if (
!(sentMessage instanceof OpenGroupMessage) &&
!(sentMessage instanceof OpenGroupVisibleMessage)
) {
const isOurDevice = UserUtils.isUsFromCache(sentMessage.device);
// if this message was for ourself, and it was not already synced,
// it means that we failed to sync it.
@ -176,7 +179,9 @@ export class MessageSentHandler {
* In this case, this function will look for it in the database and return it.
* If the message is found on the db, it will also register it to the MessageController so our subsequent calls are quicker.
*/
private static async fetchHandleMessageSentData(m: RawMessage | OpenGroupMessage) {
private static async fetchHandleMessageSentData(
m: RawMessage | OpenGroupMessage | OpenGroupVisibleMessage
) {
// if a message was sent and this message was sent after the last app restart,
// this message is still in memory in the MessageController
const msg = MessageController.getInstance().get(m.identifier);

View File

@ -1,7 +1,5 @@
import { EncryptionType } from './EncryptionType';
// TODO: Should we store failure count on raw messages??
// Might be better to have a seperate interface which takes in a raw message aswell as a failure count
export type RawMessage = {
identifier: string;
plainTextBuffer: Uint8Array;

View File

@ -1,7 +1,6 @@
import * as crypto from 'crypto';
import { Attachment } from '../../types/Attachment';
import { LokiAppDotNetServerInterface } from '../../../js/modules/loki_app_dot_net_api';
import {
AttachmentPointer,
Preview,
@ -43,11 +42,7 @@ export class AttachmentUtils {
private constructor() {}
public static getDefaultServer(): LokiAppDotNetServerInterface {
return window.tokenlessFileServerAdnAPI;
}
public static async upload(params: UploadParams): Promise<AttachmentPointer> {
public static async uploadV1(params: UploadParams): Promise<AttachmentPointer> {
const { attachment, openGroup, isAvatar = false, isRaw = false, shouldPad = false } = params;
if (typeof attachment !== 'object' || attachment == null) {
throw new Error('Invalid attachment passed.');
@ -59,7 +54,7 @@ export class AttachmentUtils {
);
}
let server = this.getDefaultServer();
let server = window.tokenlessFileServerAdnAPI;
if (openGroup) {
const openGroupServer = await window.lokiPublicChatAPI.findOrCreateServer(openGroup.server);
if (!openGroupServer) {
@ -68,7 +63,7 @@ export class AttachmentUtils {
server = openGroupServer;
}
const pointer: AttachmentPointer = {
contentType: attachment.contentType ? attachment.contentType : undefined,
contentType: attachment.contentType || undefined,
size: attachment.size,
fileName: attachment.fileName,
flags: attachment.flags,
@ -80,7 +75,7 @@ export class AttachmentUtils {
if (isRaw || openGroup) {
attachmentData = attachment.data;
} else {
server = this.getDefaultServer();
server = window.tokenlessFileServerAdnAPI;
pointer.key = new Uint8Array(crypto.randomBytes(64));
const iv = new Uint8Array(crypto.randomBytes(16));
@ -107,7 +102,7 @@ export class AttachmentUtils {
return pointer;
}
public static async uploadAvatar(
public static async uploadAvatarV1(
attachment?: Attachment
): Promise<AttachmentPointer | undefined> {
if (!attachment) {
@ -116,19 +111,19 @@ export class AttachmentUtils {
// isRaw is true since the data is already encrypted
// and doesn't need to be encrypted again
return this.upload({
return this.uploadV1({
attachment,
isAvatar: true,
isRaw: true,
});
}
public static async uploadAttachments(
public static async uploadAttachmentsV1(
attachments: Array<Attachment>,
openGroup?: OpenGroup
): Promise<Array<AttachmentPointer>> {
const promises = (attachments || []).map(async attachment =>
this.upload({
this.uploadV1({
attachment,
openGroup,
shouldPad: true,
@ -138,7 +133,7 @@ export class AttachmentUtils {
return Promise.all(promises);
}
public static async uploadLinkPreviews(
public static async uploadLinkPreviewsV1(
previews: Array<RawPreview>,
openGroup?: OpenGroup
): Promise<Array<Preview>> {
@ -149,7 +144,7 @@ export class AttachmentUtils {
}
return {
...item,
image: await this.upload({
image: await this.uploadV1({
attachment: item.image,
openGroup,
}),
@ -158,7 +153,7 @@ export class AttachmentUtils {
return Promise.all(promises);
}
public static async uploadQuoteThumbnails(
public static async uploadQuoteThumbnailsV1(
quote?: RawQuote,
openGroup?: OpenGroup
): Promise<Quote | undefined> {
@ -169,7 +164,7 @@ export class AttachmentUtils {
const promises = (quote.attachments ?? []).map(async attachment => {
let thumbnail: AttachmentPointer | undefined;
if (attachment.thumbnail) {
thumbnail = await this.upload({
thumbnail = await this.uploadV1({
attachment: attachment.thumbnail,
openGroup,
});
@ -206,7 +201,7 @@ export class AttachmentUtils {
return true;
}
private static addAttachmentPadding(data: ArrayBuffer): ArrayBuffer {
public static addAttachmentPadding(data: ArrayBuffer): ArrayBuffer {
const originalUInt = new Uint8Array(data);
const paddedSize = Math.max(

View File

@ -0,0 +1,135 @@
import * as crypto from 'crypto';
import { Attachment } from '../../types/Attachment';
import { OpenGroupRequestCommonType } from '../../opengroup/opengroupV2/ApiUtil';
import {
AttachmentPointer,
Preview,
Quote,
QuotedAttachment,
} from '../messages/outgoing/visibleMessage/VisibleMessage';
import { AttachmentUtils } from './Attachments';
import { uploadFileOpenGroupV2 } from '../../opengroup/opengroupV2/OpenGroupAPIV2';
interface UploadParamsV2 {
attachment: Attachment;
openGroup: OpenGroupRequestCommonType;
}
interface RawPreview {
url?: string;
title?: string;
image: Attachment;
}
interface RawQuoteAttachment {
contentType?: string;
fileName?: string;
thumbnail?: Attachment;
}
interface RawQuote {
id?: number;
author?: string;
text?: string;
attachments?: Array<RawQuoteAttachment>;
}
const PADDING_BYTE = 0;
export async function uploadV2(params: UploadParamsV2): Promise<AttachmentPointer> {
const { attachment, openGroup } = params;
if (typeof attachment !== 'object' || attachment == null) {
throw new Error('Invalid attachment passed.');
}
if (!(attachment.data instanceof ArrayBuffer)) {
throw new TypeError(
`attachment.data must be an ArrayBuffer but got: ${typeof attachment.data}`
);
}
const pointer: AttachmentPointer = {
contentType: attachment.contentType || undefined,
size: attachment.size,
fileName: attachment.fileName,
flags: attachment.flags,
caption: attachment.caption,
};
const paddedAttachment: ArrayBuffer =
(window.lokiFeatureFlags.padOutgoingAttachments &&
AttachmentUtils.addAttachmentPadding(attachment.data)) ||
attachment.data;
const fileId = await uploadFileOpenGroupV2(new Uint8Array(paddedAttachment), openGroup);
pointer.id = fileId || undefined;
console.warn('should we set the URL too here for that v2?');
return pointer;
}
export async function uploadAttachmentsV2(
attachments: Array<Attachment>,
openGroup: OpenGroupRequestCommonType
): Promise<Array<AttachmentPointer>> {
const promises = (attachments || []).map(async attachment =>
exports.uploadV2({
attachment,
openGroup,
})
);
return Promise.all(promises);
}
export async function uploadLinkPreviewsV2(
previews: Array<RawPreview>,
openGroup: OpenGroupRequestCommonType
): Promise<Array<Preview>> {
const promises = (previews || []).map(async item => {
// some links does not have an image associated, and it makes the whole message fail to send
if (!item.image) {
return item;
}
return {
...item,
image: await exports.uploadV2({
attachment: item.image,
openGroup,
}),
};
});
return Promise.all(promises);
}
export async function uploadQuoteThumbnailsV2(
openGroup: OpenGroupRequestCommonType,
quote?: RawQuote
): Promise<Quote | undefined> {
if (!quote) {
return undefined;
}
const promises = (quote.attachments ?? []).map(async attachment => {
let thumbnail: AttachmentPointer | undefined;
if (attachment.thumbnail) {
thumbnail = await exports.uploadV2({
attachment: attachment.thumbnail,
openGroup,
});
}
return {
...attachment,
thumbnail,
} as QuotedAttachment;
});
const attachments = await Promise.all(promises);
return {
...quote,
attachments,
};
}

View File

@ -32,7 +32,6 @@ function getEncryptionTypeFromMessageType(message: ContentMessage): EncryptionTy
export async function toRawMessage(device: PubKey, message: ContentMessage): Promise<RawMessage> {
const timestamp = message.timestamp;
const ttl = message.ttl();
// window?.log?.debug('toRawMessage proto:', message.contentProto());
const plainTextBuffer = message.plainTextBuffer();
const encryption = getEncryptionTypeFromMessageType(message);

View File

@ -8,6 +8,7 @@ import * as MenuUtils from '../../components/session/menu/Menu';
import * as ToastUtils from './Toast';
import * as UserUtils from './User';
import * as SyncUtils from './syncUtils';
import * as AttachmentsV2Utils from './AttachmentsV2';
export * from './Attachments';
export * from './TypedEmitter';
@ -24,4 +25,5 @@ export {
ToastUtils,
UserUtils,
SyncUtils,
AttachmentsV2Utils,
};