Improve implementation and testing on PNP contact merging.

This commit is contained in:
Greyson Parrelli 2022-06-29 15:29:58 -04:00 committed by Cody Henthorne
parent c64be82710
commit 61ce39b5b6
8 changed files with 1455 additions and 74 deletions

View file

@ -80,25 +80,6 @@ class DistributionListDatabaseTest {
Assert.assertEquals(StoryType.STORY_WITHOUT_REPLIES, storyType)
}
@Test
fun givenStoryExistsAndMarkedNoReplies_getAllListsForContactSelectionUi_returnsStoryWithoutReplies() {
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
Assert.assertNotNull(id)
distributionDatabase.setAllowsReplies(id!!, false)
val records = distributionDatabase.getAllListsForContactSelectionUi(null, false)
Assert.assertFalse(records.first().allowsReplies)
}
@Test
fun givenStoryExists_getAllListsForContactSelectionUi_returnsStoryWithReplies() {
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
Assert.assertNotNull(id)
val records = distributionDatabase.getAllListsForContactSelectionUi(null, false)
Assert.assertTrue(records.first().allowsReplies)
}
@Test(expected = IllegalStateException::class)
fun givenStoryDoesNotExist_getStoryType_throwsIllegalStateException() {
distributionDatabase.getStoryType(DistributionListId.from(12))

View file

@ -0,0 +1,810 @@
package org.thoughtcrime.securesms.database
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.thoughtcrime.securesms.util.Util
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
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_processPnpTupleToChangeSet {
@Rule
@JvmField
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
private lateinit var db: RecipientDatabase
@Before
fun setup() {
db = SignalDatabase.recipients
}
@Test
fun noMatch_e164Only() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, null, null, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, null, null)
),
changeSet
)
}
@Test
fun noMatch_e164AndPni() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, null, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, null)
),
changeSet
)
}
@Test
fun noMatch_aciOnly() {
val changeSet = db.processPnpTupleToChangeSet(null, null, ACI_A, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(null, null, ACI_A)
),
changeSet
)
}
@Test(expected = IllegalArgumentException::class)
fun noMatch_pniOnly() {
db.processPnpTupleToChangeSet(null, PNI_A, null, pniVerified = false)
}
@Test(expected = IllegalArgumentException::class)
fun noMatch_noData() {
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
}
@Test
fun noMatch_allFields() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, ACI_A, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, ACI_A)
),
changeSet
)
}
@Test
fun fullMatch() {
val result = applyAndAssert(
Input(E164_A, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id)
),
result.changeSet
)
}
@Test
fun onlyE164Matches() {
val result = applyAndAssert(
Input(E164_A, null, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_existingPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_B, null, pniSession = true),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_B, null),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun e164AndPniMatches_noExistingSession() {
val result = applyAndAssert(
Input(E164_A, PNI_A, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun e164AndPniMatches_existingPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun e164AndAciMatches() {
val result = applyAndAssert(
Input(E164_A, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_noExistingSession() {
val result = applyAndAssert(
Input(null, PNI_A, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_existingPniSession() {
val result = applyAndAssert(
Input(null, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_existingPniSession_changeNumber() {
val result = applyAndAssert(
Input(E164_B, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
),
PnpOperation.SessionSwitchoverInsert(result.id)
)
),
result.changeSet
)
}
@Test
fun pniAndAciMatches() {
val result = applyAndAssert(
Input(null, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
)
),
result.changeSet
)
}
@Test
fun pniAndAciMatches_changeNumber() {
val result = applyAndAssert(
Input(E164_B, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun onlyAciMatches() {
val result = applyAndAssert(
Input(null, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun onlyAciMatches_changeNumber() {
val result = applyAndAssert(
Input(E164_B, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = listOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_aciOnly() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
Input(null, null, ACI_A)
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.thirdId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
PnpOperation.Merge(
primaryId = result.thirdId,
secondaryId = result.firstId
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
PnpOperation.SetAci(
recipientId = result.firstId,
aci = ACI_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniAndE164_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(E164_B, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(
recipientId = result.firstId,
pni = PNI_A
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_pniOnly_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null),
Input(E164_B, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_sessionsExist() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null, pniSession = true),
Input(E164_B, PNI_A, null, pniSession = true),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = listOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.secondId),
PnpOperation.SessionSwitchoverInsert(result.firstId)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(null, 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.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
val result = applyAndAssert(
listOf(
Input(E164_B, PNI_A, null),
Input(null, 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.Update(
recipientId = result.secondId,
e164 = E164_A,
pni = PNI_A,
aci = ACI_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(E164_B, PNI_B, 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.secondId),
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164Aci_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(E164_B, 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.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientDatabase.TABLE_NAME,
null,
contentValuesOf(
RecipientDatabase.PHONE to e164,
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
RecipientDatabase.PNI_COLUMN to pni?.toString(),
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
)
)
return RecipientId.from(id)
}
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
SignalDatabase.rawDatabase.insert(
SessionDatabase.TABLE_NAME, null,
contentValuesOf(
SessionDatabase.ACCOUNT_ID to account.toString(),
SessionDatabase.ADDRESS to address.toString(),
SessionDatabase.DEVICE to 1,
SessionDatabase.RECORD to Util.getSecretBytes(32)
)
)
}
data class Input(val e164: String?, val pni: PNI?, val aci: ACI?, val pniSession: Boolean = false, val aciSession: Boolean = false)
data class Update(val e164: String?, val pni: PNI?, val aci: ACI?, val pniVerified: Boolean = false)
data class Output(val e164: String?, val pni: PNI?, val aci: ACI?)
data class PnpMatchResult(val ids: List<RecipientId>, val changeSet: PnpChangeSet) {
val id
get() = if (ids.size == 1) {
ids[0]
} else {
throw IllegalStateException("There are multiple IDs, but you assumed 1!")
}
val firstId
get() = ids[0]
val secondId
get() = ids[1]
val thirdId
get() = ids[2]
}
private fun applyAndAssert(input: Input, update: Update, output: Output): PnpMatchResult {
return applyAndAssert(listOf(input), update, output)
}
/**
* Helper method that will call insert your recipients, call [RecipientDatabase.processPnpTupleToChangeSet] with your params,
* and then verify your output matches what you expect.
*
* It results the inserted ID's and changeset for additional verification.
*
* But basically this is here to make the tests more readable. It gives you a clear list of:
* - input
* - update
* - output
*
* that you can spot check easily.
*
* Important: The output will only include records that contain fields from the input. That means
* for:
*
* Input: E164_B, PNI_A, null
* Update: E164_A, PNI_A, null
*
* You will get:
* Output: E164_A, PNI_A, null
*
* Even though there was an update that will also result in the row (E164_B, null, null)
*/
private fun applyAndAssert(input: List<Input>, update: Update, output: Output): PnpMatchResult {
val ids = input.map { insert(it.e164, it.pni, it.aci) }
input
.filter { it.pniSession }
.forEach { insertMockSessionFor(databaseRule.localAci, it.pni!!) }
input
.filter { it.aciSession }
.forEach { insertMockSessionFor(databaseRule.localAci, it.aci!!) }
val byE164 = update.e164?.let { db.getByE164(it).orElse(null) }
val byPniSid = update.pni?.let { db.getByServiceId(it).orElse(null) }
val byAciSid = update.aci?.let { db.getByServiceId(it).orElse(null) }
val data = PnpDataSet(
e164 = update.e164,
pni = update.pni,
aci = update.aci,
byE164 = byE164,
byPniSid = byPniSid,
byPniOnly = update.pni?.let { db.getByPni(it).orElse(null) },
byAciSid = byAciSid,
e164Record = byE164?.let { db.getRecord(it) },
pniSidRecord = byPniSid?.let { db.getRecord(it) },
aciSidRecord = byAciSid?.let { db.getRecord(it) }
)
val changeSet = db.processPnpTupleToChangeSet(update.e164, update.pni, update.aci, pniVerified = update.pniVerified)
val finalData = data.perform(changeSet.operations)
val finalRecords = setOfNotNull(finalData.e164Record, finalData.pniSidRecord, finalData.aciSidRecord)
assertEquals("There's still multiple records in the resulting record set! $finalRecords", 1, finalRecords.size)
finalRecords.firstOrNull { record -> record.e164 == output.e164 && record.pni == output.pni && record.serviceId == (output.aci ?: output.pni) }
?: throw AssertionError("Expected output was not found in the result set! Expected: $output")
return PnpMatchResult(
ids = ids,
changeSet = changeSet
)
}
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
}
}

View file

@ -0,0 +1,215 @@
package org.thoughtcrime.securesms.database
import app.cash.exhaustive.Exhaustive
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
/**
* Encapsulates data around processing a tuple of user data into a user entry in [RecipientDatabase].
* Also lets you apply a list of [PnpOperation]s to get what the resulting dataset would be.
*/
data class PnpDataSet(
val e164: String?,
val pni: PNI?,
val aci: ACI?,
val byE164: RecipientId?,
val byPniSid: RecipientId?,
val byPniOnly: RecipientId?,
val byAciSid: RecipientId?,
val e164Record: RecipientRecord? = null,
val pniSidRecord: RecipientRecord? = null,
val aciSidRecord: RecipientRecord? = null
) {
/**
* @return The common id if all non-null ids are equal, or null if all are null or at least one non-null pair doesn't match.
*/
val commonId: RecipientId? = findCommonId(listOf(byE164, byPniSid, byPniOnly, byAciSid))
fun MutableSet<RecipientRecord>.replace(recipientId: RecipientId, update: (RecipientRecord) -> RecipientRecord) {
val toUpdate = this.first { it.id == recipientId }
this -= toUpdate
this += update(toUpdate)
}
/**
* Applies the set of operations and returns the resulting dataset.
* Important: This only occurs _in memory_. You must still apply the operations to disk to persist them.
*/
fun perform(operations: List<PnpOperation>): PnpDataSet {
if (operations.isEmpty()) {
return this
}
val records: MutableSet<RecipientRecord> = listOfNotNull(e164Record, pniSidRecord, aciSidRecord).toMutableSet()
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) }
}
is PnpOperation.RemovePni -> {
records.replace(operation.recipientId) { record ->
record.copy(
pni = null,
serviceId = if (record.sidIsPni()) {
null
} else {
record.serviceId
}
)
}
}
is PnpOperation.SetAci -> {
records.replace(operation.recipientId) { it.copy(serviceId = operation.aci) }
}
is PnpOperation.SetE164 -> {
records.replace(operation.recipientId) { it.copy(e164 = operation.e164) }
}
is PnpOperation.SetPni -> {
records.replace(operation.recipientId) { record ->
record.copy(
pni = operation.pni,
serviceId = if (record.sidIsPni()) {
operation.pni
} else {
record.serviceId ?: operation.pni
}
)
}
}
is PnpOperation.Merge -> {
val primary: RecipientRecord = records.first { it.id == operation.primaryId }
val secondary: RecipientRecord = records.first { it.id == operation.secondaryId }
records.replace(primary.id) { _ ->
primary.copy(
e164 = primary.e164 ?: secondary.e164,
pni = primary.pni ?: secondary.pni,
serviceId = primary.serviceId ?: secondary.serviceId
)
}
records -= secondary
}
is PnpOperation.SessionSwitchoverInsert -> Unit
is PnpOperation.ChangeNumberInsert -> Unit
}
}
val newE164Record = if (e164 != null) records.firstOrNull { it.e164 == e164 } else null
val newPniSidRecord = if (pni != null) records.firstOrNull { it.serviceId == pni } else null
val newAciSidRecord = if (aci != null) records.firstOrNull { it.serviceId == aci } else null
return PnpDataSet(
e164 = e164,
pni = pni,
aci = aci,
byE164 = newE164Record?.id,
byPniSid = newPniSidRecord?.id,
byPniOnly = byPniOnly,
byAciSid = newAciSidRecord?.id,
e164Record = newE164Record,
pniSidRecord = newPniSidRecord,
aciSidRecord = newAciSidRecord
)
}
companion object {
private fun findCommonId(ids: List<RecipientId?>): RecipientId? {
val nonNull = ids.filterNotNull()
return when {
nonNull.isEmpty() -> null
nonNull.all { it == nonNull[0] } -> nonNull[0]
else -> null
}
}
}
}
/**
* Represents a set of actions that need to be applied to incorporate a tuple of user data
* into [RecipientDatabase].
*/
data class PnpChangeSet(
val id: PnpIdResolver,
val operations: List<PnpOperation> = emptyList()
)
sealed class PnpIdResolver {
data class PnpNoopId(
val recipientId: RecipientId
) : PnpIdResolver()
data class PnpInsert(
val e164: String?,
val pni: PNI?,
val aci: ACI?
) : PnpIdResolver()
}
/**
* An operation that needs to be performed on the [RecipientDatabase] as part of merging in new user data.
* 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()
data class RemoveE164(
val recipientId: RecipientId
) : PnpOperation()
data class RemovePni(
val recipientId: RecipientId
) : PnpOperation()
data class SetE164(
val recipientId: RecipientId,
val e164: String
) : PnpOperation()
data class SetPni(
val recipientId: RecipientId,
val pni: PNI
) : PnpOperation()
data class SetAci(
val recipientId: RecipientId,
val aci: ACI
) : PnpOperation()
/**
* Merge two rows into one. Prefer data in the primary row when there's conflicts. Delete the secondary row afterwards.
*/
data class Merge(
val primaryId: RecipientId,
val secondaryId: RecipientId
) : PnpOperation()
data class SessionSwitchoverInsert(
val recipientId: RecipientId
) : PnpOperation()
data class ChangeNumberInsert(
val recipientId: RecipientId,
val oldE164: String,
val newE164: String
) : PnpOperation()
}

View file

@ -7,6 +7,7 @@ import android.net.Uri
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import app.cash.exhaustive.Exhaustive
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import net.zetetic.database.sqlcipher.SQLiteConstraintException
@ -54,7 +55,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.identities
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.notificationProfiles
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSuccessfulTransaction
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends
@ -105,6 +105,7 @@ 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
@ -2179,43 +2180,388 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
@VisibleForTesting
fun processCdsV2Result(e164: String, pni: PNI, aci: ACI?): RecipientId {
val byE164: RecipientId? = getByE164(e164).orElse(null)
val byPni: RecipientId? = getByServiceId(pni).orElse(null)
val byPniOnly: RecipientId? = getByPni(pni).orElse(null)
val byAci: RecipientId? = aci?.let { getByServiceId(it).orElse(null) }
val result = processPnpTupleToChangeSet(e164, pni, aci, pniVerified = false)
val commonId: RecipientId? = listOf(byE164, byPni, byPniOnly, byAci).commonId()
val allRequiredDbFields: List<RecipientId?> = if (aci != null) listOf(byE164, byAci, byPniOnly) else listOf(byE164, byPni, byPniOnly)
val allRequiredDbFieldPopulated: Boolean = allRequiredDbFields.all { it != null }
// All ID's agree and the database is up-to-date
if (commonId != null && allRequiredDbFieldPopulated) {
return commonId
val id: RecipientId = when (result.id) {
is PnpIdResolver.PnpNoopId -> {
result.id.recipientId
}
is PnpIdResolver.PnpInsert -> {
val id: Long = writableDatabase.insert(TABLE_NAME, null, buildContentValuesForCdsInsert(result.id.e164, result.id.pni, result.id.aci))
RecipientId.from(id)
}
}
// All ID's agree but we need to update the database
if (commonId != null && !allRequiredDbFieldPopulated) {
writableDatabase
.update(TABLE_NAME)
.values(
PHONE to e164,
SERVICE_ID to (aci ?: pni).toString(),
PNI_COLUMN to pni.toString(),
REGISTERED to RegisteredState.REGISTERED.id,
STORAGE_SERVICE_ID to Base64.encodeBytes(StorageSyncHelper.generateKey())
for (operation in result.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.Merge -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.SessionSwitchoverInsert -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.ChangeNumberInsert -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.RemoveE164 -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.RemovePni -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.SetAci -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.SetE164 -> {
// TODO [pnp]
error("Not yet implemented")
}
is PnpOperation.SetPni -> {
// TODO [pnp]
error("Not yet implemented")
}
}
}
return id
}
/**
* Takes a tuple of (e164, pni, aci) and converts that into a list of changes that would need to be made to
* merge that data into our database.
*
* The database will be read, but not written to, during this function.
* It is assumed that we are in a transaction.
*/
@VisibleForTesting
fun processPnpTupleToChangeSet(e164: String?, pni: PNI?, aci: ACI?, pniVerified: Boolean): 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!")
val partialData = PnpDataSet(
e164 = e164,
pni = pni,
aci = aci,
byE164 = e164?.let { getByE164(it).orElse(null) },
byPniSid = pni?.let { getByServiceId(it).orElse(null) },
byPniOnly = pni?.let { getByPni(it).orElse(null) },
byAciSid = aci?.let { getByServiceId(it).orElse(null) }
)
val allRequiredDbFields: List<RecipientId?> = if (aci != null) {
listOf(partialData.byE164, partialData.byAciSid, partialData.byPniOnly)
} else {
listOf(partialData.byE164, partialData.byPniSid, partialData.byPniOnly)
}
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))
}
// 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<PnpOperation> = mutableListOf()
if (e164 != null && record.e164 != e164) {
operations += PnpOperation.SetE164(
recipientId = partialData.commonId,
e164 = e164
)
.where("$ID = ?", commonId)
.run()
return commonId
}
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 (e164 != null && record.e164 != null && record.e164 != e164) {
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
)
}
// Nothing matches
if (byE164 == null && byPni == null && byAci == null) {
val id: Long = writableDatabase.insert(TABLE_NAME, null, buildContentValuesForCdsInsert(e164, pni, aci))
return RecipientId.from(id)
if (partialData.byE164 == null && partialData.byPniSid == null && partialData.byAciSid == null) {
return PnpChangeSet(
id = PnpIdResolver.PnpInsert(
e164 = e164,
pni = pni,
aci = aci
)
)
}
throw NotImplementedError("Handle cases where IDs map to different individuals")
// TODO pni only record?
// At this point, we know that records have been found for at least two of the fields,
// and that there are at least two unique IDs among the records.
//
// In other words, *some* sort of merging of data must now occur.
// 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.
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)
val operations: MutableList<PnpOperation> = mutableListOf()
operations += processPossibleE164PniSidMerge(pni, pniVerified, fullData)
operations += processPossiblePniSidAciSidMerge(e164, pni, aci, fullData.perform(operations))
operations += processPossibleE164AciSidMerge(e164, pni, aci, fullData.perform(operations))
val finalData: PnpDataSet = fullData.perform(operations)
val primaryId: RecipientId = listOfNotNull(finalData.byAciSid, finalData.byE164, finalData.byPniSid).first()
if (finalData.byAciSid == null && aci != null) {
operations += PnpOperation.SetAci(
recipientId = primaryId,
aci = aci
)
}
if (finalData.byE164 == null && e164 != null) {
operations += PnpOperation.SetE164(
recipientId = primaryId,
e164 = e164
)
}
if (finalData.byPniSid == null && finalData.byPniOnly == null && pni != null) {
operations += PnpOperation.SetPni(
recipientId = primaryId,
pni = pni
)
}
return PnpChangeSet(
id = PnpIdResolver.PnpNoopId(primaryId),
operations = operations
)
}
private fun processPossibleE164PniSidMerge(pni: PNI?, pniVerified: Boolean, data: PnpDataSet): List<PnpOperation> {
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
val operations: MutableList<PnpOperation> = mutableListOf()
// The PNI record only has a single identifier. We know we must merge.
if (data.pniSidRecord.sidOnly(pni)) {
if (data.e164Record.pni != null) {
operations += PnpOperation.RemovePni(data.byE164)
}
operations += PnpOperation.Merge(
primaryId = data.byE164,
secondaryId = data.byPniSid
)
// TODO: Possible session switchover?
} else {
Preconditions.checkState(!data.pniSidRecord.pniAndAci())
Preconditions.checkState(data.pniSidRecord.e164 != null)
operations += PnpOperation.RemovePni(data.byPniSid)
operations += PnpOperation.SetPni(
recipientId = data.byE164,
pni = pni
)
if (!pniVerified && sessions.hasAnySessionFor(data.pniSidRecord.serviceId.toString())) {
operations += PnpOperation.SessionSwitchoverInsert(data.byPniSid)
}
if (!pniVerified && data.e164Record.serviceId != null && data.e164Record.sidIsPni() && sessions.hasAnySessionFor(data.e164Record.serviceId.toString())) {
operations += PnpOperation.SessionSwitchoverInsert(data.byE164)
}
}
return operations
}
private fun processPossiblePniSidAciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet): List<PnpOperation> {
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()
}
// We have found records for both the PNI and ACI, and they're different
val operations: MutableList<PnpOperation> = mutableListOf()
// The PNI record only has a single identifier. We know we must merge.
if (data.pniSidRecord.sidOnly(pni)) {
if (data.aciSidRecord.pni != null) {
operations += PnpOperation.RemovePni(data.byAciSid)
}
operations += PnpOperation.Merge(
primaryId = data.byAciSid,
secondaryId = data.byPniSid
)
} 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.
if (data.aciSidRecord.pni != null) {
operations += PnpOperation.RemovePni(data.byAciSid)
}
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.RemoveE164(data.byAciSid)
}
operations += PnpOperation.Merge(
primaryId = data.byAciSid,
secondaryId = data.byPniSid
)
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.ChangeNumberInsert(
recipientId = data.byAciSid,
oldE164 = data.aciSidRecord.e164,
newE164 = e164!!
)
}
} else {
Preconditions.checkState(data.pniSidRecord.e164 != null && data.pniSidRecord.e164 != e164)
operations += PnpOperation.RemovePni(data.byPniSid)
operations += PnpOperation.Update(
recipientId = data.byAciSid,
e164 = e164,
pni = pni,
aci = ACI.from(data.aciSidRecord.serviceId)
)
}
return operations
}
private fun processPossibleE164AciSidMerge(e164: String?, pni: PNI?, aci: ACI?, data: PnpDataSet): List<PnpOperation> {
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()
}
// We have found records for both the E164 and ACI, and they're different
val operations: MutableList<PnpOperation> = mutableListOf()
// The PNI record only has a single identifier. We know we must merge.
if (data.e164Record.e164Only()) {
// TODO high trust
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.RemoveE164(data.byAciSid)
}
operations += PnpOperation.Merge(
primaryId = data.byAciSid,
secondaryId = data.byE164
)
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.ChangeNumberInsert(
recipientId = data.byAciSid,
oldE164 = data.aciSidRecord.e164,
newE164 = e164
)
}
} 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.
if (data.aciSidRecord.pni != null) {
operations += PnpOperation.RemovePni(data.byAciSid)
}
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.RemoveE164(data.byAciSid)
}
operations += PnpOperation.Merge(
primaryId = data.byAciSid,
secondaryId = data.byE164
)
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.ChangeNumberInsert(
recipientId = data.byAciSid,
oldE164 = data.aciSidRecord.e164,
newE164 = e164!!
)
}
} else {
operations += PnpOperation.RemoveE164(data.byE164)
operations += PnpOperation.SetE164(
recipientId = data.byAciSid,
e164 = e164
)
if (data.aciSidRecord.e164 != null && data.aciSidRecord.e164 != e164) {
operations += PnpOperation.ChangeNumberInsert(
recipientId = data.byAciSid,
oldE164 = data.aciSidRecord.e164,
newE164 = e164
)
}
}
return operations
}
fun getUninvitedRecipientsForInsights(): List<RecipientId> {
@ -2939,16 +3285,18 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
return values
}
private fun buildContentValuesForCdsInsert(e164: String, pni: PNI, aci: ACI?): ContentValues {
val serviceId: ServiceId = aci ?: pni
return ContentValues().apply {
put(PHONE, e164)
put(SERVICE_ID, serviceId.toString())
put(PNI_COLUMN, pni.toString())
put(REGISTERED, RegisteredState.REGISTERED.id)
put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()))
put(AVATAR_COLOR, AvatarColor.random().serialize())
}
private fun buildContentValuesForCdsInsert(e164: String?, pni: PNI?, aci: ACI?): ContentValues {
Preconditions.checkArgument(pni != null || aci != null, "Must provide a serviceId!")
val serviceId: ServiceId = aci ?: pni!!
return contentValuesOf(
PHONE to e164,
SERVICE_ID to serviceId.toString(),
PNI_COLUMN to pni.toString(),
REGISTERED to RegisteredState.REGISTERED.id,
STORAGE_SERVICE_ID to Base64.encodeBytes(StorageSyncHelper.generateKey()),
AVATAR_COLOR to AvatarColor.random().serialize()
)
}
private fun getValuesForStorageContact(contact: SignalContactRecord, isInsert: Boolean): ContentValues {
@ -3026,22 +3374,6 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
}
}
/**
* @return The common id if all non-null ids are equal, or null if all are null or at least one non-null pair doesn't match.
*/
private fun Collection<RecipientId?>.commonId(): RecipientId? {
val nonNull = this.filterNotNull()
if (nonNull.isEmpty()) {
return null
}
return if (nonNull.all { it.equals(nonNull[0]) }) {
nonNull[0]
} else {
null
}
}
/**
* Should only be used for debugging! A very destructive action that clears all known serviceIds.
*/

View file

@ -7,6 +7,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireNonNullBlob
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.SessionRecord
import org.whispersystems.signalservice.api.push.ServiceId
@ -195,5 +196,19 @@ class SessionDatabase(context: Context, databaseHelper: SignalDatabase) : Databa
}
}
/**
* @return True if a session exists with this address for _any_ of your identities.
*/
fun hasAnySessionFor(addressName: String): Boolean {
readableDatabase
.select("1")
.from(TABLE_NAME)
.where("$ADDRESS = ?", addressName)
.run()
.use { cursor ->
return cursor.moveToFirst()
}
}
class SessionRow(val address: String, val deviceId: Int, val record: SessionRecord)
}

View file

@ -90,6 +90,22 @@ data class RecipientRecord(
return if (defaultSubscriptionId != -1) Optional.of(defaultSubscriptionId) else Optional.empty()
}
fun e164Only(): Boolean {
return this.e164 != null && this.serviceId == null
}
fun sidOnly(sid: ServiceId): Boolean {
return this.e164 == null && this.serviceId == sid && (this.pni == null || this.pni == sid)
}
fun sidIsPni(): Boolean {
return this.serviceId != null && this.pni != null && this.serviceId == this.pni
}
fun pniAndAci(): Boolean {
return this.serviceId != null && this.pni != null && this.serviceId != this.pni
}
/**
* A bundle of data that's only necessary when syncing to storage service, not for a
* [Recipient].

View file

@ -16,6 +16,14 @@ public final class ACI extends ServiceId {
return new ACI(uuid);
}
public static ACI from(ServiceId serviceId) {
return new ACI(serviceId.uuid());
}
public static ACI fromNullable(ServiceId serviceId) {
return serviceId != null ? new ACI(serviceId.uuid()) : null;
}
public static ACI parseOrThrow(String raw) {
return from(UUID.fromString(raw));
}

View file

@ -17,6 +17,10 @@ public final class Preconditions {
}
}
public static void checkState(boolean state) {
checkState(state, "Condition must be true!");
}
public static void checkState(boolean state, String message) {
if (!state) {
throw new IllegalStateException(message);