From 1cfda1f2107b526fe2392f09a635bcd20fd323a2 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:40:22 -0800 Subject: [PATCH] Implement megaphone conditional standard_donate with local device createdAt --- ts/Crypto.node.ts | 55 ++++++++++++ ts/background.preload.ts | 4 +- ts/components/RemoteMegaphone.dom.tsx | 6 +- ts/services/megaphone.preload.ts | 47 +++++++++- ts/test-electron/Crypto_test.preload.ts | 26 ++++++ .../textsecure/AccountManager_test.preload.ts | 29 ++++++- .../release-notes/megaphone_test.node.ts | 82 ++++++++++++++++++ .../release-notes/release-notes-v2.json | 13 +++ .../en.json | 8 ++ .../static/release-notes/donate-heart.png | Bin 0 -> 6818 bytes .../services/megaphone_test.preload.ts | 79 ++++++++++++++++- ts/textsecure/AccountManager.preload.ts | 54 ++++++++++++ ts/textsecure/WebAPI.preload.ts | 2 +- ts/textsecure/storage/User.dom.ts | 8 ++ ts/types/Storage.d.ts | 1 + ts/util/onDeviceNameChangeSync.preload.ts | 57 +++++++++--- 16 files changed, 452 insertions(+), 19 deletions(-) create mode 100644 ts/test-mock/release-notes/megaphone_test.node.ts create mode 100644 ts/test-mock/updates-data/static/release-notes/1A6FCAB5-2F4C-44D3-88B4-27140FACBF75/en.json create mode 100644 ts/test-mock/updates-data/static/release-notes/donate-heart.png diff --git a/ts/Crypto.node.ts b/ts/Crypto.node.ts index 052aed9f2e..67ee241ce6 100644 --- a/ts/Crypto.node.ts +++ b/ts/Crypto.node.ts @@ -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); } diff --git a/ts/background.preload.ts b/ts/background.preload.ts index cbe3289b53..276a6153a5 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -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 { // after connect on every startup drop(registerCapabilities()); drop(ensureAEP()); - drop(maybeQueueDeviceNameFetch()); + drop(maybeQueueDeviceInfoFetch()); Stickers.downloadQueuedPacks(); } diff --git a/ts/components/RemoteMegaphone.dom.tsx b/ts/components/RemoteMegaphone.dom.tsx index adb7f2ab5f..bd4e227660 100644 --- a/ts/components/RemoteMegaphone.dom.tsx +++ b/ts/components/RemoteMegaphone.dom.tsx @@ -51,7 +51,11 @@ export function RemoteMegaphone({ if (isFullSize) { return ( -
+
{image}
diff --git a/ts/services/megaphone.preload.ts b/ts/services/megaphone.preload.ts index 3bcff3b99d..93fc6db72d 100644 --- a/ts/services/megaphone.preload.ts +++ b/ts/services/megaphone.preload.ts @@ -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 { @@ -150,6 +191,10 @@ export function isMegaphoneShowable( return false; } + if (!isConditionalActive(megaphone.conditionalId)) { + return false; + } + if (snoozedAt) { let snoozeDuration; try { diff --git a/ts/test-electron/Crypto_test.preload.ts b/ts/test-electron/Crypto_test.preload.ts index a960516734..bcb59b1fdd 100644 --- a/ts/test-electron/Crypto_test.preload.ts +++ b/ts/test-electron/Crypto_test.preload.ts @@ -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); diff --git a/ts/test-electron/textsecure/AccountManager_test.preload.ts b/ts/test-electron/textsecure/AccountManager_test.preload.ts index 19fe5f9782..8c000f5884 100644 --- a/ts/test-electron/textsecure/AccountManager_test.preload.ts +++ b/ts/test-electron/textsecure/AccountManager_test.preload.ts @@ -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; diff --git a/ts/test-mock/release-notes/megaphone_test.node.ts b/ts/test-mock/release-notes/megaphone_test.node.ts new file mode 100644 index 0000000000..71c3443ff2 --- /dev/null +++ b/ts/test-mock/release-notes/megaphone_test.node.ts @@ -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(); + }); +}); diff --git a/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json b/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json index 2f3d5d96fc..6607ec396e 100644 --- a/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json +++ b/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json @@ -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] } } ] } diff --git a/ts/test-mock/updates-data/static/release-notes/1A6FCAB5-2F4C-44D3-88B4-27140FACBF75/en.json b/ts/test-mock/updates-data/static/release-notes/1A6FCAB5-2F4C-44D3-88B4-27140FACBF75/en.json new file mode 100644 index 0000000000..4f9c31e526 --- /dev/null +++ b/ts/test-mock/updates-data/static/release-notes/1A6FCAB5-2F4C-44D3-88B4-27140FACBF75/en.json @@ -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" +} diff --git a/ts/test-mock/updates-data/static/release-notes/donate-heart.png b/ts/test-mock/updates-data/static/release-notes/donate-heart.png new file mode 100644 index 0000000000000000000000000000000000000000..1befcd52d6801986b47d885cef1e52742652416d GIT binary patch literal 6818 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 { 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); + }); + }); + }); }); diff --git a/ts/textsecure/AccountManager.preload.ts b/ts/textsecure/AccountManager.preload.ts index 5a76531cc6..a14bfa0cf5 100644 --- a/ts/textsecure/AccountManager.preload.ts +++ b/ts/textsecure/AccountManager.preload.ts @@ -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 { + 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 { + 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 { const isNameEncrypted = itemStorage.user.getDeviceNameEncrypted(); if (isNameEncrypted) { diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index 41fdb682d5..70a47151d0 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -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(), }) ), }); diff --git a/ts/textsecure/storage/User.dom.ts b/ts/textsecure/storage/User.dom.ts index 6f58bb29fc..731823c789 100644 --- a/ts/textsecure/storage/User.dom.ts +++ b/ts/textsecure/storage/User.dom.ts @@ -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 { + return this.storage.put('deviceCreatedAt', createdAt); + } + public getDeviceName(): string | undefined { return this.storage.get('device_name'); } diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 61fb2b0973..b7e88eb828 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -83,6 +83,7 @@ export type StorageAccessType = { customColors: CustomColorsItemType; device_name: string; + deviceCreatedAt: number; existingOnboardingStoryMessageIds: ReadonlyArray | undefined; hasSetMyStoriesPrivacy: boolean; hasCompletedUsernameOnboarding: boolean; diff --git a/ts/util/onDeviceNameChangeSync.preload.ts b/ts/util/onDeviceNameChangeSync.preload.ts index 3c831443b1..300ce60edd 100644 --- a/ts/util/onDeviceNameChangeSync.preload.ts +++ b/ts/util/onDeviceNameChangeSync.preload.ts @@ -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 { +export async function maybeQueueDeviceInfoFetch(): Promise { 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 { + 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'); + } +}