Early preparations for PNP Contact Merging

This commit is contained in:
Scott Nonnenberg 2022-08-09 14:39:00 -07:00 committed by GitHub
parent 2f5dd73e58
commit faf6c41332
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1572 additions and 447 deletions

View file

@ -13,17 +13,107 @@ import type {
import type { ConversationModel } from './models/conversations';
import { getContactId } from './messages/helpers';
import { maybeDeriveGroupV2Id } from './groups';
import { assert } from './util/assert';
import { assert, strictAssert } from './util/assert';
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge';
import { UUID, isValidUuid } from './types/UUID';
import { UUID, isValidUuid, UUIDKind } from './types/UUID';
import type { UUIDStringType } from './types/UUID';
import { Address } from './types/Address';
import { QualifiedAddress } from './types/QualifiedAddress';
import * as log from './logging/log';
import * as Errors from './types/errors';
import { sleep } from './util/sleep';
import { isNotNil } from './util/isNotNil';
import { MINUTE, SECOND } from './util/durations';
type ConvoMatchType =
| {
key: 'uuid' | 'pni';
value: UUIDStringType | undefined;
match: ConversationModel | undefined;
}
| {
key: 'e164';
value: string | undefined;
match: ConversationModel | undefined;
};
const { hasOwnProperty } = Object.prototype;
function applyChangeToConversation(
conversation: ConversationModel,
suggestedChange: Partial<
Pick<ConversationAttributesType, 'uuid' | 'e164' | 'pni'>
>
) {
const change = { ...suggestedChange };
// Clear PNI if changing e164 without associated PNI
if (hasOwnProperty.call(change, 'e164') && !change.pni) {
change.pni = undefined;
}
// If we have a PNI but not an ACI, then the PNI will go in the UUID field
// Tricky: We need a special check here, because the PNI can be in the uuid slot
if (
change.pni &&
!change.uuid &&
(!conversation.get('uuid') ||
conversation.get('uuid') === conversation.get('pni'))
) {
change.uuid = change.pni;
}
// If we're clearing a PNI, but we didn't have an ACI - we need to clear UUID field
if (
!change.uuid &&
hasOwnProperty.call(change, 'pni') &&
!change.pni &&
conversation.get('uuid') === conversation.get('pni')
) {
change.uuid = undefined;
}
if (hasOwnProperty.call(change, 'uuid')) {
conversation.updateUuid(change.uuid);
}
if (hasOwnProperty.call(change, 'e164')) {
conversation.updateE164(change.e164);
}
if (hasOwnProperty.call(change, 'pni')) {
conversation.updatePni(change.pni);
}
// Note: we don't do a conversation.set here, because change is limited to these fields
}
async function mergeConversations({
logId,
oldConversation,
newConversation,
}: {
logId: string;
oldConversation: ConversationModel;
newConversation: ConversationModel;
}) {
try {
await window.ConversationController.combineConversations(
newConversation,
oldConversation
);
// If the old conversation was currently displayed, we load the new one
window.Whisper.events.trigger('refreshConversation', {
newId: newConversation.get('id'),
oldId: oldConversation.get('id'),
});
} catch (error) {
log.warn(
`${logId}: error combining contacts: ${Errors.toLogFormat(error)}`
);
}
}
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
const {
@ -55,6 +145,8 @@ export class ConversationController {
private _hasQueueEmptied = false;
private _combineConversationsQueue = new PQueue({ concurrency: 1 });
constructor(private _conversations: ConversationModelCollectionType) {
const debouncedUpdateUnreadCount = debounce(
this.updateUnreadCount.bind(this),
@ -172,8 +264,8 @@ export class ConversationController {
if (type === 'group') {
conversation = this._conversations.add({
id,
uuid: null,
e164: null,
uuid: undefined,
e164: undefined,
groupId: identifier,
type,
version: 2,
@ -183,8 +275,8 @@ export class ConversationController {
conversation = this._conversations.add({
id,
uuid: identifier,
e164: null,
groupId: null,
e164: undefined,
groupId: undefined,
type,
version: 2,
...additionalInitialProps,
@ -192,9 +284,9 @@ export class ConversationController {
} else {
conversation = this._conversations.add({
id,
uuid: null,
uuid: undefined,
e164: identifier,
groupId: null,
groupId: undefined,
type,
version: 2,
...additionalInitialProps,
@ -270,13 +362,25 @@ export class ConversationController {
getOurConversationId(): string | undefined {
const e164 = window.textsecure.storage.user.getNumber();
const uuid = window.textsecure.storage.user.getUuid()?.toString();
return this.ensureContactIds({
const aci = window.textsecure.storage.user
.getUuid(UUIDKind.ACI)
?.toString();
const pni = window.textsecure.storage.user
.getUuid(UUIDKind.PNI)
?.toString();
if (!e164 && !aci && !pni) {
return undefined;
}
const conversation = this.maybeMergeContacts({
aci,
e164,
uuid,
highTrust: true,
pni,
reason: 'getOurConversationId',
});
return conversation?.id;
}
getOurConversationIdOrThrow(): string {
@ -311,39 +415,210 @@ export class ConversationController {
return ourDeviceId === 1;
}
// Note: If you don't know what kind of UUID it is, put it in the 'aci' param.
maybeMergeContacts({
aci: providedAci,
e164,
pni: providedPni,
reason,
mergeOldAndNew = mergeConversations,
}: {
aci?: string;
e164?: string;
pni?: string;
reason: string;
recursionCount?: number;
mergeOldAndNew?: (options: {
logId: string;
oldConversation: ConversationModel;
newConversation: ConversationModel;
}) => Promise<void>;
}): ConversationModel | undefined {
const dataProvided = [];
if (providedAci) {
dataProvided.push('aci');
}
if (e164) {
dataProvided.push('e164');
}
if (providedPni) {
dataProvided.push('pni');
}
const logId = `maybeMergeContacts/${reason}/${dataProvided.join('+')}`;
const aci = providedAci ? UUID.cast(providedAci) : undefined;
const pni = providedPni ? UUID.cast(providedPni) : undefined;
if (!aci && !e164 && !pni) {
throw new Error(
`${logId}: Need to provide at least one of: aci, e164, pni`
);
}
if (pni && !e164) {
throw new Error(`${logId}: Cannot provide pni without an e164`);
}
const identifier = aci || e164 || pni;
strictAssert(identifier, `${logId}: identifier must be truthy!`);
const matches: Array<ConvoMatchType> = [
{
key: 'uuid',
value: aci,
match: window.ConversationController.get(aci),
},
{
key: 'e164',
value: e164,
match: window.ConversationController.get(e164),
},
{ key: 'pni', value: pni, match: window.ConversationController.get(pni) },
];
let unusedMatches: Array<ConvoMatchType> = [];
let targetConversation: ConversationModel | undefined;
let matchCount = 0;
matches.forEach(item => {
const { key, value, match } = item;
if (!value) {
return;
}
if (!match) {
if (targetConversation) {
log.info(
`${logId}: No match for ${key}, applying to target conversation`
);
// Note: This line might erase a known e164 or PNI
applyChangeToConversation(targetConversation, {
[key]: value,
});
} else {
unusedMatches.push(item);
}
return;
}
matchCount += 1;
unusedMatches.forEach(unused => {
strictAssert(unused.value, 'An unused value should always be truthy');
// Example: If we find that our PNI match has no ACI, then it will be our target.
// Tricky: PNI can end up in UUID slot, so we need to special-case it
if (
!targetConversation &&
(!match.get(unused.key) ||
(unused.key === 'uuid' && match.get(unused.key) === pni))
) {
log.info(
`${logId}: Match on ${key} does not have ${unused.key}, ` +
`so it will be our target conversation - ${match.idForLogging()}`
);
targetConversation = match;
}
// If PNI match already has an ACI, then we need to create a new one
if (!targetConversation) {
targetConversation = this.getOrCreate(unused.value, 'private');
log.info(
`${logId}: Match on ${key} already had ${unused.key}, ` +
`so created new target conversation - ${targetConversation.idForLogging()}`
);
}
log.info(
`${logId}: Applying new value for ${unused.key} to target conversation`
);
applyChangeToConversation(targetConversation, {
[unused.key]: unused.value,
});
});
unusedMatches = [];
if (targetConversation && targetConversation !== match) {
// Clear the value on the current match, since it belongs on targetConversation!
// Note: we need to do the remove first, because it will clear the lookup!
log.info(
`${logId}: Clearing ${key} on match, and adding it to target conversation`
);
const change: Pick<
Partial<ConversationAttributesType>,
'uuid' | 'e164' | 'pni'
> = {
[key]: undefined,
};
// When the PNI is being used in the uuid field alone, we need to clear it
if (key === 'pni' && match.get('uuid') === pni) {
change.uuid = undefined;
}
applyChangeToConversation(match, change);
applyChangeToConversation(targetConversation, {
[key]: value,
});
// Note: The PNI check here is just to be bulletproof; if we know a UUID is a PNI,
// then that should be put in the UUID field as well!
if (!match.get('uuid') && !match.get('e164') && !match.get('pni')) {
log.warn(
`${logId}: Removing old conversation which matched on ${key}. ` +
'Merging with target conversation.'
);
mergeOldAndNew({
logId,
oldConversation: match,
newConversation: targetConversation,
});
}
} else if (targetConversation && !targetConversation?.get(key)) {
// This is mostly for the situation where PNI was erased when updating e164
// log.debug(`${logId}: Re-adding ${key} on target conversation`);
applyChangeToConversation(targetConversation, {
[key]: value,
});
}
if (!targetConversation) {
// log.debug(
// `${logId}: Match on ${key} is target conversation - ${match.idForLogging()}`
// );
targetConversation = match;
}
});
if (targetConversation) {
return targetConversation;
}
strictAssert(
matchCount === 0,
`${logId}: should be no matches if no targetConversation`
);
log.info(`${logId}: Creating a new conversation with all inputs`);
return this.getOrCreate(identifier, 'private', { e164, pni });
}
/**
* Given a UUID and/or an E164, resolves to a string representing the local
* database id of the given contact. In high trust mode, it may create new contacts,
* and it may merge contacts.
*
* highTrust = uuid/e164 pairing came from CDS, the server, or your own device
* Given a UUID and/or an E164, returns a string representing the local
* database id of the given contact. Will create a new conversation if none exists;
* otherwise will return whatever is found.
*/
ensureContactIds({
lookupOrCreate({
e164,
uuid,
highTrust,
reason,
}:
| {
e164?: string | null;
uuid?: string | null;
highTrust?: false;
reason?: void;
}
| {
e164?: string | null;
uuid?: string | null;
highTrust: true;
reason: string;
}): string | undefined {
// Check for at least one parameter being provided. This is necessary
// because this path can be called on startup to resolve our own ID before
// our phone number or UUID are known. The existing behavior in these
// cases can handle a returned `undefined` id, so we do that.
}: {
e164?: string | null;
uuid?: string | null;
}): ConversationModel | undefined {
const normalizedUuid = uuid ? uuid.toLowerCase() : undefined;
const identifier = normalizedUuid || e164;
if ((!e164 && !uuid) || !identifier) {
log.warn('lookupOrCreate: Called with neither e164 nor uuid!');
return undefined;
}
@ -352,147 +627,52 @@ export class ConversationController {
// 1. Handle no match at all
if (!convoE164 && !convoUuid) {
log.info(
'ensureContactIds: Creating new contact, no matches found',
highTrust ? reason : 'no reason'
);
log.info('lookupOrCreate: Creating new contact, no matches found');
const newConvo = this.getOrCreate(identifier, 'private');
if (highTrust && e164) {
// `identifier` would resolve to uuid if we had both, so fix up e164
if (normalizedUuid && e164) {
newConvo.updateE164(e164);
}
if (normalizedUuid) {
newConvo.updateUuid(normalizedUuid);
}
if ((highTrust && e164) || normalizedUuid) {
updateConversation(newConvo.attributes);
}
return newConvo.get('id');
// 2. Handle match on only E164
return newConvo;
}
if (convoE164 && !convoUuid) {
const haveUuid = Boolean(normalizedUuid);
log.info(
`ensureContactIds: e164-only match found (have UUID: ${haveUuid})`
);
// If we are only searching based on e164 anyway, then return the first result
if (!normalizedUuid) {
return convoE164.get('id');
}
// Fill in the UUID for an e164-only contact
if (normalizedUuid && !convoE164.get('uuid')) {
if (highTrust) {
log.info(
`ensureContactIds: Adding UUID (${uuid}) to e164-only match ` +
`(${e164}), reason: ${reason}`
);
convoE164.updateUuid(normalizedUuid);
updateConversation(convoE164.attributes);
}
return convoE164.get('id');
}
log.info(
'ensureContactIds: e164 already had UUID, creating a new contact'
);
// If existing e164 match already has UUID, create a new contact...
const newConvo = this.getOrCreate(normalizedUuid, 'private');
if (highTrust) {
log.info(
`ensureContactIds: Moving e164 (${e164}) from old contact ` +
`(${convoE164.get('uuid')}) to new (${uuid}), reason: ${reason}`
);
// Remove the e164 from the old contact...
convoE164.set({ e164: undefined });
updateConversation(convoE164.attributes);
// ...and add it to the new one.
newConvo.updateE164(e164);
updateConversation(newConvo.attributes);
}
return newConvo.get('id');
// 3. Handle match on only UUID
}
// 2. Handle match on only UUID
if (!convoE164 && convoUuid) {
if (e164 && highTrust) {
log.info(
`ensureContactIds: Adding e164 (${e164}) to UUID-only match ` +
`(${uuid}), reason: ${reason}`
);
convoUuid.updateE164(e164);
updateConversation(convoUuid.attributes);
}
return convoUuid.get('id');
return convoUuid;
}
// 3. Handle match on only E164
if (convoE164 && !convoUuid) {
return convoE164;
}
// For some reason, TypeScript doesn't believe that we can trust that these two values
// are truthy by this point. So we'll throw if we get there.
// are truthy by this point. So we'll throw if that isn't the case.
if (!convoE164 || !convoUuid) {
throw new Error('ensureContactIds: convoE164 or convoUuid are falsey!');
}
// Now, we know that we have a match for both e164 and uuid checks
if (convoE164 === convoUuid) {
return convoUuid.get('id');
}
if (highTrust) {
// Conflict: If e164 match already has a UUID, we remove its e164.
if (convoE164.get('uuid') && convoE164.get('uuid') !== normalizedUuid) {
log.info(
`ensureContactIds: e164 match (${e164}) had different ` +
`UUID(${convoE164.get('uuid')}) than incoming pair (${uuid}), ` +
`removing its e164, reason: ${reason}`
);
// Remove the e164 from the old contact...
convoE164.set({ e164: undefined });
updateConversation(convoE164.attributes);
// ...and add it to the new one.
convoUuid.updateE164(e164);
updateConversation(convoUuid.attributes);
return convoUuid.get('id');
}
log.warn(
`ensureContactIds: Found a split contact - UUID ${normalizedUuid} and E164 ${e164}. Merging.`
throw new Error(
'lookupOrCreate: convoE164 or convoUuid are falsey but should both be true!'
);
// Conflict: If e164 match has no UUID, we merge. We prefer the UUID match.
// Note: no await here, we want to keep this function synchronous
convoUuid.updateE164(e164);
// `then` is used to trigger async updates, not affecting return value
// eslint-disable-next-line more/no-then
this.combineConversations(convoUuid, convoE164)
.then(() => {
// If the old conversation was currently displayed, we load the new one
window.Whisper.events.trigger('refreshConversation', {
newId: convoUuid.get('id'),
oldId: convoE164.get('id'),
});
})
.catch(error => {
const errorText = error && error.stack ? error.stack : error;
log.warn(`ensureContactIds error combining contacts: ${errorText}`);
});
}
return convoUuid.get('id');
// 4. If the two lookups agree, return that conversation
if (convoE164 === convoUuid) {
return convoUuid;
}
// 5. If the two lookups disagree, log and return the UUID match
log.warn(
`lookupOrCreate: Found a split contact - UUID ${normalizedUuid} and E164 ${e164}. Returning UUID match.`
);
return convoUuid;
}
async checkForConflicts(): Promise<void> {
log.info('checkForConflicts: starting...');
const byUuid = Object.create(null);
const byE164 = Object.create(null);
const byPni = Object.create(null);
const byGroupV2Id = Object.create(null);
// We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map
// here. Instead, we check for duplicates on the derived GV2 ID.
@ -509,6 +689,7 @@ export class ConversationController {
);
const uuid = conversation.get('uuid');
const pni = conversation.get('pni');
const e164 = conversation.get('e164');
if (uuid) {
@ -532,6 +713,27 @@ export class ConversationController {
}
}
if (pni) {
const existing = byPni[pni];
if (!existing) {
byPni[pni] = conversation;
} else {
log.warn(`checkForConflicts: Found conflict with pni ${pni}`);
// Keep the newer one if it has a uuid, otherwise keep existing
if (conversation.get('uuid')) {
// Keep new one
// eslint-disable-next-line no-await-in-loop
await this.combineConversations(conversation, existing);
byPni[pni] = conversation;
} else {
// Keep existing - note that this applies if neither had an e164
// eslint-disable-next-line no-await-in-loop
await this.combineConversations(existing, conversation);
}
}
}
if (e164) {
const existing = byE164[e164];
if (!existing) {
@ -619,100 +821,110 @@ export class ConversationController {
current: ConversationModel,
obsolete: ConversationModel
): Promise<void> {
const conversationType = current.get('type');
return this._combineConversationsQueue.add(async () => {
const conversationType = current.get('type');
if (obsolete.get('type') !== conversationType) {
assert(
false,
'combineConversations cannot combine a private and group conversation. Doing nothing'
);
return;
}
const obsoleteId = obsolete.get('id');
const obsoleteUuid = obsolete.getUuid();
const currentId = current.get('id');
log.warn('combineConversations: Combining two conversations', {
obsolete: obsoleteId,
current: currentId,
});
if (conversationType === 'private' && obsoleteUuid) {
if (!current.get('profileKey') && obsolete.get('profileKey')) {
if (!this.get(obsolete.id)) {
log.warn(
'combineConversations: Copying profile key from old to new contact'
`combineConversations: Already combined obsolete conversation ${obsolete.id}`
);
const profileKey = obsolete.get('profileKey');
if (profileKey) {
await current.setProfileKey(profileKey);
}
}
log.warn(
'combineConversations: Delete all sessions tied to old conversationId'
);
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({
ourUuid,
identifier: obsoleteUuid.toString(),
if (obsolete.get('type') !== conversationType) {
assert(
false,
'combineConversations cannot combine a private and group conversation. Doing nothing'
);
return;
}
const obsoleteId = obsolete.get('id');
const obsoleteUuid = obsolete.getUuid();
const currentId = current.get('id');
log.warn('combineConversations: Combining two conversations', {
obsolete: obsoleteId,
current: currentId,
});
await Promise.all(
deviceIds.map(async deviceId => {
const addr = new QualifiedAddress(
ourUuid,
new Address(obsoleteUuid, deviceId)
if (conversationType === 'private' && obsoleteUuid) {
if (!current.get('profileKey') && obsolete.get('profileKey')) {
log.warn(
'combineConversations: Copying profile key from old to new contact'
);
await window.textsecure.storage.protocol.removeSession(addr);
})
);
log.warn(
'combineConversations: Delete all identity information tied to old conversationId'
);
const profileKey = obsolete.get('profileKey');
if (obsoleteUuid) {
await window.textsecure.storage.protocol.removeIdentityKey(
obsoleteUuid
if (profileKey) {
await current.setProfileKey(profileKey);
}
}
log.warn(
'combineConversations: Delete all sessions tied to old conversationId'
);
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
{
ourUuid,
identifier: obsoleteUuid.toString(),
}
);
await Promise.all(
deviceIds.map(async deviceId => {
const addr = new QualifiedAddress(
ourUuid,
new Address(obsoleteUuid, deviceId)
);
await window.textsecure.storage.protocol.removeSession(addr);
})
);
log.warn(
'combineConversations: Delete all identity information tied to old conversationId'
);
if (obsoleteUuid) {
await window.textsecure.storage.protocol.removeIdentityKey(
obsoleteUuid
);
}
log.warn(
'combineConversations: Ensure that all V1 groups have new conversationId instead of old'
);
const groups = await this.getAllGroupsInvolvingUuid(obsoleteUuid);
groups.forEach(group => {
const members = group.get('members');
const withoutObsolete = without(members, obsoleteId);
const currentAdded = uniq([...withoutObsolete, currentId]);
group.set({
members: currentAdded,
});
updateConversation(group.attributes);
});
}
// Note: we explicitly don't want to update V2 groups
log.warn(
'combineConversations: Ensure that all V1 groups have new conversationId instead of old'
'combineConversations: Delete the obsolete conversation from the database'
);
const groups = await this.getAllGroupsInvolvingUuid(obsoleteUuid);
groups.forEach(group => {
const members = group.get('members');
const withoutObsolete = without(members, obsoleteId);
const currentAdded = uniq([...withoutObsolete, currentId]);
await removeConversation(obsoleteId);
group.set({
members: currentAdded,
});
updateConversation(group.attributes);
log.warn('combineConversations: Update messages table');
await migrateConversationMessages(obsoleteId, currentId);
log.warn(
'combineConversations: Eliminate old conversation from ConversationController lookups'
);
this._conversations.remove(obsolete);
this._conversations.resetLookups();
log.warn('combineConversations: Complete!', {
obsolete: obsoleteId,
current: currentId,
});
}
// Note: we explicitly don't want to update V2 groups
log.warn(
'combineConversations: Delete the obsolete conversation from the database'
);
await removeConversation(obsoleteId);
log.warn('combineConversations: Update messages table');
await migrateConversationMessages(obsoleteId, currentId);
log.warn(
'combineConversations: Eliminate old conversation from ConversationController lookups'
);
this._conversations.remove(obsolete);
this._conversations.resetLookups();
log.warn('combineConversations: Complete!', {
obsolete: obsoleteId,
current: currentId,
});
}