diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java index fac23c82e4..703bca01ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -59,6 +59,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -102,32 +103,14 @@ public class DirectoryHelper { recipientDatabase.updatePhoneNumbers(result.getNumberRewrites()); } - HashMap uuidMap = new HashMap<>(); - - // TODO [greyson] [cds] Probably want to do this in a DB transaction to prevent concurrent operations - for (Map.Entry entry : result.getRegisteredNumbers().entrySet()) { - // TODO [greyson] [cds] This is where we'll have to do record merging - String e164 = entry.getKey(); - UUID uuid = entry.getValue(); - Optional uuidEntry = uuid != null ? recipientDatabase.getByUuid(uuid) : Optional.absent(); - - // TODO [greyson] [cds] Handle phone numbers changing, possibly conflicting - if (uuidEntry.isPresent()) { - recipientDatabase.setPhoneNumber(uuidEntry.get(), e164); - } - - RecipientId id = uuidEntry.isPresent() ? uuidEntry.get() : recipientDatabase.getOrInsertFromE164(e164); - - uuidMap.put(id, uuid != null ? uuid.toString() : null); - } - - Set activeNumbers = result.getRegisteredNumbers().keySet(); - Set activeIds = uuidMap.keySet(); - Set inactiveIds = Stream.of(allNumbers) - .filterNot(activeNumbers::contains) - .filterNot(n -> result.getNumberRewrites().containsKey(n)) - .map(recipientDatabase::getOrInsertFromE164) - .collect(Collectors.toSet()); + Map uuidMap = recipientDatabase.bulkProcessCdsResult(result.getRegisteredNumbers()); + Set activeNumbers = result.getRegisteredNumbers().keySet(); + Set activeIds = uuidMap.keySet(); + Set inactiveIds = Stream.of(allNumbers) + .filterNot(activeNumbers::contains) + .filterNot(n -> result.getNumberRewrites().containsKey(n)) + .map(recipientDatabase::getOrInsertFromE164) + .collect(Collectors.toSet()); recipientDatabase.bulkUpdatedRegisteredStatus(uuidMap, inactiveIds); @@ -162,7 +145,10 @@ public class DirectoryHelper { if (recipient.hasUuid() && !recipient.hasE164()) { boolean isRegistered = isUuidRegistered(context, recipient); if (isRegistered) { - recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get()); + boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get()); + if (idChanged) { + Log.w(TAG, "ID changed during refresh by UUID."); + } } else { recipientDatabase.markUnregistered(recipient.getId()); } @@ -189,7 +175,15 @@ public class DirectoryHelper { } if (result.getRegisteredNumbers().size() > 0) { - recipientDatabase.markRegistered(recipient.getId(), result.getRegisteredNumbers().values().iterator().next()); + UUID uuid = result.getRegisteredNumbers().values().iterator().next(); + if (uuid != null) { + boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), uuid); + if (idChanged) { + recipient = Recipient.resolved(recipientDatabase.getByUuid(uuid).get()); + } + } else { + recipientDatabase.markRegistered(recipient.getId()); + } } else { recipientDatabase.markUnregistered(recipient.getId()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 95aa9632da..72db106a38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -39,29 +39,30 @@ public class DatabaseFactory { private static DatabaseFactory instance; - private final SQLCipherOpenHelper databaseHelper; - private final SmsDatabase sms; - private final MmsDatabase mms; - private final AttachmentDatabase attachments; - private final MediaDatabase media; - private final ThreadDatabase thread; - private final MmsSmsDatabase mmsSmsDatabase; - private final IdentityDatabase identityDatabase; - private final DraftDatabase draftDatabase; - private final PushDatabase pushDatabase; - private final GroupDatabase groupDatabase; - private final RecipientDatabase recipientDatabase; - private final ContactsDatabase contactsDatabase; - private final GroupReceiptDatabase groupReceiptDatabase; - private final OneTimePreKeyDatabase preKeyDatabase; - private final SignedPreKeyDatabase signedPreKeyDatabase; - private final SessionDatabase sessionDatabase; - private final SearchDatabase searchDatabase; - private final JobDatabase jobDatabase; - private final StickerDatabase stickerDatabase; - private final StorageKeyDatabase storageKeyDatabase; - private final KeyValueDatabase keyValueDatabase; - private final MegaphoneDatabase megaphoneDatabase; + private final SQLCipherOpenHelper databaseHelper; + private final SmsDatabase sms; + private final MmsDatabase mms; + private final AttachmentDatabase attachments; + private final MediaDatabase media; + private final ThreadDatabase thread; + private final MmsSmsDatabase mmsSmsDatabase; + private final IdentityDatabase identityDatabase; + private final DraftDatabase draftDatabase; + private final PushDatabase pushDatabase; + private final GroupDatabase groupDatabase; + private final RecipientDatabase recipientDatabase; + private final ContactsDatabase contactsDatabase; + private final GroupReceiptDatabase groupReceiptDatabase; + private final OneTimePreKeyDatabase preKeyDatabase; + private final SignedPreKeyDatabase signedPreKeyDatabase; + private final SessionDatabase sessionDatabase; + private final SearchDatabase searchDatabase; + private final JobDatabase jobDatabase; + private final StickerDatabase stickerDatabase; + private final StorageKeyDatabase storageKeyDatabase; + private final KeyValueDatabase keyValueDatabase; + private final MegaphoneDatabase megaphoneDatabase; + private final RemappedRecordsDatabase remappedRecordsDatabase; public static DatabaseFactory getInstance(Context context) { synchronized (lock) { @@ -160,6 +161,10 @@ public class DatabaseFactory { return getInstance(context).megaphoneDatabase; } + static RemappedRecordsDatabase getRemappedRecordsDatabase(Context context) { + return getInstance(context).remappedRecordsDatabase; + } + public static SQLiteDatabase getBackupDatabase(Context context) { return getInstance(context).databaseHelper.getReadableDatabase(); } @@ -175,8 +180,8 @@ public class DatabaseFactory { } } - static SQLCipherOpenHelper getRawDatabase(Context context) { - return getInstance(context).databaseHelper; + public static boolean inTransaction(Context context) { + return getInstance(context).databaseHelper.getWritableDatabase().inTransaction(); } private DatabaseFactory(@NonNull Context context) { @@ -185,29 +190,30 @@ public class DatabaseFactory { DatabaseSecret databaseSecret = new DatabaseSecretProvider(context).getOrCreateDatabaseSecret(); AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); - this.databaseHelper = new SQLCipherOpenHelper(context, databaseSecret); - this.sms = new SmsDatabase(context, databaseHelper); - this.mms = new MmsDatabase(context, databaseHelper); - this.attachments = new AttachmentDatabase(context, databaseHelper, attachmentSecret); - this.media = new MediaDatabase(context, databaseHelper); - this.thread = new ThreadDatabase(context, databaseHelper); - this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper); - this.identityDatabase = new IdentityDatabase(context, databaseHelper); - this.draftDatabase = new DraftDatabase(context, databaseHelper); - this.pushDatabase = new PushDatabase(context, databaseHelper); - this.groupDatabase = new GroupDatabase(context, databaseHelper); - this.recipientDatabase = new RecipientDatabase(context, databaseHelper); - this.groupReceiptDatabase = new GroupReceiptDatabase(context, databaseHelper); - this.contactsDatabase = new ContactsDatabase(context); - this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper); - this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper); - this.sessionDatabase = new SessionDatabase(context, databaseHelper); - this.searchDatabase = new SearchDatabase(context, databaseHelper); - this.jobDatabase = new JobDatabase(context, databaseHelper); - this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret); - this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper); - this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper); - this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper); + this.databaseHelper = new SQLCipherOpenHelper(context, databaseSecret); + this.sms = new SmsDatabase(context, databaseHelper); + this.mms = new MmsDatabase(context, databaseHelper); + this.attachments = new AttachmentDatabase(context, databaseHelper, attachmentSecret); + this.media = new MediaDatabase(context, databaseHelper); + this.thread = new ThreadDatabase(context, databaseHelper); + this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper); + this.identityDatabase = new IdentityDatabase(context, databaseHelper); + this.draftDatabase = new DraftDatabase(context, databaseHelper); + this.pushDatabase = new PushDatabase(context, databaseHelper); + this.groupDatabase = new GroupDatabase(context, databaseHelper); + this.recipientDatabase = new RecipientDatabase(context, databaseHelper); + this.groupReceiptDatabase = new GroupReceiptDatabase(context, databaseHelper); + this.contactsDatabase = new ContactsDatabase(context); + this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper); + this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper); + this.sessionDatabase = new SessionDatabase(context, databaseHelper); + this.searchDatabase = new SearchDatabase(context, databaseHelper); + this.jobDatabase = new JobDatabase(context, databaseHelper); + this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret); + this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper); + this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper); + this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper); + this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java index f4a9d3df5e..f717e29743 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -17,7 +17,7 @@ import java.util.Set; public class DraftDatabase extends Database { - private static final String TABLE_NAME = "drafts"; + static final String TABLE_NAME = "drafts"; public static final String ID = "_id"; public static final String THREAD_ID = "thread_id"; public static final String DRAFT_TYPE = "type"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 5320c872c2..103ce18461 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -53,7 +53,7 @@ public final class GroupDatabase extends Database { static final String GROUP_ID = "group_id"; static final String RECIPIENT_ID = "recipient_id"; private static final String TITLE = "title"; - private static final String MEMBERS = "members"; + static final String MEMBERS = "members"; private static final String AVATAR_ID = "avatar_id"; private static final String AVATAR_KEY = "avatar_key"; private static final String AVATAR_CONTENT_TYPE = "avatar_content_type"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java index 9911925916..959a71fbcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java @@ -23,7 +23,7 @@ public class GroupReceiptDatabase extends Database { private static final String ID = "_id"; public static final String MMS_ID = "mms_id"; - private static final String RECIPIENT_ID = "address"; + static final String RECIPIENT_ID = "address"; private static final String STATUS = "status"; private static final String TIMESTAMP = "timestamp"; private static final String UNIDENTIFIED = "unidentified"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 6334b560d0..f39a254bbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -12,6 +12,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import com.google.android.gms.common.util.ArrayUtils; +import net.sqlcipher.database.SQLiteConstraintException; import net.sqlcipher.database.SQLiteDatabase; import org.signal.zkgroup.InvalidInputException; @@ -44,6 +45,7 @@ import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -66,6 +68,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -358,6 +361,156 @@ public class RecipientDatabase extends Database { return getByColumn(USERNAME, username); } + public @NonNull RecipientId getAndPossiblyMerge(@Nullable UUID uuid, @Nullable String e164, boolean highTrust) { + if (uuid == null && e164 == null) { + throw new IllegalArgumentException("Must provide a UUID or E164!"); + } + + RecipientId recipientNeedingRefresh = null; + Pair remapped = null; + boolean transactionSuccessful = false; + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + Optional byE164 = e164 != null ? getByE164(e164) : Optional.absent(); + Optional byUuid = uuid != null ? getByUuid(uuid) : Optional.absent(); + + RecipientId finalId; + + if (!byE164.isPresent() && !byUuid.isPresent()) { + Log.i(TAG, "Discovered a completely new user. Inserting."); + if (highTrust) { + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(e164, uuid)); + finalId = RecipientId.from(id); + } else { + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(uuid == null ? e164 : null, uuid)); + finalId = RecipientId.from(id); + } + } else if (byE164.isPresent() && !byUuid.isPresent()) { + if (uuid != null) { + RecipientSettings e164Settings = getRecipientSettings(byE164.get()); + if (e164Settings.uuid != null) { + if (highTrust) { + Log.w(TAG, "Found out about a UUID for a known E164 user, but that user already has a UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to a new entry."); + + removePhoneNumber(byE164.get(), db); + recipientNeedingRefresh = byE164.get(); + + ContentValues insertValues = buildContentValuesForNewUser(e164, uuid); + insertValues.put(BLOCKED, e164Settings.blocked ? 1 : 0); + + long id = db.insert(TABLE_NAME, null, insertValues); + finalId = RecipientId.from(id); + } else { + Log.w(TAG, "Found out about a UUID for a known E164 user, but that user already has a UUID. Likely a case of re-registration. Low-trust, so making a new user for the UUID."); + + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid)); + finalId = RecipientId.from(id); + } + } else { + if (highTrust) { + Log.i(TAG, "Found out about a UUID for a known E164 user. High-trust, so updating."); + markRegisteredOrThrow(byE164.get(), uuid); + finalId = byE164.get(); + } else { + Log.i(TAG, "Found out about a UUID for a known E164 user. Low-trust, so making a new user for the UUID."); + long id = db.insert(TABLE_NAME, null, buildContentValuesForNewUser(null, uuid)); + finalId = RecipientId.from(id); + } + } + } else { + finalId = byE164.get(); + } + } else if (!byE164.isPresent() && byUuid.isPresent()) { + if (e164 != null) { + if (highTrust) { + Log.i(TAG, "Found out about an E164 for a known UUID user. High-trust, so updating."); + setPhoneNumberOrThrow(byUuid.get(), e164); + finalId = byUuid.get(); + } else { + Log.i(TAG, "Found out about an E164 for a known UUID user. Low-trust, so doing nothing."); + finalId = byUuid.get(); + } + } else { + finalId = byUuid.get(); + } + } else { + if (byE164.equals(byUuid)) { + finalId = byUuid.get(); + } else { + Log.w(TAG, "Hit a conflict between " + byE164.get() + " (E164) and " + byUuid.get() + " (UUID). They map to different recipients.", new Throwable()); + + RecipientSettings e164Settings = getRecipientSettings(byE164.get()); + + if (e164Settings.getUuid() != null) { + if (highTrust) { + Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. High-trust, so stripping the E164 from the existing account and assigning it to the UUID entry."); + + removePhoneNumber(byE164.get(), db); + recipientNeedingRefresh = byE164.get(); + + setPhoneNumberOrThrow(byUuid.get(), Objects.requireNonNull(e164)); + + finalId = byUuid.get(); + } else { + Log.w(TAG, "The E164 contact has a different UUID. Likely a case of re-registration. Low-trust, so doing nothing."); + finalId = byUuid.get(); + } + } else { + if (highTrust) { + Log.w(TAG, "We have one contact with just an E164, and another with UUID. High-trust, so merging the two rows together."); + finalId = merge(byUuid.get(), byE164.get()); + recipientNeedingRefresh = byUuid.get(); + remapped = new Pair<>(byE164.get(), byUuid.get()); + } else { + Log.w(TAG, "We have one contact with just an E164, and another with UUID. Low-trust, so doing nothing."); + finalId = byUuid.get(); + } + } + } + } + + db.setTransactionSuccessful(); + transactionSuccessful = true; + return finalId; + } finally { + db.endTransaction(); + + if (transactionSuccessful) { + if (recipientNeedingRefresh != null) { + Recipient.live(recipientNeedingRefresh).refresh(); + } + + if (remapped != null) { + Recipient.live(remapped.first()).refresh(remapped.second()); + } + + if (recipientNeedingRefresh != null || remapped != null) { + StorageSyncHelper.scheduleSyncForDataChange(); + RecipientId.clearCache(); + } + } + } + } + + private static ContentValues buildContentValuesForNewUser(@Nullable String e164, @Nullable UUID uuid) { + ContentValues values = new ContentValues(); + + values.put(PHONE, e164); + + if (uuid != null) { + values.put(UUID, uuid.toString().toLowerCase()); + values.put(REGISTERED, RegisteredState.REGISTERED.getId()); + values.put(DIRTY, DirtyState.INSERT.getId()); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); + } + + return values; + } + + public @NonNull RecipientId getOrInsertFromUuid(@NonNull UUID uuid) { return getOrInsertByColumn(UUID, uuid.toString()).recipientId; } @@ -423,7 +576,13 @@ public class RecipientDatabase extends Database { if (cursor != null && cursor.moveToNext()) { return getRecipientSettings(context, cursor); } else { - throw new MissingRecipientException(id); + Optional remapped = RemappedRecords.getInstance().getRecipient(context, id); + if (remapped.isPresent()) { + Log.w(TAG, "Missing recipient, but found it in the remapped records."); + return getRecipientSettings(remapped.get()); + } else { + throw new MissingRecipientException(id); + } } } } @@ -527,46 +686,73 @@ public class RecipientDatabase extends Database { db.beginTransaction(); try { - for (SignalContactRecord insert : contactInserts) { ContentValues values = validateContactValuesForInsert(getValuesForStorageContact(insert, true)); long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); + RecipientId recipientId; + if (id < 0) { values = validateContactValuesForInsert(getValuesForStorageContact(insert, false)); Log.w(TAG, "Failed to insert! It's likely that these were newly-registered users that were missed in the merge. Doing an update instead."); if (insert.getAddress().getNumber().isPresent()) { - int count = db.update(TABLE_NAME, values, PHONE + " = ?", new String[] { insert.getAddress().getNumber().get() }); - Log.w(TAG, "Updated " + count + " users by E164."); - } else { - int count = db.update(TABLE_NAME, values, UUID + " = ?", new String[] { insert.getAddress().getUuid().get().toString() }); - Log.w(TAG, "Updated " + count + " users by UUID."); - } - } else { - RecipientId recipientId = RecipientId.from(id); - - if (insert.getIdentityKey().isPresent()) { try { - IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0); - - DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.getIdentityState())); - } catch (InvalidKeyException e) { - Log.w(TAG, "Failed to process identity key during insert! Skipping.", e); + int count = db.update(TABLE_NAME, values, PHONE + " = ?", new String[] { insert.getAddress().getNumber().get() }); + recipientId = getByE164(insert.getAddress().getNumber().get()).get(); + Log.w(TAG, "Updated " + count + " users by E164."); + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Failed to update the UUID on an existing E164 user. Possibly merging."); + recipientId = getAndPossiblyMerge(insert.getAddress().getUuid().get(), insert.getAddress().getNumber().get(), true); + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Resulting id: " + recipientId); + } + } else { + try { + int count = db.update(TABLE_NAME, values, UUID + " = ?", new String[] { insert.getAddress().getUuid().get().toString() }); + recipientId = getByUuid(insert.getAddress().getUuid().get()).get(); + Log.w(TAG, "Updated " + count + " users by UUID."); + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Failed to update the E164 on an existing UUID user. Possibly merging."); + recipientId = getAndPossiblyMerge(insert.getAddress().getUuid().get(), insert.getAddress().getNumber().get(), true); + Log.w(TAG, "[applyStorageSyncUpdates -- Insert] Resulting id: " + recipientId); } } - - threadDatabase.setArchived(recipientId, insert.isArchived()); - needsRefresh.add(recipientId); + } else { + recipientId = RecipientId.from(id); } + + if (insert.getIdentityKey().isPresent()) { + try { + IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0); + + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.getIdentityState())); + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to process identity key during insert! Skipping.", e); + } + } + + threadDatabase.setArchived(recipientId, insert.isArchived()); + needsRefresh.add(recipientId); } for (RecordUpdate update : contactUpdates) { - ContentValues values = getValuesForStorageContact(update.getNew(), false); - int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + ContentValues values = getValuesForStorageContact(update.getNew(), false); - if (updateCount < 1) { - throw new AssertionError("Had an update, but it didn't match any rows!"); + try { + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncUpdates -- Update] Failed to update a user by storageId."); + + RecipientId recipientId = getByColumn(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getOld().getId().getRaw())).get(); + Log.w(TAG, "[applyStorageSyncUpdates -- Update] Found user " + recipientId + ". Possibly merging."); + + recipientId = getAndPossiblyMerge(update.getNew().getAddress().getUuid().orNull(), update.getNew().getAddress().getNumber().orNull(), true); + Log.w(TAG, "[applyStorageSyncUpdates -- Update] Merged into " + recipientId); + + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)); } RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getId().getRaw()); @@ -1284,9 +1470,44 @@ public class RecipientDatabase extends Database { } } - public void setPhoneNumber(@NonNull RecipientId id, @NonNull String e164) { + /** + * @return True if setting the phone number resulted in changed recipientId, otherwise false. + */ + public boolean setPhoneNumber(@NonNull RecipientId id, @NonNull String e164) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + setPhoneNumberOrThrow(id, e164); + db.setTransactionSuccessful(); + return false; + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[setPhoneNumber] Hit a conflict when trying to update " + id + ". Possibly merging."); + + RecipientSettings existing = getRecipientSettings(id); + RecipientId newId = getAndPossiblyMerge(existing.getUuid(), e164, true); + Log.w(TAG, "[setPhoneNumber] Resulting id: " + newId); + + db.setTransactionSuccessful(); + return !newId.equals(existing.getId()); + } finally { + db.endTransaction(); + } + } + + private void removePhoneNumber(@NonNull RecipientId recipientId, @NonNull SQLiteDatabase db) { + ContentValues values = new ContentValues(); + values.putNull(PHONE); + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)); + } + + /** + * Should only use if you are confident that this will not result in any contact merging. + */ + public void setPhoneNumberOrThrow(@NonNull RecipientId id, @NonNull String e164) { ContentValues contentValues = new ContentValues(1); contentValues.put(PHONE, e164); + if (update(id, contentValues)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); @@ -1337,13 +1558,38 @@ public class RecipientDatabase extends Database { return results; } - public void markRegistered(@NonNull RecipientId id, @Nullable UUID uuid) { + /** + * @return True if setting the UUID resulted in changed recipientId, otherwise false. + */ + public boolean markRegistered(@NonNull RecipientId id, @NonNull UUID uuid) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.beginTransaction(); + + try { + markRegisteredOrThrow(id, uuid); + db.setTransactionSuccessful(); + return false; + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[markRegistered] Hit a conflict when trying to update " + id + ". Possibly merging."); + + RecipientSettings existing = getRecipientSettings(id); + RecipientId newId = getAndPossiblyMerge(uuid, existing.getE164(), true); + Log.w(TAG, "[markRegistered] Merged into " + newId); + + db.setTransactionSuccessful(); + return !newId.equals(existing.getId()); + } finally { + db.endTransaction(); + } + } + + /** + * Should only use if you are confident that this shouldn't result in any contact merging. + */ + public void markRegisteredOrThrow(@NonNull RecipientId id, @NonNull UUID uuid) { ContentValues contentValues = new ContentValues(2); contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); - - if (uuid != null) { - contentValues.put(UUID, uuid.toString().toLowerCase()); - } + contentValues.put(UUID, uuid.toString().toLowerCase()); if (update(id, contentValues)) { markDirty(id, DirtyState.INSERT); @@ -1368,7 +1614,6 @@ public class RecipientDatabase extends Database { public void markUnregistered(@NonNull RecipientId id) { ContentValues contentValues = new ContentValues(2); contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); - contentValues.putNull(UUID); if (update(id, contentValues)) { markDirty(id, DirtyState.DELETE); Recipient.live(id).refresh(); @@ -1388,8 +1633,16 @@ public class RecipientDatabase extends Database { values.put(UUID, entry.getValue().toLowerCase()); } - if (update(entry.getKey(), values)) { - markDirty(entry.getKey(), DirtyState.INSERT); + try { + if (update(entry.getKey(), values)) { + markDirty(entry.getKey(), DirtyState.INSERT); + } + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[bulkUpdateRegisteredStatus] Hit a conflict when trying to update " + entry.getKey() + ". Possibly merging."); + + RecipientSettings existing = getRecipientSettings(entry.getKey()); + RecipientId newId = getAndPossiblyMerge(UuidUtil.parseOrThrow(entry.getValue()), existing.getE164(), true); + Log.w(TAG, "[bulkUpdateRegisteredStatus] Merged into " + newId); } } @@ -1449,6 +1702,37 @@ public class RecipientDatabase extends Database { } } + public @NonNull Map bulkProcessCdsResult(@NonNull Map mapping) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + HashMap uuidMap = new HashMap<>(); + + db.beginTransaction(); + try { + for (Map.Entry entry : mapping.entrySet()) { + String e164 = entry.getKey(); + UUID uuid = entry.getValue(); + Optional uuidEntry = uuid != null ? getByUuid(uuid) : Optional.absent(); + + if (uuidEntry.isPresent()) { + boolean idChanged = setPhoneNumber(uuidEntry.get(), e164); + if (idChanged) { + uuidEntry = getByUuid(Objects.requireNonNull(uuid)); + } + } + + RecipientId id = uuidEntry.isPresent() ? uuidEntry.get() : getOrInsertFromE164(e164); + + uuidMap.put(id, uuid != null ? uuid.toString() : null); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return uuidMap; + } + public @NonNull List getUninvitedRecipientsForInsights() { SQLiteDatabase db = databaseHelper.getReadableDatabase(); List results = new LinkedList<>(); @@ -1745,7 +2029,13 @@ public class RecipientDatabase extends Database { values.put(DIRTY, DirtyState.CLEAN.getId()); for (RecipientId id : recipients) { - db.update(TABLE_NAME, values, ID_WHERE, new String[]{ id.serialize() }); + Optional remapped = RemappedRecords.getInstance().getRecipient(context, id); + if (remapped.isPresent()) { + Log.w(TAG, "While clearing dirty state, noticed we have a remapped contact (" + id + " to " + remapped.get() + "). Safe to delete now."); + db.delete(TABLE_NAME, ID_WHERE, new String[]{id.serialize()}); + } else { + db.update(TABLE_NAME, values, ID_WHERE, new String[]{id.serialize()}); + } } db.setTransactionSuccessful(); @@ -1858,6 +2148,136 @@ public class RecipientDatabase extends Database { } } + /** + * Merges one UUID recipient with an E164 recipient. It is assumed that the E164 recipient does + * *not* have a UUID. + */ + @SuppressWarnings("ConstantConditions") + private @NonNull RecipientId merge(@NonNull RecipientId byUuid, @NonNull RecipientId byE164) { + ensureInTransaction(); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + RecipientSettings uuidSettings = getRecipientSettings(byUuid); + RecipientSettings e164Settings = getRecipientSettings(byE164); + + // Recipient + if (e164Settings.getStorageId() == null) { + Log.w(TAG, "No storageId on the E164 recipient. Can delete right away."); + db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164)); + } else { + Log.w(TAG, "The E164 recipient has a storageId. Clearing data and marking for deletion."); + ContentValues values = new ContentValues(); + values.putNull(PHONE); + values.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); + values.put(DIRTY, DirtyState.DELETE.getId()); + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(byE164)); + } + RemappedRecords.getInstance().addRecipient(context, byE164, byUuid); + + ContentValues uuidValues = new ContentValues(); + uuidValues.put(PHONE, e164Settings.getE164()); + uuidValues.put(BLOCKED, e164Settings.isBlocked() || uuidSettings.isBlocked()); + uuidValues.put(MESSAGE_RINGTONE, Optional.fromNullable(uuidSettings.getMessageRingtone()).or(Optional.fromNullable(e164Settings.getMessageRingtone())).transform(Uri::toString).orNull()); + uuidValues.put(MESSAGE_VIBRATE, uuidSettings.getMessageVibrateState() != VibrateState.DEFAULT ? uuidSettings.getMessageVibrateState().getId() : e164Settings.getMessageVibrateState().getId()); + uuidValues.put(CALL_RINGTONE, Optional.fromNullable(uuidSettings.getCallRingtone()).or(Optional.fromNullable(e164Settings.getCallRingtone())).transform(Uri::toString).orNull()); + uuidValues.put(CALL_VIBRATE, uuidSettings.getCallVibrateState() != VibrateState.DEFAULT ? uuidSettings.getCallVibrateState().getId() : e164Settings.getCallVibrateState().getId()); + uuidValues.put(NOTIFICATION_CHANNEL, uuidSettings.getNotificationChannel() != null ? uuidSettings.getNotificationChannel() : e164Settings.getNotificationChannel()); + uuidValues.put(MUTE_UNTIL, uuidSettings.getMuteUntil() > 0 ? uuidSettings.getMuteUntil() : e164Settings.getMuteUntil()); + uuidValues.put(COLOR, Optional.fromNullable(uuidSettings.getColor()).or(Optional.fromNullable(e164Settings.getColor())).transform(MaterialColor::serialize).orNull()); + uuidValues.put(SEEN_INVITE_REMINDER, e164Settings.getInsightsBannerTier().getId()); + uuidValues.put(DEFAULT_SUBSCRIPTION_ID, e164Settings.getDefaultSubscriptionId().or(-1)); + uuidValues.put(MESSAGE_EXPIRATION_TIME, uuidSettings.getExpireMessages() > 0 ? uuidSettings.getExpireMessages() : e164Settings.getExpireMessages()); + uuidValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); + uuidValues.put(SYSTEM_DISPLAY_NAME, e164Settings.getSystemDisplayName()); + uuidValues.put(SYSTEM_PHOTO_URI, e164Settings.getSystemContactPhotoUri()); + uuidValues.put(SYSTEM_PHONE_LABEL, e164Settings.getSystemPhoneLabel()); + uuidValues.put(SYSTEM_CONTACT_URI, e164Settings.getSystemContactUri()); + uuidValues.put(PROFILE_SHARING, uuidSettings.isProfileSharing() || e164Settings.isProfileSharing()); + uuidValues.put(GROUPS_V2_CAPABILITY, uuidSettings.getGroupsV2Capability() != Recipient.Capability.UNKNOWN ? uuidSettings.getGroupsV2Capability().serialize() : e164Settings.getGroupsV2Capability().serialize()); + if (uuidSettings.getProfileKey() != null) { + updateProfileValuesForMerge(uuidValues, uuidSettings); + } else if (e164Settings.getProfileKey() != null) { + updateProfileValuesForMerge(uuidValues, e164Settings); + } + db.update(TABLE_NAME, uuidValues, ID_WHERE, SqlUtil.buildArgs(byUuid)); + + // Identities + db.delete(IdentityDatabase.TABLE_NAME, IdentityDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // Group Receipts + ContentValues groupReceiptValues = new ContentValues(); + groupReceiptValues.put(GroupReceiptDatabase.RECIPIENT_ID, byUuid.serialize()); + db.update(GroupReceiptDatabase.TABLE_NAME, groupReceiptValues, GroupReceiptDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // Groups + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + for (GroupDatabase.GroupRecord group : groupDatabase.getGroupsContainingMember(byE164, false)) { + List newMembers = new ArrayList<>(group.getMembers()); + newMembers.remove(byE164); + + ContentValues groupValues = new ContentValues(); + groupValues.put(GroupDatabase.MEMBERS, RecipientId.toSerializedList(newMembers)); + db.update(GroupDatabase.TABLE_NAME, groupValues, GroupDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(group.getRecipientId())); + } + + // Threads + ThreadDatabase.MergeResult threadMerge = DatabaseFactory.getThreadDatabase(context).merge(byUuid, byE164); + + // SMS Messages + ContentValues smsValues = new ContentValues(); + smsValues.put(SmsDatabase.RECIPIENT_ID, byUuid.serialize()); + if (threadMerge.neededMerge) { + smsValues.put(SmsDatabase.THREAD_ID, threadMerge.threadId); + } + db.update(SmsDatabase.TABLE_NAME, smsValues, SmsDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // MMS Messages + ContentValues mmsValues = new ContentValues(); + mmsValues.put(MmsDatabase.RECIPIENT_ID, byUuid.serialize()); + if (threadMerge.neededMerge) { + mmsValues.put(MmsDatabase.THREAD_ID, threadMerge.threadId); + } + db.update(MmsDatabase.TABLE_NAME, mmsValues, MmsDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + + // Sessions + boolean hasE164Session = DatabaseFactory.getSessionDatabase(context).getAllFor(byE164).size() > 0; + boolean hasUuidSession = DatabaseFactory.getSessionDatabase(context).getAllFor(byUuid).size() > 0; + + if (hasE164Session && hasUuidSession) { + Log.w(TAG, "Had a session for both users. Deleting the E164."); + db.delete(SessionDatabase.TABLE_NAME, SessionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + } else if (hasE164Session && !hasUuidSession) { + Log.w(TAG, "Had a session for E164, but not UUID. Re-assigning to the UUID."); + ContentValues values = new ContentValues(); + values.put(SessionDatabase.RECIPIENT_ID, byUuid.serialize()); + db.update(SessionDatabase.TABLE_NAME, values, SessionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164)); + } else if (!hasE164Session && hasUuidSession) { + Log.w(TAG, "Had a session for UUID, but not E164. No action necessary."); + } else { + Log.w(TAG, "Had no sessions. No action necessary."); + } + + DatabaseFactory.getThreadDatabase(context).update(threadMerge.threadId, false, false); + + return byUuid; + } + + private static void updateProfileValuesForMerge(@NonNull ContentValues values, @NonNull RecipientSettings settings) { + values.put(PROFILE_KEY, settings.getProfileKey() != null ? Base64.encodeBytes(settings.getProfileKey()) : null); + values.put(PROFILE_KEY_CREDENTIAL, settings.getProfileKeyCredential() != null ? Base64.encodeBytes(settings.getProfileKeyCredential()) : null); + values.put(SIGNAL_PROFILE_AVATAR, settings.getProfileAvatar()); + values.put(PROFILE_GIVEN_NAME, settings.getProfileName().getGivenName()); + values.put(PROFILE_FAMILY_NAME, settings.getProfileName().getFamilyName()); + values.put(PROFILE_JOINED_NAME, settings.getProfileName().toString()); + } + + private void ensureInTransaction() { + if (!databaseHelper.getWritableDatabase().inTransaction()) { + throw new IllegalStateException("Must be in a transaction!"); + } + } + public class BulkOperationsHandle { private final SQLiteDatabase database; @@ -2245,6 +2665,24 @@ public class RecipientDatabase extends Database { } } + public final static class RecipientIdResult { + private final RecipientId recipientId; + private final boolean requiresDirectoryRefresh; + + public RecipientIdResult(@NonNull RecipientId recipientId, boolean requiresDirectoryRefresh) { + this.recipientId = recipientId; + this.requiresDirectoryRefresh = requiresDirectoryRefresh; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public boolean requiresDirectoryRefresh() { + return requiresDirectoryRefresh; + } + } + private static class PendingContactInfo { private final String displayName; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecords.java b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecords.java new file mode 100644 index 0000000000..96e37c306d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecords.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Merging together recipients and threads is messy business. We can easily replace *almost* all of + * the references, but there are specific places (notably reactions, jobs, etc) that are really + * expensive to address. For these cases, we keep mappings of old IDs to new ones to use as a + * fallback. + * + * There should be very few of these, so we keep them in a fast, lazily-loaded memory cache. + * + * One important thing to note is that this class will often be accesses inside of database + * transactions. As a result, it cannot attempt to acquire a database lock while holding a + * separate lock. Instead, we use the database lock itself as a locking mechanism. + */ +class RemappedRecords { + + private static final RemappedRecords INSTANCE = new RemappedRecords(); + + private Map recipientMap; + private Map threadMap; + + private RemappedRecords() {} + + static RemappedRecords getInstance() { + return INSTANCE; + } + + @NonNull Optional getRecipient(@NonNull Context context, @NonNull RecipientId oldId) { + ensureRecipientMapIsPopulated(context); + return Optional.fromNullable(recipientMap.get(oldId)); + } + + @NonNull Optional getThread(@NonNull Context context, long oldId) { + ensureThreadMapIsPopulated(context); + return Optional.fromNullable(threadMap.get(oldId)); + } + + /** + * Can only be called inside of a transaction. + */ + void addRecipient(@NonNull Context context, @NonNull RecipientId oldId, @NonNull RecipientId newId) { + ensureInTransaction(context); + ensureRecipientMapIsPopulated(context); + recipientMap.put(oldId, newId); + DatabaseFactory.getRemappedRecordsDatabase(context).addRecipientMapping(oldId, newId); + } + + /** + * Can only be called inside of a transaction. + */ + void addThread(@NonNull Context context, long oldId, long newId) { + ensureInTransaction(context); + ensureRecipientMapIsPopulated(context); + threadMap.put(oldId, newId); + DatabaseFactory.getRemappedRecordsDatabase(context).addThreadMapping(oldId, newId); + } + + private void ensureRecipientMapIsPopulated(@NonNull Context context) { + if (recipientMap == null) { + recipientMap = DatabaseFactory.getRemappedRecordsDatabase(context).getAllRecipientMappings(); + } + } + + private void ensureThreadMapIsPopulated(@NonNull Context context) { + if (threadMap == null) { + threadMap = DatabaseFactory.getRemappedRecordsDatabase(context).getAllThreadMappings(); + } + } + + private void ensureInTransaction(@NonNull Context context) { + if (!DatabaseFactory.inTransaction(context)) { + throw new IllegalStateException("Must be in a transaction!"); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecordsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecordsDatabase.java new file mode 100644 index 0000000000..7757f36e61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RemappedRecordsDatabase.java @@ -0,0 +1,141 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; + +import androidx.annotation.NonNull; + +import net.sqlcipher.Cursor; +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * The backing datastore for {@link RemappedRecords}. See that class for more details. + */ +public class RemappedRecordsDatabase extends Database { + + public static final String[] CREATE_TABLE = { Recipients.CREATE_TABLE, + Threads.CREATE_TABLE }; + + private static class SharedColumns { + protected static final String ID = "_id"; + protected static final String OLD_ID = "old_id"; + protected static final String NEW_ID = "new_id"; + } + + private static final class Recipients extends SharedColumns { + private static final String TABLE_NAME = "remapped_recipients"; + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + OLD_ID + " INTEGER UNIQUE, " + + NEW_ID + " INTEGER)"; + } + + private static final class Threads extends SharedColumns { + private static final String TABLE_NAME = "remapped_threads"; + private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + OLD_ID + " INTEGER UNIQUE, " + + NEW_ID + " INTEGER)"; + } + + RemappedRecordsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + @NonNull Map getAllRecipientMappings() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Map recipientMap = new HashMap<>(); + + db.beginTransaction(); + try { + List mappings = getAllMappings(Recipients.TABLE_NAME); + + for (Mapping mapping : mappings) { + RecipientId oldId = RecipientId.from(mapping.getOldId()); + RecipientId newId = RecipientId.from(mapping.getNewId()); + recipientMap.put(oldId, newId); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return recipientMap; + } + + @NonNull Map getAllThreadMappings() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Map threadMap = new HashMap<>(); + + db.beginTransaction(); + try { + List mappings = getAllMappings(Threads.TABLE_NAME); + + for (Mapping mapping : mappings) { + threadMap.put(mapping.getOldId(), mapping.getNewId()); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return threadMap; + } + + void addRecipientMapping(@NonNull RecipientId oldId, @NonNull RecipientId newId) { + addMapping(Recipients.TABLE_NAME, new Mapping(oldId.toLong(), newId.toLong())); + } + + void addThreadMapping(long oldId, long newId) { + addMapping(Threads.TABLE_NAME, new Mapping(oldId, newId)); + } + + private @NonNull List getAllMappings(@NonNull String table) { + List mappings = new LinkedList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(table, null, null, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long oldId = CursorUtil.requireLong(cursor, SharedColumns.OLD_ID); + long newId = CursorUtil.requireLong(cursor, SharedColumns.NEW_ID); + mappings.add(new Mapping(oldId, newId)); + } + } + + return mappings; + } + + private void addMapping(@NonNull String table, @NonNull Mapping mapping) { + ContentValues values = new ContentValues(); + values.put(SharedColumns.OLD_ID, mapping.getOldId()); + values.put(SharedColumns.NEW_ID, mapping.getNewId()); + + databaseHelper.getWritableDatabase().insert(table, null, values); + } + + static final class Mapping { + private final long oldId; + private final long newId; + + public Mapping(long oldId, long newId) { + this.oldId = oldId; + this.newId = newId; + } + + public long getOldId() { + return oldId; + } + + public long getNewId() { + return newId; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 4b4736bf1f..a2d5cbffa7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -22,6 +22,7 @@ import android.content.Context; import android.database.Cursor; import android.database.MergeCursor; import android.net.Uri; +import android.util.SparseArray; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -49,6 +50,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.CursorUtil; import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -729,6 +731,19 @@ public class ThreadDatabase extends Database { } } + public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId) { + return getOrCreateValidThreadId(recipient, candidateId, DistributionTypes.DEFAULT); + } + + public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId, int distributionType) { + if (candidateId != -1) { + Optional remapped = RemappedRecords.getInstance().getThread(context, candidateId); + return remapped.isPresent() ? remapped.get() : candidateId; + } else { + return getThreadIdFor(recipient, distributionType); + } + } + public long getThreadIdFor(@NonNull Recipient recipient) { return getThreadIdFor(recipient, DistributionTypes.DEFAULT); } @@ -742,7 +757,7 @@ public class ThreadDatabase extends Database { } } - public Long getThreadIdFor(@NonNull RecipientId recipientId) { + public @Nullable Long getThreadIdFor(@NonNull RecipientId recipientId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String where = RECIPIENT_ID + " = ?"; String[] recipientsArg = new String[]{recipientId.serialize()}; @@ -799,12 +814,18 @@ public class ThreadDatabase extends Database { } public boolean update(long threadId, boolean unarchive) { + return update(threadId, unarchive, true); + } + + public boolean update(long threadId, boolean unarchive, boolean allowDeletion) { MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); long count = mmsSmsDatabase.getConversationCount(threadId); if (count == 0) { - deleteThread(threadId); - notifyConversationListListeners(); + if (allowDeletion) { + deleteThread(threadId); + notifyConversationListListeners(); + } return true; } @@ -832,6 +853,81 @@ public class ThreadDatabase extends Database { } } + @NonNull MergeResult merge(@NonNull RecipientId primaryRecipientId, @NonNull RecipientId secondaryRecipientId) { + if (!databaseHelper.getWritableDatabase().inTransaction()) { + throw new IllegalStateException("Must be in a transaction!"); + } + + Log.w(TAG, "Merging threads. Primary: " + primaryRecipientId + ", Secondary: " + secondaryRecipientId); + + ThreadRecord primary = getThreadRecord(getThreadIdFor(primaryRecipientId)); + ThreadRecord secondary = getThreadRecord(getThreadIdFor(secondaryRecipientId)); + + if (primary != null && secondary == null) { + Log.w(TAG, "[merge] Only had a thread for primary. Returning that."); + return new MergeResult(primary.getThreadId(), false); + } else if (primary == null && secondary != null) { + Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary."); + + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, primaryRecipientId.serialize()); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(secondary.getThreadId())); + return new MergeResult(secondary.getThreadId(), false); + } else if (primary == null && secondary == null) { + Log.w(TAG, "[merge] No thread for either."); + return new MergeResult(-1, false); + } else { + Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together."); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(secondary.getThreadId())); + + if (primary.getExpiresIn() != secondary.getExpiresIn()) { + ContentValues values = new ContentValues(); + if (primary.getExpiresIn() == 0) { + values.put(EXPIRES_IN, secondary.getExpiresIn()); + } else if (secondary.getExpiresIn() == 0) { + values.put(EXPIRES_IN, primary.getExpiresIn()); + } else { + values.put(EXPIRES_IN, Math.min(primary.getExpiresIn(), secondary.getExpiresIn())); + } + + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(primary.getThreadId())); + } + + ContentValues draftValues = new ContentValues(); + draftValues.put(DraftDatabase.THREAD_ID, primary.getThreadId()); + db.update(DraftDatabase.TABLE_NAME, draftValues, DraftDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); + + ContentValues searchValues = new ContentValues(); + searchValues.put(SearchDatabase.THREAD_ID, primary.getThreadId()); + db.update(SearchDatabase.SMS_FTS_TABLE_NAME, searchValues, SearchDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); + db.update(SearchDatabase.MMS_FTS_TABLE_NAME, searchValues, SearchDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); + + RemappedRecords.getInstance().addThread(context, secondary.getThreadId(), primary.getThreadId()); + + return new MergeResult(primary.getThreadId(), true); + } + } + + private @Nullable ThreadRecord getThreadRecord(@Nullable Long threadId) { + if (threadId == null) { + return null; + } + + String query = createQuery(TABLE_NAME + "." + ID + " = ?", 1); + + try (Cursor cursor = databaseHelper.getReadableDatabase().rawQuery(query, SqlUtil.buildArgs(threadId))) { + if (cursor != null && cursor.moveToFirst()) { + return readerFor(cursor).getCurrent(); + } + } + + return null; + } + private @Nullable Uri getAttachmentUriFor(MessageRecord record) { if (!record.isMms() || record.isMmsNotification() || record.isGroupAction()) return null; @@ -1164,4 +1260,14 @@ public class ThreadDatabase extends Database { return lastScrolled; } } + + static final class MergeResult { + final long threadId; + final boolean neededMerge; + + private MergeResult(long threadId, boolean neededMerge) { + this.threadId = threadId; + this.neededMerge = neededMerge; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 6eb1aae952..75e909bf6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -22,6 +22,7 @@ import net.sqlcipher.database.SQLiteDatabaseHook; import net.sqlcipher.database.SQLiteOpenHelper; import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; +import org.thoughtcrime.securesms.database.RemappedRecordsDatabase; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -137,8 +138,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int SERVER_DELIVERED_TIMESTAMP = 64; private static final int QUOTE_CLEANUP = 65; private static final int BORDERLESS = 66; + private static final int REMAPPED_RECORDS = 67; - private static final int DATABASE_VERSION = 66; + private static final int DATABASE_VERSION = 67; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -184,6 +186,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(MegaphoneDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, JobDatabase.CREATE_TABLE); + executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); executeStatements(db, RecipientDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS); @@ -958,6 +961,15 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE part ADD COLUMN borderless INTEGER DEFAULT 0"); } + if (oldVersion < REMAPPED_RECORDS) { + db.execSQL("CREATE TABLE remapped_recipients (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "old_id INTEGER UNIQUE, " + + "new_id INTEGER)"); + db.execSQL("CREATE TABLE remapped_threads (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "old_id INTEGER UNIQUE, " + + "new_id INTEGER)"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java index 69fa098f9a..43bab80e37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -86,7 +86,7 @@ public final class GroupProtoUtil { return Recipient.UNKNOWN; } - return Recipient.externalPush(context, uuid, null); + return Recipient.externalPush(context, uuid, null, false); } @WorkerThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java index 6366fab555..91225b7ecd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java @@ -108,7 +108,7 @@ public final class GroupV1MessageProcessor { database.create(id, group.getName().orNull(), members, avatar != null && avatar.isPointer() ? avatar.asPointer() : null, null); - Recipient sender = Recipient.externalPush(context, content.getSender()); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); if (sender.isSystemContact() || sender.isProfileSharing()) { Log.i(TAG, "Auto-enabling profile sharing because 'adder' is trusted. contact: " + sender.isSystemContact() + ", profileSharing: " + sender.isProfileSharing()); @@ -186,7 +186,7 @@ public final class GroupV1MessageProcessor { @NonNull SignalServiceContent content, @NonNull GroupRecord record) { - Recipient sender = Recipient.externalPush(context, content.getSender()); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); if (record.getMembers().contains(sender.getId())) { ApplicationDependencies.getJobManager().add(new PushGroupUpdateJob(sender.getId(), record.getId())); @@ -208,8 +208,10 @@ public final class GroupV1MessageProcessor { GroupContext.Builder builder = createGroupContext(group); builder.setType(GroupContext.Type.QUIT); - if (members.contains(Recipient.externalPush(context, content.getSender()).getId())) { - database.remove(id, Recipient.externalPush(context, content.getSender()).getId()); + RecipientId senderId = RecipientId.fromHighTrust(content.getSender()); + + if (members.contains(senderId)) { + database.remove(id, senderId); if (outgoing) database.setActive(id, false); return storeMessage(context, content, group, builder.build(), outgoing); @@ -245,7 +247,7 @@ public final class GroupV1MessageProcessor { } else { SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); String body = Base64.encodeBytes(storage.toByteArray()); - IncomingTextMessage incoming = new IncomingTextMessage(Recipient.externalPush(context, content.getSender()).getId(), content.getSenderDevice(), content.getTimestamp(), content.getServerReceivedTimestamp(), body, Optional.of(GroupId.v1orThrow(group.getGroupId())), 0, content.isNeedsReceipt()); + IncomingTextMessage incoming = new IncomingTextMessage(Recipient.externalHighTrustPush(context, content.getSender()).getId(), content.getSenderDevice(), content.getTimestamp(), content.getServerReceivedTimestamp(), body, Optional.of(GroupId.v1orThrow(group.getGroupId())), 0, content.isNeedsReceipt()); IncomingGroupUpdateMessage groupMessage = new IncomingGroupUpdateMessage(incoming, storage, body); Optional insertResult = smsDatabase.insertMessageInbox(groupMessage); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 8b98d84e01..5dacb2deec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -251,7 +251,7 @@ public final class PushProcessMessageJob extends BaseJob { Log.w(TAG, "Bad groupId! Using default queue."); } } else { - queueSuffix = RecipientId.from(content.getSender()).toQueueKey(); + queueSuffix = RecipientId.fromHighTrust(content.getSender()).toQueueKey(); } } else if (exceptionMetadata != null) { Recipient recipient = exceptionMetadata.groupId != null ? Recipient.externalGroup(context, exceptionMetadata.groupId) @@ -498,7 +498,7 @@ public final class PushProcessMessageJob extends BaseJob { database.markAsMissedCall(smsMessageId.get()); } else { Intent intent = new Intent(context, WebRtcCallService.class); - RemotePeer remotePeer = new RemotePeer(Recipient.externalPush(context, content.getSender()).getId()); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); intent.setAction(WebRtcCallService.ACTION_RECEIVE_OFFER) .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) @@ -520,7 +520,7 @@ public final class PushProcessMessageJob extends BaseJob { { Log.i(TAG, "handleCallAnswerMessage..."); Intent intent = new Intent(context, WebRtcCallService.class); - RemotePeer remotePeer = new RemotePeer(Recipient.externalPush(context, content.getSender()).getId()); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); intent.setAction(WebRtcCallService.ACTION_RECEIVE_ANSWER) .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) @@ -545,7 +545,7 @@ public final class PushProcessMessageJob extends BaseJob { } Intent intent = new Intent(context, WebRtcCallService.class); - RemotePeer remotePeer = new RemotePeer(Recipient.externalPush(context, content.getSender()).getId()); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); intent.setAction(WebRtcCallService.ACTION_RECEIVE_ICE_CANDIDATES) .putExtra(WebRtcCallService.EXTRA_CALL_ID, callId) @@ -565,7 +565,7 @@ public final class PushProcessMessageJob extends BaseJob { DatabaseFactory.getSmsDatabase(context).markAsMissedCall(smsMessageId.get()); } else { Intent intent = new Intent(context, WebRtcCallService.class); - RemotePeer remotePeer = new RemotePeer(Recipient.externalPush(context, content.getSender()).getId()); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); intent.setAction(WebRtcCallService.ACTION_RECEIVE_HANGUP) .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) @@ -585,7 +585,7 @@ public final class PushProcessMessageJob extends BaseJob { Log.i(TAG, "handleCallBusyMessage"); Intent intent = new Intent(context, WebRtcCallService.class); - RemotePeer remotePeer = new RemotePeer(Recipient.externalPush(context, content.getSender()).getId()); + RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId()); intent.setAction(WebRtcCallService.ACTION_RECEIVE_BUSY) .putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId()) @@ -599,7 +599,7 @@ public final class PushProcessMessageJob extends BaseJob { @NonNull Optional smsMessageId) { SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); - IncomingTextMessage incomingTextMessage = new IncomingTextMessage(Recipient.externalPush(context, content.getSender()).getId(), + IncomingTextMessage incomingTextMessage = new IncomingTextMessage(Recipient.externalHighTrustPush(context, content.getSender()).getId(), content.getSenderDevice(), content.getTimestamp(), content.getServerReceivedTimestamp(), @@ -677,7 +677,7 @@ public final class PushProcessMessageJob extends BaseJob { if (group.getGroupV1().isPresent()) { SignalServiceGroup groupV1 = group.getGroupV1().get(); if (groupV1.getType() != SignalServiceGroup.Type.REQUEST_INFO) { - ApplicationDependencies.getJobManager().add(new RequestGroupInfoJob(Recipient.externalPush(context, content.getSender()).getId(), GroupId.v1(groupV1.getGroupId()))); + ApplicationDependencies.getJobManager().add(new RequestGroupInfoJob(Recipient.externalHighTrustPush(context, content.getSender()).getId(), GroupId.v1(groupV1.getGroupId()))); } else { Log.w(TAG, "Received a REQUEST_INFO message for a group we don't know about. Ignoring."); } @@ -708,7 +708,7 @@ public final class PushProcessMessageJob extends BaseJob { try { MmsDatabase database = DatabaseFactory.getMmsDatabase(context); - Recipient sender = Recipient.externalPush(context, content.getSender()); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); IncomingMediaMessage mediaMessage = new IncomingMediaMessage(sender.getId(), content.getTimestamp(), content.getServerReceivedTimestamp(), @@ -744,7 +744,7 @@ public final class PushProcessMessageJob extends BaseJob { MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(reaction.getTargetSentTimestamp(), targetAuthor.getId()); if (targetMessage != null && !targetMessage.isRemoteDelete()) { - Recipient reactionAuthor = Recipient.externalPush(context, content.getSender()); + Recipient reactionAuthor = Recipient.externalHighTrustPush(context, content.getSender()); MessagingDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); if (reaction.isRemove()) { @@ -766,7 +766,7 @@ public final class PushProcessMessageJob extends BaseJob { private void handleRemoteDelete(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { SignalServiceDataMessage.RemoteDelete delete = message.getRemoteDelete().get(); - Recipient sender = Recipient.externalPush(context, content.getSender()); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(delete.getTargetSentTimestamp(), sender.getId()); if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, sender, content.getServerReceivedTimestamp())) { @@ -1024,7 +1024,7 @@ public final class PushProcessMessageJob extends BaseJob { Optional> sharedContacts = getContacts(message.getSharedContacts()); Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); Optional sticker = getStickerAttachment(message.getSticker()); - IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Recipient.externalPush(context, content.getSender()).getId(), + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(RecipientId.fromHighTrust(content.getSender()), message.getTimestamp(), content.getServerReceivedTimestamp(), -1, @@ -1243,7 +1243,7 @@ public final class PushProcessMessageJob extends BaseJob { } else { notifyTypingStoppedFromIncomingMessage(recipient, content.getSender(), content.getSenderDevice()); - IncomingTextMessage textMessage = new IncomingTextMessage(Recipient.externalPush(context, content.getSender()).getId(), + IncomingTextMessage textMessage = new IncomingTextMessage(RecipientId.fromHighTrust(content.getSender()), content.getSenderDevice(), message.getTimestamp(), content.getServerReceivedTimestamp(), @@ -1445,7 +1445,7 @@ public final class PushProcessMessageJob extends BaseJob { @NonNull byte[] messageProfileKeyBytes) { RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context); - Recipient recipient = Recipient.externalPush(context, content.getSender()); + Recipient recipient = Recipient.externalHighTrustPush(context, content.getSender()); ProfileKey messageProfileKey = ProfileKeyUtil.profileKeyOrNull(messageProfileKeyBytes); if (messageProfileKey != null) { @@ -1460,7 +1460,7 @@ public final class PushProcessMessageJob extends BaseJob { private void handleNeedsDeliveryReceipt(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { - ApplicationDependencies.getJobManager().add(new SendDeliveryReceiptJob(Recipient.externalPush(context, content.getSender()).getId(), message.getTimestamp())); + ApplicationDependencies.getJobManager().add(new SendDeliveryReceiptJob(RecipientId.fromHighTrust(content.getSender()), message.getTimestamp())); } @SuppressLint("DefaultLocale") @@ -1470,7 +1470,7 @@ public final class PushProcessMessageJob extends BaseJob { for (long timestamp : message.getTimestamps()) { Log.i(TAG, String.format("Received encrypted delivery receipt: (XXXXX, %d)", timestamp)); DatabaseFactory.getMmsSmsDatabase(context) - .incrementDeliveryReceiptCount(new SyncMessageId(Recipient.externalPush(context, content.getSender()).getId(), timestamp), System.currentTimeMillis()); + .incrementDeliveryReceiptCount(new SyncMessageId(RecipientId.fromHighTrust(content.getSender()), timestamp), System.currentTimeMillis()); } } @@ -1482,7 +1482,7 @@ public final class PushProcessMessageJob extends BaseJob { for (long timestamp : message.getTimestamps()) { Log.i(TAG, String.format("Received encrypted read receipt: (XXXXX, %d)", timestamp)); - Recipient sender = Recipient.externalPush(context, content.getSender()); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); SyncMessageId id = new SyncMessageId(sender.getId(), timestamp); boolean handled = DatabaseFactory.getMmsSmsDatabase(context) .incrementReadReceiptCount(id, content.getTimestamp()); @@ -1503,7 +1503,7 @@ public final class PushProcessMessageJob extends BaseJob { return; } - Recipient author = Recipient.externalPush(context, content.getSender()); + Recipient author = Recipient.externalHighTrustPush(context, content.getSender()); long threadId; @@ -1703,7 +1703,7 @@ public final class PushProcessMessageJob extends BaseJob { @NonNull SignalServiceDataMessage message) throws BadGroupIdException { - return getGroupRecipient(message.getGroupContext()).or(() -> Recipient.externalPush(context, content.getSender())); + return getGroupRecipient(message.getGroupContext()).or(() -> Recipient.externalHighTrustPush(context, content.getSender())); } private Recipient getMessageDestination(@NonNull SignalServiceContent content, @@ -1740,7 +1740,7 @@ public final class PushProcessMessageJob extends BaseJob { return true; } - Recipient sender = Recipient.externalPush(context, content.getSender()); + Recipient sender = Recipient.externalHighTrustPush(context, content.getSender()); if (content.getDataMessage().isPresent()) { SignalServiceDataMessage message = content.getDataMessage().get(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java index 428b51d8d7..25319e70cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java @@ -77,7 +77,7 @@ public class IncomingMessageProcessor { */ public @Nullable String processEnvelope(@NonNull SignalServiceEnvelope envelope) { if (envelope.hasSource()) { - Recipient.externalPush(context, envelope.getSourceAddress()); + Recipient.externalHighTrustPush(context, envelope.getSourceAddress()); } if (envelope.isReceipt()) { @@ -110,7 +110,7 @@ public class IncomingMessageProcessor { private void processReceipt(@NonNull SignalServiceEnvelope envelope) { Log.i(TAG, String.format(Locale.ENGLISH, "Received receipt: (XXXXX, %d)", envelope.getTimestamp())); - mmsSmsDatabase.incrementDeliveryReceiptCount(new SyncMessageId(Recipient.externalPush(context, envelope.getSourceAddress()).getId(), envelope.getTimestamp()), + mmsSmsDatabase.incrementDeliveryReceiptCount(new SyncMessageId(Recipient.externalHighTrustPush(context, envelope.getSourceAddress()).getId(), envelope.getTimestamp()), System.currentTimeMillis()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java index e7b6378c6e..5036967580 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/UuidMigrationJob.java @@ -74,7 +74,7 @@ public class UuidMigrationJob extends MigrationJob { RecipientId self = Recipient.self().getId(); UUID localUuid = ApplicationDependencies.getSignalServiceAccountManager().getOwnUuid(); - DatabaseFactory.getRecipientDatabase(context).markRegistered(self, localUuid); + DatabaseFactory.getRecipientDatabase(context).markRegisteredOrThrow(self, localUuid); TextSecurePreferences.setLocalUuid(context, localUuid); } 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 588e45d509..20e5adf944 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -142,18 +142,27 @@ public final class LiveRecipient { return updated; } + @WorkerThread + public void refresh() { + refresh(getId()); + } + /** * Forces a reload of the underlying recipient. */ @WorkerThread - public void refresh() { + public void refresh(@NonNull RecipientId id) { + if (!getId().equals(id)) { + Log.w(TAG, "Switching ID from " + getId() + " to " + id); + } + if (getId().isUnknown()) return; if (Util.isMainThread()) { - Log.w(TAG, "[Refresh][MAIN] " + getId(), new Throwable()); + Log.w(TAG, "[Refresh][MAIN] " + id, new Throwable()); } - Recipient recipient = fetchAndCacheRecipientFromDisk(getId()); + Recipient recipient = fetchAndCacheRecipientFromDisk(id); List participants = Stream.of(recipient.getParticipants()) .map(Recipient::getId) .map(this::fetchAndCacheRecipientFromDisk) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 2c6baf980b..c70df20abb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientIdResult; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; @@ -129,7 +130,7 @@ public class Recipient { */ @WorkerThread public static @NonNull Recipient externalUsername(@NonNull Context context, @NonNull UUID uuid, @NonNull String username) { - Recipient recipient = externalPush(context, uuid, null); + Recipient recipient = externalPush(context, uuid, null, false); DatabaseFactory.getRecipientDatabase(context).setUsername(recipient.getId(), username); return recipient; } @@ -137,11 +138,25 @@ public class Recipient { /** * Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress}, * creating one in the database if necessary. Convenience overload of - * {@link #externalPush(Context, UUID, String)} + * {@link #externalPush(Context, UUID, String, boolean)} */ @WorkerThread public static @NonNull Recipient externalPush(@NonNull Context context, @NonNull SignalServiceAddress signalServiceAddress) { - return externalPush(context, signalServiceAddress.getUuid().orNull(), signalServiceAddress.getNumber().orNull()); + return externalPush(context, signalServiceAddress.getUuid().orNull(), signalServiceAddress.getNumber().orNull(), false); + } + + /** + * Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress}, + * creating one in the database if necessary. This should only used for high-trust sources, + * which are limited to: + * - Envelopes + * - UD Certs + * - CDS + * - Storage Service + */ + @WorkerThread + public static @NonNull Recipient externalHighTrustPush(@NonNull Context context, @NonNull SignalServiceAddress signalServiceAddress) { + return externalPush(context, signalServiceAddress.getUuid().orNull(), signalServiceAddress.getNumber().orNull(), true); } /** @@ -152,73 +167,20 @@ public class Recipient { * In particular, while we'll eventually get the UUID of a user created via a phone number * (through a directory sync), the only way we can store the phone number is by retrieving it from * sent messages and whatnot. So we should store it when available. + * + * @param highTrust This should only be set to true if the source of the E164-UUID pairing is one + * that can be trusted as accurate (like an envelope). */ @WorkerThread - public static @NonNull Recipient externalPush(@NonNull Context context, @Nullable UUID uuid, @Nullable String e164) { + public static @NonNull Recipient externalPush(@NonNull Context context, @Nullable UUID uuid, @Nullable String e164, boolean highTrust) { if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { throw new AssertionError(); } - RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); - Optional uuidUser = uuid != null ? db.getByUuid(uuid) : Optional.absent(); - Optional e164User = e164 != null ? db.getByE164(e164) : Optional.absent(); + RecipientDatabase db = DatabaseFactory.getRecipientDatabase(context); + RecipientId recipientId = db.getAndPossiblyMerge(uuid, e164, highTrust); - if (uuidUser.isPresent()) { - Recipient recipient = resolved(uuidUser.get()); - - if (e164 != null && !recipient.getE164().isPresent() && !e164User.isPresent()) { - db.setPhoneNumber(recipient.getId(), e164); - } - - return resolved(recipient.getId()); - } else if (e164User.isPresent()) { - Recipient recipient = resolved(e164User.get()); - - if (uuid != null && !recipient.getUuid().isPresent()) { - db.markRegistered(recipient.getId(), uuid); - } else if (!recipient.isRegistered()) { - db.markRegistered(recipient.getId()); - - if (FeatureFlags.cds()) { - Log.i(TAG, "No UUID! Scheduling a fetch."); - ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(recipient, false)); - } - } - - return resolved(recipient.getId()); - } else if (uuid != null) { - if (FeatureFlags.uuidOnlyContacts() || e164 != null) { - RecipientId id = db.getOrInsertFromUuid(uuid); - db.markRegistered(id, uuid); - - if (e164 != null) { - db.setPhoneNumber(id, e164); - } - - return resolved(id); - } else { - if (!FeatureFlags.uuidOnlyContacts() && FeatureFlags.groupsV2()) { - throw new RuntimeException(new UuidRecipientError()); - } else { - throw new UuidRecipientError(); - } - } - } else if (e164 != null) { - Recipient recipient = resolved(db.getOrInsertFromE164(e164)); - - if (!recipient.isRegistered()) { - db.markRegistered(recipient.getId()); - - if (FeatureFlags.cds()) { - Log.i(TAG, "No UUID! Scheduling a fetch."); - ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(recipient, false)); - } - } - - return resolved(recipient.getId()); - } else { - throw new AssertionError("You must provide either a UUID or phone number!"); - } + return resolved(recipientId); } /** @@ -259,7 +221,7 @@ public class Recipient { * or serialized groupId. * * If the identifier is a UUID of a Signal user, prefer using - * {@link #externalPush(Context, UUID, String)} or its overload, as this will let us associate + * {@link #externalPush(Context, UUID, String, boolean)} or its overload, as this will let us associate * the phone number with the recipient. */ @WorkerThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java index dd0e56ae41..b881f2302a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.recipients; import android.annotation.SuppressLint; +import android.content.Context; import android.os.Parcel; import android.os.Parcelable; @@ -49,7 +50,16 @@ public class RecipientId implements Parcelable, Comparable { @AnyThread public static @NonNull RecipientId from(@NonNull SignalServiceAddress address) { - return from(address.getUuid().orNull(), address.getNumber().orNull()); + return from(address.getUuid().orNull(), address.getNumber().orNull(), false); + } + + /** + * Indicates that the pairing is from a high-trust source. + * See {@link Recipient#externalHighTrustPush(Context, SignalServiceAddress)} + */ + @AnyThread + public static @NonNull RecipientId fromHighTrust(@NonNull SignalServiceAddress address) { + return from(address.getUuid().orNull(), address.getNumber().orNull(), true); } /** @@ -58,15 +68,26 @@ public class RecipientId implements Parcelable, Comparable { @AnyThread @SuppressLint("WrongThread") public static @NonNull RecipientId from(@Nullable UUID uuid, @Nullable String e164) { + return from(uuid, e164, false); + } + + @AnyThread + @SuppressLint("WrongThread") + private static @NonNull RecipientId from(@Nullable UUID uuid, @Nullable String e164, boolean highTrust) { RecipientId recipientId = RecipientIdCache.INSTANCE.get(uuid, e164); if (recipientId == null) { - recipientId = Recipient.externalPush(ApplicationDependencies.getApplication(), uuid, e164).getId(); + recipientId = Recipient.externalPush(ApplicationDependencies.getApplication(), uuid, e164, highTrust).getId(); } return recipientId; } + @AnyThread + public static void clearCache() { + RecipientIdCache.INSTANCE.clear(); + } + private RecipientId(long id) { this.id = id; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java index 7c60c1b646..f388d6fb1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientIdCache.java @@ -70,4 +70,8 @@ final class RecipientIdCache { return null; } + + synchronized void clear() { + ids.clear(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java index b6bd280e84..44f0c16fcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java @@ -240,7 +240,7 @@ public final class CodeVerificationRequest { RecipientId selfId = recipientDatabase.getOrInsertFromE164(credentials.getE164number()); recipientDatabase.setProfileSharing(selfId, true); - recipientDatabase.markRegistered(selfId, uuid); + recipientDatabase.markRegisteredOrThrow(selfId, uuid); TextSecurePreferences.setLocalNumber(context, credentials.getE164number()); TextSecurePreferences.setLocalUuid(context, uuid); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index d9bb268758..b0738ffd57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -101,15 +101,8 @@ public class MessageSender { Recipient recipient = message.getRecipient(); boolean keyExchange = message.isKeyExchange(); - long allocatedThreadId; - - if (threadId == -1) { - allocatedThreadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); - } else { - allocatedThreadId = threadId; - } - - long messageId = database.insertMessageOutbox(allocatedThreadId, message, forceSms, System.currentTimeMillis(), insertListener); + long allocatedThreadId = DatabaseFactory.getThreadDatabase(context).getOrCreateValidThreadId(recipient, threadId); + long messageId = database.insertMessageOutbox(allocatedThreadId, message, forceSms, System.currentTimeMillis(), insertListener); sendTextMessage(context, recipient, forceSms, keyExchange, messageId); onMessageSent(); @@ -127,16 +120,9 @@ public class MessageSender { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); MmsDatabase database = DatabaseFactory.getMmsDatabase(context); - long allocatedThreadId; - - if (threadId == -1) { - allocatedThreadId = threadDatabase.getThreadIdFor(message.getRecipient(), message.getDistributionType()); - } else { - allocatedThreadId = threadId; - } - - Recipient recipient = message.getRecipient(); - long messageId = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener); + long allocatedThreadId = threadDatabase.getOrCreateValidThreadId(message.getRecipient(), threadId, message.getDistributionType()); + Recipient recipient = message.getRecipient(); + long messageId = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener); sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList()); onMessageSent(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java index 5d4490ba63..c0149dd014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java @@ -7,6 +7,9 @@ import androidx.annotation.NonNull; import net.sqlcipher.database.SQLiteDatabase; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -39,6 +42,22 @@ public final class SqlUtil { return false; } + public static String[] buildArgs(Object... objects) { + String[] args = new String[objects.length]; + + for (int i = 0; i < objects.length; i++) { + if (objects[i] == null) { + throw new NullPointerException("Cannot have null arg!"); + } else if (objects[i] instanceof RecipientId) { + args[i] = ((RecipientId) objects[i]).serialize(); + } else { + args[i] = objects[i].toString(); + } + } + + return args; + } + /** * Returns an updated query and args pairing that will only update rows that would *actually* * change. In other words, if {@link SQLiteDatabase#update(String, ContentValues, String, String[])} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java index fc912b606f..b8013853dc 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/SignalServiceAddress.java @@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.push; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.util.OptionalUtil; import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.UUID; @@ -43,8 +44,7 @@ public class SignalServiceAddress { * Convenience constructor that will consider a UUID/E164 string absent if it is null or empty. */ public SignalServiceAddress(UUID uuid, String e164) { - this(Optional.fromNullable(uuid), - e164 != null && !e164.isEmpty() ? Optional.of(e164) : Optional.absent()); + this(Optional.fromNullable(uuid), OptionalUtil.absentIfEmpty(e164)); } public SignalServiceAddress(Optional uuid, Optional e164) {