mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Implement megaphone conditional standard_donate with local device createdAt
This commit is contained in:
@@ -158,6 +158,61 @@ export function decryptDeviceName(
|
||||
return Bytes.toString(plaintext);
|
||||
}
|
||||
|
||||
// For testing
|
||||
export function encryptDeviceCreatedAt(
|
||||
createdAt: number,
|
||||
deviceId: number,
|
||||
registrationId: number,
|
||||
identityPublic: PublicKey
|
||||
): Uint8Array {
|
||||
const createdAtBuffer = new ArrayBuffer(8);
|
||||
const dataView = new DataView(createdAtBuffer);
|
||||
dataView.setBigUint64(0, BigInt(createdAt), false);
|
||||
const createdAtBytes = new Uint8Array(createdAtBuffer);
|
||||
|
||||
const associatedData = getAssociatedDataForDeviceCreatedAt(
|
||||
deviceId,
|
||||
registrationId
|
||||
);
|
||||
|
||||
return identityPublic.seal(createdAtBytes, 'deviceCreatedAt', associatedData);
|
||||
}
|
||||
|
||||
// createdAtCiphertext is an Int64, encrypted using the identity key
|
||||
// PrivateKey with 5 bytes of associated data (deviceId || registrationId).
|
||||
export function decryptDeviceCreatedAt(
|
||||
createdAtCiphertext: Uint8Array,
|
||||
deviceId: number,
|
||||
registrationId: number,
|
||||
identityPrivate: PrivateKey
|
||||
): number {
|
||||
const associatedData = getAssociatedDataForDeviceCreatedAt(
|
||||
deviceId,
|
||||
registrationId
|
||||
);
|
||||
const createdAtData = identityPrivate.open(
|
||||
createdAtCiphertext,
|
||||
'deviceCreatedAt',
|
||||
associatedData
|
||||
);
|
||||
return Number(Bytes.readBigUint64BE(createdAtData));
|
||||
}
|
||||
|
||||
function getAssociatedDataForDeviceCreatedAt(
|
||||
deviceId: number,
|
||||
registrationId: number
|
||||
): Uint8Array {
|
||||
if (deviceId > 255) {
|
||||
throw new Error('deviceId above 255, must be 1 byte');
|
||||
}
|
||||
|
||||
const associatedDataBuffer = new ArrayBuffer(5);
|
||||
const dataView = new DataView(associatedDataBuffer);
|
||||
dataView.setUint8(0, deviceId);
|
||||
dataView.setUint32(1, registrationId, false);
|
||||
return new Uint8Array(associatedDataBuffer);
|
||||
}
|
||||
|
||||
export function deriveMasterKey(accountEntropyPool: string): Uint8Array {
|
||||
return AccountEntropyPool.deriveSvrKey(accountEntropyPool);
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ import { ReleaseNoteAndMegaphoneFetcher } from './services/releaseNoteAndMegapho
|
||||
import { initMegaphoneCheckService } from './services/megaphone.preload.js';
|
||||
import { BuildExpirationService } from './services/buildExpiration.preload.js';
|
||||
import {
|
||||
maybeQueueDeviceNameFetch,
|
||||
maybeQueueDeviceInfoFetch,
|
||||
onDeviceNameChangeSync,
|
||||
} from './util/onDeviceNameChangeSync.preload.js';
|
||||
import { postSaveUpdates } from './util/cleanup.preload.js';
|
||||
@@ -1807,7 +1807,7 @@ export async function startApp(): Promise<void> {
|
||||
// after connect on every startup
|
||||
drop(registerCapabilities());
|
||||
drop(ensureAEP());
|
||||
drop(maybeQueueDeviceNameFetch());
|
||||
drop(maybeQueueDeviceInfoFetch());
|
||||
Stickers.downloadQueuedPacks();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ export function RemoteMegaphone({
|
||||
|
||||
if (isFullSize) {
|
||||
return (
|
||||
<div className={wrapperClassName} aria-live="polite">
|
||||
<div
|
||||
className={wrapperClassName}
|
||||
aria-live="polite"
|
||||
data-testid="RemoteMegaphone"
|
||||
>
|
||||
<div className={tw('flex items-start gap-3')}>
|
||||
{image}
|
||||
<div className={tw('w-full')}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type RemoteMegaphoneType,
|
||||
type VisibleRemoteMegaphoneType,
|
||||
} from '../types/Megaphone.std.js';
|
||||
import { HOUR } from '../util/durations/index.std.js';
|
||||
import { DAY, HOUR } from '../util/durations/index.std.js';
|
||||
import { DataReader, DataWriter } from '../sql/Client.preload.js';
|
||||
import { drop } from '../util/drop.std.js';
|
||||
import {
|
||||
@@ -21,10 +21,13 @@ import {
|
||||
import { isEnabled } from '../RemoteConfig.dom.js';
|
||||
import { safeSetTimeout } from '../util/timeout.std.js';
|
||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js';
|
||||
import { itemStorage } from '../textsecure/Storage.preload.js';
|
||||
import { isMoreRecentThan } from '../util/timestamp.std.js';
|
||||
|
||||
const log = createLogger('megaphoneService');
|
||||
|
||||
const CHECK_INTERVAL = 12 * HOUR;
|
||||
const CONDITIONAL_STANDARD_DONATE_DEVICE_AGE = 7 * DAY;
|
||||
|
||||
let nextCheckTimeout: NodeJS.Timeout | null;
|
||||
|
||||
@@ -91,6 +94,44 @@ export function isRemoteMegaphoneEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isConditionalActive(conditionalId: string | null): boolean {
|
||||
if (conditionalId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (conditionalId === 'standard_donate') {
|
||||
const deviceCreatedAt = itemStorage.user.getDeviceCreatedAt();
|
||||
if (
|
||||
!deviceCreatedAt ||
|
||||
isMoreRecentThan(deviceCreatedAt, CONDITIONAL_STANDARD_DONATE_DEVICE_AGE)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const me = window.ConversationController.getOurConversation();
|
||||
if (!me) {
|
||||
log.error(
|
||||
"isConditionalActive: Can't check badges because our conversation not available"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasBadges = me.attributes.badges && me.attributes.badges.length > 0;
|
||||
return !hasBadges;
|
||||
}
|
||||
|
||||
if (conditionalId === 'internal_user') {
|
||||
return isEnabled('desktop.internalUser');
|
||||
}
|
||||
|
||||
if (conditionalId === 'test') {
|
||||
return isMockEnvironment();
|
||||
}
|
||||
|
||||
log.error(`isConditionalActive: Invalid value ${conditionalId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
async function processMegaphone(megaphone: RemoteMegaphoneType): Promise<void> {
|
||||
@@ -150,6 +191,10 @@ export function isMegaphoneShowable(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isConditionalActive(megaphone.conditionalId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snoozedAt) {
|
||||
let snoozeDuration;
|
||||
try {
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
decryptAttachmentV1,
|
||||
padAndEncryptAttachment,
|
||||
CipherType,
|
||||
encryptDeviceCreatedAt,
|
||||
decryptDeviceCreatedAt,
|
||||
} from '../Crypto.node.js';
|
||||
import {
|
||||
_generateAttachmentIv,
|
||||
@@ -389,6 +391,30 @@ describe('Crypto', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypted device createdAt', () => {
|
||||
it('roundtrips', () => {
|
||||
const deviceId = 2;
|
||||
const registrationId = 123;
|
||||
const identityKey = Curve.generateKeyPair();
|
||||
const createdAt = new Date().getTime();
|
||||
|
||||
const encrypted = encryptDeviceCreatedAt(
|
||||
createdAt,
|
||||
deviceId,
|
||||
registrationId,
|
||||
identityKey.publicKey
|
||||
);
|
||||
const decrypted = decryptDeviceCreatedAt(
|
||||
encrypted,
|
||||
deviceId,
|
||||
registrationId,
|
||||
identityKey.privateKey
|
||||
);
|
||||
|
||||
assert.strictEqual(decrypted, createdAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyHmacSha256', () => {
|
||||
it('rejects if their MAC is too short', () => {
|
||||
const key = getRandomBytes(32);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { assert } from 'chai';
|
||||
import lodash from 'lodash';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import { getRandomBytes } from '../../Crypto.node.js';
|
||||
import { generateRegistrationId, getRandomBytes } from '../../Crypto.node.js';
|
||||
import { generateKeyPair } from '../../Curve.node.js';
|
||||
import AccountManager from '../../textsecure/AccountManager.preload.js';
|
||||
import type {
|
||||
@@ -32,6 +32,7 @@ describe('AccountManager', () => {
|
||||
|
||||
const ourAci = generateAci();
|
||||
const ourPni = generatePni();
|
||||
const ourRegistrationId = generateRegistrationId();
|
||||
const identityKey = generateKeyPair();
|
||||
const pubKey = getRandomBytes(33);
|
||||
const privKey = getRandomBytes(32);
|
||||
@@ -42,6 +43,9 @@ describe('AccountManager', () => {
|
||||
sandbox
|
||||
.stub(signalProtocolStore, 'getIdentityKeyPair')
|
||||
.returns(identityKey);
|
||||
sandbox
|
||||
.stub(signalProtocolStore, 'getLocalRegistrationId')
|
||||
.resolves(ourRegistrationId);
|
||||
const { user } = itemStorage;
|
||||
sandbox.stub(user, 'getAci').returns(ourAci);
|
||||
sandbox.stub(user, 'getPni').returns(ourPni);
|
||||
@@ -77,6 +81,29 @@ describe('AccountManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypted device createdAt', () => {
|
||||
it('roundtrips', async () => {
|
||||
const deviceId = 2;
|
||||
const createdAt = new Date().getTime();
|
||||
|
||||
const encrypted = await accountManager._encryptDeviceCreatedAt(
|
||||
createdAt,
|
||||
deviceId
|
||||
);
|
||||
if (!encrypted) {
|
||||
throw new Error('failed to encrypt!');
|
||||
}
|
||||
|
||||
assert.strictEqual(typeof encrypted, 'string');
|
||||
const decrypted = await accountManager.decryptDeviceCreatedAt(
|
||||
encrypted,
|
||||
deviceId
|
||||
);
|
||||
|
||||
assert.strictEqual(decrypted, createdAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#_cleanSignedPreKeys', () => {
|
||||
let originalLoadSignedPreKeys: any;
|
||||
let originalRemoveSignedPreKey: any;
|
||||
|
||||
82
ts/test-mock/release-notes/megaphone_test.node.ts
Normal file
82
ts/test-mock/release-notes/megaphone_test.node.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import createDebug from 'debug';
|
||||
|
||||
import { expect } from 'playwright/test';
|
||||
import { StorageState } from '@signalapp/mock-server';
|
||||
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup.js';
|
||||
import Long from 'long';
|
||||
|
||||
import type { App } from '../playwright.node.js';
|
||||
import { Bootstrap } from '../bootstrap.node.js';
|
||||
import { MINUTE } from '../../util/durations/index.std.js';
|
||||
|
||||
export const debug = createDebug('mock:test:megaphone');
|
||||
|
||||
describe('megaphone', function (this: Mocha.Suite) {
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
let nextApp: App;
|
||||
|
||||
this.timeout(MINUTE);
|
||||
|
||||
beforeEach(async () => {
|
||||
bootstrap = new Bootstrap();
|
||||
await bootstrap.init();
|
||||
|
||||
let state = StorageState.getEmpty();
|
||||
|
||||
const { phone } = bootstrap;
|
||||
|
||||
state = state.updateAccount({
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
givenName: phone.profileName,
|
||||
readReceipts: true,
|
||||
hasCompletedUsernameOnboarding: true,
|
||||
backupTier: Long.fromNumber(BackupLevel.Free),
|
||||
});
|
||||
|
||||
await phone.setStorageState(state);
|
||||
|
||||
app = await bootstrap.link();
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextApp) {
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, nextApp);
|
||||
}
|
||||
await nextApp?.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('shows megaphone', async () => {
|
||||
const firstWindow = await app.getWindow();
|
||||
|
||||
await app.waitForReleaseNoteAndMegaphoneFetcher();
|
||||
await firstWindow.evaluate(
|
||||
'window.SignalCI.resetReleaseNoteAndMegaphoneFetcher()'
|
||||
);
|
||||
|
||||
await app.close();
|
||||
|
||||
nextApp = await bootstrap.startApp();
|
||||
|
||||
const secondWindow = await nextApp.getWindow();
|
||||
|
||||
debug('waiting for megaphone');
|
||||
const megaphoneEl = secondWindow.getByTestId('RemoteMegaphone');
|
||||
await megaphoneEl.waitFor();
|
||||
|
||||
await expect(megaphoneEl.locator('text=/Donate Today/')).toBeVisible();
|
||||
await expect(megaphoneEl.locator('img')).toBeVisible();
|
||||
await expect(
|
||||
megaphoneEl.getByText('Donate', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(megaphoneEl.locator('text=/Not now/')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,19 @@
|
||||
"conditionalId": "standard_donate",
|
||||
"dontShowAfterEpochSeconds": 1734616800,
|
||||
"secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] }
|
||||
},
|
||||
{
|
||||
"primaryCtaId": "donate",
|
||||
"secondaryCtaId": "snooze",
|
||||
"countries": "1:1000000",
|
||||
"desktopMinVersion": "6.7.0",
|
||||
"priority": 100,
|
||||
"dontShowBeforeEpochSeconds": 1732024800,
|
||||
"uuid": "1A6FCAB5-2F4C-44D3-88B4-27140FACBF75",
|
||||
"showForNumberOfDays": 30,
|
||||
"conditionalId": "test",
|
||||
"dontShowAfterEpochSeconds": 2147485547,
|
||||
"secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"image": "/static/release-notes/donate-heart.png",
|
||||
"uuid": "1A6FCAB5-2F4C-44D3-88B4-27140FACBF75",
|
||||
"title": "Donate Today",
|
||||
"body": "As a nonprofit, Signal needs your support.",
|
||||
"primaryCtaText": "Donate",
|
||||
"secondaryCtaText": "Not now"
|
||||
}
|
||||
BIN
ts/test-mock/updates-data/static/release-notes/donate-heart.png
Normal file
BIN
ts/test-mock/updates-data/static/release-notes/donate-heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -3,6 +3,7 @@
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
isMegaphoneCtaIdValid,
|
||||
@@ -14,6 +15,10 @@ import type {
|
||||
RemoteMegaphoneId,
|
||||
RemoteMegaphoneType,
|
||||
} from '../../types/Megaphone.std.js';
|
||||
import { generateAci } from '../../types/ServiceId.std.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import type { ConversationController } from '../../ConversationController.preload.js';
|
||||
import type { ConversationModel } from '../../models/conversations.preload.js';
|
||||
|
||||
const FAKE_MEGAPHONE: RemoteMegaphoneType = {
|
||||
id: uuid() as RemoteMegaphoneId,
|
||||
@@ -22,7 +27,7 @@ const FAKE_MEGAPHONE: RemoteMegaphoneType = {
|
||||
dontShowBeforeEpochMs: Date.now() - 1 * DAY,
|
||||
dontShowAfterEpochMs: Date.now() + 14 * DAY,
|
||||
showForNumberOfDays: 30,
|
||||
conditionalId: 'standard_donate',
|
||||
conditionalId: 'test',
|
||||
primaryCtaId: 'donate',
|
||||
primaryCtaData: null,
|
||||
secondaryCtaId: 'snooze',
|
||||
@@ -125,4 +130,76 @@ describe('megaphone service', () => {
|
||||
assert.strictEqual(isMegaphoneShowable(megaphone), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionals', () => {
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
let deviceCreatedAt: number;
|
||||
let oldConversationController: ConversationController;
|
||||
let ourConversation: ConversationModel;
|
||||
|
||||
const ourAci = generateAci();
|
||||
const createMeWithBadges = (
|
||||
badges: Array<{
|
||||
id: string;
|
||||
}>
|
||||
): ConversationModel => {
|
||||
const attrs = {
|
||||
id: 'our-conversation-id',
|
||||
serviceId: ourAci,
|
||||
badges,
|
||||
type: 'private',
|
||||
sharedGroupNames: [],
|
||||
version: 0,
|
||||
expireTimerVersion: 1,
|
||||
};
|
||||
return {
|
||||
...attrs,
|
||||
attributes: attrs,
|
||||
} as unknown as ConversationModel;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox();
|
||||
sandbox.stub(itemStorage, 'get').callsFake(key => {
|
||||
if (key === 'deviceCreatedAt') {
|
||||
return deviceCreatedAt;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
deviceCreatedAt = Date.now();
|
||||
ourConversation = createMeWithBadges([]);
|
||||
|
||||
oldConversationController = window.ConversationController;
|
||||
window.ConversationController = {
|
||||
getOurConversation: () => ourConversation,
|
||||
conversationUpdated: () => undefined,
|
||||
} as unknown as ConversationController;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.ConversationController = oldConversationController;
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('standard_donate', async () => {
|
||||
const megaphone = getMegaphone({
|
||||
conditionalId: 'standard_donate',
|
||||
});
|
||||
|
||||
it('true when desktop has been registered for a week and has no badges', () => {
|
||||
deviceCreatedAt = Date.now() - 7 * DAY;
|
||||
assert.strictEqual(isMegaphoneShowable(megaphone), true);
|
||||
});
|
||||
|
||||
it('false with fresh linked desktop', () => {
|
||||
assert.strictEqual(isMegaphoneShowable(megaphone), false);
|
||||
});
|
||||
|
||||
it('false with badges', () => {
|
||||
ourConversation = createMeWithBadges([{ id: 'cool' }]);
|
||||
assert.strictEqual(isMegaphoneShowable(megaphone), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
encryptDeviceName,
|
||||
generateRegistrationId,
|
||||
getRandomBytes,
|
||||
decryptDeviceCreatedAt,
|
||||
encryptDeviceCreatedAt,
|
||||
} from '../Crypto.node.js';
|
||||
import {
|
||||
generateKeyPair,
|
||||
@@ -311,6 +313,58 @@ export default class AccountManager extends EventTarget {
|
||||
return name;
|
||||
}
|
||||
|
||||
// For testing
|
||||
async _encryptDeviceCreatedAt(
|
||||
createdAt: number,
|
||||
deviceId: number
|
||||
): Promise<string> {
|
||||
const ourAci = itemStorage.user.getCheckedAci();
|
||||
const identityKey = signalProtocolStore.getIdentityKeyPair(ourAci);
|
||||
const registrationId =
|
||||
await signalProtocolStore.getLocalRegistrationId(ourAci);
|
||||
strictAssert(identityKey, 'Missing identity key pair');
|
||||
strictAssert(registrationId, 'Missing registrationId for our Aci');
|
||||
|
||||
const createdAtCiphertextBytes = encryptDeviceCreatedAt(
|
||||
createdAt,
|
||||
deviceId,
|
||||
registrationId,
|
||||
identityKey.publicKey
|
||||
);
|
||||
|
||||
return Bytes.toBase64(createdAtCiphertextBytes);
|
||||
}
|
||||
|
||||
async decryptDeviceCreatedAt(
|
||||
createdAtCiphertextBase64: string,
|
||||
deviceId: number
|
||||
): Promise<number> {
|
||||
const ourAci = itemStorage.user.getCheckedAci();
|
||||
const identityKey = signalProtocolStore.getIdentityKeyPair(ourAci);
|
||||
if (!identityKey) {
|
||||
throw new Error('decryptDeviceCreatedAt: No identity key pair!');
|
||||
}
|
||||
|
||||
const registrationId =
|
||||
await signalProtocolStore.getLocalRegistrationId(ourAci);
|
||||
if (!registrationId) {
|
||||
throw new Error('decryptDeviceCreatedAt: No registrationId for our Aci!');
|
||||
}
|
||||
|
||||
const createdAtCiphertextBytes = Bytes.fromBase64(
|
||||
createdAtCiphertextBase64
|
||||
);
|
||||
|
||||
const createdAt = decryptDeviceCreatedAt(
|
||||
createdAtCiphertextBytes,
|
||||
deviceId,
|
||||
registrationId,
|
||||
identityKey.privateKey
|
||||
);
|
||||
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
async maybeUpdateDeviceName(): Promise<void> {
|
||||
const isNameEncrypted = itemStorage.user.getDeviceNameEncrypted();
|
||||
if (isNameEncrypted) {
|
||||
|
||||
@@ -967,7 +967,7 @@ const getDevicesResultZod = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().nullish(), // primary devices may not have a name
|
||||
lastSeen: z.number().nullish(),
|
||||
created: z.number().nullish(),
|
||||
createdAtCiphertext: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
@@ -152,6 +152,14 @@ export class User {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
public getDeviceCreatedAt(): number | undefined {
|
||||
return this.storage.get('deviceCreatedAt');
|
||||
}
|
||||
|
||||
public async setDeviceCreatedAt(createdAt: number): Promise<void> {
|
||||
return this.storage.put('deviceCreatedAt', createdAt);
|
||||
}
|
||||
|
||||
public getDeviceName(): string | undefined {
|
||||
return this.storage.get('device_name');
|
||||
}
|
||||
|
||||
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
@@ -83,6 +83,7 @@ export type StorageAccessType = {
|
||||
|
||||
customColors: CustomColorsItemType;
|
||||
device_name: string;
|
||||
deviceCreatedAt: number;
|
||||
existingOnboardingStoryMessageIds: ReadonlyArray<string> | undefined;
|
||||
hasSetMyStoriesPrivacy: boolean;
|
||||
hasCompletedUsernameOnboarding: boolean;
|
||||
|
||||
@@ -27,41 +27,45 @@ export async function onDeviceNameChangeSync(
|
||||
const { confirm } = event;
|
||||
|
||||
const maybeQueueAndThenConfirm = async () => {
|
||||
await maybeQueueDeviceNameFetch();
|
||||
await maybeQueueDeviceInfoFetch();
|
||||
confirm();
|
||||
};
|
||||
|
||||
drop(maybeQueueAndThenConfirm());
|
||||
}
|
||||
|
||||
export async function maybeQueueDeviceNameFetch(): Promise<void> {
|
||||
export async function maybeQueueDeviceInfoFetch(): Promise<void> {
|
||||
if (deviceNameFetchQueue.size >= 1) {
|
||||
log.info('maybeQueueDeviceNameFetch: skipping; fetch already queued');
|
||||
log.info('maybeQueueDeviceInfoFetch: skipping; fetch already queued');
|
||||
}
|
||||
|
||||
try {
|
||||
await deviceNameFetchQueue.add(fetchAndUpdateDeviceName);
|
||||
await deviceNameFetchQueue.add(fetchAndUpdateDeviceInfo);
|
||||
} catch (e) {
|
||||
log.error(
|
||||
'maybeQueueDeviceNameFetch: error when fetching device name',
|
||||
'maybeQueueDeviceInfoFetch: error when fetching device name',
|
||||
toLogFormat(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndUpdateDeviceName() {
|
||||
async function fetchAndUpdateDeviceInfo() {
|
||||
const { devices } = await getDevices();
|
||||
const localDeviceId = parseIntOrThrow(
|
||||
itemStorage.user.getDeviceId(),
|
||||
'fetchAndUpdateDeviceName: localDeviceId'
|
||||
'fetchAndUpdateDeviceInfo: localDeviceId'
|
||||
);
|
||||
const ourDevice = devices.find(device => device.id === localDeviceId);
|
||||
strictAssert(ourDevice, 'ourDevice must be returned from devices endpoint');
|
||||
|
||||
const newNameEncrypted = ourDevice.name;
|
||||
await maybeUpdateDeviceCreatedAt(
|
||||
ourDevice.createdAtCiphertext,
|
||||
localDeviceId
|
||||
);
|
||||
|
||||
const newNameEncrypted = ourDevice.name;
|
||||
if (!newNameEncrypted) {
|
||||
log.error('fetchAndUpdateDeviceName: device had empty name');
|
||||
log.error('fetchAndUpdateDeviceInfo: device had empty name');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,20 +75,49 @@ async function fetchAndUpdateDeviceName() {
|
||||
} catch (e) {
|
||||
const deviceNameWasEncrypted = itemStorage.user.getDeviceNameEncrypted();
|
||||
log.error(
|
||||
`fetchAndUpdateDeviceName: failed to decrypt device name. Was encrypted local state: ${deviceNameWasEncrypted}`
|
||||
`fetchAndUpdateDeviceInfo: failed to decrypt device name. Was encrypted local state: ${deviceNameWasEncrypted}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingName = itemStorage.user.getDeviceName();
|
||||
if (newName === existingName) {
|
||||
log.info('fetchAndUpdateDeviceName: new name matches existing name');
|
||||
log.info('fetchAndUpdateDeviceInfo: new name matches existing name');
|
||||
return;
|
||||
}
|
||||
|
||||
await itemStorage.user.setDeviceName(newName);
|
||||
window.Whisper.events.emit('deviceNameChanged');
|
||||
log.info(
|
||||
'fetchAndUpdateDeviceName: successfully updated new device name locally'
|
||||
'fetchAndUpdateDeviceInfo: successfully updated new device name locally'
|
||||
);
|
||||
}
|
||||
|
||||
async function maybeUpdateDeviceCreatedAt(
|
||||
createdAtCiphertext: string,
|
||||
deviceId: number
|
||||
): Promise<void> {
|
||||
const existingCreatedAt = itemStorage.user.getDeviceCreatedAt();
|
||||
if (existingCreatedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAtEncrypted = createdAtCiphertext;
|
||||
let createdAt: number | undefined;
|
||||
try {
|
||||
createdAt = await accountManager.decryptDeviceCreatedAt(
|
||||
createdAtEncrypted,
|
||||
deviceId
|
||||
);
|
||||
} catch (e) {
|
||||
log.error(
|
||||
'maybeUpdateDeviceCreatedAt: failed to decrypt device createdAt',
|
||||
toLogFormat(e)
|
||||
);
|
||||
}
|
||||
|
||||
if (createdAt) {
|
||||
await itemStorage.user.setDeviceCreatedAt(createdAt);
|
||||
log.info('maybeUpdateDeviceCreatedAt: saved createdAt');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user