mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Fix attachment ciphertext size calculations for backup tier downloads
This commit is contained in:
@@ -52,6 +52,7 @@ import { missingCaseError } from './util/missingCaseError.js';
|
|||||||
import { getEnvironment, Environment } from './environment.js';
|
import { getEnvironment, Environment } from './environment.js';
|
||||||
import { isNotEmpty, toBase64, toHex } from './Bytes.js';
|
import { isNotEmpty, toBase64, toHex } from './Bytes.js';
|
||||||
import { decipherWithAesKey } from './util/decipherWithAesKey.js';
|
import { decipherWithAesKey } from './util/decipherWithAesKey.js';
|
||||||
|
import { MediaTier } from './types/AttachmentDownload.js';
|
||||||
|
|
||||||
const { ensureFile } = fsExtra;
|
const { ensureFile } = fsExtra;
|
||||||
|
|
||||||
@@ -198,7 +199,12 @@ export async function encryptAttachmentV2({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
chunkSizeChoice = isNumber(size)
|
chunkSizeChoice = isNumber(size)
|
||||||
? inferChunkSize(getAttachmentCiphertextLength(size))
|
? inferChunkSize(
|
||||||
|
getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: size,
|
||||||
|
mediaTier: MediaTier.STANDARD,
|
||||||
|
})
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
incrementalDigestCreator =
|
incrementalDigestCreator =
|
||||||
needIncrementalMac && chunkSizeChoice
|
needIncrementalMac && chunkSizeChoice
|
||||||
@@ -664,20 +670,38 @@ export function measureSize({
|
|||||||
return passthrough;
|
return passthrough;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAttachmentCiphertextLength(plaintextLength: number): number {
|
export function getAttachmentCiphertextSize({
|
||||||
const paddedPlaintextSize = logPadSize(plaintextLength);
|
unpaddedPlaintextSize,
|
||||||
|
mediaTier,
|
||||||
|
}: {
|
||||||
|
unpaddedPlaintextSize: number;
|
||||||
|
mediaTier: MediaTier;
|
||||||
|
}): number {
|
||||||
|
const paddedSize = logPadSize(unpaddedPlaintextSize);
|
||||||
|
|
||||||
|
switch (mediaTier) {
|
||||||
|
case MediaTier.STANDARD:
|
||||||
|
return getCiphertextSize(paddedSize);
|
||||||
|
case MediaTier.BACKUP:
|
||||||
|
// objects on backup tier are doubly encrypted!
|
||||||
|
return getCiphertextSize(getCiphertextSize(paddedSize));
|
||||||
|
default:
|
||||||
|
throw missingCaseError(mediaTier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCiphertextSize(paddedPlaintextSize: number): number {
|
||||||
return (
|
return (
|
||||||
IV_LENGTH +
|
IV_LENGTH +
|
||||||
getAesCbcCiphertextLength(paddedPlaintextSize) +
|
getAesCbcCiphertextSize(paddedPlaintextSize) +
|
||||||
ATTACHMENT_MAC_LENGTH
|
ATTACHMENT_MAC_LENGTH
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAesCbcCiphertextLength(plaintextLength: number): number {
|
export function getAesCbcCiphertextSize(plaintextSize: number): number {
|
||||||
const AES_CBC_BLOCK_SIZE = 16;
|
const AES_CBC_BLOCK_SIZE = 16;
|
||||||
return (
|
return (
|
||||||
(1 + Math.floor(plaintextLength / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE
|
(1 + Math.floor(plaintextSize / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import {
|
|||||||
} from '../services/backups/index.js';
|
} from '../services/backups/index.js';
|
||||||
import {
|
import {
|
||||||
type EncryptedAttachmentV2,
|
type EncryptedAttachmentV2,
|
||||||
getAttachmentCiphertextLength,
|
|
||||||
getAesCbcCiphertextLength,
|
|
||||||
decryptAttachmentV2ToSink,
|
decryptAttachmentV2ToSink,
|
||||||
|
getAttachmentCiphertextSize,
|
||||||
} from '../AttachmentCrypto.js';
|
} from '../AttachmentCrypto.js';
|
||||||
import {
|
import {
|
||||||
getBackupMediaRootKey,
|
getBackupMediaRootKey,
|
||||||
@@ -66,6 +65,7 @@ import { findRetryAfterTimeFromError } from './helpers/findRetryAfterTimeFromErr
|
|||||||
import { BackupCredentialType } from '../types/backups.js';
|
import { BackupCredentialType } from '../types/backups.js';
|
||||||
import { supportsIncrementalMac } from '../types/MIME.js';
|
import { supportsIncrementalMac } from '../types/MIME.js';
|
||||||
import type { MIMEType } from '../types/MIME.js';
|
import type { MIMEType } from '../types/MIME.js';
|
||||||
|
import { MediaTier } from '../types/AttachmentDownload.js';
|
||||||
|
|
||||||
const log = createLogger('AttachmentBackupManager');
|
const log = createLogger('AttachmentBackupManager');
|
||||||
|
|
||||||
@@ -582,7 +582,10 @@ async function copyToBackupTier({
|
|||||||
dependencies.backupMediaBatch,
|
dependencies.backupMediaBatch,
|
||||||
'backupMediaBatch must be intialized'
|
'backupMediaBatch must be intialized'
|
||||||
);
|
);
|
||||||
const ciphertextLength = getAttachmentCiphertextLength(size);
|
const ciphertextSizeOnTransitTier = getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: size,
|
||||||
|
mediaTier: MediaTier.STANDARD,
|
||||||
|
});
|
||||||
|
|
||||||
const { responses } = await dependencies.backupMediaBatch({
|
const { responses } = await dependencies.backupMediaBatch({
|
||||||
headers: await dependencies.backupsService.credentials.getHeadersForToday(
|
headers: await dependencies.backupsService.credentials.getHeadersForToday(
|
||||||
@@ -594,7 +597,7 @@ async function copyToBackupTier({
|
|||||||
cdn: cdnNumber,
|
cdn: cdnNumber,
|
||||||
key: cdnKey,
|
key: cdnKey,
|
||||||
},
|
},
|
||||||
objectLength: ciphertextLength,
|
objectLength: ciphertextSizeOnTransitTier,
|
||||||
mediaId,
|
mediaId,
|
||||||
hmacKey: macKey,
|
hmacKey: macKey,
|
||||||
encryptionKey: aesKey,
|
encryptionKey: aesKey,
|
||||||
@@ -613,9 +616,17 @@ async function copyToBackupTier({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update our local understanding of what's in the backup cdn
|
// Update our local understanding of what's in the backup cdn
|
||||||
const sizeOnBackupCdn = getAesCbcCiphertextLength(ciphertextLength);
|
const ciphertextSizeOnBackupTier = getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: size,
|
||||||
|
mediaTier: MediaTier.BACKUP,
|
||||||
|
});
|
||||||
|
|
||||||
await DataWriter.saveBackupCdnObjectMetadata([
|
await DataWriter.saveBackupCdnObjectMetadata([
|
||||||
{ mediaId, cdnNumber: response.cdn, sizeOnBackupCdn },
|
{
|
||||||
|
mediaId,
|
||||||
|
cdnNumber: response.cdn,
|
||||||
|
sizeOnBackupCdn: ciphertextSizeOnBackupTier,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type CoreAttachmentDownloadJobType,
|
type CoreAttachmentDownloadJobType,
|
||||||
AttachmentDownloadUrgency,
|
AttachmentDownloadUrgency,
|
||||||
coreAttachmentDownloadJobSchema,
|
coreAttachmentDownloadJobSchema,
|
||||||
|
MediaTier,
|
||||||
} from '../types/AttachmentDownload.js';
|
} from '../types/AttachmentDownload.js';
|
||||||
import {
|
import {
|
||||||
downloadAttachment as downloadAttachmentUtil,
|
downloadAttachment as downloadAttachmentUtil,
|
||||||
@@ -52,7 +53,7 @@ import { IMAGE_WEBP } from '../types/MIME.js';
|
|||||||
import { AttachmentDownloadSource } from '../sql/Interface.js';
|
import { AttachmentDownloadSource } from '../sql/Interface.js';
|
||||||
import { drop } from '../util/drop.js';
|
import { drop } from '../util/drop.js';
|
||||||
import {
|
import {
|
||||||
getAttachmentCiphertextLength,
|
getAttachmentCiphertextSize,
|
||||||
type ReencryptedAttachmentV2,
|
type ReencryptedAttachmentV2,
|
||||||
} from '../AttachmentCrypto.js';
|
} from '../AttachmentCrypto.js';
|
||||||
import { safeParsePartial } from '../util/schemas.js';
|
import { safeParsePartial } from '../util/schemas.js';
|
||||||
@@ -319,7 +320,15 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
|
|||||||
const parseResult = safeParsePartial(coreAttachmentDownloadJobSchema, {
|
const parseResult = safeParsePartial(coreAttachmentDownloadJobSchema, {
|
||||||
attachment,
|
attachment,
|
||||||
attachmentType,
|
attachmentType,
|
||||||
ciphertextSize: getAttachmentCiphertextLength(attachment.size),
|
// ciphertextSize is used for backup media download progress accounting; it may not
|
||||||
|
// exactly match what we end up downloading, and that's OK (e.g. we may fallback to
|
||||||
|
// download from the transit tier, which would be a slightly smaller size)
|
||||||
|
ciphertextSize: getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: attachment.size,
|
||||||
|
mediaTier: AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA
|
||||||
|
? MediaTier.BACKUP
|
||||||
|
: MediaTier.STANDARD,
|
||||||
|
}),
|
||||||
contentType: attachment.contentType,
|
contentType: attachment.contentType,
|
||||||
attachmentSignature: getUndownloadedAttachmentSignature(attachment),
|
attachmentSignature: getUndownloadedAttachmentSignature(attachment),
|
||||||
isManualDownload,
|
isManualDownload,
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ import {
|
|||||||
_generateAttachmentIv,
|
_generateAttachmentIv,
|
||||||
decryptAttachmentV2,
|
decryptAttachmentV2,
|
||||||
encryptAttachmentV2ToDisk,
|
encryptAttachmentV2ToDisk,
|
||||||
getAesCbcCiphertextLength,
|
getAesCbcCiphertextSize,
|
||||||
getAttachmentCiphertextLength,
|
getAttachmentCiphertextSize,
|
||||||
splitKeys,
|
splitKeys,
|
||||||
generateAttachmentKeys,
|
generateAttachmentKeys,
|
||||||
type DecryptedAttachmentV2,
|
type DecryptedAttachmentV2,
|
||||||
@@ -55,6 +55,7 @@ import type { AciString, PniString } from '../types/ServiceId.js';
|
|||||||
import { createTempDir, deleteTempDir } from '../updater/common.js';
|
import { createTempDir, deleteTempDir } from '../updater/common.js';
|
||||||
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js';
|
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js';
|
||||||
import { getPath } from '../windows/main/attachments.js';
|
import { getPath } from '../windows/main/attachments.js';
|
||||||
|
import { MediaTier } from '../types/AttachmentDownload.js';
|
||||||
|
|
||||||
const { emptyDir } = fsExtra;
|
const { emptyDir } = fsExtra;
|
||||||
|
|
||||||
@@ -780,7 +781,10 @@ describe('Crypto', () => {
|
|||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
encryptedAttachment.ciphertextSize,
|
encryptedAttachment.ciphertextSize,
|
||||||
getAttachmentCiphertextLength(data.byteLength)
|
getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: data.byteLength,
|
||||||
|
mediaTier: MediaTier.STANDARD,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (overrideSize == null) {
|
if (overrideSize == null) {
|
||||||
@@ -1193,7 +1197,7 @@ describe('Crypto', () => {
|
|||||||
}
|
}
|
||||||
it('calculates cipherTextLength correctly', () => {
|
it('calculates cipherTextLength correctly', () => {
|
||||||
for (let i = 0; i < 128; i += 1) {
|
for (let i = 0; i < 128; i += 1) {
|
||||||
assert.strictEqual(getAesCbcCiphertextLength(i), encrypt(i).length);
|
assert.strictEqual(getAesCbcCiphertextSize(i), encrypt(i).length);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type AttachmentDownloadJobType,
|
type AttachmentDownloadJobType,
|
||||||
AttachmentDownloadUrgency,
|
AttachmentDownloadUrgency,
|
||||||
|
MediaTier,
|
||||||
} from '../../types/AttachmentDownload.js';
|
} from '../../types/AttachmentDownload.js';
|
||||||
import { DataReader, DataWriter } from '../../sql/Client.js';
|
import { DataReader, DataWriter } from '../../sql/Client.js';
|
||||||
import { DAY, MINUTE, MONTH } from '../../util/durations/index.js';
|
import { DAY, MINUTE, MONTH } from '../../util/durations/index.js';
|
||||||
@@ -29,7 +30,7 @@ import type { downloadAttachment as downloadAttachmentUtil } from '../../util/do
|
|||||||
import { AttachmentDownloadSource } from '../../sql/Interface.js';
|
import { AttachmentDownloadSource } from '../../sql/Interface.js';
|
||||||
import {
|
import {
|
||||||
generateAttachmentKeys,
|
generateAttachmentKeys,
|
||||||
getAttachmentCiphertextLength,
|
getAttachmentCiphertextSize,
|
||||||
} from '../../AttachmentCrypto.js';
|
} from '../../AttachmentCrypto.js';
|
||||||
import { MEBIBYTE } from '../../types/AttachmentSize.js';
|
import { MEBIBYTE } from '../../types/AttachmentSize.js';
|
||||||
import { generateAci } from '../../types/ServiceId.js';
|
import { generateAci } from '../../types/ServiceId.js';
|
||||||
@@ -58,7 +59,10 @@ function composeJob({
|
|||||||
attachmentType: 'attachment',
|
attachmentType: 'attachment',
|
||||||
attachmentSignature: `${digest}.${plaintextHash}`,
|
attachmentSignature: `${digest}.${plaintextHash}`,
|
||||||
size,
|
size,
|
||||||
ciphertextSize: getAttachmentCiphertextLength(size),
|
ciphertextSize: getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: size,
|
||||||
|
mediaTier: MediaTier.STANDARD,
|
||||||
|
}),
|
||||||
contentType,
|
contentType,
|
||||||
active: false,
|
active: false,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '../types/Attachment.js';
|
} from '../types/Attachment.js';
|
||||||
import * as Bytes from '../Bytes.js';
|
import * as Bytes from '../Bytes.js';
|
||||||
import {
|
import {
|
||||||
getAttachmentCiphertextLength,
|
getAttachmentCiphertextSize,
|
||||||
safeUnlink,
|
safeUnlink,
|
||||||
splitKeys,
|
splitKeys,
|
||||||
type ReencryptedAttachmentV2,
|
type ReencryptedAttachmentV2,
|
||||||
@@ -142,6 +142,7 @@ export async function downloadAttachment(
|
|||||||
let downloadResult: Awaited<ReturnType<typeof downloadToDisk>>;
|
let downloadResult: Awaited<ReturnType<typeof downloadToDisk>>;
|
||||||
|
|
||||||
let downloadPath =
|
let downloadPath =
|
||||||
|
mediaTier === MediaTier.STANDARD &&
|
||||||
options.variant === AttachmentVariant.Default
|
options.variant === AttachmentVariant.Default
|
||||||
? attachment.downloadPath
|
? attachment.downloadPath
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -170,11 +171,13 @@ export async function downloadAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expectedCiphertextSize = getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: size,
|
||||||
|
mediaTier,
|
||||||
|
});
|
||||||
|
|
||||||
// Start over if we go over the size
|
// Start over if we go over the size
|
||||||
if (
|
if (downloadOffset >= expectedCiphertextSize && absoluteDownloadPath) {
|
||||||
downloadOffset >= getAttachmentCiphertextLength(size) &&
|
|
||||||
absoluteDownloadPath
|
|
||||||
) {
|
|
||||||
log.warn('went over, retrying');
|
log.warn('went over, retrying');
|
||||||
await safeUnlink(absoluteDownloadPath);
|
await safeUnlink(absoluteDownloadPath);
|
||||||
downloadOffset = 0;
|
downloadOffset = 0;
|
||||||
@@ -211,7 +214,7 @@ export async function downloadAttachment(
|
|||||||
downloadPath,
|
downloadPath,
|
||||||
downloadStream,
|
downloadStream,
|
||||||
onSizeUpdate: options.onSizeUpdate,
|
onSizeUpdate: options.onSizeUpdate,
|
||||||
size,
|
expectedCiphertextSize,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
strictAssert(mediaTier === MediaTier.BACKUP, 'backup media tier');
|
strictAssert(mediaTier === MediaTier.BACKUP, 'backup media tier');
|
||||||
@@ -256,12 +259,14 @@ export async function downloadAttachment(
|
|||||||
downloadPath,
|
downloadPath,
|
||||||
downloadOffset,
|
downloadOffset,
|
||||||
onSizeUpdate: options.onSizeUpdate,
|
onSizeUpdate: options.onSizeUpdate,
|
||||||
size: getAttachmentCiphertextLength(
|
expectedCiphertextSize:
|
||||||
options.variant === AttachmentVariant.ThumbnailFromBackup
|
options.variant === AttachmentVariant.ThumbnailFromBackup
|
||||||
? // be generous, accept downloads of up to twice what we expect for thumbnail
|
? getAttachmentCiphertextSize({
|
||||||
MAX_BACKUP_THUMBNAIL_SIZE * 2
|
// to be generous, we accept downloads of up to twice what we expect
|
||||||
: size
|
unpaddedPlaintextSize: MAX_BACKUP_THUMBNAIL_SIZE * 2,
|
||||||
),
|
mediaTier: MediaTier.BACKUP,
|
||||||
|
})
|
||||||
|
: expectedCiphertextSize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,13 +350,13 @@ async function downloadToDisk({
|
|||||||
downloadPath,
|
downloadPath,
|
||||||
downloadStream,
|
downloadStream,
|
||||||
onSizeUpdate,
|
onSizeUpdate,
|
||||||
size,
|
expectedCiphertextSize,
|
||||||
}: {
|
}: {
|
||||||
downloadOffset?: number;
|
downloadOffset?: number;
|
||||||
downloadPath?: string;
|
downloadPath?: string;
|
||||||
downloadStream: Readable;
|
downloadStream: Readable;
|
||||||
onSizeUpdate: (totalBytes: number) => void;
|
onSizeUpdate: (totalBytes: number) => void;
|
||||||
size: number;
|
expectedCiphertextSize: number;
|
||||||
}): Promise<{ absolutePath: string; downloadSize: number }> {
|
}): Promise<{ absolutePath: string; downloadSize: number }> {
|
||||||
const absoluteTargetPath = downloadPath
|
const absoluteTargetPath = downloadPath
|
||||||
? window.Signal.Migrations.getAbsoluteDownloadsPath(downloadPath)
|
? window.Signal.Migrations.getAbsoluteDownloadsPath(downloadPath)
|
||||||
@@ -373,7 +378,7 @@ async function downloadToDisk({
|
|||||||
writeStream = createWriteStream(absoluteTargetPath);
|
writeStream = createWriteStream(absoluteTargetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetSize = getAttachmentCiphertextLength(size) - downloadOffset;
|
const targetSize = expectedCiphertextSize - downloadOffset;
|
||||||
let downloadSize = 0;
|
let downloadSize = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -428,7 +433,7 @@ function checkSize(expectedBytes: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalBytes > expectedBytes) {
|
if (totalBytes > expectedBytes) {
|
||||||
log.warn(
|
log.error(
|
||||||
`checkSize: Received ${totalBytes} bytes, expected ${expectedBytes}`
|
`checkSize: Received ${totalBytes} bytes, expected ${expectedBytes}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { handleVideoAttachment } from './handleVideoAttachment.js';
|
|||||||
import { isHeic, stringToMIMEType } from '../types/MIME.js';
|
import { isHeic, stringToMIMEType } from '../types/MIME.js';
|
||||||
import { ToastType } from '../types/Toast.js';
|
import { ToastType } from '../types/Toast.js';
|
||||||
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js';
|
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js';
|
||||||
import { getAttachmentCiphertextLength } from '../AttachmentCrypto.js';
|
import { getAttachmentCiphertextSize } from '../AttachmentCrypto.js';
|
||||||
|
import { MediaTier } from '../types/AttachmentDownload.js';
|
||||||
|
|
||||||
const log = createLogger('processAttachment');
|
const log = createLogger('processAttachment');
|
||||||
|
|
||||||
@@ -85,7 +86,10 @@ function isAttachmentSizeOkay(attachment: Readonly<AttachmentType>): boolean {
|
|||||||
const limitBytes =
|
const limitBytes =
|
||||||
getMaximumOutgoingAttachmentSizeInKb(getRemoteConfigValue) * KIBIBYTE;
|
getMaximumOutgoingAttachmentSizeInKb(getRemoteConfigValue) * KIBIBYTE;
|
||||||
|
|
||||||
const paddedAndEncryptedSize = getAttachmentCiphertextLength(attachment.size);
|
const paddedAndEncryptedSize = getAttachmentCiphertextSize({
|
||||||
|
unpaddedPlaintextSize: attachment.size,
|
||||||
|
mediaTier: MediaTier.STANDARD,
|
||||||
|
});
|
||||||
if (paddedAndEncryptedSize > limitBytes) {
|
if (paddedAndEncryptedSize > limitBytes) {
|
||||||
window.reduxActions.toast.showToast({
|
window.reduxActions.toast.showToast({
|
||||||
toastType: ToastType.FileSize,
|
toastType: ToastType.FileSize,
|
||||||
|
|||||||
Reference in New Issue
Block a user