From 61ce39b5b6b2d040a5ed0f784a9e5c41110c8c20 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 29 Jun 2022 15:29:58 -0400 Subject: [PATCH] Improve implementation and testing on PNP contact merging. --- .../database/DistributionListDatabaseTest.kt | 19 - ...DatabaseTest_processPnpTupleToChangeSet.kt | 810 ++++++++++++++++++ .../securesms/database/PnpOperations.kt | 215 +++++ .../securesms/database/RecipientDatabase.kt | 442 ++++++++-- .../securesms/database/SessionDatabase.kt | 15 + .../database/model/RecipientRecord.kt | 16 + .../signalservice/api/push/ACI.java | 8 + .../signalservice/api/util/Preconditions.java | 4 + 8 files changed, 1455 insertions(+), 74 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTupleToChangeSet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt index 6ffa8fed9..e346c9275 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/DistributionListDatabaseTest.kt @@ -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)) 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 new file mode 100644 index 000000000..0f282068f --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_processPnpTupleToChangeSet.kt @@ -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, 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, 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" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt new file mode 100644 index 000000000..39c1f2a7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PnpOperations.kt @@ -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.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): PnpDataSet { + if (operations.isEmpty()) { + return this + } + + val records: MutableSet = 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? { + 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 = 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() +} 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 91a5213ad..5c96ba011 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -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 = 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 = 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 = 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 = 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 { + 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 = 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 { + 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 = 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 { + 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 = 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 { @@ -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.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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.kt index efa0e1813..4d685d7f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionDatabase.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index aa74aad2f..19f329a22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -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]. diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ACI.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ACI.java index 2d7004902..f8f9dc0bb 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ACI.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/ACI.java @@ -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)); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Preconditions.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Preconditions.java index 47b5ab9a8..aaf02c9c5 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Preconditions.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/Preconditions.java @@ -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);