Backups: subscription info improvements

This commit is contained in:
automated-signal
2025-06-25 14:59:07 -05:00
committed by GitHub
parent 1bd43ad83f
commit 7da5cb514b
12 changed files with 146 additions and 78 deletions

View File

@@ -6757,7 +6757,7 @@
"description": "Feature name for message backups using the Signal service."
},
"icu:Preferences--signal-backups-off-description": {
"messageformat": "Automatic backups with Signals secure, end-to-end encrypted storage service. Get started on your phone. <learnMoreLink>Learn more.</learnMoreLink>",
"messageformat": "Automatic backups with Signals secure, end-to-end encrypted storage service. Get started on your phone. <learnMoreLink>Learn more</learnMoreLink>.",
"description": "Description for message backups using the Signal service."
},
"icu:Preferences--backup-section-description": {

View File

@@ -778,6 +778,10 @@ $secondary-text-color: light-dark(
padding-block: 8px;
margin-block-start: 8px;
a {
text-decoration: none;
}
&:not(:last-child) {
padding-block-end: 24px;
}

View File

@@ -196,6 +196,7 @@ export default {
backupFeatureEnabled: false,
backupKeyViewed: false,
backupLocalBackupsEnabled: false,
backupSubscriptionStatus: { status: 'off' },
badge: undefined,
blockedCount: 0,
customColors: {},
@@ -444,9 +445,8 @@ BackupsPaidActive.args = {
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
cloudBackupStatus: {
mediaSize: 539_249_410_039,
protoSize: 100_000_000,
createdAt: new Date(Date.now() - WEEK).getTime(),
createdTimestamp: Date.now() - WEEK,
},
backupSubscriptionStatus: {
status: 'active',
@@ -454,7 +454,7 @@ BackupsPaidActive.args = {
amount: 22.99,
currencyCode: 'USD',
},
renewalDate: new Date(Date.now() + 20 * DAY),
renewalTimestamp: Date.now() + 20 * DAY,
},
};
@@ -464,9 +464,8 @@ BackupsPaidCancelled.args = {
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
cloudBackupStatus: {
mediaSize: 539_249_410_039,
protoSize: 100_000_000,
createdAt: new Date(Date.now() - WEEK).getTime(),
createdTimestamp: Date.now() - WEEK,
},
backupSubscriptionStatus: {
status: 'pending-cancellation',
@@ -474,7 +473,7 @@ BackupsPaidCancelled.args = {
amount: 22.99,
currencyCode: 'USD',
},
expiryDate: new Date(Date.now() + 20 * DAY),
expiryTimestamp: Date.now() + 20 * DAY,
},
};
@@ -512,9 +511,8 @@ BackupsSubscriptionNotFound.args = {
status: 'not-found',
},
cloudBackupStatus: {
mediaSize: 539_249_410_039,
protoSize: 100_000_000,
createdAt: new Date(Date.now() - WEEK).getTime(),
createdTimestamp: Date.now() - WEEK,
},
};

View File

@@ -102,7 +102,7 @@ export type PropsDataType = {
backupLocalBackupsEnabled: boolean;
localBackupFolder: string | undefined;
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
backupSubscriptionStatus: BackupsSubscriptionType;
blockedCount: number;
customColors: Record<string, CustomColorType>;
defaultConversationColor: DefaultConversationColorType;
@@ -502,8 +502,7 @@ export function Preferences({
setSelectedLanguageLocale(localeOverride);
}
const shouldShowBackupsPage =
(backupFeatureEnabled && backupSubscriptionStatus != null) ||
backupLocalBackupsEnabled;
backupFeatureEnabled || backupLocalBackupsEnabled;
if (page === Page.Backups && !shouldShowBackupsPage) {
setPage(Page.General);
@@ -519,13 +518,6 @@ export function Preferences({
});
}
useEffect(() => {
if (page === Page.Backups) {
refreshCloudBackupStatus();
refreshBackupSubscriptionStatus();
}
}, [page, refreshCloudBackupStatus, refreshBackupSubscriptionStatus]);
const onZoomSelectChange = useCallback(
(value: string) => {
const number = parseFloat(value);
@@ -2008,6 +2000,8 @@ export function Preferences({
pickLocalBackupFolder={pickLocalBackupFolder}
page={page}
promptOSAuth={promptOSAuth}
refreshCloudBackupStatus={refreshCloudBackupStatus}
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
setPage={setPage}
showToast={showToast}
/>

View File

@@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import type {
@@ -44,12 +44,14 @@ export function PreferencesBackups({
pickLocalBackupFolder,
page,
promptOSAuth,
refreshCloudBackupStatus,
refreshBackupSubscriptionStatus,
setPage,
showToast,
}: {
accountEntropyPool: string | undefined;
backupKeyViewed: boolean;
backupSubscriptionStatus?: BackupsSubscriptionType;
backupSubscriptionStatus: BackupsSubscriptionType;
cloudBackupStatus?: BackupStatusType;
localBackupFolder: string | undefined;
i18n: LocalizerType;
@@ -60,14 +62,29 @@ export function PreferencesBackups({
promptOSAuth: (
reason: PromptOSAuthReasonType
) => Promise<PromptOSAuthResultType>;
refreshCloudBackupStatus: () => void;
refreshBackupSubscriptionStatus: () => void;
setPage: (page: PreferencesBackupPage) => void;
showToast: ShowToastAction;
}): JSX.Element {
}): JSX.Element | null {
const [authError, setAuthError] =
useState<Omit<PromptOSAuthResultType, 'success'>>();
const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
useEffect(() => {
if (page === Page.Backups) {
refreshBackupSubscriptionStatus();
} else if (page === Page.BackupsDetails) {
refreshBackupSubscriptionStatus();
refreshCloudBackupStatus();
}
}, [page, refreshBackupSubscriptionStatus, refreshCloudBackupStatus]);
if (page === Page.BackupsDetails) {
if (backupSubscriptionStatus.status === 'off') {
setPage(Page.Backups);
return null;
}
return (
<BackupsDetailsPage
i18n={i18n}
@@ -116,7 +133,28 @@ export function PreferencesBackups({
</div>
</div>
{backupSubscriptionStatus ? (
{backupSubscriptionStatus.status === 'off' ? (
<SettingsRow className="Preferences--BackupsRow">
<Control
icon="Preferences__BackupsIcon"
left={
<label>
{i18n('icu:Preferences--signal-backups')}{' '}
<div className="Preferences--backup-details__value">
<I18n
id="icu:Preferences--signal-backups-off-description"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
</div>
</label>
}
right={null}
/>
</SettingsRow>
) : (
<SettingsRow className="Preferences--BackupsRow">
<FlowingControl>
<div className="Preferences__two-thirds-flow">
@@ -149,27 +187,6 @@ export function PreferencesBackups({
</div>
</FlowingControl>
</SettingsRow>
) : (
<SettingsRow className="Preferences--BackupsRow">
<Control
icon="Preferences__BackupsIcon"
left={
<label>
{i18n('icu:Preferences--signal-backups')}{' '}
<div className="Preferences--backup-details__value">
<I18n
id="icu:Preferences--signal-backups-off-description"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
</div>
</label>
}
right={null}
/>
</SettingsRow>
)}
<SettingsRow
@@ -264,10 +281,10 @@ function getSubscriptionDetails({
/ month
</div>
) : null}
{subscriptionStatus.renewalDate ? (
{subscriptionStatus.renewalTimestamp ? (
<div className="Preferences--backups-summary__renewal-date">
{i18n('icu:Preferences--backup-plan__renewal-date', {
date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), {
date: formatTimestamp(subscriptionStatus.renewalTimestamp, {
dateStyle: 'medium',
}),
})}
@@ -282,10 +299,10 @@ function getSubscriptionDetails({
<div className="Preferences--backups-summary__canceled">
{i18n('icu:Preferences--backup-plan__canceled')}
</div>
{subscriptionStatus.expiryDate ? (
{subscriptionStatus.expiryTimestamp ? (
<div className="Preferences--backups-summary__expiry-date">
{i18n('icu:Preferences--backup-plan__expiry-date', {
date: formatTimestamp(subscriptionStatus.expiryDate.getTime(), {
date: formatTimestamp(subscriptionStatus.expiryTimestamp, {
dateStyle: 'medium',
}),
})}
@@ -313,6 +330,8 @@ export function renderBackupsSubscriptionDetails({
const { status } = subscriptionStatus;
switch (status) {
case 'off':
return null;
case 'active':
case 'pending-cancellation':
return (
@@ -398,6 +417,8 @@ export function renderBackupsSubscriptionSummary({
const { status } = subscriptionStatus;
switch (status) {
case 'off':
return null;
case 'active':
case 'pending-cancellation':
return (
@@ -449,7 +470,7 @@ function BackupsDetailsPage({
locale,
}: {
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
backupSubscriptionStatus: BackupsSubscriptionType;
i18n: LocalizerType;
locale: string;
}): JSX.Element {
@@ -468,7 +489,7 @@ function BackupsDetailsPage({
className="Preferences--backup-details"
title={i18n('icu:Preferences--backup-details__header')}
>
{cloudBackupStatus.createdAt ? (
{cloudBackupStatus.createdTimestamp ? (
<div className="Preferences--backup-details__row">
<label>{i18n('icu:Preferences--backup-created-at__label')}</label>
<div
@@ -478,7 +499,7 @@ function BackupsDetailsPage({
{/* TODO (DESKTOP-8509) */}
{i18n('icu:Preferences--backup-created-by-phone')}
<span className="Preferences--backup-details__value-divider" />
{formatTimestamp(cloudBackupStatus.createdAt, {
{formatTimestamp(cloudBackupStatus.createdTimestamp, {
dateStyle: 'medium',
timeStyle: 'short',
})}

View File

@@ -253,14 +253,14 @@ export class BackupAPI {
return {
status: 'pending-cancellation',
cost,
expiryDate: endOfCurrentPeriod,
expiryTimestamp: endOfCurrentPeriod?.getTime(),
};
}
return {
status: 'active',
cost,
renewalDate: endOfCurrentPeriod,
renewalTimestamp: endOfCurrentPeriod?.getTime(),
};
}

View File

@@ -27,7 +27,7 @@ import { prependStream } from '../../util/prependStream';
import { appendMacStream } from '../../util/appendMacStream';
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
import { missingCaseError } from '../../util/missingCaseError';
import { DAY, HOUR, MINUTE } from '../../util/durations';
import { DAY, HOUR, SECOND } from '../../util/durations';
import type { ExplodePromiseResultType } from '../../util/explodePromise';
import { explodePromise } from '../../util/explodePromise';
import type { RetryBackupImportValue } from '../../state/ducks/installer';
@@ -153,11 +153,11 @@ export class BackupsService {
public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials);
public readonly throttledFetchCloudBackupStatus = throttle(
MINUTE,
30 * SECOND,
this.fetchCloudBackupStatus.bind(this)
);
public readonly throttledFetchSubscriptionStatus = throttle(
MINUTE,
30 * SECOND,
this.fetchSubscriptionStatus.bind(this)
);
@@ -986,8 +986,7 @@ export class BackupsService {
} catch (error) {
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
}
drop(this.fetchCloudBackupStatus());
drop(this.fetchSubscriptionStatus());
await this.refreshBackupAndSubscriptionStatus();
}
async #unlinkAndDeleteAllData() {
@@ -1064,24 +1063,15 @@ export class BackupsService {
}
}
async #getBackedUpMediaSize(): Promise<number> {
const backupInfo = await this.api.getInfo(BackupCredentialType.Media);
return backupInfo.usedSpace ?? 0;
}
async fetchCloudBackupStatus(): Promise<BackupStatusType | undefined> {
let result: BackupStatusType | undefined;
const [backupProtoInfo, mediaSize] = await Promise.all([
this.api.getBackupProtoInfo(),
this.#getBackedUpMediaSize(),
]);
const backupProtoInfo = await this.api.getBackupProtoInfo();
if (backupProtoInfo.backupExists) {
const { createdAt, size: protoSize } = backupProtoInfo;
result = {
createdAt: createdAt.getTime(),
createdTimestamp: createdAt.getTime(),
protoSize,
mediaSize,
};
}
@@ -1097,6 +1087,10 @@ export class BackupsService {
switch (backupTier) {
case null:
case undefined:
result = {
status: 'off',
};
break;
case BackupLevel.Free:
result = {
status: 'free',
@@ -1114,6 +1108,13 @@ export class BackupsService {
return result;
}
async refreshBackupAndSubscriptionStatus(): Promise<void> {
await Promise.all([
this.fetchSubscriptionStatus(),
this.fetchCloudBackupStatus(),
]);
}
hasMediaBackups(): boolean {
return window.storage.get('backupTier') === BackupLevel.Paid;
}

View File

@@ -83,6 +83,7 @@ import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue';
import {
generateBackupsSubscriberData,
saveBackupsSubscriberData,
saveBackupTier,
} from '../util/backupSubscriptionData';
import { getLinkPreviewSetting } from '../types/LinkPreview';
import {
@@ -1568,7 +1569,7 @@ export async function mergeAccountRecord(
}
await saveBackupsSubscriberData(backupSubscriberData);
await window.storage.put('backupTier', backupTier?.toNumber());
await saveBackupTier(backupTier?.toNumber());
await window.storage.put(
'displayBadgesOnProfile',

View File

@@ -685,7 +685,7 @@ export function SmartPreferences(): JSX.Element | null {
availableSpeakers={availableSpeakers}
backupFeatureEnabled={backupFeatureEnabled}
backupKeyViewed={backupKeyViewed}
backupSubscriptionStatus={backupSubscriptionStatus}
backupSubscriptionStatus={backupSubscriptionStatus ?? { status: 'off' }}
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
badge={badge}
blockedCount={blockedCount}

View File

@@ -1414,17 +1414,50 @@ const backupFileHeadersSchema = z.object({
type BackupFileHeadersType = z.infer<typeof backupFileHeadersSchema>;
// See: https://docs.stripe.com/currencies?presentment-currency=US
const ZERO_DECIMAL_CURRENCIES = new Set([
'bif',
'clp',
'djf',
'gnf',
'jpy',
'kmf',
'krw',
'mga',
'pyg',
'rwf',
'vnd',
'vuv',
'xaf',
'xof',
'xpf',
]);
const secondsTimestampToDate = z.coerce
.number()
.transform(sec => new Date(sec * 1_000));
const subscriptionResponseSchema = z.object({
subscription: z
.object({
level: z.number(),
billingCycleAnchor: z.coerce.date().optional(),
endOfCurrentPeriod: z.coerce.date().optional(),
billingCycleAnchor: secondsTimestampToDate.optional(),
endOfCurrentPeriod: secondsTimestampToDate.optional(),
active: z.boolean(),
cancelAtPeriodEnd: z.boolean().optional(),
currency: z.string().optional(),
amount: z.number().nonnegative().optional(),
})
.transform(data => {
const result = { ...data };
if (result.currency && result.amount) {
result.amount = ZERO_DECIMAL_CURRENCIES.has(
result.currency.toLowerCase()
)
? result.amount
: result.amount / 100;
}
return result;
})
.nullish(),
});

View File

@@ -36,14 +36,13 @@ export type SubscriptionCostType = {
};
export type BackupStatusType = {
createdAt?: number;
createdTimestamp?: number;
protoSize?: number;
mediaSize?: number;
};
export type BackupsSubscriptionType =
| {
status: 'not-found' | 'expired';
status: 'off' | 'not-found' | 'expired';
}
| {
status: 'free';
@@ -52,12 +51,12 @@ export type BackupsSubscriptionType =
| (
| {
status: 'active';
renewalDate?: Date;
renewalTimestamp?: number;
cost?: SubscriptionCostType;
}
| {
status: 'pending-cancellation';
expiryDate?: Date;
expiryTimestamp?: number;
cost?: SubscriptionCostType;
}
);

View File

@@ -4,6 +4,7 @@
import Long from 'long';
import type { Backups, SignalService } from '../protobuf';
import * as Bytes from '../Bytes';
import { drop } from './drop';
// These two proto messages (Backups.AccountData.IIAPSubscriberData &&
// SignalService.AccountRecord.IIAPSubscriberData) should remain in sync. If they drift,
@@ -15,6 +16,12 @@ export async function saveBackupsSubscriberData(
| null
| undefined
): Promise<void> {
const previousSubscriberId = window.storage.get('backupsSubscriberId');
if (previousSubscriberId !== backupsSubscriberData?.subscriberId) {
drop(window.Signal.Services.backups.refreshBackupAndSubscriptionStatus());
}
if (backupsSubscriberData == null) {
await window.storage.remove('backupsSubscriberId');
await window.storage.remove('backupsSubscriberPurchaseToken');
@@ -47,6 +54,16 @@ export async function saveBackupsSubscriberData(
}
}
export async function saveBackupTier(
backupTier: number | undefined
): Promise<void> {
const previousBackupTier = window.storage.get('backupTier');
await window.storage.put('backupTier', backupTier);
if (backupTier !== previousBackupTier) {
drop(window.Signal.Services.backups.refreshBackupAndSubscriptionStatus());
}
}
export function generateBackupsSubscriberData(): Backups.AccountData.IIAPSubscriberData | null {
const backupsSubscriberId = window.storage.get('backupsSubscriberId');