>;
- onChange: (value: Enum) => void;
-}): JSX.Element {
- const htmlIds = useMemo(() => {
- return Array.from({ length: options.length }, () => uuid());
- }, [options.length]);
-
- return (
-
- {options.map(({ text, value: optionValue, readOnly, onClick }, i) => {
- const htmlId = htmlIds[i];
- return (
-
- );
- })}
-
- );
-}
-
function localizeDefault(i18n: LocalizerType, deviceLabel: string): string {
return deviceLabel.toLowerCase().startsWith('default')
? deviceLabel.replace(
diff --git a/ts/components/PreferencesBackups.tsx b/ts/components/PreferencesBackups.tsx
new file mode 100644
index 00000000000..2896833d554
--- /dev/null
+++ b/ts/components/PreferencesBackups.tsx
@@ -0,0 +1,220 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import type {
+ BackupsSubscriptionType,
+ BackupStatusType,
+} from '../types/backups';
+import type { LocalizerType } from '../types/I18N';
+import { formatTimestamp } from '../util/formatTimestamp';
+import { formatFileSize } from '../util/formatFileSize';
+import { SettingsRow } from './PreferencesUtil';
+import { missingCaseError } from '../util/missingCaseError';
+
+export function PreferencesBackups({
+ cloudBackupStatus,
+ backupSubscriptionStatus,
+ i18n,
+ locale,
+}: {
+ cloudBackupStatus?: BackupStatusType;
+ backupSubscriptionStatus?: BackupsSubscriptionType;
+ i18n: LocalizerType;
+ locale: string;
+}): JSX.Element {
+ return (
+ <>
+
+
+ {i18n('icu:Preferences__button--backups')}
+
+
+
+
+ {getBackupsSubscriptionSummary({
+ subscriptionStatus: backupSubscriptionStatus,
+ i18n,
+ locale,
+ })}
+
+
+ {cloudBackupStatus ? (
+
+ {cloudBackupStatus.createdAt ? (
+
+
+
+ {/* TODO (DESKTOP-8509) */}
+ {i18n('icu:Preferences--backup-created-by-phone')}
+
+ {formatTimestamp(cloudBackupStatus.createdAt, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ })}
+
+
+ ) : null}
+ {cloudBackupStatus.mediaSize != null ||
+ cloudBackupStatus.protoSize != null}
+
+
+
+
+ ) : null}
+ >
+ );
+}
+function getSubscriptionDetails({
+ i18n,
+ subscriptionStatus,
+ locale,
+}: {
+ i18n: LocalizerType;
+ locale: string;
+ subscriptionStatus: BackupsSubscriptionType;
+}): JSX.Element | null {
+ if (subscriptionStatus.status === 'active') {
+ return (
+ <>
+ {subscriptionStatus.cost ? (
+
+ {new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: subscriptionStatus.cost.currencyCode,
+ currencyDisplay: 'narrowSymbol',
+ }).format(subscriptionStatus.cost.amount)}{' '}
+ / month
+
+ ) : null}
+ {subscriptionStatus.renewalDate ? (
+
+ {i18n('icu:Preferences--backup-plan__renewal-date', {
+ date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), {
+ dateStyle: 'medium',
+ }),
+ })}
+
+ ) : null}
+ >
+ );
+ }
+ if (subscriptionStatus.status === 'pending-cancellation') {
+ return (
+ <>
+
+ {i18n('icu:Preferences--backup-plan__canceled')}
+
+ {subscriptionStatus.expiryDate ? (
+
+ {i18n('icu:Preferences--backup-plan__expiry-date', {
+ date: formatTimestamp(subscriptionStatus.expiryDate.getTime(), {
+ dateStyle: 'medium',
+ }),
+ })}
+
+ ) : null}
+ >
+ );
+ }
+
+ return null;
+}
+export function getBackupsSubscriptionSummary({
+ subscriptionStatus,
+ i18n,
+ locale,
+}: {
+ locale: string;
+ subscriptionStatus?: BackupsSubscriptionType;
+ i18n: LocalizerType;
+}): JSX.Element | null {
+ if (!subscriptionStatus) {
+ return null;
+ }
+
+ const { status } = subscriptionStatus;
+ switch (status) {
+ case 'active':
+ case 'pending-cancellation':
+ return (
+ <>
+
+
+
+ {i18n('icu:Preferences--backup-media-plan__description')}
+
+
+ {getSubscriptionDetails({ i18n, locale, subscriptionStatus })}
+
+
+ {subscriptionStatus.status === 'active' ? (
+
+ ) : (
+
+ )}
+
+
+ {i18n('icu:Preferences--backup-media-plan__note')}
+
+ >
+ );
+ case 'free':
+ return (
+ <>
+
+
+
+ {i18n('icu:Preferences--backup-messages-plan__description', {
+ mediaDayCount:
+ subscriptionStatus.mediaIncludedInBackupDurationDays,
+ })}
+
+
+ {i18n(
+ 'icu:Preferences--backup-messages-plan__cost-description'
+ )}
+
+
+
+
+
+ {i18n('icu:Preferences--backup-messages-plan__note')}
+
+ >
+ );
+ case 'not-found':
+ case 'expired':
+ return (
+ <>
+
+
+ {i18n('icu:Preferences--backup-plan-not-found__description')}
+
+
+
+
+
+ {i18n('icu:Preferences--backup-plan__not-found__note')}
+
+
+ >
+ );
+ default:
+ throw missingCaseError(status);
+ }
+}
diff --git a/ts/components/PreferencesUtil.tsx b/ts/components/PreferencesUtil.tsx
new file mode 100644
index 00000000000..a885dc8d28f
--- /dev/null
+++ b/ts/components/PreferencesUtil.tsx
@@ -0,0 +1,118 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import classNames from 'classnames';
+import React, { type ReactNode, useMemo } from 'react';
+import { v4 as uuid } from 'uuid';
+import { noop } from 'lodash';
+import {
+ CircleCheckbox,
+ Variant as CircleCheckboxVariant,
+} from './CircleCheckbox';
+
+export function SettingsRow({
+ children,
+ title,
+ className,
+}: {
+ children: ReactNode;
+ title?: string;
+ className?: string;
+}): JSX.Element {
+ return (
+
+ );
+}
+
+export function SettingsControl({
+ icon,
+ left,
+ onClick,
+ right,
+}: {
+ /** A className or `true` to leave room for icon */
+ icon?: string | true;
+ left: ReactNode;
+ onClick?: () => unknown;
+ right: ReactNode;
+}): JSX.Element {
+ const content = (
+ <>
+ {icon && (
+
+ )}
+ {left}
+ {right}
+ >
+ );
+
+ if (onClick) {
+ return (
+
+ );
+ }
+
+ return {content}
;
+}
+
+type SettingsRadioOptionType = Readonly<{
+ text: string;
+ value: Enum;
+ readOnly?: boolean;
+ onClick?: () => void;
+}>;
+
+export function SettingsRadio({
+ value,
+ options,
+ onChange,
+}: {
+ value: Enum;
+ options: ReadonlyArray>;
+ onChange: (value: Enum) => void;
+}): JSX.Element {
+ const htmlIds = useMemo(() => {
+ return Array.from({ length: options.length }, () => uuid());
+ }, [options.length]);
+
+ return (
+
+ {options.map(({ text, value: optionValue, readOnly, onClick }, i) => {
+ const htmlId = htmlIds[i];
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts
index dbb5583c504..674282ae7c1 100644
--- a/ts/main/settingsChannel.ts
+++ b/ts/main/settingsChannel.ts
@@ -68,6 +68,13 @@ export class SettingsChannel extends EventEmitter {
this.#installCallback('setEmojiSkinToneDefault');
this.#installCallback('getEmojiSkinToneDefault');
+ // Backups
+ this.#installSetting('backupFeatureEnabled', { setter: false });
+ this.#installSetting('cloudBackupStatus', { setter: false });
+ this.#installSetting('backupSubscriptionStatus', { setter: false });
+ this.#installCallback('refreshCloudBackupStatus');
+ this.#installCallback('refreshBackupSubscriptionStatus');
+
// Getters only. These are set by the primary device
this.#installSetting('blockedCount', { setter: false });
this.#installSetting('linkPreviewSetting', { setter: false });
diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts
index caa1da5a6e0..4e7d18c6e94 100644
--- a/ts/services/backups/api.ts
+++ b/ts/services/backups/api.ts
@@ -12,10 +12,18 @@ import type {
BackupMediaBatchResponseType,
BackupListMediaResponseType,
TransferArchiveType,
+ SubscriptionResponseType,
} from '../../textsecure/WebAPI';
import type { BackupCredentials } from './credentials';
-import { BackupCredentialType } from '../../types/backups';
+import {
+ BackupCredentialType,
+ type BackupsSubscriptionType,
+ type SubscriptionCostType,
+} from '../../types/backups';
import { uploadFile } from '../../util/uploadAttachment';
+import { HTTPError } from '../../textsecure/Errors';
+import * as log from '../../logging/log';
+import { toLogFormat } from '../../types/errors';
export type DownloadOptionsType = Readonly<{
downloadOffset: number;
@@ -113,6 +121,34 @@ export class BackupAPI {
});
}
+ public async getBackupProtoInfo(): Promise<
+ | { backupExists: false }
+ | { backupExists: true; size: number; createdAt: Date }
+ > {
+ const { cdn, backupDir, backupName } = await this.#getCachedInfo(
+ BackupCredentialType.Messages
+ );
+ const { headers } = await this.credentials.getCDNReadCredentials(
+ cdn,
+ BackupCredentialType.Messages
+ );
+ try {
+ const { 'content-length': size, 'last-modified': createdAt } =
+ await this.#server.getBackupFileHeaders({
+ cdn,
+ backupDir,
+ backupName,
+ headers,
+ });
+ return { backupExists: true, size, createdAt };
+ } catch (error) {
+ if (error instanceof HTTPError && error.code === 404) {
+ return { backupExists: false };
+ }
+ throw error;
+ }
+ }
+
public async getTransferArchive(
abortSignal: AbortSignal
): Promise {
@@ -169,6 +205,59 @@ export class BackupAPI {
});
}
+ public async getSubscriptionInfo(): Promise {
+ const subscriberId = window.storage.get('backupsSubscriberId');
+ if (!subscriberId) {
+ log.error('Backups.getSubscriptionInfo: missing subscriberId');
+ return { status: 'not-found' };
+ }
+
+ let subscriptionResponse: SubscriptionResponseType;
+ try {
+ subscriptionResponse = await this.#server.getSubscription(subscriberId);
+ } catch (e) {
+ log.error(
+ 'Backups.getSubscriptionInfo: error fetching subscription',
+ toLogFormat(e)
+ );
+ return { status: 'not-found' };
+ }
+
+ const { subscription } = subscriptionResponse;
+ const { active, amount, currency, endOfCurrentPeriod, cancelAtPeriodEnd } =
+ subscription;
+
+ if (!active) {
+ return { status: 'expired' };
+ }
+
+ let cost: SubscriptionCostType | undefined;
+ if (amount && currency) {
+ cost = {
+ amount,
+ currencyCode: currency,
+ };
+ } else {
+ log.error(
+ 'Backups.getSubscriptionInfo: invalid amount/currency returned for active subscription'
+ );
+ }
+
+ if (cancelAtPeriodEnd) {
+ return {
+ status: 'pending-cancellation',
+ cost,
+ expiryDate: endOfCurrentPeriod,
+ };
+ }
+
+ return {
+ status: 'active',
+ cost,
+ renewalDate: endOfCurrentPeriod,
+ };
+ }
+
public clearCache(): void {
this.#cachedBackupInfo.clear();
}
diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts
index 219b6f29b63..2b9c04c6599 100644
--- a/ts/services/backups/index.ts
+++ b/ts/services/backups/index.ts
@@ -13,6 +13,7 @@ import { createCipheriv, createHmac, randomBytes } from 'crypto';
import { noop } from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
+import { throttle } from 'lodash/fp';
import { DataReader, DataWriter } from '../../sql/Client';
import * as log from '../../logging/log';
@@ -26,7 +27,7 @@ import { appendMacStream } from '../../util/appendMacStream';
import { getIvAndDecipher } from '../../util/getIvAndDecipher';
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
import { missingCaseError } from '../../util/missingCaseError';
-import { HOUR } from '../../util/durations';
+import { DAY, HOUR, MINUTE } from '../../util/durations';
import type { ExplodePromiseResultType } from '../../util/explodePromise';
import { explodePromise } from '../../util/explodePromise';
import type { RetryBackupImportValue } from '../../state/ducks/installer';
@@ -36,7 +37,11 @@ import {
InstallScreenBackupError,
} from '../../types/InstallScreen';
import * as Errors from '../../types/errors';
-import { BackupCredentialType } from '../../types/backups';
+import {
+ BackupCredentialType,
+ type BackupsSubscriptionType,
+ type BackupStatusType,
+} from '../../types/backups';
import { HTTPError } from '../../textsecure/Errors';
import { constantTimeEqual } from '../../Crypto';
import { measureSize } from '../../AttachmentCrypto';
@@ -58,6 +63,7 @@ import {
} from './errors';
import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version';
+import { getMessageQueueTime } from '../../util/getMessageQueueTime';
export { BackupType };
@@ -102,6 +108,14 @@ export class BackupsService {
public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials);
+ public readonly throttledFetchCloudBackupStatus = throttle(
+ MINUTE,
+ this.fetchCloudBackupStatus.bind(this)
+ );
+ public readonly throttledFetchSubscriptionStatus = throttle(
+ MINUTE,
+ this.fetchSubscriptionStatus.bind(this)
+ );
public start(): void {
if (this.#isStarted) {
@@ -778,6 +792,8 @@ export class BackupsService {
} catch (error) {
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
}
+ drop(this.fetchCloudBackupStatus());
+ drop(this.fetchSubscriptionStatus());
}
async #unlinkAndDeleteAllData() {
@@ -812,6 +828,75 @@ export class BackupsService {
public isExportRunning(): boolean {
return this.#isRunning === 'export';
}
+
+ #getBackupTierFromStorage(): BackupLevel | null {
+ const backupTier = window.storage.get('backupTier');
+ switch (backupTier) {
+ case BackupLevel.Free:
+ return BackupLevel.Free;
+ case BackupLevel.Paid:
+ return BackupLevel.Paid;
+ case undefined:
+ return null;
+ default:
+ log.error('Unknown backupTier in storage', backupTier);
+ return null;
+ }
+ }
+
+ async #getBackedUpMediaSize(): Promise {
+ const backupInfo = await this.api.getInfo(BackupCredentialType.Media);
+ return backupInfo.usedSpace ?? 0;
+ }
+
+ async fetchCloudBackupStatus(): Promise {
+ let result: BackupStatusType | undefined;
+ const [backupProtoInfo, mediaSize] = await Promise.all([
+ this.api.getBackupProtoInfo(),
+ this.#getBackedUpMediaSize(),
+ ]);
+
+ if (backupProtoInfo.backupExists) {
+ const { createdAt, size: protoSize } = backupProtoInfo;
+ result = {
+ createdAt: createdAt.getTime(),
+ protoSize,
+ mediaSize,
+ };
+ }
+
+ await window.storage.put('cloudBackupStatus', result);
+ return result;
+ }
+
+ async fetchSubscriptionStatus(): Promise<
+ BackupsSubscriptionType | undefined
+ > {
+ const backupTier = this.#getBackupTierFromStorage();
+ let result: BackupsSubscriptionType;
+ switch (backupTier) {
+ case null:
+ case undefined:
+ case BackupLevel.Free:
+ result = {
+ status: 'free',
+ mediaIncludedInBackupDurationDays: getMessageQueueTime() / DAY,
+ };
+ break;
+ case BackupLevel.Paid:
+ result = await this.api.getSubscriptionInfo();
+ break;
+ default:
+ throw missingCaseError(backupTier);
+ }
+
+ drop(window.storage.put('backupSubscriptionStatus', result));
+ return result;
+ }
+
+ getCachedCloudBackupStatus(): BackupStatusType | undefined {
+ return window.storage.get('cloudBackupStatus');
+ }
}
export const backupsService = new BackupsService();
diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts
index fed0645a1bf..da521662c65 100644
--- a/ts/services/storageRecordOps.ts
+++ b/ts/services/storageRecordOps.ts
@@ -469,6 +469,10 @@ export function toAccountRecord(
}
accountRecord.backupSubscriberData = generateBackupsSubscriberData();
+ const backupTier = window.storage.get('backupTier');
+ if (backupTier) {
+ accountRecord.backupTier = Long.fromNumber(backupTier);
+ }
const displayBadgesOnProfile = window.storage.get('displayBadgesOnProfile');
if (displayBadgesOnProfile !== undefined) {
@@ -1398,6 +1402,7 @@ export async function mergeAccountRecord(
subscriberCurrencyCode,
donorSubscriptionManuallyCancelled,
backupSubscriberData,
+ backupTier,
displayBadgesOnProfile,
keepMutedChatsArchived,
hasCompletedUsernameOnboarding,
@@ -1614,6 +1619,7 @@ export async function mergeAccountRecord(
}
await saveBackupsSubscriberData(backupSubscriberData);
+ await window.storage.put('backupTier', backupTier?.toNumber());
await window.storage.put(
'displayBadgesOnProfile',
diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts
index 01903e59e74..268f8de2edd 100644
--- a/ts/state/ducks/installer.ts
+++ b/ts/state/ducks/installer.ts
@@ -17,7 +17,6 @@ import { type Loadable, LoadingState } from '../../util/loadable';
import { isRecord } from '../../util/isRecord';
import { strictAssert } from '../../util/assert';
import * as Registration from '../../util/registration';
-import { isBackupEnabled } from '../../util/isBackupEnabled';
import { missingCaseError } from '../../util/missingCaseError';
import { HTTPError } from '../../textsecure/Errors';
import {
@@ -322,7 +321,7 @@ function finishInstall({
const accountManager = window.getAccountManager();
strictAssert(accountManager, 'Expected an account manager');
- if (isBackupEnabled() || isLinkAndSync) {
+ if (isLinkAndSync) {
dispatch({ type: SHOW_BACKUP_IMPORT });
} else {
dispatch({ type: SHOW_LINK_IN_PROGRESS });
diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts
index f0c325fe5b7..6a74f708ba7 100644
--- a/ts/textsecure/AccountManager.ts
+++ b/ts/textsecure/AccountManager.ts
@@ -63,7 +63,7 @@ import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import type { StorageAccessType } from '../types/Storage';
import { getRelativePath, createName } from '../util/attachmentPath';
-import { isBackupEnabled } from '../util/isBackupEnabled';
+import { isBackupFeatureEnabled } from '../util/isBackupEnabled';
import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled';
import { getMessageQueueTime } from '../util/getMessageQueueTime';
@@ -1115,7 +1115,7 @@ export default class AccountManager extends EventTarget {
}
const shouldDownloadBackup =
- isBackupEnabled() ||
+ isBackupFeatureEnabled() ||
(isLinkAndSyncEnabled() && options.ephemeralBackupKey);
// Set backup download path before storing credentials to ensure that
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index 235d6eac082..7c9e667215d 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -18,7 +18,6 @@ import type { Readable } from 'stream';
import { Net } from '@signalapp/libsignal-client';
import { assertDev, strictAssert } from '../util/assert';
import { drop } from '../util/drop';
-import { isRecord } from '../util/isRecord';
import * as durations from '../util/durations';
import type { ExplodePromiseResultType } from '../util/explodePromise';
import { explodePromise } from '../util/explodePromise';
@@ -212,6 +211,7 @@ type PromiseAjaxOptionsType = {
| 'jsonwithdetails'
| 'bytes'
| 'byteswithdetails'
+ | 'raw'
| 'stream'
| 'streamwithdetails';
stack?: string;
@@ -251,6 +251,33 @@ type StreamWithDetailsType = {
response: Response;
};
+type GetAttachmentArgsType = {
+ cdnPath: string;
+ cdnNumber: number;
+ headers?: Record;
+ redactor: RedactUrl;
+ options?: {
+ disableRetries?: boolean;
+ timeout?: number;
+ downloadOffset?: number;
+ onProgress?: (currentBytes: number, totalBytes: number) => void;
+ abortSignal?: AbortSignal;
+ };
+};
+
+type GetAttachmentFromBackupTierArgsType = {
+ mediaId: string;
+ backupDir: string;
+ mediaDir: string;
+ cdnNumber: number;
+ headers: Record;
+ options?: {
+ disableRetries?: boolean;
+ timeout?: number;
+ downloadOffset?: number;
+ };
+};
+
export const multiRecipient200ResponseSchema = z.object({
uuids404: z.array(serviceIdSchema).optional(),
needsSync: z.boolean().optional(),
@@ -466,6 +493,8 @@ async function _promiseAjax(
options.responseType === 'streamwithdetails'
) {
result = response.body;
+ } else if (options.responseType === 'raw') {
+ result = response;
} else {
result = await response.textConverted();
}
@@ -619,6 +648,10 @@ function _outerAjax(
providedUrl: string | null,
options: PromiseAjaxOptionsType & { responseType: 'streamwithdetails' }
): Promise;
+function _outerAjax(
+ providedUrl: string | null,
+ options: PromiseAjaxOptionsType & { responseType: 'raw' }
+): Promise;
function _outerAjax(
providedUrl: string | null,
options: PromiseAjaxOptionsType
@@ -1354,6 +1387,29 @@ export type ProxiedRequestParams = Readonly<{
signal?: AbortSignal;
}>;
+const backupFileHeadersSchema = z.object({
+ 'content-length': z.coerce.number(),
+ 'last-modified': z.coerce.date(),
+});
+
+type BackupFileHeadersType = z.infer;
+
+const subscriptionResponseSchema = z.object({
+ subscription: z.object({
+ level: z.number(),
+ billingCycleAnchor: z.coerce.date().optional(),
+ endOfCurrentPeriod: z.coerce.date().optional(),
+ active: z.boolean(),
+ cancelAtPeriodEnd: z.boolean().optional(),
+ currency: z.string().optional(),
+ amount: z.number().nonnegative().optional(),
+ }),
+});
+
+export type SubscriptionResponseType = z.infer<
+ typeof subscriptionResponseSchema
+>;
+
export type WebAPIType = {
startRegistration(): unknown;
finishRegistration(baton: unknown): void;
@@ -1371,19 +1427,9 @@ export type WebAPIType = {
version: string,
imageFiles: Array
) => Promise>;
- getAttachmentFromBackupTier: (args: {
- mediaId: string;
- backupDir: string;
- mediaDir: string;
- cdnNumber: number;
- headers: Record;
- options?: {
- disableRetries?: boolean;
- timeout?: number;
- downloadOffset?: number;
- abortSignal: AbortSignal;
- };
- }) => Promise;
+ getAttachmentFromBackupTier: (
+ args: GetAttachmentFromBackupTierArgsType
+ ) => Promise;
getAttachment: (args: {
cdnKey: string;
cdnNumber?: number;
@@ -1444,6 +1490,9 @@ export type WebAPIType = {
getSubscriptionConfiguration: (
userLanguages: ReadonlyArray
) => Promise;
+ getSubscription: (
+ subscriberId: Uint8Array
+ ) => Promise;
getProvisioningResource: (
handler: IRequestHandler,
timeout?: number
@@ -1572,6 +1621,12 @@ export type WebAPIType = {
headers: BackupPresentationHeadersType
) => Promise;
getBackupStream: (options: GetBackupStreamOptionsType) => Promise;
+ getBackupFileHeaders: (
+ options: Pick<
+ GetBackupStreamOptionsType,
+ 'cdn' | 'backupDir' | 'backupName' | 'headers'
+ >
+ ) => Promise<{ 'content-length': number; 'last-modified': Date }>;
getEphemeralBackupStream: (
options: GetEphemeralBackupStreamOptionsType
) => Promise;
@@ -1950,6 +2005,7 @@ export function initialize({
getBackupCDNCredentials,
getBackupInfo,
getBackupStream,
+ getBackupFileHeaders,
getBackupMediaUploadForm,
getBackupUploadForm,
getBadgeImageFile,
@@ -1985,6 +2041,7 @@ export function initialize({
getStorageCredentials,
getStorageManifest,
getStorageRecords,
+ getSubscription,
getSubscriptionConfiguration,
linkDevice,
logout,
@@ -3244,6 +3301,25 @@ export function initialize({
},
});
}
+ async function getBackupFileHeaders({
+ headers,
+ cdn,
+ backupDir,
+ backupName,
+ }: Pick<
+ GetBackupStreamOptionsType,
+ 'headers' | 'cdn' | 'backupDir' | 'backupName'
+ >): Promise {
+ const result = await _getAttachmentHeaders({
+ cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`,
+ cdnNumber: cdn,
+ redactor: _createRedactor(backupDir, backupName),
+ headers,
+ });
+ const responseHeaders = Object.fromEntries(result.entries());
+
+ return parseUnknown(backupFileHeadersSchema, responseHeaders as unknown);
+ }
async function getEphemeralBackupStream({
cdn,
@@ -3974,20 +4050,14 @@ export function initialize({
cdnNumber,
headers,
options,
- }: {
- mediaId: string;
- backupDir: string;
- mediaDir: string;
- cdnNumber: number;
- headers: Record;
- options?: {
- disableRetries?: boolean;
- timeout?: number;
- downloadOffset?: number;
- };
- }) {
+ }: GetAttachmentFromBackupTierArgsType) {
return _getAttachment({
- cdnPath: `/backups/${backupDir}/${mediaDir}/${mediaId}`,
+ cdnPath: urlPathFromComponents([
+ 'backups',
+ backupDir,
+ mediaDir,
+ mediaId,
+ ]),
cdnNumber,
headers,
redactor: _createRedactor(backupDir, mediaDir, mediaId),
@@ -3995,27 +4065,44 @@ export function initialize({
});
}
+ function getCheckedCdnUrl(cdnNumber: number, cdnPath: string) {
+ const baseUrl = cdnUrlObject[cdnNumber] ?? cdnUrlObject['0'];
+ const { origin: expectedOrigin } = new URL(baseUrl);
+ const fullCdnUrl = `${baseUrl}${cdnPath}`;
+ const { origin } = new URL(fullCdnUrl);
+
+ strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`);
+ return fullCdnUrl;
+ }
+
+ async function _getAttachmentHeaders({
+ cdnPath,
+ cdnNumber,
+ headers = {},
+ redactor,
+ }: Omit): Promise {
+ const fullCdnUrl = getCheckedCdnUrl(cdnNumber, cdnPath);
+ const response = await _outerAjax(fullCdnUrl, {
+ headers,
+ certificateAuthority,
+ proxyUrl,
+ responseType: 'raw',
+ timeout: DEFAULT_TIMEOUT,
+ type: 'HEAD',
+ redactUrl: redactor,
+ version,
+ });
+ return response.headers;
+ }
+
async function _getAttachment({
cdnPath,
cdnNumber,
headers = {},
redactor,
options,
- }: {
- cdnPath: string;
- cdnNumber: number;
- headers?: Record;
- redactor: RedactUrl;
- options?: {
- disableRetries?: boolean;
- timeout?: number;
- downloadOffset?: number;
- onProgress?: (currentBytes: number, totalBytes: number) => void;
- abortSignal?: AbortSignal;
- };
- }): Promise {
+ }: GetAttachmentArgsType): Promise {
const abortController = new AbortController();
- const cdnUrl = cdnUrlObject[cdnNumber] ?? cdnUrlObject['0'];
let streamWithDetails: StreamWithDetailsType | undefined;
@@ -4035,10 +4122,7 @@ export function initialize({
if (options?.downloadOffset) {
targetHeaders.range = `bytes=${options.downloadOffset}-`;
}
- const { origin: expectedOrigin } = new URL(cdnUrl);
- const fullCdnUrl = `${cdnUrl}${cdnPath}`;
- const { origin } = new URL(fullCdnUrl);
- strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`);
+ const fullCdnUrl = getCheckedCdnUrl(cdnNumber, cdnPath);
streamWithDetails = await _outerAjax(fullCdnUrl, {
headers: targetHeaders,
@@ -4075,7 +4159,7 @@ export function initialize({
);
strictAssert(
!streamWithDetails.contentType?.includes('multipart'),
- `Expected non-multipart response for ${cdnUrl}${cdnPath}`
+ 'Expected non-multipart response'
);
const range = streamWithDetails.response.headers.get('content-range');
@@ -4686,11 +4770,11 @@ export function initialize({
};
}
- async function getHasSubscription(
+ async function getSubscription(
subscriberId: Uint8Array
- ): Promise {
+ ): Promise {
const formattedId = toWebSafeBase64(Bytes.toBase64(subscriberId));
- const data = await _ajax({
+ const response = await _ajax({
call: 'subscriptions',
httpType: 'GET',
urlParameters: `/${formattedId}`,
@@ -4701,11 +4785,14 @@ export function initialize({
redactUrl: _createRedactor(formattedId),
});
- return (
- isRecord(data) &&
- isRecord(data.subscription) &&
- Boolean(data.subscription.active)
- );
+ return parseUnknown(subscriptionResponseSchema, response);
+ }
+
+ async function getHasSubscription(
+ subscriberId: Uint8Array
+ ): Promise {
+ const data = await getSubscription(subscriberId);
+ return data.subscription.active;
}
function getProvisioningResource(
diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts
index bf70e8928ec..f060fa43e0c 100644
--- a/ts/types/Storage.d.ts
+++ b/ts/types/Storage.d.ts
@@ -17,7 +17,11 @@ import type {
SessionResetsType,
StorageServiceCredentials,
} from '../textsecure/Types.d';
-import type { BackupCredentialWrapperType } from './backups';
+import type {
+ BackupCredentialWrapperType,
+ BackupsSubscriptionType,
+ BackupStatusType,
+} from './backups';
import type { ServiceIdString } from './ServiceId';
import type { RegisteredChallengeType } from '../challenge';
@@ -222,6 +226,10 @@ export type StorageAccessType = {
key: string;
};
+ backupTier: number | undefined;
+ cloudBackupStatus: BackupStatusType | undefined;
+ backupSubscriptionStatus: BackupsSubscriptionType;
+
// If true Desktop message history was restored from backup
isRestoredFromBackup: boolean;
diff --git a/ts/types/backups.ts b/ts/types/backups.ts
index 2a23dbd5a20..290c73cd5ef 100644
--- a/ts/types/backups.ts
+++ b/ts/types/backups.ts
@@ -29,3 +29,35 @@ export type BackupCdnReadCredentialType = Readonly<{
retrievedAtMs: number;
cdnNumber: number;
}>;
+
+export type SubscriptionCostType = {
+ amount: number;
+ currencyCode: string;
+};
+
+export type BackupStatusType = {
+ createdAt?: number;
+ protoSize?: number;
+ mediaSize?: number;
+};
+
+export type BackupsSubscriptionType =
+ | {
+ status: 'not-found' | 'expired';
+ }
+ | {
+ status: 'free';
+ mediaIncludedInBackupDurationDays: number;
+ }
+ | (
+ | {
+ status: 'active';
+ renewalDate?: Date;
+ cost?: SubscriptionCostType;
+ }
+ | {
+ status: 'pending-cancellation';
+ expiryDate?: Date;
+ cost?: SubscriptionCostType;
+ }
+ );
diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts
index e41daae6371..8932b4d41c6 100644
--- a/ts/util/createIPCEvents.ts
+++ b/ts/util/createIPCEvents.ts
@@ -60,6 +60,11 @@ import { sendSyncRequests } from '../textsecure/syncRequests';
import { waitForEvent } from '../shims/events';
import { DEFAULT_AUTO_DOWNLOAD_ATTACHMENT } from '../textsecure/Storage';
import { EmojiSkinTone } from '../components/fun/data/emojis';
+import type {
+ BackupsSubscriptionType,
+ BackupStatusType,
+} from '../types/backups';
+import { isBackupFeatureEnabled } from './isBackupEnabled';
type SentMediaQualityType = 'standard' | 'high';
type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
@@ -95,7 +100,9 @@ export type IPCEventsValuesType = {
mediaCameraPermissions: boolean | undefined;
// Only getters
-
+ backupFeatureEnabled: boolean;
+ cloudBackupStatus: BackupStatusType | undefined;
+ backupSubscriptionStatus: BackupsSubscriptionType | undefined;
blockedCount: number;
linkPreviewSetting: boolean;
phoneNumberDiscoverabilitySetting: PhoneNumberDiscoverability;
@@ -114,6 +121,8 @@ export type IPCEventsCallbacksType = {
availableMicrophones: Array;
availableSpeakers: Array;
}>;
+ refreshCloudBackupStatus(): void;
+ refreshBackupSubscriptionStatus(): void;
addCustomColor: (customColor: CustomColorType) => void;
addDarkOverlay: () => void;
cleanupDownloads: () => Promise;
@@ -185,6 +194,9 @@ type ValuesWithSetters = Omit<
| 'typingIndicatorSetting'
| 'deviceName'
| 'phoneNumber'
+ | 'backupFeatureEnabled'
+ | 'cloudBackupStatus'
+ | 'backupSubscriptionStatus'
// Optional
| 'mediaPermissions'
@@ -394,6 +406,19 @@ export function createIPCEvents(
availableSpeakers,
};
},
+ getBackupFeatureEnabled: () => {
+ return isBackupFeatureEnabled();
+ },
+ getCloudBackupStatus: () => {
+ return window.storage.get('cloudBackupStatus');
+ },
+ getBackupSubscriptionStatus: () => {
+ return window.storage.get('backupSubscriptionStatus');
+ },
+ refreshCloudBackupStatus:
+ window.Signal.Services.backups.throttledFetchCloudBackupStatus,
+ refreshBackupSubscriptionStatus:
+ window.Signal.Services.backups.throttledFetchSubscriptionStatus,
getBlockedCount: () =>
window.storage.blocked.getBlockedServiceIds().length +
window.storage.blocked.getBlockedGroups().length,
diff --git a/ts/util/isBackupEnabled.ts b/ts/util/isBackupEnabled.ts
index 92704dd280a..9c4dd6346be 100644
--- a/ts/util/isBackupEnabled.ts
+++ b/ts/util/isBackupEnabled.ts
@@ -5,7 +5,7 @@ import * as RemoteConfig from '../RemoteConfig';
import { isTestOrMockEnvironment } from '../environment';
import { isStagingServer } from './isStagingServer';
-export function isBackupEnabled(): boolean {
+export function isBackupFeatureEnabled(): boolean {
if (isStagingServer() || isTestOrMockEnvironment()) {
return true;
}
diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts
index c039064c252..415c136c251 100644
--- a/ts/windows/preload.ts
+++ b/ts/windows/preload.ts
@@ -19,6 +19,16 @@ installCallback('resetDefaultChatColor');
installCallback('setGlobalDefaultConversationColor');
installCallback('getDefaultConversationColor');
+installSetting('backupFeatureEnabled', {
+ setter: false,
+});
+installSetting('backupSubscriptionStatus', {
+ setter: false,
+});
+installSetting('cloudBackupStatus', {
+ setter: false,
+});
+
// Getters only. These are set by the primary device
installSetting('blockedCount', {
setter: false,
@@ -33,6 +43,8 @@ installSetting('typingIndicatorSetting', {
setter: false,
});
+installCallback('refreshCloudBackupStatus');
+installCallback('refreshBackupSubscriptionStatus');
installCallback('deleteAllMyStories');
installCallback('isPrimary');
installCallback('syncRequest');
diff --git a/ts/windows/settings/app.tsx b/ts/windows/settings/app.tsx
index 5694ed53615..8ecc53f35b4 100644
--- a/ts/windows/settings/app.tsx
+++ b/ts/windows/settings/app.tsx
@@ -30,8 +30,11 @@ SettingsWindowProps.onRender(
availableLocales,
availableMicrophones,
availableSpeakers,
+ backupFeatureEnabled,
+ backupSubscriptionStatus,
blockedCount,
closeSettings,
+ cloudBackupStatus,
customColors,
defaultConversationColor,
deviceName,
@@ -110,6 +113,8 @@ SettingsWindowProps.onRender(
onWhoCanSeeMeChange,
onZoomFactorChange,
preferredSystemLocales,
+ refreshCloudBackupStatus,
+ refreshBackupSubscriptionStatus,
removeCustomColor,
removeCustomColorOnConversations,
resetAllChatColors,
@@ -135,8 +140,11 @@ SettingsWindowProps.onRender(
availableLocales={availableLocales}
availableMicrophones={availableMicrophones}
availableSpeakers={availableSpeakers}
+ backupFeatureEnabled={backupFeatureEnabled}
+ backupSubscriptionStatus={backupSubscriptionStatus}
blockedCount={blockedCount}
closeSettings={closeSettings}
+ cloudBackupStatus={cloudBackupStatus}
customColors={customColors}
defaultConversationColor={defaultConversationColor}
deviceName={deviceName}
@@ -221,6 +229,8 @@ SettingsWindowProps.onRender(
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange}
preferredSystemLocales={preferredSystemLocales}
+ refreshCloudBackupStatus={refreshCloudBackupStatus}
+ refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
removeCustomColorOnConversations={removeCustomColorOnConversations}
removeCustomColor={removeCustomColor}
resetAllChatColors={resetAllChatColors}
diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts
index 5df39e32478..4bbed65dc04 100644
--- a/ts/windows/settings/preload.ts
+++ b/ts/windows/settings/preload.ts
@@ -60,6 +60,18 @@ const settingZoomFactor = createSetting('zoomFactor');
// Getters only.
const settingBlockedCount = createSetting('blockedCount');
+const settingBackupFeatureEnabled = createSetting('backupFeatureEnabled', {
+ setter: false,
+});
+const settingCloudBackupStatus = createSetting('cloudBackupStatus', {
+ setter: false,
+});
+const settingBackupSubscriptionStatus = createSetting(
+ 'backupSubscriptionStatus',
+ {
+ setter: false,
+ }
+);
const settingLinkPreview = createSetting('linkPreviewSetting', {
setter: false,
});
@@ -88,6 +100,10 @@ const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault');
const ipcIsSyncNotSupported = createCallback('isPrimary');
const ipcMakeSyncRequest = createCallback('syncRequest');
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
+const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
+const ipcRefreshBackupSubscriptionStatus = createCallback(
+ 'refreshBackupSubscriptionStatus'
+);
// ChatColorPicker redux hookups
// The redux actions update over IPC through a preferences re-render
@@ -144,7 +160,10 @@ function attachRenderCallback(f: (value: Value) => Promise) {
async function renderPreferences() {
const {
autoDownloadAttachment,
+ backupFeatureEnabled,
+ backupSubscriptionStatus,
blockedCount,
+ cloudBackupStatus,
deviceName,
emojiSkinToneDefault,
hasAudioNotifications,
@@ -188,7 +207,10 @@ async function renderPreferences() {
isSyncNotSupported,
} = await awaitObject({
autoDownloadAttachment: settingAutoDownloadAttachment.getValue(),
+ backupFeatureEnabled: settingBackupFeatureEnabled.getValue(),
+ backupSubscriptionStatus: settingBackupSubscriptionStatus.getValue(),
blockedCount: settingBlockedCount.getValue(),
+ cloudBackupStatus: settingCloudBackupStatus.getValue(),
deviceName: settingDeviceName.getValue(),
hasAudioNotifications: settingAudioNotification.getValue(),
hasAutoConvertEmoji: settingAutoConvertEmoji.getValue(),
@@ -279,7 +301,10 @@ async function renderPreferences() {
availableLocales,
availableMicrophones,
availableSpeakers,
+ backupFeatureEnabled,
+ backupSubscriptionStatus,
blockedCount,
+ cloudBackupStatus,
customColors,
defaultConversationColor,
deviceName,
@@ -333,12 +358,13 @@ async function renderPreferences() {
initialSpellCheckSetting:
MinimalSignalContext.config.appStartInitialSpellcheckSetting,
makeSyncRequest: ipcMakeSyncRequest,
+ refreshCloudBackupStatus: ipcRefreshCloudBackupStatus,
+ refreshBackupSubscriptionStatus: ipcRefreshBackupSubscriptionStatus,
removeCustomColor: ipcRemoveCustomColor,
removeCustomColorOnConversations: ipcRemoveCustomColorOnConversations,
resetAllChatColors: ipcResetAllChatColors,
resetDefaultChatColor: ipcResetDefaultChatColor,
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
-
// Limited support features
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(
OS,