{i18n('icu:Preferences--backup-plan__renewal-date', {
- date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), {
+ date: formatTimestamp(subscriptionStatus.renewalTimestamp, {
dateStyle: 'medium',
}),
})}
@@ -282,10 +299,10 @@ function getSubscriptionDetails({
- {subscriptionStatus.expiryDate ? (
+ {subscriptionStatus.expiryTimestamp ? (
{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 ? (
- {formatTimestamp(cloudBackupStatus.createdAt, {
+ {formatTimestamp(cloudBackupStatus.createdTimestamp, {
dateStyle: 'medium',
timeStyle: 'short',
})}
diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts
index 34ae9fe86c..741f81ba06 100644
--- a/ts/services/backups/api.ts
+++ b/ts/services/backups/api.ts
@@ -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(),
};
}
diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts
index 545666d760..e73731e4a9 100644
--- a/ts/services/backups/index.ts
+++ b/ts/services/backups/index.ts
@@ -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
{
- 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(),
- ]);
+ 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 {
+ await Promise.all([
+ this.fetchSubscriptionStatus(),
+ this.fetchCloudBackupStatus(),
+ ]);
+ }
+
hasMediaBackups(): boolean {
return window.storage.get('backupTier') === BackupLevel.Paid;
}
diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts
index 0b34193e3a..0be5804c9a 100644
--- a/ts/services/storageRecordOps.ts
+++ b/ts/services/storageRecordOps.ts
@@ -79,6 +79,7 @@ import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue';
import {
generateBackupsSubscriberData,
saveBackupsSubscriberData,
+ saveBackupTier,
} from '../util/backupSubscriptionData';
import {
toAciObject,
@@ -1601,7 +1602,7 @@ export async function mergeAccountRecord(
}
await saveBackupsSubscriberData(backupSubscriberData);
- await window.storage.put('backupTier', backupTier?.toNumber());
+ await saveBackupTier(backupTier?.toNumber());
await window.storage.put(
'displayBadgesOnProfile',
diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx
index 47052209e1..467f581a7c 100644
--- a/ts/state/smart/Preferences.tsx
+++ b/ts/state/smart/Preferences.tsx
@@ -692,7 +692,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}
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index adee026fa6..95d86c390e 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -1414,17 +1414,50 @@ const backupFileHeadersSchema = z.object({
type BackupFileHeadersType = z.infer;
+// 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(),
});
diff --git a/ts/types/backups.ts b/ts/types/backups.ts
index 9120a3ed32..3bf80b64cb 100644
--- a/ts/types/backups.ts
+++ b/ts/types/backups.ts
@@ -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;
}
);
diff --git a/ts/util/backupSubscriptionData.ts b/ts/util/backupSubscriptionData.ts
index baf0e30541..9f61624388 100644
--- a/ts/util/backupSubscriptionData.ts
+++ b/ts/util/backupSubscriptionData.ts
@@ -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 {
+ 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 {
+ 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');