From f004b72ba2940c2d2a58864a535c2b167d85602b Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 9 Aug 2022 18:36:04 -0400 Subject: [PATCH] Use the PNP merging function for everything. --- ...tDatabaseTest_getAndPossiblyMergeLegacy.kt | 1 + ...ientDatabaseTest_getAndPossiblyMergePnp.kt | 39 +- .../RecipientDatabaseTest_processPnpTuple.kt | 13 +- ...DatabaseTest_processPnpTupleToChangeSet.kt | 47 +- .../securesms/database/PnpOperations.kt | 59 ++- .../securesms/database/RecipientDatabase.kt | 467 ++++++++++++------ .../securesms/recipients/LiveRecipient.java | 2 +- .../securesms/util/FeatureFlags.java | 14 +- 8 files changed, 430 insertions(+), 212 deletions(-) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergeLegacy.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergeLegacy.kt index 0f08fca545..2fb77a98e2 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergeLegacy.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergeLegacy.kt @@ -292,6 +292,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy { assertEquals(ACI_B, existingRecipient2.requireServiceId()) assertFalse(existingRecipient2.hasE164()) + changeNumberListener.waitForJobManager() assert(changeNumberListener.numberChangeWasEnqueued) } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergePnp.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergePnp.kt index 9b01dcfcb1..82971c3eec 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergePnp.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_getAndPossiblyMergePnp.kt @@ -9,7 +9,6 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Before -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.signal.core.util.CursorUtil @@ -188,7 +187,6 @@ class RecipientDatabaseTest_getAndPossiblyMergePnp { } /** We never want to remove the e164 of our own contact entry. Leave the e164 alone. */ - @Ignore("Change self isn't implemented yet!") @Test fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_e164BelongsToLocalUser() { val dataSet = KeyValueDataSet().apply { @@ -237,16 +235,15 @@ class RecipientDatabaseTest_getAndPossiblyMergePnp { val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, null) val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMergePnp(null, E164_A) - val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A) - assertEquals(existingAciId, retrievedId) + val mergedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A) + assertEquals(existingAciId, mergedId) - val retrievedRecipient = Recipient.resolved(retrievedId) + val retrievedRecipient = Recipient.resolved(mergedId) assertEquals(ACI_A, retrievedRecipient.requireServiceId()) assertEquals(E164_A, retrievedRecipient.requireE164()) - // TODO: Recipient remapping! -// val existingE164Recipient = Recipient.resolved(existingE164Id) -// assertEquals(retrievedId, existingE164Recipient.id) + val existingE164Recipient = Recipient.resolved(existingE164Id) + assertEquals(mergedId, existingE164Recipient.id) changeNumberListener.waitForJobManager() assertFalse(changeNumberListener.numberChangeWasEnqueued) @@ -268,13 +265,11 @@ class RecipientDatabaseTest_getAndPossiblyMergePnp { assertEquals(ACI_A, retrievedRecipient.requireServiceId()) assertEquals(E164_A, retrievedRecipient.requireE164()) - // TODO: Recipient remapping! -// val existingE164Recipient = Recipient.resolved(existingE164Id) -// assertEquals(retrievedId, existingE164Recipient.id) + val existingE164Recipient = Recipient.resolved(existingE164Id) + assertEquals(retrievedId, existingE164Recipient.id) - // TODO: Change number! -// changeNumberListener.waitForJobManager() -// assert(changeNumberListener.numberChangeWasEnqueued) + changeNumberListener.waitForJobManager() + assert(changeNumberListener.numberChangeWasEnqueued) } /** No new rules here, just a more complex scenario to show how different rules interact. */ @@ -297,8 +292,8 @@ class RecipientDatabaseTest_getAndPossiblyMergePnp { assertEquals(ACI_B, existingRecipient2.requireServiceId()) assertFalse(existingRecipient2.hasE164()) - // TODO: Change number! -// assert(changeNumberListener.numberChangeWasEnqueued) + changeNumberListener.waitForJobManager() + assert(changeNumberListener.numberChangeWasEnqueued) } /** @@ -319,13 +314,11 @@ class RecipientDatabaseTest_getAndPossiblyMergePnp { assertFalse(recipientDatabase.getByE164(E164_B).isPresent) - // TODO: Recipient remapping! -// val recipientWithId2 = Recipient.resolved(existingId2) -// assertEquals(retrievedId, recipientWithId2.id) + val recipientWithId2 = Recipient.resolved(existingId2) + assertEquals(retrievedId, recipientWithId2.id) } /** We never want to remove the e164 of our own contact entry. Leave the e164 alone. */ - @Ignore("Change self isn't implemented yet!") @Test fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_e164BelongsToLocalUser() { val dataSet = KeyValueDataSet().apply { @@ -402,13 +395,11 @@ class RecipientDatabaseTest_getAndPossiblyMergePnp { assertEquals(ACI_A, retrievedRecipient.requireServiceId()) assertEquals(E164_B, retrievedRecipient.requireE164()) - // TODO: Change number! -// changeNumberListener.waitForJobManager() -// assert(changeNumberListener.numberChangeWasEnqueued) + changeNumberListener.waitForJobManager() + assert(changeNumberListener.numberChangeWasEnqueued) } /** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */ - @Ignore("This level of merging isn't implemented yet!") @Test fun getAndPossiblyMerge_merge_general() { // Setup diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTuple.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTuple.kt index f282790d86..c50f051a7c 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTuple.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTuple.kt @@ -16,7 +16,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.push.ACI import org.whispersystems.signalservice.api.push.PNI import org.whispersystems.signalservice.api.push.ServiceId -import java.lang.IllegalArgumentException import java.util.UUID @RunWith(AndroidJUnit4::class) @@ -61,14 +60,14 @@ class RecipientDatabaseTest_processPnpTuple { } } - @Test(expected = IllegalArgumentException::class) + @Test(expected = IllegalStateException::class) fun noMatch_pniOnly() { test { process(null, PNI_A, null) } } - @Test(expected = IllegalArgumentException::class) + @Test(expected = IllegalStateException::class) fun noMatch_noData() { test { process(null, null, null) @@ -416,7 +415,13 @@ class RecipientDatabaseTest_processPnpTuple { } fun process(e164: String?, pni: PNI?, aci: ACI?) { - generatedIds += recipientDatabase.processPnpTuple(e164, pni, aci, false) + SignalDatabase.rawDatabase.beginTransaction() + try { + generatedIds += recipientDatabase.processPnpTuple(e164, pni, aci, pniVerified = false, pnpEnabled = true).finalId + SignalDatabase.rawDatabase.setTransactionSuccessful() + } finally { + SignalDatabase.rawDatabase.endTransaction() + } } fun expect(e164: String?, pni: PNI?, aci: ACI?) { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTupleToChangeSet.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTupleToChangeSet.kt index 0f282068f4..eab6aca1f7 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTupleToChangeSet.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTupleToChangeSet.kt @@ -14,7 +14,6 @@ import org.whispersystems.signalservice.api.push.ACI import org.whispersystems.signalservice.api.push.PNI import org.whispersystems.signalservice.api.push.ServiceId import java.lang.AssertionError -import java.lang.IllegalArgumentException import java.lang.IllegalStateException import java.util.UUID @@ -68,12 +67,12 @@ class RecipientDatabaseTest_processPnpTupleToChangeSet { ) } - @Test(expected = IllegalArgumentException::class) + @Test(expected = IllegalStateException::class) fun noMatch_pniOnly() { db.processPnpTupleToChangeSet(null, PNI_A, null, pniVerified = false) } - @Test(expected = IllegalArgumentException::class) + @Test(expected = IllegalStateException::class) fun noMatch_noData() { db.processPnpTupleToChangeSet(null, null, null, pniVerified = false) } @@ -603,11 +602,48 @@ class RecipientDatabaseTest_processPnpTupleToChangeSet { id = PnpIdResolver.PnpNoopId(result.secondId), operations = listOf( PnpOperation.RemovePni(result.firstId), - PnpOperation.Update( + PnpOperation.SetPni( + recipientId = result.secondId, + pni = PNI_A, + ), + PnpOperation.SetE164( recipientId = result.secondId, e164 = E164_A, + ) + ) + ), + result.changeSet + ) + } + + @Test + fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164_changeNumber() { + val result = applyAndAssert( + listOf( + Input(E164_B, PNI_A, null), + Input(E164_C, null, ACI_A), + ), + Update(E164_A, PNI_A, ACI_A), + Output(E164_A, PNI_A, ACI_A) + ) + + assertEquals( + PnpChangeSet( + id = PnpIdResolver.PnpNoopId(result.secondId), + operations = listOf( + PnpOperation.RemovePni(result.firstId), + PnpOperation.SetPni( + recipientId = result.secondId, pni = PNI_A, - aci = ACI_A + ), + PnpOperation.SetE164( + recipientId = result.secondId, + e164 = E164_A, + ), + PnpOperation.ChangeNumberInsert( + recipientId = result.secondId, + oldE164 = E164_C, + newE164 = E164_A ) ) ), @@ -806,5 +842,6 @@ class RecipientDatabaseTest_processPnpTupleToChangeSet { const val E164_A = "+12221234567" const val E164_B = "+13331234567" + const val E164_C = "+14441234567" } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt index 39c1f2a7ca..5e35416b2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt @@ -47,15 +47,6 @@ data class PnpDataSet( for (operation in operations) { @Exhaustive when (operation) { - is PnpOperation.Update -> { - records.replace(operation.recipientId) { record -> - record.copy( - e164 = operation.e164, - pni = operation.pni, - serviceId = operation.aci ?: operation.pni - ) - } - } is PnpOperation.RemoveE164 -> { records.replace(operation.recipientId) { it.copy(e164 = null) } } @@ -145,8 +136,28 @@ data class PnpDataSet( */ data class PnpChangeSet( val id: PnpIdResolver, - val operations: List = emptyList() -) + val operations: List = emptyList(), + val breadCrumbs: List = emptyList() +) { + // We want to exclude breadcrumbs from equality for testing purposes + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PnpChangeSet + + if (id != other.id) return false + if (operations != other.operations) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + operations.hashCode() + return result + } +} sealed class PnpIdResolver { data class PnpNoopId( @@ -165,33 +176,28 @@ sealed class PnpIdResolver { * Lets us describe various situations as a series of operations, making code clearer and tests easier. */ sealed class PnpOperation { - data class Update( - val recipientId: RecipientId, - val e164: String?, - val pni: PNI?, - val aci: ACI? - ) : PnpOperation() + abstract val recipientId: RecipientId data class RemoveE164( - val recipientId: RecipientId + override val recipientId: RecipientId ) : PnpOperation() data class RemovePni( - val recipientId: RecipientId + override val recipientId: RecipientId ) : PnpOperation() data class SetE164( - val recipientId: RecipientId, + override val recipientId: RecipientId, val e164: String ) : PnpOperation() data class SetPni( - val recipientId: RecipientId, + override val recipientId: RecipientId, val pni: PNI ) : PnpOperation() data class SetAci( - val recipientId: RecipientId, + override val recipientId: RecipientId, val aci: ACI ) : PnpOperation() @@ -201,14 +207,17 @@ sealed class PnpOperation { data class Merge( val primaryId: RecipientId, val secondaryId: RecipientId - ) : PnpOperation() + ) : PnpOperation() { + override val recipientId: RecipientId + get() = throw UnsupportedOperationException() + } data class SessionSwitchoverInsert( - val recipientId: RecipientId + override val recipientId: RecipientId ) : PnpOperation() data class ChangeNumberInsert( - val recipientId: RecipientId, + override val recipientId: RecipientId, val oldE164: String, val newE164: String ) : PnpOperation() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index 5fce3d0b05..68c1d7bad2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -106,7 +106,6 @@ import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV2Record import org.whispersystems.signalservice.api.storage.StorageId -import org.whispersystems.signalservice.api.util.Preconditions import java.io.Closeable import java.io.IOException import java.util.Arrays @@ -421,7 +420,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : @JvmOverloads fun getAndPossiblyMerge(serviceId: ServiceId?, e164: String?, changeSelf: Boolean = false): RecipientId { - return if (FeatureFlags.phoneNumberPrivacy()) { + return if (FeatureFlags.recipientMergeV2()) { getAndPossiblyMergePnp(serviceId, e164, changeSelf) } else { getAndPossiblyMergeLegacy(serviceId, e164, changeSelf) @@ -525,15 +524,54 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : fun getAndPossiblyMergePnp(serviceId: ServiceId?, e164: String?, changeSelf: Boolean = false): RecipientId { require(!(serviceId == null && e164 == null)) { "Must provide an ACI or E164!" } - return writableDatabase.withinTransaction { - when { + val db = writableDatabase + var transactionSuccessful = false + lateinit var result: ProcessPnpTupleResult + + db.beginTransaction() + try { + result = when { serviceId is PNI -> processPnpTuple(e164, serviceId, null, pniVerified = false, changeSelf = changeSelf) serviceId is ACI -> processPnpTuple(e164, null, serviceId, pniVerified = false, changeSelf = changeSelf) serviceId == null -> processPnpTuple(e164, null, null, pniVerified = false, changeSelf = changeSelf) getByPni(PNI.from(serviceId.uuid())).isPresent -> processPnpTuple(e164, PNI.from(serviceId.uuid()), null, pniVerified = false, changeSelf = changeSelf) else -> processPnpTuple(e164, null, ACI.fromNullable(serviceId), pniVerified = false, changeSelf = changeSelf) } + + if (result.operations.isNotEmpty()) { + Log.i(TAG, "[getAndPossiblyMergePnp] BreadCrumbs: ${result.breadCrumbs}, Operations: ${result.operations}") + } + + db.setTransactionSuccessful() + transactionSuccessful = true + } finally { + db.endTransaction() + + if (transactionSuccessful) { + if (result.affectedIds.isNotEmpty()) { + result.affectedIds.forEach { ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(it) } + RetrieveProfileJob.enqueue(result.affectedIds) + } + + if (result.oldIds.isNotEmpty()) { + result.oldIds.forEach { oldId -> + Recipient.live(oldId).refresh(result.finalId) + ApplicationDependencies.getRecipientCache().remap(oldId, result.finalId) + } + } + + if (result.affectedIds.isNotEmpty() || result.oldIds.isNotEmpty()) { + StorageSyncHelper.scheduleSyncForDataChange() + RecipientId.clearCache() + } + + if (result.changedNumberId != null) { + ApplicationDependencies.getJobManager().add(RecipientChangedNumberJob(result.changedNumberId!!)) + } + } } + + return result.finalId } fun getAllServiceIdProfileKeyPairs(): Map { @@ -2190,7 +2228,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : db.beginTransaction() try { for ((e164, result) in mapping) { - ids += processPnpTuple(e164, result.pni, result.aci, false) + ids += processPnpTuple(e164, result.pni, result.aci, false).finalId } db.setTransactionSuccessful() @@ -2208,26 +2246,49 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : * @return The [RecipientId] of the resulting recipient. */ @VisibleForTesting - fun processPnpTuple(e164: String?, pni: PNI?, aci: ACI?, pniVerified: Boolean, changeSelf: Boolean = false): RecipientId { - val changeSet = processPnpTupleToChangeSet(e164, pni, aci, pniVerified, changeSelf) - return writePnpChangeSetToDisk(changeSet) - } + fun processPnpTuple(e164: String?, pni: PNI?, aci: ACI?, pniVerified: Boolean, changeSelf: Boolean = false, pnpEnabled: Boolean = FeatureFlags.phoneNumberPrivacy()): ProcessPnpTupleResult { + val changeSet: PnpChangeSet = processPnpTupleToChangeSet(e164, pni, aci, pniVerified, changeSelf) + + val affectedIds: MutableSet = mutableSetOf() + val oldIds: MutableSet = mutableSetOf() + var changedNumberId: RecipientId? = null - @VisibleForTesting - fun writePnpChangeSetToDisk(changeSet: PnpChangeSet): RecipientId { for (operation in changeSet.operations) { @Exhaustive when (operation) { - is PnpOperation.Update -> { - writableDatabase.update(TABLE_NAME) - .values( - PHONE to operation.e164, - SERVICE_ID to (operation.aci ?: operation.pni).toString(), - PNI_COLUMN to operation.pni.toString() - ) - .where("$ID = ?", operation.recipientId) - .run() + is PnpOperation.RemoveE164, + is PnpOperation.RemovePni, + is PnpOperation.SetAci, + is PnpOperation.SetE164, + is PnpOperation.SetPni -> { + affectedIds.add(operation.recipientId) } + is PnpOperation.Merge -> { + oldIds.add(operation.secondaryId) + affectedIds.add(operation.primaryId) + } + is PnpOperation.SessionSwitchoverInsert -> {} + is PnpOperation.ChangeNumberInsert -> changedNumberId = operation.recipientId + } + } + + val finalId: RecipientId = writePnpChangeSetToDisk(changeSet, pnpEnabled) + + return ProcessPnpTupleResult( + finalId = finalId, + affectedIds = affectedIds, + oldIds = oldIds, + changedNumberId = changedNumberId, + operations = changeSet.operations, + breadCrumbs = changeSet.breadCrumbs + ) + } + + @VisibleForTesting + fun writePnpChangeSetToDisk(changeSet: PnpChangeSet, pnpEnabled: Boolean): RecipientId { + for (operation in changeSet.operations) { + @Exhaustive + when (operation) { is PnpOperation.RemoveE164 -> { writableDatabase .update(TABLE_NAME) @@ -2282,26 +2343,34 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : .run() } is PnpOperation.Merge -> { - Log.w(TAG, "WARNING: Performing a PNP merge! This operation currently only has a basic implementation only suitable for basic testing!") - val primary = getRecord(operation.primaryId) val secondary = getRecord(operation.secondaryId) - writableDatabase - .delete(TABLE_NAME) - .where("$ID = ?", operation.secondaryId) - .run() + if (primary.serviceId != null && !primary.sidIsPni() && secondary.e164 != null) { + merge(operation.primaryId, operation.secondaryId) + } else { + if (!pnpEnabled) { + throw AssertionError("This type of merge is not supported in production!") + } - writableDatabase - .update(TABLE_NAME) - .values( - PHONE to (primary.e164 ?: secondary.e164), - PNI_COLUMN to (primary.pni ?: secondary.pni)?.toString(), - SERVICE_ID to (primary.serviceId ?: secondary.serviceId)?.toString(), - REGISTERED to RegisteredState.REGISTERED.id - ) - .where("$ID = ?", operation.primaryId) - .run() + Log.w(TAG, "WARNING: Performing an unfinished PNP merge! This operation currently only has a basic implementation only suitable for basic testing!") + + writableDatabase + .delete(TABLE_NAME) + .where("$ID = ?", operation.secondaryId) + .run() + + writableDatabase + .update(TABLE_NAME) + .values( + PHONE to (primary.e164 ?: secondary.e164), + PNI_COLUMN to (primary.pni ?: secondary.pni)?.toString(), + SERVICE_ID to (primary.serviceId ?: secondary.serviceId)?.toString(), + REGISTERED to RegisteredState.REGISTERED.id + ) + .where("$ID = ?", operation.primaryId) + .run() + } } is PnpOperation.SessionSwitchoverInsert -> { // TODO [pnp] @@ -2334,8 +2403,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : */ @VisibleForTesting fun processPnpTupleToChangeSet(e164: String?, pni: PNI?, aci: ACI?, pniVerified: Boolean, changeSelf: Boolean = false): PnpChangeSet { - Preconditions.checkArgument(e164 != null || pni != null || aci != null, "Must provide at least one field!") - Preconditions.checkArgument(pni == null || e164 != null, "If a PNI is provided, you must also provide an E164!") + check(e164 != null || pni != null || aci != null) { "Must provide at least one field!" } + check(pni == null || e164 != null) { "If a PNI is provided, you must also provide an E164!" } + + val breadCrumbs: MutableList = mutableListOf() val partialData = PnpDataSet( e164 = e164, @@ -2347,91 +2418,44 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : byAciSid = aci?.let { getByServiceId(it).orElse(null) } ) - val allRequiredDbFields: List = if (aci != null) { - listOf(partialData.byE164, partialData.byAciSid, partialData.byPniOnly) - } else { - listOf(partialData.byE164, partialData.byPniSid, partialData.byPniOnly) + val allRequiredDbFields: MutableList = mutableListOf() + if (e164 != null) { + allRequiredDbFields += partialData.byE164 + } + if (aci != null) { + allRequiredDbFields += partialData.byAciSid + } + if (pni != null) { + allRequiredDbFields += partialData.byPniOnly + } + if (pni != null && aci == null) { + allRequiredDbFields += partialData.byPniSid } val allRequiredDbFieldPopulated: Boolean = allRequiredDbFields.all { it != null } // All IDs agree and the database is up-to-date if (partialData.commonId != null && allRequiredDbFieldPopulated) { - return PnpChangeSet(id = PnpIdResolver.PnpNoopId(partialData.commonId)) + breadCrumbs.add("CommonIdAndUpToDate") + return PnpChangeSet(id = PnpIdResolver.PnpNoopId(partialData.commonId), breadCrumbs = breadCrumbs) } // All ID's agree, but we need to update the database if (partialData.commonId != null && !allRequiredDbFieldPopulated) { - val record: RecipientRecord = getRecord(partialData.commonId) - - val operations: MutableList = mutableListOf() - - // This is a special case. The ACI passed in doesn't match the common record. We can't change ACIs, so we need to make a new record. - if (aci != null && aci != record.serviceId && record.serviceId != null && !record.sidIsPni()) { - if (record.e164 == e164) { - operations += PnpOperation.RemoveE164(record.id) - operations += PnpOperation.RemovePni(record.id) - } else if (record.pni == pni) { - operations += PnpOperation.RemovePni(record.id) - } - - return PnpChangeSet( - id = PnpIdResolver.PnpInsert(e164, pni, aci), - operations = operations - ) - } - - var updatedNumber = false - if (e164 != null && record.e164 != e164 && (changeSelf || aci != SignalStore.account().aci)) { - operations += PnpOperation.SetE164( - recipientId = partialData.commonId, - e164 = e164 - ) - updatedNumber = true - } - - if (pni != null && record.pni != pni) { - operations += PnpOperation.SetPni( - recipientId = partialData.commonId, - pni = pni - ) - } - - if (aci != null && record.serviceId != aci) { - operations += PnpOperation.SetAci( - recipientId = partialData.commonId, - aci = aci - ) - } - - if (record.e164 != null && updatedNumber) { - operations += PnpOperation.ChangeNumberInsert( - recipientId = partialData.commonId, - oldE164 = record.e164, - newE164 = e164!! - ) - } - - val newServiceId: ServiceId? = aci ?: pni ?: record.serviceId - - if (!pniVerified && record.serviceId != null && record.serviceId != newServiceId && sessions.hasAnySessionFor(record.serviceId.toString())) { - operations += PnpOperation.SessionSwitchoverInsert(partialData.commonId) - } - - return PnpChangeSet( - id = PnpIdResolver.PnpNoopId(partialData.commonId), - operations = operations - ) + breadCrumbs.add("CommonIdButNeedsUpdate") + return processNonMergePnpUpdate(e164, pni, aci, commonId = partialData.commonId, pniVerified = pniVerified, changeSelf = changeSelf, breadCrumbs = breadCrumbs) } // Nothing matches if (partialData.byE164 == null && partialData.byPniSid == null && partialData.byAciSid == null) { + breadCrumbs += "NothingMatches" return PnpChangeSet( id = PnpIdResolver.PnpInsert( e164 = e164, pni = pni, aci = aci - ) + ), + breadCrumbs = breadCrumbs ) } @@ -2444,32 +2468,36 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : // It may be that some data just gets shuffled around, or it may be that // two or more records get merged into one record, with the others being deleted. + breadCrumbs += "NeedsMerge" + val fullData = partialData.copy( e164Record = partialData.byE164?.let { getRecord(it) }, pniSidRecord = partialData.byPniSid?.let { getRecord(it) }, aciSidRecord = partialData.byAciSid?.let { getRecord(it) }, ) - Preconditions.checkState(fullData.commonId == null) - Preconditions.checkState(listOfNotNull(fullData.byE164, fullData.byPniSid, fullData.byPniOnly, fullData.byAciSid).size >= 2) + check(fullData.commonId == null) + check(listOfNotNull(fullData.byE164, fullData.byPniSid, fullData.byPniOnly, fullData.byAciSid).size >= 2) val operations: MutableList = mutableListOf() - operations += processPossibleE164PniSidMerge(pni, pniVerified, fullData) - operations += processPossiblePniSidAciSidMerge(e164, pni, aci, fullData.perform(operations)) - operations += processPossibleE164AciSidMerge(e164, pni, aci, fullData.perform(operations)) + operations += processPossibleE164PniSidMerge(pni, pniVerified, fullData, breadCrumbs) + operations += processPossiblePniSidAciSidMerge(e164, pni, aci, fullData.perform(operations), changeSelf, breadCrumbs) + operations += processPossibleE164AciSidMerge(e164, pni, aci, fullData.perform(operations), changeSelf, breadCrumbs) val finalData: PnpDataSet = fullData.perform(operations) val primaryId: RecipientId = listOfNotNull(finalData.byAciSid, finalData.byE164, finalData.byPniSid).first() if (finalData.byAciSid == null && aci != null) { + breadCrumbs += "FinalUpdateAci" operations += PnpOperation.SetAci( recipientId = primaryId, aci = aci ) } - if (finalData.byE164 == null && e164 != null && (changeSelf || aci != SignalStore.account().aci)) { + if (finalData.byE164 == null && e164 != null && (changeSelf || notSelf(e164, pni, aci))) { + breadCrumbs += "FinalUpdateE164" operations += PnpOperation.SetE164( recipientId = primaryId, e164 = e164 @@ -2477,6 +2505,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } if (finalData.byPniSid == null && finalData.byPniOnly == null && pni != null) { + breadCrumbs += "FinalUpdatePni" operations += PnpOperation.SetPni( recipientId = primaryId, pni = pni @@ -2485,21 +2514,109 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : return PnpChangeSet( id = PnpIdResolver.PnpNoopId(primaryId), - operations = operations + operations = operations, + breadCrumbs = breadCrumbs ) } - private fun processPossibleE164PniSidMerge(pni: PNI?, pniVerified: Boolean, data: PnpDataSet): List { + private fun notSelf(e164: String?, pni: PNI?, aci: ACI?): Boolean { + return (e164 == null || e164 != SignalStore.account().e164) && + (pni == null || pni != SignalStore.account().pni) && + (aci == null || aci != SignalStore.account().aci) + } + + private fun isSelf(e164: String?, pni: PNI?, aci: ACI?): Boolean { + return (e164 != null && e164 == SignalStore.account().e164) || + (pni != null && pni == SignalStore.account().pni) || + (aci != null && aci == SignalStore.account().aci) + } + + private fun processNonMergePnpUpdate(e164: String?, pni: PNI?, aci: ACI?, pniVerified: Boolean, changeSelf: Boolean, commonId: RecipientId, breadCrumbs: MutableList): PnpChangeSet { + val record: RecipientRecord = getRecord(commonId) + + val operations: MutableList = mutableListOf() + + // This is a special case. The ACI passed in doesn't match the common record. We can't change ACIs, so we need to make a new record. + if (aci != null && aci != record.serviceId && record.serviceId != null && !record.sidIsPni()) { + breadCrumbs += "AciDoesNotMatchCommonRecord" + + if (record.e164 == e164 && (changeSelf || notSelf(e164, pni, aci))) { + breadCrumbs += "StealingE164" + operations += PnpOperation.RemoveE164(record.id) + operations += PnpOperation.RemovePni(record.id) + } else if (record.pni == pni) { + breadCrumbs += "StealingPni" + operations += PnpOperation.RemovePni(record.id) + } + + val insertE164: String? = if (changeSelf || notSelf(e164, pni, aci)) e164 else null + val insertPni: PNI? = if (changeSelf || notSelf(e164, pni, aci)) pni else null + + return PnpChangeSet( + id = PnpIdResolver.PnpInsert(insertE164, insertPni, aci), + operations = operations, + breadCrumbs = breadCrumbs + ) + } + + var updatedNumber = false + if (e164 != null && record.e164 != e164 && (changeSelf || notSelf(e164, pni, aci))) { + operations += PnpOperation.SetE164( + recipientId = commonId, + e164 = e164 + ) + updatedNumber = true + } + + if (pni != null && record.pni != pni) { + operations += PnpOperation.SetPni( + recipientId = commonId, + pni = pni + ) + } + + if (aci != null && record.serviceId != aci) { + operations += PnpOperation.SetAci( + recipientId = commonId, + aci = aci + ) + } + + if (record.e164 != null && updatedNumber) { + operations += PnpOperation.ChangeNumberInsert( + recipientId = commonId, + oldE164 = record.e164, + newE164 = e164!! + ) + } + + val newServiceId: ServiceId? = aci ?: pni ?: record.serviceId + + if (!pniVerified && record.serviceId != null && record.serviceId != newServiceId && sessions.hasAnySessionFor(record.serviceId.toString())) { + operations += PnpOperation.SessionSwitchoverInsert(commonId) + } + + return PnpChangeSet( + id = PnpIdResolver.PnpNoopId(commonId), + operations = operations, + breadCrumbs = breadCrumbs + ) + } + + private fun processPossibleE164PniSidMerge(pni: PNI?, pniVerified: Boolean, data: PnpDataSet, breadCrumbs: MutableList): List { if (pni == null || data.byE164 == null || data.byPniSid == null || data.e164Record == null || data.pniSidRecord == null || data.e164Record.id == data.pniSidRecord.id) { return emptyList() } // We have found records for both the E164 and PNI, and they're different + breadCrumbs += "E164PniSidMerge" val operations: MutableList = mutableListOf() // The PNI record only has a single identifier. We know we must merge. if (data.pniSidRecord.sidOnly(pni)) { + breadCrumbs += "PniOnly" + if (data.e164Record.pni != null) { operations += PnpOperation.RemovePni(data.byE164) } @@ -2511,8 +2628,9 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : // TODO: Possible session switchover? } else { - Preconditions.checkState(!data.pniSidRecord.pniAndAci()) - Preconditions.checkState(data.pniSidRecord.e164 != null) + check(!data.pniSidRecord.pniAndAci() && data.pniSidRecord.e164 != null) + + breadCrumbs += "PniSidRecordHasE164" operations += PnpOperation.RemovePni(data.byPniSid) operations += PnpOperation.SetPni( @@ -2532,17 +2650,25 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : return operations } - private fun processPossiblePniSidAciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet): List { + private fun processPossiblePniSidAciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet, changeSelf: Boolean, breadCrumbs: MutableList): List { if (pni == null || aci == null || data.byPniSid == null || data.byAciSid == null || data.pniSidRecord == null || data.aciSidRecord == null || data.pniSidRecord.id == data.aciSidRecord.id) { return emptyList() } + if (!changeSelf && isSelf(e164, pni, aci)) { + breadCrumbs += "ChangeSelfPreventsPniSidAciSidMerge" + return emptyList() + } + // We have found records for both the PNI and ACI, and they're different + breadCrumbs += "PniSidAciSidMerge" val operations: MutableList = mutableListOf() // The PNI record only has a single identifier. We know we must merge. if (data.pniSidRecord.sidOnly(pni)) { + breadCrumbs += "PniOnly" + if (data.aciSidRecord.pni != null) { operations += PnpOperation.RemovePni(data.byAciSid) } @@ -2554,6 +2680,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } else if (data.pniSidRecord.e164 == e164) { // The PNI record also has the E164 on it. We're going to be stealing both fields, // so this is basically a merge with a little bit of extra prep. + breadCrumbs += "PniSidRecordHasMatchingE164" if (data.aciSidRecord.pni != null) { operations += PnpOperation.RemovePni(data.byAciSid) @@ -2576,33 +2703,55 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : ) } } else { - Preconditions.checkState(data.pniSidRecord.e164 != null && data.pniSidRecord.e164 != e164) + check(data.pniSidRecord.e164 != null && data.pniSidRecord.e164 != e164) + breadCrumbs += "PniSidRecordHasNonMatchingE164" operations += PnpOperation.RemovePni(data.byPniSid) - operations += PnpOperation.Update( - recipientId = data.byAciSid, - e164 = e164, - pni = pni, - aci = ACI.from(data.aciSidRecord.serviceId) - ) + if (data.aciSidRecord.pni != pni) { + operations += PnpOperation.SetPni( + recipientId = data.byAciSid, + pni = pni + ) + } + + if (e164 != null && data.aciSidRecord.e164 != e164) { + operations += PnpOperation.SetE164( + recipientId = data.byAciSid, + e164 = e164 + ) + + if (data.aciSidRecord.e164 != null) { + operations += PnpOperation.ChangeNumberInsert( + recipientId = data.byAciSid, + oldE164 = data.aciSidRecord.e164, + newE164 = e164 + ) + } + } } return operations } - private fun processPossibleE164AciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet): List { + private fun processPossibleE164AciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet, changeSelf: Boolean, breadCrumbs: MutableList): List { if (e164 == null || aci == null || data.byE164 == null || data.byAciSid == null || data.e164Record == null || data.aciSidRecord == null || data.e164Record.id == data.aciSidRecord.id) { return emptyList() } + if (!changeSelf && isSelf(e164, pni, aci)) { + breadCrumbs += "ChangeSelfPreventsE164AciSidMerge" + return emptyList() + } + // We have found records for both the E164 and ACI, and they're different + breadCrumbs += "E164AciSidMerge" val operations: MutableList = mutableListOf() - // The PNI record only has a single identifier. We know we must merge. + // The E164 record only has a single identifier. We know we must merge. if (data.e164Record.e164Only()) { - // TODO high trust + breadCrumbs += "E164Only" if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) { operations += PnpOperation.RemoveE164(data.byAciSid) @@ -2623,6 +2772,8 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } else if (data.e164Record.pni != null && data.e164Record.pni == pni) { // The E164 record also has the PNI on it. We're going to be stealing both fields, // so this is basically a merge with a little bit of extra prep. + breadCrumbs += "E164RecordHasMatchingPni" + if (data.aciSidRecord.pni != null) { operations += PnpOperation.RemovePni(data.byAciSid) } @@ -2644,6 +2795,9 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : ) } } else { + check(data.e164Record.pni == null || data.e164Record.pni != pni) + breadCrumbs += "E164RecordHasNonMatchingPni" + operations += PnpOperation.RemoveE164(data.byE164) operations += PnpOperation.SetE164( @@ -3332,32 +3486,34 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164)) RemappedRecords.getInstance().addRecipient(byE164, byAci) - val uuidValues = ContentValues().apply { - put(PHONE, e164Record.e164) - put(BLOCKED, e164Record.isBlocked || aciRecord.isBlocked) - put(MESSAGE_RINGTONE, Optional.ofNullable(aciRecord.messageRingtone).or(Optional.ofNullable(e164Record.messageRingtone)).map { obj: Uri? -> obj.toString() }.orElse(null)) - put(MESSAGE_VIBRATE, if (aciRecord.messageVibrateState != VibrateState.DEFAULT) aciRecord.messageVibrateState.id else e164Record.messageVibrateState.id) - put(CALL_RINGTONE, Optional.ofNullable(aciRecord.callRingtone).or(Optional.ofNullable(e164Record.callRingtone)).map { obj: Uri? -> obj.toString() }.orElse(null)) - put(CALL_VIBRATE, if (aciRecord.callVibrateState != VibrateState.DEFAULT) aciRecord.callVibrateState.id else e164Record.callVibrateState.id) - put(NOTIFICATION_CHANNEL, aciRecord.notificationChannel ?: e164Record.notificationChannel) - put(MUTE_UNTIL, if (aciRecord.muteUntil > 0) aciRecord.muteUntil else e164Record.muteUntil) - put(CHAT_COLORS, Optional.ofNullable(aciRecord.chatColors).or(Optional.ofNullable(e164Record.chatColors)).map { colors: ChatColors? -> colors!!.serialize().toByteArray() }.orElse(null)) - put(AVATAR_COLOR, aciRecord.avatarColor.serialize()) - put(CUSTOM_CHAT_COLORS_ID, Optional.ofNullable(aciRecord.chatColors).or(Optional.ofNullable(e164Record.chatColors)).map { colors: ChatColors? -> colors!!.id.longValue }.orElse(null)) - put(SEEN_INVITE_REMINDER, e164Record.insightsBannerTier.id) - put(DEFAULT_SUBSCRIPTION_ID, e164Record.getDefaultSubscriptionId().orElse(-1)) - put(MESSAGE_EXPIRATION_TIME, if (aciRecord.expireMessages > 0) aciRecord.expireMessages else e164Record.expireMessages) - put(REGISTERED, RegisteredState.REGISTERED.id) - put(SYSTEM_GIVEN_NAME, e164Record.systemProfileName.givenName) - put(SYSTEM_FAMILY_NAME, e164Record.systemProfileName.familyName) - put(SYSTEM_JOINED_NAME, e164Record.systemProfileName.toString()) - put(SYSTEM_PHOTO_URI, e164Record.systemContactPhotoUri) - put(SYSTEM_PHONE_LABEL, e164Record.systemPhoneLabel) - put(SYSTEM_CONTACT_URI, e164Record.systemContactUri) - put(PROFILE_SHARING, aciRecord.profileSharing || e164Record.profileSharing) - put(CAPABILITIES, max(aciRecord.rawCapabilities, e164Record.rawCapabilities)) - put(MENTION_SETTING, if (aciRecord.mentionSetting != MentionSetting.ALWAYS_NOTIFY) aciRecord.mentionSetting.id else e164Record.mentionSetting.id) - } + // TODO [pnp] We should pass in the PNI involved in the merge and prefer that over either of the ones in the records + val uuidValues = contentValuesOf( + PHONE to e164Record.e164, + PNI_COLUMN to (e164Record.pni ?: aciRecord.pni)?.toString(), + BLOCKED to (e164Record.isBlocked || aciRecord.isBlocked), + MESSAGE_RINGTONE to Optional.ofNullable(aciRecord.messageRingtone).or(Optional.ofNullable(e164Record.messageRingtone)).map { obj: Uri? -> obj.toString() }.orElse(null), + MESSAGE_VIBRATE to if (aciRecord.messageVibrateState != VibrateState.DEFAULT) aciRecord.messageVibrateState.id else e164Record.messageVibrateState.id, + CALL_RINGTONE to Optional.ofNullable(aciRecord.callRingtone).or(Optional.ofNullable(e164Record.callRingtone)).map { obj: Uri? -> obj.toString() }.orElse(null), + CALL_VIBRATE to if (aciRecord.callVibrateState != VibrateState.DEFAULT) aciRecord.callVibrateState.id else e164Record.callVibrateState.id, + NOTIFICATION_CHANNEL to (aciRecord.notificationChannel ?: e164Record.notificationChannel), + MUTE_UNTIL to if (aciRecord.muteUntil > 0) aciRecord.muteUntil else e164Record.muteUntil, + CHAT_COLORS to Optional.ofNullable(aciRecord.chatColors).or(Optional.ofNullable(e164Record.chatColors)).map { colors: ChatColors? -> colors!!.serialize().toByteArray() }.orElse(null), + AVATAR_COLOR to aciRecord.avatarColor.serialize(), + CUSTOM_CHAT_COLORS_ID to Optional.ofNullable(aciRecord.chatColors).or(Optional.ofNullable(e164Record.chatColors)).map { colors: ChatColors? -> colors!!.id.longValue }.orElse(null), + SEEN_INVITE_REMINDER to e164Record.insightsBannerTier.id, + DEFAULT_SUBSCRIPTION_ID to e164Record.getDefaultSubscriptionId().orElse(-1), + MESSAGE_EXPIRATION_TIME to if (aciRecord.expireMessages > 0) aciRecord.expireMessages else e164Record.expireMessages, + REGISTERED to RegisteredState.REGISTERED.id, + SYSTEM_GIVEN_NAME to e164Record.systemProfileName.givenName, + SYSTEM_FAMILY_NAME to e164Record.systemProfileName.familyName, + SYSTEM_JOINED_NAME to e164Record.systemProfileName.toString(), + SYSTEM_PHOTO_URI to e164Record.systemContactPhotoUri, + SYSTEM_PHONE_LABEL to e164Record.systemPhoneLabel, + SYSTEM_CONTACT_URI to e164Record.systemContactUri, + PROFILE_SHARING to (aciRecord.profileSharing || e164Record.profileSharing), + CAPABILITIES to max(aciRecord.rawCapabilities, e164Record.rawCapabilities), + MENTION_SETTING to if (aciRecord.mentionSetting != MentionSetting.ALWAYS_NOTIFY) aciRecord.mentionSetting.id else e164Record.mentionSetting.id + ) if (aciRecord.profileKey != null) { updateProfileValuesForMerge(uuidValues, aciRecord) @@ -4098,4 +4254,13 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } } } + + data class ProcessPnpTupleResult( + val finalId: RecipientId, + val affectedIds: Set, + val oldIds: Set, + val changedNumberId: RecipientId?, + val operations: List, + val breadCrumbs: List, + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index d4356da4fa..7eb840ee5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -201,7 +201,7 @@ public final class LiveRecipient { details = RecipientDetails.forIndividual(context, record); } - Recipient recipient = new Recipient(id, details, true); + Recipient recipient = new Recipient(record.getId(), details, true); RecipientIdCache.INSTANCE.put(recipient); return recipient; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 6f0a6e410c..46eb921226 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -100,6 +100,7 @@ public final class FeatureFlags { private static final String TELECOM_MANUFACTURER_ALLOWLIST = "android.calling.telecomAllowList"; private static final String TELECOM_MODEL_BLOCKLIST = "android.calling.telecomModelBlockList"; private static final String CAMERAX_MODEL_BLOCKLIST = "android.cameraXModelBlockList"; + private static final String RECIPIENT_MERGE_V2 = "android.recipientMergeV2"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -152,7 +153,8 @@ public final class FeatureFlags { GIFT_BADGE_SEND_SUPPORT, TELECOM_MANUFACTURER_ALLOWLIST, TELECOM_MODEL_BLOCKLIST, - CAMERAX_MODEL_BLOCKLIST + CAMERAX_MODEL_BLOCKLIST, + RECIPIENT_MERGE_V2 ); @VisibleForTesting @@ -214,7 +216,8 @@ public final class FeatureFlags { USE_FCM_FOREGROUND_SERVICE, TELECOM_MANUFACTURER_ALLOWLIST, TELECOM_MODEL_BLOCKLIST, - CAMERAX_MODEL_BLOCKLIST + CAMERAX_MODEL_BLOCKLIST, + RECIPIENT_MERGE_V2 ); /** @@ -535,6 +538,13 @@ public final class FeatureFlags { return giftBadgeReceiveSupport() && getBoolean(GIFT_BADGE_SEND_SUPPORT, Environment.IS_STAGING); } + /** + * Whether or not we should use the new recipient merging strategy. + */ + public static boolean recipientMergeV2() { + return getBoolean(RECIPIENT_MERGE_V2, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES);