Process username changes in storage service
This commit is contained in:
parent
71c97e9580
commit
1381e8df5d
8 changed files with 252 additions and 53 deletions
|
@ -146,7 +146,6 @@ import { showToast } from './util/showToast';
|
||||||
import { startInteractionMode } from './windows/startInteractionMode';
|
import { startInteractionMode } from './windows/startInteractionMode';
|
||||||
import type { MainWindowStatsType } from './windows/context';
|
import type { MainWindowStatsType } from './windows/context';
|
||||||
import { deliveryReceiptsJobQueue } from './jobs/deliveryReceiptsJobQueue';
|
import { deliveryReceiptsJobQueue } from './jobs/deliveryReceiptsJobQueue';
|
||||||
import { updateOurUsernameAndPni } from './util/updateOurUsernameAndPni';
|
|
||||||
import { ReactionSource } from './reactions/ReactionSource';
|
import { ReactionSource } from './reactions/ReactionSource';
|
||||||
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
|
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
|
||||||
import { getInitialState } from './state/getInitialState';
|
import { getInitialState } from './state/getInitialState';
|
||||||
|
@ -2262,18 +2261,15 @@ export async function startApp(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Note: we always have to register our capabilities all at once, so we do this
|
// Note: we always have to register our capabilities all at once, so we do this
|
||||||
// after connect on every startup
|
// after connect on every startup
|
||||||
await Promise.all([
|
await server.registerCapabilities({
|
||||||
server.registerCapabilities({
|
announcementGroup: true,
|
||||||
announcementGroup: true,
|
giftBadges: true,
|
||||||
giftBadges: true,
|
'gv2-3': true,
|
||||||
'gv2-3': true,
|
senderKey: true,
|
||||||
senderKey: true,
|
changeNumber: true,
|
||||||
changeNumber: true,
|
stories: true,
|
||||||
stories: true,
|
pni: isPnpEnabled(),
|
||||||
pni: isPnpEnabled(),
|
});
|
||||||
}),
|
|
||||||
updateOurUsernameAndPni(),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
'Error: Unable to register our capabilities.',
|
'Error: Unable to register our capabilities.',
|
||||||
|
@ -3532,10 +3528,7 @@ export async function startApp(): Promise<void> {
|
||||||
log.info('onFetchLatestSync: fetching latest local profile');
|
log.info('onFetchLatestSync: fetching latest local profile');
|
||||||
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
||||||
const ourE164 = window.textsecure.storage.user.getNumber();
|
const ourE164 = window.textsecure.storage.user.getNumber();
|
||||||
await Promise.all([
|
await getProfile(ourUuid, ourE164);
|
||||||
getProfile(ourUuid, ourE164),
|
|
||||||
updateOurUsernameAndPni(),
|
|
||||||
]);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
||||||
|
|
|
@ -97,6 +97,7 @@ import {
|
||||||
getProfileName,
|
getProfileName,
|
||||||
getTitle,
|
getTitle,
|
||||||
getTitleNoDefault,
|
getTitleNoDefault,
|
||||||
|
canHaveUsername,
|
||||||
} from '../util/getTitle';
|
} from '../util/getTitle';
|
||||||
import { markConversationRead } from '../util/markConversationRead';
|
import { markConversationRead } from '../util/markConversationRead';
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
|
@ -349,6 +350,10 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
this.on('newmessage', this.onNewMessage);
|
this.on('newmessage', this.onNewMessage);
|
||||||
this.on('change:profileKey', this.onChangeProfileKey);
|
this.on('change:profileKey', this.onChangeProfileKey);
|
||||||
|
this.on(
|
||||||
|
'change:name change:profileName change:profileFamilyName change:e164',
|
||||||
|
() => this.maybeClearUsername()
|
||||||
|
);
|
||||||
|
|
||||||
const sealedSender = this.get('sealedSender');
|
const sealedSender = this.get('sealedSender');
|
||||||
if (sealedSender === undefined) {
|
if (sealedSender === undefined) {
|
||||||
|
@ -1826,7 +1831,9 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
// We had previously stored `null` instead of `undefined` in some cases. We should
|
// We had previously stored `null` instead of `undefined` in some cases. We should
|
||||||
// be able to remove this `dropNull` once usernames have gone to production.
|
// be able to remove this `dropNull` once usernames have gone to production.
|
||||||
username: dropNull(this.get('username')),
|
username: canHaveUsername(this.attributes, ourConversationId)
|
||||||
|
? dropNull(this.get('username'))
|
||||||
|
: undefined,
|
||||||
|
|
||||||
about: this.getAboutText(),
|
about: this.getAboutText(),
|
||||||
aboutText: this.get('about'),
|
aboutText: this.get('about'),
|
||||||
|
@ -4218,6 +4225,50 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async maybeClearUsername(): Promise<void> {
|
||||||
|
const ourConversationId =
|
||||||
|
window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
|
// Clear username once we have other information about the contact
|
||||||
|
if (
|
||||||
|
canHaveUsername(this.attributes, ourConversationId) ||
|
||||||
|
!this.get('username')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`maybeClearUsername(${this.idForLogging()}): clearing username`);
|
||||||
|
|
||||||
|
this.unset('username');
|
||||||
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
this.captureChange('clearUsername');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUsername(
|
||||||
|
username: string | undefined,
|
||||||
|
{ shouldSave = true }: { shouldSave?: boolean } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const ourConversationId =
|
||||||
|
window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
|
if (!canHaveUsername(this.attributes, ourConversationId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.get('username') === username) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`updateUsername(${this.idForLogging()}): updating username`);
|
||||||
|
|
||||||
|
this.set('username', username);
|
||||||
|
this.captureChange('updateUsername');
|
||||||
|
|
||||||
|
if (shouldSave) {
|
||||||
|
await window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateLastMessage(): Promise<void> {
|
async updateLastMessage(): Promise<void> {
|
||||||
if (!this.id) {
|
if (!this.id) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -33,6 +33,7 @@ import {
|
||||||
getSafeLongFromTimestamp,
|
getSafeLongFromTimestamp,
|
||||||
getTimestampFromLong,
|
getTimestampFromLong,
|
||||||
} from '../util/timestampLongUtils';
|
} from '../util/timestampLongUtils';
|
||||||
|
import { canHaveUsername } from '../util/getTitle';
|
||||||
import {
|
import {
|
||||||
get as getUniversalExpireTimer,
|
get as getUniversalExpireTimer,
|
||||||
set as setUniversalExpireTimer,
|
set as setUniversalExpireTimer,
|
||||||
|
@ -156,6 +157,11 @@ export async function toContactRecord(
|
||||||
if (e164) {
|
if (e164) {
|
||||||
contactRecord.serviceE164 = e164;
|
contactRecord.serviceE164 = e164;
|
||||||
}
|
}
|
||||||
|
const username = conversation.get('username');
|
||||||
|
const ourID = window.ConversationController.getOurConversationId();
|
||||||
|
if (username && canHaveUsername(conversation.attributes, ourID)) {
|
||||||
|
contactRecord.username = username;
|
||||||
|
}
|
||||||
const pni = conversation.get('pni');
|
const pni = conversation.get('pni');
|
||||||
if (pni && RemoteConfig.isEnabled('desktop.pnp')) {
|
if (pni && RemoteConfig.isEnabled('desktop.pnp')) {
|
||||||
contactRecord.pni = pni;
|
contactRecord.pni = pni;
|
||||||
|
@ -978,6 +984,10 @@ export async function mergeContactRecord(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await conversation.updateUsername(dropNull(contactRecord.username), {
|
||||||
|
shouldSave: false,
|
||||||
|
});
|
||||||
|
|
||||||
let needsProfileFetch = false;
|
let needsProfileFetch = false;
|
||||||
if (contactRecord.profileKey && contactRecord.profileKey.length > 0) {
|
if (contactRecord.profileKey && contactRecord.profileKey.length > 0) {
|
||||||
needsProfileFetch = await conversation.setProfileKey(
|
needsProfileFetch = await conversation.setProfileKey(
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||||
import dataInterface from '../sql/Client';
|
|
||||||
import { updateOurUsernameAndPni } from '../util/updateOurUsernameAndPni';
|
|
||||||
import { sleep } from '../util/sleep';
|
import { sleep } from '../util/sleep';
|
||||||
import type { UsernameReservationType } from '../types/Username';
|
import type { UsernameReservationType } from '../types/Username';
|
||||||
import { ReserveUsernameError } from '../types/Username';
|
import { ReserveUsernameError } from '../types/Username';
|
||||||
|
@ -54,7 +52,6 @@ export async function reserveUsername(
|
||||||
const { nickname, previousUsername, abortSignal } = options;
|
const { nickname, previousUsername, abortSignal } = options;
|
||||||
|
|
||||||
const me = window.ConversationController.getOurConversationOrThrow();
|
const me = window.ConversationController.getOurConversationOrThrow();
|
||||||
await updateOurUsernameAndPni();
|
|
||||||
|
|
||||||
if (me.get('username') !== previousUsername) {
|
if (me.get('username') !== previousUsername) {
|
||||||
throw new Error('reserveUsername: Username has changed on another device');
|
throw new Error('reserveUsername: Username has changed on another device');
|
||||||
|
@ -96,8 +93,7 @@ async function updateUsernameAndSyncProfile(
|
||||||
const me = window.ConversationController.getOurConversationOrThrow();
|
const me = window.ConversationController.getOurConversationOrThrow();
|
||||||
|
|
||||||
// Update backbone, update DB, then tell linked devices about profile update
|
// Update backbone, update DB, then tell linked devices about profile update
|
||||||
me.set({ username });
|
await me.updateUsername(username);
|
||||||
dataInterface.updateConversation(me.attributes);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await singleProtoJobQueue.add(
|
await singleProtoJobQueue.add(
|
||||||
|
@ -123,7 +119,6 @@ export async function confirmUsername(
|
||||||
const { previousUsername, username, reservationToken } = reservation;
|
const { previousUsername, username, reservationToken } = reservation;
|
||||||
|
|
||||||
const me = window.ConversationController.getOurConversationOrThrow();
|
const me = window.ConversationController.getOurConversationOrThrow();
|
||||||
await updateOurUsernameAndPni();
|
|
||||||
|
|
||||||
if (me.get('username') !== previousUsername) {
|
if (me.get('username') !== previousUsername) {
|
||||||
throw new Error('Username has changed on another device');
|
throw new Error('Username has changed on another device');
|
||||||
|
@ -161,7 +156,6 @@ export async function deleteUsername(
|
||||||
}
|
}
|
||||||
|
|
||||||
const me = window.ConversationController.getOurConversationOrThrow();
|
const me = window.ConversationController.getOurConversationOrThrow();
|
||||||
await updateOurUsernameAndPni();
|
|
||||||
|
|
||||||
if (me.get('username') !== previousUsername) {
|
if (me.get('username') !== previousUsername) {
|
||||||
throw new Error('Username has changed on another device');
|
throw new Error('Username has changed on another device');
|
||||||
|
|
137
ts/test-mock/pnp/username_test.ts
Normal file
137
ts/test-mock/pnp/username_test.ts
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { Proto, StorageState } from '@signalapp/mock-server';
|
||||||
|
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||||
|
import createDebug from 'debug';
|
||||||
|
|
||||||
|
import * as durations from '../../util/durations';
|
||||||
|
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||||
|
import { MY_STORY_ID } from '../../types/Stories';
|
||||||
|
import { Bootstrap } from '../bootstrap';
|
||||||
|
import type { App } from '../bootstrap';
|
||||||
|
|
||||||
|
export const debug = createDebug('mock:test:username');
|
||||||
|
|
||||||
|
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
|
||||||
|
|
||||||
|
const USERNAME = 'signalapp.55';
|
||||||
|
|
||||||
|
describe('pnp/username', function needsName() {
|
||||||
|
this.timeout(durations.MINUTE);
|
||||||
|
|
||||||
|
let bootstrap: Bootstrap;
|
||||||
|
let app: App;
|
||||||
|
let usernameContact: PrimaryDevice;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bootstrap = new Bootstrap({ contactCount: 0 });
|
||||||
|
await bootstrap.init();
|
||||||
|
|
||||||
|
const { server, phone } = bootstrap;
|
||||||
|
|
||||||
|
usernameContact = await server.createPrimaryDevice({
|
||||||
|
profileName: 'ACI Contact',
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = StorageState.getEmpty();
|
||||||
|
|
||||||
|
state = state.updateAccount({
|
||||||
|
profileKey: phone.profileKey.serialize(),
|
||||||
|
e164: phone.device.number,
|
||||||
|
});
|
||||||
|
|
||||||
|
state = state.addContact(usernameContact, {
|
||||||
|
username: USERNAME,
|
||||||
|
serviceE164: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Put contact into left pane
|
||||||
|
state = state.pin(usernameContact);
|
||||||
|
|
||||||
|
// Add my story
|
||||||
|
state = state.addRecord({
|
||||||
|
type: IdentifierType.STORY_DISTRIBUTION_LIST,
|
||||||
|
record: {
|
||||||
|
storyDistributionList: {
|
||||||
|
allowsReplies: true,
|
||||||
|
identifier: uuidToBytes(MY_STORY_ID),
|
||||||
|
isBlockList: true,
|
||||||
|
name: MY_STORY_ID,
|
||||||
|
recipientUuids: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await phone.setStorageState(state);
|
||||||
|
|
||||||
|
app = await bootstrap.link();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function after() {
|
||||||
|
if (this.currentTest?.state !== 'passed') {
|
||||||
|
await bootstrap.saveLogs(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
await bootstrap.teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops username when contact name becomes known', async () => {
|
||||||
|
const { phone } = bootstrap;
|
||||||
|
|
||||||
|
const window = await app.getWindow();
|
||||||
|
const leftPane = window.locator('.left-pane-wrapper');
|
||||||
|
|
||||||
|
debug('find username in the left pane');
|
||||||
|
await leftPane
|
||||||
|
.locator(
|
||||||
|
`[data-testid="${usernameContact.device.uuid}"] >> "@${USERNAME}"`
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
debug('adding profile key for username contact');
|
||||||
|
let state: StorageState = await phone.expectStorageState(
|
||||||
|
'consistency check'
|
||||||
|
);
|
||||||
|
state = state.updateContact(usernameContact, {
|
||||||
|
profileKey: usernameContact.profileKey.serialize(),
|
||||||
|
});
|
||||||
|
await phone.setStorageState(state);
|
||||||
|
await phone.sendFetchStorage({
|
||||||
|
timestamp: bootstrap.getTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug('find profile name in the left pane');
|
||||||
|
await leftPane
|
||||||
|
.locator(
|
||||||
|
`[data-testid="${usernameContact.device.uuid}"] >> ` +
|
||||||
|
`"${usernameContact.profileName}"`
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
debug('verify that storage service state is updated');
|
||||||
|
{
|
||||||
|
const newState = await phone.waitForStorageState({
|
||||||
|
after: state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { added, removed } = newState.diff(state);
|
||||||
|
assert.strictEqual(added.length, 1, 'only one record must be added');
|
||||||
|
assert.strictEqual(removed.length, 1, 'only one record must be removed');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
added[0].contact?.serviceUuid,
|
||||||
|
usernameContact.device.uuid
|
||||||
|
);
|
||||||
|
assert.strictEqual(added[0].contact?.username, '');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
removed[0].contact?.serviceUuid,
|
||||||
|
usernameContact.device.uuid
|
||||||
|
);
|
||||||
|
assert.strictEqual(removed[0].contact?.username, USERNAME);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -36,7 +36,7 @@ export function getTitleNoDefault(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(isShort ? attributes.systemGivenName : undefined) ||
|
(isShort ? attributes.systemGivenName : undefined) ||
|
||||||
attributes.name ||
|
getSystemName(attributes) ||
|
||||||
(isShort ? attributes.profileName : undefined) ||
|
(isShort ? attributes.profileName : undefined) ||
|
||||||
getProfileName(attributes) ||
|
getProfileName(attributes) ||
|
||||||
getNumber(attributes) ||
|
getNumber(attributes) ||
|
||||||
|
@ -44,6 +44,30 @@ export function getTitleNoDefault(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note that the used attributes field should match the ones we listen for
|
||||||
|
// change on in ConversationModel (see `ConversationModel#maybeClearUsername`)
|
||||||
|
export function canHaveUsername(
|
||||||
|
attributes: Pick<
|
||||||
|
ConversationAttributesType,
|
||||||
|
'id' | 'type' | 'name' | 'profileName' | 'profileFamilyName' | 'e164'
|
||||||
|
>,
|
||||||
|
ourConversationId: string | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!isDirectConversation(attributes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ourConversationId === attributes.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
!getSystemName(attributes) &&
|
||||||
|
!getProfileName(attributes) &&
|
||||||
|
!getNumber(attributes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProfileName(
|
export function getProfileName(
|
||||||
attributes: Pick<
|
attributes: Pick<
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
|
@ -57,6 +81,22 @@ export function getProfileName(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSystemName(
|
||||||
|
attributes: Pick<
|
||||||
|
ConversationAttributesType,
|
||||||
|
'systemGivenName' | 'systemFamilyName' | 'type'
|
||||||
|
>
|
||||||
|
): string | undefined {
|
||||||
|
if (isDirectConversation(attributes)) {
|
||||||
|
return combineNames(
|
||||||
|
attributes.systemGivenName,
|
||||||
|
attributes.systemFamilyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function getNumber(
|
export function getNumber(
|
||||||
attributes: Pick<ConversationAttributesType, 'e164' | 'type'>
|
attributes: Pick<ConversationAttributesType, 'e164' | 'type'>
|
||||||
): string {
|
): string {
|
||||||
|
|
|
@ -97,7 +97,7 @@ export async function lookupConversationWithoutUuid(
|
||||||
|
|
||||||
conversationId = convo.id;
|
conversationId = convo.id;
|
||||||
|
|
||||||
convo.set({ username: foundUsername.username });
|
await convo.updateUsername(foundUsername.username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { strictAssert } from './assert';
|
|
||||||
import { dropNull } from './dropNull';
|
|
||||||
|
|
||||||
export async function updateOurUsernameAndPni(): Promise<void> {
|
|
||||||
const { server } = window.textsecure;
|
|
||||||
|
|
||||||
strictAssert(
|
|
||||||
server,
|
|
||||||
'updateOurUsernameAndPni: window.textsecure.server not available'
|
|
||||||
);
|
|
||||||
|
|
||||||
const me = window.ConversationController.getOurConversationOrThrow();
|
|
||||||
const { username } = await server.whoami();
|
|
||||||
|
|
||||||
me.set({ username: dropNull(username) });
|
|
||||||
window.Signal.Data.updateConversation(me.attributes);
|
|
||||||
|
|
||||||
const manager = window.getAccountManager();
|
|
||||||
strictAssert(
|
|
||||||
manager,
|
|
||||||
'updateOurUsernameAndPni: AccountManager not available'
|
|
||||||
);
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue