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 0000000000..1befcd52d6 Binary files /dev/null and b/ts/test-mock/updates-data/static/release-notes/donate-heart.png differ diff --git a/ts/test-node/services/megaphone_test.preload.ts b/ts/test-node/services/megaphone_test.preload.ts index cf9fd7b29d..1b7831b406 100644 --- a/ts/test-node/services/megaphone_test.preload.ts +++ b/ts/test-node/services/megaphone_test.preload.ts @@ -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); + }); + }); + }); }); 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'); + } +}