Signal-Android/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java

2952 lines
124 KiB
Java

package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteConstraintException;
import net.sqlcipher.database.SQLiteDatabase;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate;
import org.thoughtcrime.securesms.storage.StorageSyncModels;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.StringUtil;
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;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
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.UuidUtil;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
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;
public class RecipientDatabase extends Database {
private static final String TAG = RecipientDatabase.class.getSimpleName();
static final String TABLE_NAME = "recipient";
public static final String ID = "_id";
private static final String UUID = "uuid";
private static final String USERNAME = "username";
public static final String PHONE = "phone";
public static final String EMAIL = "email";
static final String GROUP_ID = "group_id";
private static final String GROUP_TYPE = "group_type";
private static final String BLOCKED = "blocked";
private static final String MESSAGE_RINGTONE = "message_ringtone";
private static final String MESSAGE_VIBRATE = "message_vibrate";
private static final String CALL_RINGTONE = "call_ringtone";
private static final String CALL_VIBRATE = "call_vibrate";
private static final String NOTIFICATION_CHANNEL = "notification_channel";
private static final String MUTE_UNTIL = "mute_until";
private static final String COLOR = "color";
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id";
private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time";
public static final String REGISTERED = "registered";
public static final String SYSTEM_DISPLAY_NAME = "system_display_name";
private static final String SYSTEM_PHOTO_URI = "system_photo_uri";
public static final String SYSTEM_PHONE_TYPE = "system_phone_type";
public static final String SYSTEM_PHONE_LABEL = "system_phone_label";
private static final String SYSTEM_CONTACT_URI = "system_contact_uri";
private static final String SYSTEM_INFO_PENDING = "system_info_pending";
private static final String PROFILE_KEY = "profile_key";
private static final String PROFILE_KEY_CREDENTIAL = "profile_key_credential";
private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar";
private static final String PROFILE_SHARING = "profile_sharing";
private static final String LAST_PROFILE_FETCH = "last_profile_fetch";
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
private static final String UUID_CAPABILITY = "uuid_supported";
private static final String GROUPS_V2_CAPABILITY = "gv2_capability";
private static final String STORAGE_SERVICE_ID = "storage_service_key";
private static final String DIRTY = "dirty";
private static final String PROFILE_GIVEN_NAME = "signal_profile_name";
private static final String PROFILE_FAMILY_NAME = "profile_family_name";
private static final String PROFILE_JOINED_NAME = "profile_joined_name";
private static final String MENTION_SETTING = "mention_setting";
private static final String STORAGE_PROTO = "storage_proto";
public static final String SEARCH_PROFILE_NAME = "search_signal_profile";
private static final String SORT_NAME = "sort_name";
private static final String IDENTITY_STATUS = "identity_status";
private static final String IDENTITY_KEY = "identity_key";
private static final String[] RECIPIENT_PROJECTION = new String[] {
ID, UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE,
BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED,
PROFILE_KEY, PROFILE_KEY_CREDENTIAL,
SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI,
PROFILE_GIVEN_NAME, PROFILE_FAMILY_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, LAST_PROFILE_FETCH,
NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION,
UUID_CAPABILITY, GROUPS_V2_CAPABILITY,
STORAGE_SERVICE_ID, DIRTY,
MENTION_SETTING
};
private static final String[] ID_PROJECTION = new String[]{ID};
private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ") AS " + SORT_NAME};
public static final String[] SEARCH_PROJECTION_NAMES = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, SEARCH_PROFILE_NAME, SORT_NAME};
private static final String[] TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
.map(columnName -> TABLE_NAME + "." + columnName)
.toList().toArray(new String[0]);
static final String[] TYPED_RECIPIENT_PROJECTION_NO_ID = Arrays.copyOfRange(TYPED_RECIPIENT_PROJECTION, 1, TYPED_RECIPIENT_PROJECTION.length);
private static final String[] MENTION_SEARCH_PROJECTION = new String[]{ID, removeWhitespace("COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ", " + nullIfEmpty(PHONE) + ")") + " AS " + SORT_NAME};
public static final String[] CREATE_INDEXS = new String[] {
"CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");",
"CREATE INDEX IF NOT EXISTS recipient_group_type_index ON " + TABLE_NAME + " (" + GROUP_TYPE + ");",
};
public enum VibrateState {
DEFAULT(0), ENABLED(1), DISABLED(2);
private final int id;
VibrateState(int id) {
this.id = id;
}
public int getId() {
return id;
}
public static VibrateState fromId(int id) {
return values()[id];
}
public static VibrateState fromBoolean(boolean enabled) {
return enabled ? ENABLED : DISABLED;
}
}
public enum RegisteredState {
UNKNOWN(0), REGISTERED(1), NOT_REGISTERED(2);
private final int id;
RegisteredState(int id) {
this.id = id;
}
public int getId() {
return id;
}
public static RegisteredState fromId(int id) {
return values()[id];
}
}
public enum UnidentifiedAccessMode {
UNKNOWN(0), DISABLED(1), ENABLED(2), UNRESTRICTED(3);
private final int mode;
UnidentifiedAccessMode(int mode) {
this.mode = mode;
}
public int getMode() {
return mode;
}
public static UnidentifiedAccessMode fromMode(int mode) {
return values()[mode];
}
}
public enum InsightsBannerTier {
NO_TIER(0), TIER_ONE(1), TIER_TWO(2);
private final int id;
InsightsBannerTier(int id) {
this.id = id;
}
public int getId() {
return id;
}
public boolean seen(InsightsBannerTier tier) {
return tier.getId() <= id;
}
public static InsightsBannerTier fromId(int id) {
return values()[id];
}
}
public enum DirtyState {
CLEAN(0), UPDATE(1), INSERT(2), DELETE(3);
private final int id;
DirtyState(int id) {
this.id = id;
}
int getId() {
return id;
}
public static DirtyState fromId(int id) {
return values()[id];
}
}
public enum GroupType {
NONE(0), MMS(1), SIGNAL_V1(2), SIGNAL_V2(3);
private final int id;
GroupType(int id) {
this.id = id;
}
int getId() {
return id;
}
public static GroupType fromId(int id) {
return values()[id];
}
}
public enum MentionSetting {
ALWAYS_NOTIFY(0), DO_NOT_NOTIFY(1);
private final int id;
MentionSetting(int id) {
this.id = id;
}
int getId() {
return id;
}
public static MentionSetting fromId(int id) {
return values()[id];
}
}
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " +
USERNAME + " TEXT UNIQUE DEFAULT NULL, " +
PHONE + " TEXT UNIQUE DEFAULT NULL, " +
EMAIL + " TEXT UNIQUE DEFAULT NULL, " +
GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " +
GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " +
BLOCKED + " INTEGER DEFAULT 0," +
MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " +
MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
CALL_RINGTONE + " TEXT DEFAULT NULL, " +
CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " +
MUTE_UNTIL + " INTEGER DEFAULT 0, " +
COLOR + " TEXT DEFAULT NULL, " +
SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " +
DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " +
REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " +
SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " +
SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " +
SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " +
SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " +
SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " +
SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " +
PROFILE_KEY + " TEXT DEFAULT NULL, " +
PROFILE_KEY_CREDENTIAL + " TEXT DEFAULT NULL, " +
PROFILE_GIVEN_NAME + " TEXT DEFAULT NULL, " +
PROFILE_FAMILY_NAME + " TEXT DEFAULT NULL, " +
PROFILE_JOINED_NAME + " TEXT DEFAULT NULL, " +
SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
PROFILE_SHARING + " INTEGER DEFAULT 0, " +
LAST_PROFILE_FETCH + " INTEGER DEFAULT 0, " +
UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " +
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " +
GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " +
STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " +
DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " +
MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.ALWAYS_NOTIFY.getId() + ", " +
STORAGE_PROTO + " TEXT DEFAULT NULL);";
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
" FROM " + TABLE_NAME +
" INNER JOIN " + ThreadDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID +
" WHERE " +
TABLE_NAME + "." + GROUP_ID + " IS NULL AND " +
TABLE_NAME + "." + REGISTERED + " = " + RegisteredState.NOT_REGISTERED.id + " AND " +
TABLE_NAME + "." + SEEN_INVITE_REMINDER + " < " + InsightsBannerTier.TIER_TWO.id + " AND " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.HAS_SENT + " AND " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " > ?" +
" ORDER BY " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC LIMIT 50";
public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public @NonNull boolean containsPhoneOrUuid(@NonNull String id) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = UUID + " = ? OR " + PHONE + " = ?";
String[] args = new String[]{id, id};
try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, query, args, null, null, null)) {
return cursor != null && cursor.moveToFirst();
}
}
public @NonNull Optional<RecipientId> getByE164(@NonNull String e164) {
return getByColumn(PHONE, e164);
}
public @NonNull Optional<RecipientId> getByEmail(@NonNull String email) {
return getByColumn(EMAIL, email);
}
public @NonNull Optional<RecipientId> getByGroupId(@NonNull String groupId) {
return getByColumn(GROUP_ID, groupId);
}
public @NonNull Optional<RecipientId> getByUuid(@NonNull UUID uuid) {
return getByColumn(UUID, uuid.toString());
}
public @NonNull Optional<RecipientId> getByUsername(@NonNull String username) {
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<RecipientId, RecipientId> remapped = null;
boolean transactionSuccessful = false;
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
Optional<RecipientId> byE164 = e164 != null ? getByE164(e164) : Optional.absent();
Optional<RecipientId> 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;
}
public @NonNull RecipientId getOrInsertFromE164(@NonNull String e164) {
return getOrInsertByColumn(PHONE, e164).recipientId;
}
public @NonNull RecipientId getOrInsertFromEmail(@NonNull String email) {
return getOrInsertByColumn(EMAIL, email).recipientId;
}
public @NonNull RecipientId getOrInsertFromGroupId(@NonNull GroupId groupId) {
GetOrInsertResult result = getOrInsertByColumn(GROUP_ID, groupId.toString());
if (result.neededInsert) {
ContentValues values = new ContentValues();
if (groupId.isMms()) {
values.put(GROUP_TYPE, GroupType.MMS.getId());
} else {
if (groupId.isV2()) {
values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId());
} else {
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
}
values.put(DIRTY, DirtyState.INSERT.getId());
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()));
}
update(result.recipientId, values);
}
return result.recipientId;
}
public Cursor getBlocked() {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
return database.query(TABLE_NAME, ID_PROJECTION, BLOCKED + " = 1",
null, null, null, null, null);
}
public RecipientReader readerForBlocked(Cursor cursor) {
return new RecipientReader(cursor);
}
public RecipientReader getRecipientsWithNotificationChannels() {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.query(TABLE_NAME, ID_PROJECTION, NOTIFICATION_CHANNEL + " NOT NULL",
null, null, null, null, null);
return new RecipientReader(cursor);
}
public @NonNull RecipientSettings getRecipientSettings(@NonNull RecipientId id) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String query = ID + " = ?";
String[] args = new String[] { id.serialize() };
try (Cursor cursor = database.query(TABLE_NAME, RECIPIENT_PROJECTION, query, args, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return getRecipientSettings(context, cursor);
} else {
Optional<RecipientId> 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);
}
}
}
}
public @NonNull DirtyState getDirtyState(@NonNull RecipientId recipientId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, new String[] { DIRTY }, ID_WHERE, new String[] { recipientId.serialize() }, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return DirtyState.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(DIRTY)));
}
}
return DirtyState.CLEAN;
}
public @Nullable RecipientSettings getRecipientSettingsForSync(@NonNull RecipientId id) {
String query = TABLE_NAME + "." + ID + " = ?";
String[] args = new String[]{id.serialize()};
List<RecipientSettings> recipientSettingsForSync = getRecipientSettingsForSync(query, args);
if (recipientSettingsForSync.isEmpty()) {
return null;
}
if (recipientSettingsForSync.size() > 1) {
throw new AssertionError();
}
return recipientSettingsForSync.get(0);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncUpdates() {
String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()), Recipient.self().getId().serialize() };
return getRecipientSettingsForSync(query, args);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncInsertions() {
String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()), Recipient.self().getId().serialize() };
return getRecipientSettingsForSync(query, args);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncDeletions() {
String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()), Recipient.self().getId().serialize() };
return getRecipientSettingsForSync(query, args);
}
public @Nullable RecipientSettings getByStorageId(@NonNull byte[] storageId) {
List<RecipientSettings> result = getRecipientSettingsForSync(TABLE_NAME + "." + STORAGE_SERVICE_ID + " = ?", new String[] { Base64.encodeBytes(storageId) });
if (result.size() > 0) {
return result.get(0);
}
return null;
}
public void markNeedsSync(@NonNull Collection<RecipientId> recipientIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (RecipientId recipientId : recipientIds) {
markDirty(recipientId, DirtyState.UPDATE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public void markNeedsSync(@NonNull RecipientId recipientId) {
markDirty(recipientId, DirtyState.UPDATE);
}
public void applyStorageIdUpdates(@NonNull Map<RecipientId, StorageId> storageIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
String query = ID + " = ?";
for (Map.Entry<RecipientId, StorageId> entry : storageIds.entrySet()) {
ContentValues values = new ContentValues();
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() });
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
for (RecipientId id : storageIds.keySet()) {
Recipient.live(id).refresh();
}
}
public void applyStorageSyncUpdates(@NonNull Collection<SignalContactRecord> contactInserts,
@NonNull Collection<RecordUpdate<SignalContactRecord>> contactUpdates,
@NonNull Collection<SignalGroupV1Record> groupV1Inserts,
@NonNull Collection<RecordUpdate<SignalGroupV1Record>> groupV1Updates,
@NonNull Collection<SignalGroupV2Record> groupV2Inserts,
@NonNull Collection<RecordUpdate<SignalGroupV2Record>> groupV2Updates)
{
SQLiteDatabase db = databaseHelper.getWritableDatabase();
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
Set<RecipientId> needsRefresh = new HashSet<>();
db.beginTransaction();
try {
for (SignalContactRecord insert : contactInserts) {
ContentValues values = getValuesForStorageContact(insert, true);
long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
RecipientId recipientId = null;
if (id < 0) {
values = 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()) {
try {
int count = db.update(TABLE_NAME, values, PHONE + " = ?", new String[] { insert.getAddress().getNumber().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);
}
}
if (recipientId == null && insert.getAddress().getUuid().isPresent()) {
try {
int count = db.update(TABLE_NAME, values, UUID + " = ?", new String[] { insert.getAddress().getUuid().get().toString() });
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);
}
}
if (recipientId == null && insert.getAddress().getNumber().isPresent()) {
recipientId = getByE164(insert.getAddress().getNumber().get()).orNull();
}
if (recipientId == null && insert.getAddress().getUuid().isPresent()) {
recipientId = getByUuid(insert.getAddress().getUuid().get()).orNull();
}
if (recipientId == null) {
Log.w(TAG, "Failed to recover from a failed insert!");
continue;
}
} 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.applyStorageSyncUpdate(recipientId, insert);
needsRefresh.add(recipientId);
}
for (RecordUpdate<SignalContactRecord> update : contactUpdates) {
ContentValues values = getValuesForStorageContact(update.getNew(), false);
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());
if (StorageSyncHelper.profileKeyChanged(update)) {
clearProfileKeyCredential(recipientId);
}
try {
Optional<IdentityRecord> oldIdentityRecord = identityDatabase.getIdentity(recipientId);
if (update.getNew().getIdentityKey().isPresent()) {
IdentityKey identityKey = new IdentityKey(update.getNew().getIdentityKey().get(), 0);
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.getNew().getIdentityState()));
}
Optional<IdentityRecord> newIdentityRecord = identityDatabase.getIdentity(recipientId);
if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED) &&
(!oldIdentityRecord.isPresent() || oldIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED))
{
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
} else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED) &&
(oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED))
{
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), false, true);
}
} catch (InvalidKeyException e) {
Log.w(TAG, "Failed to process identity key during update! Skipping.", e);
}
threadDatabase.applyStorageSyncUpdate(recipientId, update.getNew());
needsRefresh.add(recipientId);
}
for (SignalGroupV1Record insert : groupV1Inserts) {
db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert));
Recipient recipient = Recipient.externalGroup(context, GroupId.v1orThrow(insert.getGroupId()));
threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert);
needsRefresh.add(recipient.getId());
}
for (RecordUpdate<SignalGroupV1Record> update : groupV1Updates) {
ContentValues values = getValuesForStorageGroupV1(update.getNew());
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!");
}
Recipient recipient = Recipient.externalGroup(context, GroupId.v1orThrow(update.getOld().getGroupId()));
threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew());
needsRefresh.add(recipient.getId());
}
for (SignalGroupV2Record insert : groupV2Inserts) {
GroupMasterKey masterKey = insert.getMasterKeyOrThrow();
GroupId.V2 groupId = GroupId.v2(masterKey);
ContentValues values = getValuesForStorageGroupV2(insert);
long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
Recipient recipient = Recipient.externalGroup(context, groupId);
if (id < 0) {
Log.w(TAG, String.format("Recipient %s is already linked to group %s", recipient.getId(), groupId));
} else {
Log.i(TAG, String.format("Inserted recipient %s for group %s", recipient.getId(), groupId));
}
Log.i(TAG, "Creating restore placeholder for " + groupId);
DatabaseFactory.getGroupDatabase(context)
.create(masterKey,
DecryptedGroup.newBuilder()
.setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION)
.build());
Log.i(TAG, "Scheduling request for latest group info for " + groupId);
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId));
threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert);
needsRefresh.add(recipient.getId());
}
for (RecordUpdate<SignalGroupV2Record> update : groupV2Updates) {
ContentValues values = getValuesForStorageGroupV2(update.getNew());
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!");
}
GroupMasterKey masterKey = update.getOld().getMasterKeyOrThrow();
Recipient recipient = Recipient.externalGroup(context, GroupId.v2(masterKey));
threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew());
needsRefresh.add(recipient.getId());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
for (RecipientId id : needsRefresh) {
Recipient.live(id).refresh();
}
}
public void applyStorageSyncUpdates(@NonNull StorageId storageId, SignalAccountRecord update) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
ProfileName profileName = ProfileName.fromParts(update.getGivenName().orNull(), update.getFamilyName().orNull());
Optional<ProfileKey> localKey = ProfileKeyUtil.profileKeyOptional(Recipient.self().getProfileKey());
Optional<ProfileKey> remoteKey = ProfileKeyUtil.profileKeyOptional(update.getProfileKey().orNull());
String profileKey = remoteKey.or(localKey).transform(ProfileKey::serialize).transform(Base64::encodeBytes).orNull();
if (!remoteKey.isPresent()) {
Log.w(TAG, "Got an empty profile key while applying an account record update!");
}
values.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName());
values.put(PROFILE_JOINED_NAME, profileName.toString());
values.put(PROFILE_KEY, profileKey);
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getId().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
if (update.hasUnknownFields()) {
values.put(STORAGE_PROTO, Base64.encodeBytes(update.serializeUnknownFields()));
} else {
values.putNull(STORAGE_PROTO);
}
int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(storageId.getRaw())});
if (updateCount < 1) {
throw new AssertionError("Account update didn't match any rows!");
}
if (!remoteKey.equals(localKey)) {
ApplicationDependencies.getJobManager().add(new RefreshAttributesJob());
}
DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(Recipient.self().getId(), update);
Recipient.self().live().refresh();
}
public void updatePhoneNumbers(@NonNull Map<String, String> mapping) {
if (mapping.isEmpty()) return;
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
String query = PHONE + " = ?";
for (Map.Entry<String, String> entry : mapping.entrySet()) {
ContentValues values = new ContentValues();
values.put(PHONE, entry.getValue());
db.updateWithOnConflict(TABLE_NAME, values, query, new String[] { entry.getKey() }, SQLiteDatabase.CONFLICT_IGNORE);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
private @NonNull RecipientId getByStorageKeyOrThrow(byte[] storageKey) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = STORAGE_SERVICE_ID + " = ?";
String[] args = new String[]{Base64.encodeBytes(storageKey)};
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
return RecipientId.from(id);
} else {
throw new AssertionError("No recipient with that storage key!");
}
}
}
private static @NonNull ContentValues getValuesForStorageContact(@NonNull SignalContactRecord contact, boolean isInsert) {
ContentValues values = new ContentValues();
if (contact.getAddress().getUuid().isPresent()) {
values.put(UUID, contact.getAddress().getUuid().get().toString());
}
ProfileName profileName = ProfileName.fromParts(contact.getGivenName().orNull(), contact.getFamilyName().orNull());
String username = contact.getUsername().orNull();
values.put(PHONE, contact.getAddress().getNumber().orNull());
values.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName());
values.put(PROFILE_JOINED_NAME, profileName.toString());
values.put(PROFILE_KEY, contact.getProfileKey().transform(Base64::encodeBytes).orNull());
values.put(USERNAME, TextUtils.isEmpty(username) ? null : username);
values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0");
values.put(BLOCKED, contact.isBlocked() ? "1" : "0");
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(contact.getId().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
if (contact.isProfileSharingEnabled() && isInsert && !profileName.isEmpty()) {
values.put(COLOR, ContactColors.generateFor(profileName.toString()).serialize());
}
if (contact.hasUnknownFields()) {
values.put(STORAGE_PROTO, Base64.encodeBytes(contact.serializeUnknownFields()));
} else {
values.putNull(STORAGE_PROTO);
}
return values;
}
private static @NonNull ContentValues getValuesForStorageGroupV1(@NonNull SignalGroupV1Record groupV1) {
ContentValues values = new ContentValues();
values.put(GROUP_ID, GroupId.v1orThrow(groupV1.getGroupId()).toString());
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
values.put(PROFILE_SHARING, groupV1.isProfileSharingEnabled() ? "1" : "0");
values.put(BLOCKED, groupV1.isBlocked() ? "1" : "0");
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV1.getId().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
if (groupV1.hasUnknownFields()) {
values.put(STORAGE_PROTO, Base64.encodeBytes(groupV1.serializeUnknownFields()));
} else {
values.putNull(STORAGE_PROTO);
}
return values;
}
private static @NonNull ContentValues getValuesForStorageGroupV2(@NonNull SignalGroupV2Record groupV2) {
ContentValues values = new ContentValues();
values.put(GROUP_ID, GroupId.v2(groupV2.getMasterKeyOrThrow()).toString());
values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId());
values.put(PROFILE_SHARING, groupV2.isProfileSharingEnabled() ? "1" : "0");
values.put(BLOCKED, groupV2.isBlocked() ? "1" : "0");
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV2.getId().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
if (groupV2.hasUnknownFields()) {
values.put(STORAGE_PROTO, Base64.encodeBytes(groupV2.serializeUnknownFields()));
} else {
values.putNull(STORAGE_PROTO);
}
return values;
}
private List<RecipientSettings> getRecipientSettingsForSync(@Nullable String query, @Nullable String[] args) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID
+ " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + GROUP_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID
+ " LEFT OUTER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID;
List<RecipientSettings> out = new ArrayList<>();
String[] columns = Stream.of(TYPED_RECIPIENT_PROJECTION,
new String[]{ RecipientDatabase.TABLE_NAME + "." + STORAGE_PROTO,
GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY,
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ARCHIVED,
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.READ,
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.VERIFIED + " AS " + IDENTITY_STATUS,
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.IDENTITY_KEY + " AS " + IDENTITY_KEY })
.flatMap(Stream::of)
.toArray(String[]::new);
try (Cursor cursor = db.query(table, columns, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
out.add(getRecipientSettings(context, cursor));
}
}
return out;
}
/**
* @return All storage ids for ContactRecords, excluding the ones that need to be deleted.
*/
public List<StorageId> getContactStorageSyncIds() {
return new ArrayList<>(getContactStorageSyncIdsMap().values());
}
/**
* @return All storage IDs for ContactRecords, excluding the ones that need to be deleted.
*/
public @NonNull Map<RecipientId, StorageId> getContactStorageSyncIdsMap() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " != ? AND " + ID + " != ? AND " + GROUP_TYPE + " != ?";
String[] args = { String.valueOf(DirtyState.DELETE.getId()), Recipient.self().getId().serialize(), String.valueOf(GroupType.SIGNAL_V2.getId()) };
Map<RecipientId, StorageId> out = new HashMap<>();
try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_ID, GROUP_TYPE }, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)));
String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_ID));
GroupType groupType = GroupType.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE)));
byte[] key = Base64.decodeOrThrow(encodedKey);
switch (groupType) {
case NONE : out.put(id, StorageId.forContact(key)); break;
case SIGNAL_V1 : out.put(id, StorageId.forGroupV1(key)); break;
default : throw new AssertionError();
}
}
}
for (GroupId.V2 id : DatabaseFactory.getGroupDatabase(context).getAllGroupV2Ids()) {
Recipient recipient = Recipient.externalGroup(context, id);
RecipientId recipientId = recipient.getId();
RecipientSettings recipientSettingsForSync = getRecipientSettingsForSync(recipientId);
if (recipientSettingsForSync == null) {
throw new AssertionError();
}
byte[] key = recipientSettingsForSync.storageId;
if (key == null) {
throw new AssertionError();
}
out.put(recipientId, StorageId.forGroupV2(key));
}
return out;
}
static @NonNull RecipientSettings getRecipientSettings(@NonNull Context context, @NonNull Cursor cursor) {
long id = CursorUtil.requireLong(cursor, ID);
UUID uuid = UuidUtil.parseOrNull(CursorUtil.requireString(cursor, UUID));
String username = CursorUtil.requireString(cursor, USERNAME);
String e164 = CursorUtil.requireString(cursor, PHONE);
String email = CursorUtil.requireString(cursor, EMAIL);
GroupId groupId = GroupId.parseNullableOrThrow(CursorUtil.requireString(cursor, GROUP_ID));
int groupType = CursorUtil.requireInt(cursor, GROUP_TYPE);
boolean blocked = CursorUtil.requireBoolean(cursor, BLOCKED);
String messageRingtone = CursorUtil.requireString(cursor, MESSAGE_RINGTONE);
String callRingtone = CursorUtil.requireString(cursor, CALL_RINGTONE);
int messageVibrateState = CursorUtil.requireInt(cursor, MESSAGE_VIBRATE);
int callVibrateState = CursorUtil.requireInt(cursor, CALL_VIBRATE);
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
String serializedColor = CursorUtil.requireString(cursor, COLOR);
int insightsBannerTier = CursorUtil.requireInt(cursor, SEEN_INVITE_REMINDER);
int defaultSubscriptionId = CursorUtil.requireInt(cursor, DEFAULT_SUBSCRIPTION_ID);
int expireMessages = CursorUtil.requireInt(cursor, MESSAGE_EXPIRATION_TIME);
int registeredState = CursorUtil.requireInt(cursor, REGISTERED);
String profileKeyString = CursorUtil.requireString(cursor, PROFILE_KEY);
String profileKeyCredentialString = CursorUtil.requireString(cursor, PROFILE_KEY_CREDENTIAL);
String systemDisplayName = CursorUtil.requireString(cursor, SYSTEM_DISPLAY_NAME);
String systemContactPhoto = CursorUtil.requireString(cursor, SYSTEM_PHOTO_URI);
String systemPhoneLabel = CursorUtil.requireString(cursor, SYSTEM_PHONE_LABEL);
String systemContactUri = CursorUtil.requireString(cursor, SYSTEM_CONTACT_URI);
String profileGivenName = CursorUtil.requireString(cursor, PROFILE_GIVEN_NAME);
String profileFamilyName = CursorUtil.requireString(cursor, PROFILE_FAMILY_NAME);
String signalProfileAvatar = CursorUtil.requireString(cursor, SIGNAL_PROFILE_AVATAR);
boolean profileSharing = CursorUtil.requireBoolean(cursor, PROFILE_SHARING);
long lastProfileFetch = cursor.getLong(cursor.getColumnIndexOrThrow(LAST_PROFILE_FETCH));
String notificationChannel = CursorUtil.requireString(cursor, NOTIFICATION_CHANNEL);
int unidentifiedAccessMode = CursorUtil.requireInt(cursor, UNIDENTIFIED_ACCESS_MODE);
boolean forceSmsSelection = CursorUtil.requireBoolean(cursor, FORCE_SMS_SELECTION);
int uuidCapabilityValue = CursorUtil.requireInt(cursor, UUID_CAPABILITY);
int groupsV2CapabilityValue = CursorUtil.requireInt(cursor, GROUPS_V2_CAPABILITY);
String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID);
int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING);
MaterialColor color;
byte[] profileKey = null;
byte[] profileKeyCredential = null;
try {
color = serializedColor == null ? null : MaterialColor.fromSerialized(serializedColor);
} catch (MaterialColor.UnknownColorException e) {
Log.w(TAG, e);
color = null;
}
if (profileKeyString != null) {
try {
profileKey = Base64.decode(profileKeyString);
} catch (IOException e) {
Log.w(TAG, e);
profileKey = null;
}
if (profileKeyCredentialString != null) {
try {
profileKeyCredential = Base64.decode(profileKeyCredentialString);
} catch (IOException e) {
Log.w(TAG, e);
profileKeyCredential = null;
}
}
}
byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null;
return new RecipientSettings(RecipientId.from(id),
uuid,
username,
e164,
email,
groupId,
GroupType.fromId(groupType),
blocked,
muteUntil,
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone),
Util.uri(callRingtone),
color,
defaultSubscriptionId,
expireMessages,
RegisteredState.fromId(registeredState),
profileKey,
profileKeyCredential,
systemDisplayName,
systemContactPhoto,
systemPhoneLabel,
systemContactUri,
ProfileName.fromParts(profileGivenName, profileFamilyName),
signalProfileAvatar,
AvatarHelper.hasAvatar(context, RecipientId.from(id)),
profileSharing,
lastProfileFetch,
notificationChannel,
UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection,
Recipient.Capability.deserialize(uuidCapabilityValue),
Recipient.Capability.deserialize(groupsV2CapabilityValue),
InsightsBannerTier.fromId(insightsBannerTier),
storageKey,
MentionSetting.fromId(mentionSettingId),
getSyncExtras(cursor));
}
private static @NonNull RecipientSettings.SyncExtras getSyncExtras(@NonNull Cursor cursor) {
String storageProtoRaw = CursorUtil.getString(cursor, STORAGE_PROTO).orNull();
byte[] storageProto = storageProtoRaw != null ? Base64.decodeOrThrow(storageProtoRaw) : null;
boolean archived = CursorUtil.getBoolean(cursor, ThreadDatabase.ARCHIVED).or(false);
boolean forcedUnread = CursorUtil.getInt(cursor, ThreadDatabase.READ).transform(status -> status == ThreadDatabase.ReadStatus.FORCED_UNREAD.serialize()).or(false);
GroupMasterKey groupMasterKey = CursorUtil.getBlob(cursor, GroupDatabase.V2_MASTER_KEY).transform(GroupUtil::requireMasterKey).orNull();
byte[] identityKey = CursorUtil.getString(cursor, IDENTITY_KEY).transform(Base64::decodeOrThrow).orNull();
VerifiedStatus identityStatus = CursorUtil.getInt(cursor, IDENTITY_STATUS).transform(VerifiedStatus::forState).or(VerifiedStatus.DEFAULT);
return new RecipientSettings.SyncExtras(storageProto, groupMasterKey, identityKey, identityStatus, archived, forcedUnread);
}
public BulkOperationsHandle beginBulkSystemContactUpdate() {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.beginTransaction();
ContentValues contentValues = new ContentValues(1);
contentValues.put(SYSTEM_INFO_PENDING, 1);
database.update(TABLE_NAME, contentValues, SYSTEM_CONTACT_URI + " NOT NULL", null);
return new BulkOperationsHandle(database);
}
public void setColor(@NonNull RecipientId id, @NonNull MaterialColor color) {
ContentValues values = new ContentValues();
values.put(COLOR, color.serialize());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setColorIfNotSet(@NonNull RecipientId id, @NonNull MaterialColor color) {
if (setColorIfNotSetInternal(id, color)) {
Recipient.live(id).refresh();
}
}
private boolean setColorIfNotSetInternal(@NonNull RecipientId id, @NonNull MaterialColor color) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String query = ID + " = ? AND " + COLOR + " IS NULL";
String[] args = new String[]{ id.serialize() };
ContentValues values = new ContentValues();
values.put(COLOR, color.serialize());
return db.update(TABLE_NAME, values, query, args) > 0;
}
public void setDefaultSubscriptionId(@NonNull RecipientId id, int defaultSubscriptionId) {
ContentValues values = new ContentValues();
values.put(DEFAULT_SUBSCRIPTION_ID, defaultSubscriptionId);
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setForceSmsSelection(@NonNull RecipientId id, boolean forceSmsSelection) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(FORCE_SMS_SELECTION, forceSmsSelection ? 1 : 0);
if (update(id, contentValues)) {
Recipient.live(id).refresh();
}
}
public void setBlocked(@NonNull RecipientId id, boolean blocked) {
ContentValues values = new ContentValues();
values.put(BLOCKED, blocked ? 1 : 0);
if (update(id, values)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
}
}
public void setMessageRingtone(@NonNull RecipientId id, @Nullable Uri notification) {
ContentValues values = new ContentValues();
values.put(MESSAGE_RINGTONE, notification == null ? null : notification.toString());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setCallRingtone(@NonNull RecipientId id, @Nullable Uri ringtone) {
ContentValues values = new ContentValues();
values.put(CALL_RINGTONE, ringtone == null ? null : ringtone.toString());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setMessageVibrate(@NonNull RecipientId id, @NonNull VibrateState enabled) {
ContentValues values = new ContentValues();
values.put(MESSAGE_VIBRATE, enabled.getId());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setCallVibrate(@NonNull RecipientId id, @NonNull VibrateState enabled) {
ContentValues values = new ContentValues();
values.put(CALL_VIBRATE, enabled.getId());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setMuted(@NonNull RecipientId id, long until) {
ContentValues values = new ContentValues();
values.put(MUTE_UNTIL, until);
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setSeenFirstInviteReminder(@NonNull RecipientId id) {
setInsightsBannerTier(id, InsightsBannerTier.TIER_ONE);
}
public void setSeenSecondInviteReminder(@NonNull RecipientId id) {
setInsightsBannerTier(id, InsightsBannerTier.TIER_TWO);
}
public void setHasSentInvite(@NonNull RecipientId id) {
setSeenSecondInviteReminder(id);
}
private void setInsightsBannerTier(@NonNull RecipientId id, @NonNull InsightsBannerTier insightsBannerTier) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues(1);
String query = ID + " = ? AND " + SEEN_INVITE_REMINDER + " < ?";
String[] args = new String[]{ id.serialize(), String.valueOf(insightsBannerTier) };
values.put(SEEN_INVITE_REMINDER, insightsBannerTier.id);
database.update(TABLE_NAME, values, query, args);
Recipient.live(id).refresh();
}
public void setExpireMessages(@NonNull RecipientId id, int expiration) {
ContentValues values = new ContentValues(1);
values.put(MESSAGE_EXPIRATION_TIME, expiration);
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setUnidentifiedAccessMode(@NonNull RecipientId id, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
ContentValues values = new ContentValues(1);
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
if (update(id, values)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
}
}
public void setCapabilities(@NonNull RecipientId id, @NonNull SignalServiceProfile.Capabilities capabilities) {
ContentValues values = new ContentValues(2);
values.put(UUID_CAPABILITY, Recipient.Capability.fromBoolean(capabilities.isUuid()).serialize());
values.put(GROUPS_V2_CAPABILITY, Recipient.Capability.fromBoolean(capabilities.isGv2()).serialize());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
public void setMentionSetting(@NonNull RecipientId id, @NonNull MentionSetting mentionSetting) {
ContentValues values = new ContentValues();
values.put(MENTION_SETTING, mentionSetting.getId());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
/**
* Updates the profile key.
* <p>
* If it changes, it clears out the profile key credential and resets the unidentified access mode.
* @return true iff changed.
*/
public boolean setProfileKey(@NonNull RecipientId id, @NonNull ProfileKey profileKey) {
String selection = ID + " = ?";
String[] args = new String[]{id.serialize()};
ContentValues valuesToCompare = new ContentValues(1);
ContentValues valuesToSet = new ContentValues(3);
String encodedProfileKey = Base64.encodeBytes(profileKey.serialize());
valuesToCompare.put(PROFILE_KEY, encodedProfileKey);
valuesToSet.put(PROFILE_KEY, encodedProfileKey);
valuesToSet.putNull(PROFILE_KEY_CREDENTIAL);
valuesToSet.put(UNIDENTIFIED_ACCESS_MODE, UnidentifiedAccessMode.UNKNOWN.getMode());
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, valuesToCompare);
if (update(updateQuery, valuesToSet)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
StorageSyncHelper.scheduleSyncForDataChange();
return true;
}
return false;
}
/**
* Sets the profile key iff currently null.
* <p>
* If it sets it, it also clears out the profile key credential and resets the unidentified access mode.
* @return true iff changed.
*/
public boolean setProfileKeyIfAbsent(@NonNull RecipientId id, @NonNull ProfileKey profileKey) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
String selection = ID + " = ? AND " + PROFILE_KEY + " is NULL";
String[] args = new String[]{id.serialize()};
ContentValues valuesToSet = new ContentValues(3);
valuesToSet.put(PROFILE_KEY, Base64.encodeBytes(profileKey.serialize()));
valuesToSet.putNull(PROFILE_KEY_CREDENTIAL);
valuesToSet.put(UNIDENTIFIED_ACCESS_MODE, UnidentifiedAccessMode.UNKNOWN.getMode());
if (database.update(TABLE_NAME, valuesToSet, selection, args) > 0) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
return true;
} else {
return false;
}
}
/**
* Updates the profile key credential as long as the profile key matches.
*/
public void setProfileKeyCredential(@NonNull RecipientId id,
@NonNull ProfileKey profileKey,
@NonNull ProfileKeyCredential profileKeyCredential)
{
String selection = ID + " = ? AND " + PROFILE_KEY + " = ?";
String[] args = new String[]{id.serialize(), Base64.encodeBytes(profileKey.serialize())};
ContentValues values = new ContentValues(1);
values.put(PROFILE_KEY_CREDENTIAL, Base64.encodeBytes(profileKeyCredential.serialize()));
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
if (update(updateQuery, values)) {
// TODO [greyson] If we sync this in future, mark dirty
//markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
}
}
private void clearProfileKeyCredential(@NonNull RecipientId id) {
ContentValues values = new ContentValues(1);
values.putNull(PROFILE_KEY_CREDENTIAL);
if (update(id, values)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
}
}
/**
* Fills in gaps (nulls) in profile key knowledge from new profile keys.
* <p>
* If from authoritative source, this will overwrite local, otherwise it will only write to the
* database if missing.
*/
public Set<RecipientId> persistProfileKeySet(@NonNull ProfileKeySet profileKeySet) {
Map<UUID, ProfileKey> profileKeys = profileKeySet.getProfileKeys();
Map<UUID, ProfileKey> authoritativeProfileKeys = profileKeySet.getAuthoritativeProfileKeys();
int totalKeys = profileKeys.size() + authoritativeProfileKeys.size();
if (totalKeys == 0) {
return Collections.emptySet();
}
Log.i(TAG, String.format(Locale.US, "Persisting %d Profile keys, %d of which are authoritative", totalKeys, authoritativeProfileKeys.size()));
HashSet<RecipientId> updated = new HashSet<>(totalKeys);
RecipientId selfId = Recipient.self().getId();
for (Map.Entry<UUID, ProfileKey> entry : profileKeys.entrySet()) {
RecipientId recipientId = getOrInsertFromUuid(entry.getKey());
if (setProfileKeyIfAbsent(recipientId, entry.getValue())) {
Log.i(TAG, "Learned new profile key");
updated.add(recipientId);
}
}
for (Map.Entry<UUID, ProfileKey> entry : authoritativeProfileKeys.entrySet()) {
RecipientId recipientId = getOrInsertFromUuid(entry.getKey());
if (selfId.equals(recipientId)) {
Log.i(TAG, "Seen authoritative update for self");
if (!entry.getValue().equals(ProfileKeyUtil.getSelfProfileKey())) {
Log.w(TAG, "Seen authoritative update for self that didn't match local, scheduling storage sync");
StorageSyncHelper.scheduleSyncForDataChange();
}
} else {
Log.i(TAG, String.format("Profile key from owner %s", recipientId));
if (setProfileKey(recipientId, entry.getValue())) {
Log.i(TAG, "Learned new profile key from owner");
updated.add(recipientId);
}
}
}
return updated;
}
public void setProfileName(@NonNull RecipientId id, @NonNull ProfileName profileName) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
contentValues.put(PROFILE_FAMILY_NAME, profileName.getFamilyName());
contentValues.put(PROFILE_JOINED_NAME, profileName.toString());
if (update(id, contentValues)) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
StorageSyncHelper.scheduleSyncForDataChange();
}
}
public void setProfileAvatar(@NonNull RecipientId id, @Nullable String profileAvatar) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar);
if (update(id, contentValues)) {
Recipient.live(id).refresh();
if (id.equals(Recipient.self().getId())) {
markDirty(id, DirtyState.UPDATE);
StorageSyncHelper.scheduleSyncForDataChange();
}
}
}
public void setProfileSharing(@NonNull RecipientId id, @SuppressWarnings("SameParameterValue") boolean enabled) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(PROFILE_SHARING, enabled ? 1 : 0);
boolean profiledUpdated = update(id, contentValues);
boolean colorUpdated = enabled && setColorIfNotSetInternal(id, ContactColors.generateFor(Recipient.resolved(id).getDisplayName(context)));
if (profiledUpdated || colorUpdated) {
markDirty(id, DirtyState.UPDATE);
Recipient.live(id).refresh();
StorageSyncHelper.scheduleSyncForDataChange();
}
}
public void setNotificationChannel(@NonNull RecipientId id, @Nullable String notificationChannel) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(NOTIFICATION_CHANNEL, notificationChannel);
if (update(id, contentValues)) {
Recipient.live(id).refresh();
}
}
/**
* @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();
StorageSyncHelper.scheduleSyncForDataChange();
}
}
public void setUsername(@NonNull RecipientId id, @Nullable String username) {
if (username != null) {
Optional<RecipientId> existingUsername = getByUsername(username);
if (existingUsername.isPresent() && !id.equals(existingUsername.get())) {
Log.i(TAG, "Username was previously thought to be owned by " + existingUsername.get() + ". Clearing their username.");
setUsername(existingUsername.get(), null);
}
}
ContentValues contentValues = new ContentValues(1);
contentValues.put(USERNAME, username);
if (update(id, contentValues)) {
Recipient.live(id).refresh();
StorageSyncHelper.scheduleSyncForDataChange();
}
}
public void clearUsernameIfExists(@NonNull String username) {
Optional<RecipientId> existingUsername = getByUsername(username);
if (existingUsername.isPresent()) {
setUsername(existingUsername.get(), null);
}
}
public Set<String> getAllPhoneNumbers() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Set<String> results = new HashSet<>();
try (Cursor cursor = db.query(TABLE_NAME, new String[] { PHONE }, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String number = cursor.getString(cursor.getColumnIndexOrThrow(PHONE));
if (!TextUtils.isEmpty(number)) {
results.add(number);
}
}
}
return results;
}
/**
* @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());
contentValues.put(UUID, uuid.toString().toLowerCase());
if (update(id, contentValues)) {
markDirty(id, DirtyState.INSERT);
Recipient.live(id).refresh();
}
}
/**
* Marks the user as registered without providing a UUID. This should only be used when one
* cannot be reasonably obtained. {@link #markRegistered(RecipientId, UUID)} should be strongly
* preferred.
*/
public void markRegistered(@NonNull RecipientId id) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
if (update(id, contentValues)) {
markDirty(id, DirtyState.INSERT);
Recipient.live(id).refresh();
}
}
public void markUnregistered(@NonNull RecipientId id) {
ContentValues contentValues = new ContentValues(2);
contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
if (update(id, contentValues)) {
markDirty(id, DirtyState.DELETE);
Recipient.live(id).refresh();
}
}
public void bulkUpdatedRegisteredStatus(@NonNull Map<RecipientId, String> registered, Collection<RecipientId> unregistered) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (Map.Entry<RecipientId, String> entry : registered.entrySet()) {
ContentValues values = new ContentValues(2);
values.put(REGISTERED, RegisteredState.REGISTERED.getId());
if (entry.getValue() != null) {
values.put(UUID, entry.getValue().toLowerCase());
}
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);
}
}
for (RecipientId id : unregistered) {
ContentValues values = new ContentValues(2);
values.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
if (update(id, values)) {
markDirty(id, DirtyState.DELETE);
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@Deprecated
public void setRegistered(@NonNull RecipientId id, RegisteredState registeredState) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(REGISTERED, registeredState.getId());
if (update(id, contentValues)) {
if (registeredState == RegisteredState.REGISTERED) {
markDirty(id, DirtyState.INSERT);
} else if (registeredState == RegisteredState.NOT_REGISTERED) {
markDirty(id, DirtyState.DELETE);
}
Recipient.live(id).refresh();
}
}
@Deprecated
public void setRegistered(@NonNull Collection<RecipientId> activeIds,
@NonNull Collection<RecipientId> inactiveIds)
{
for (RecipientId activeId : activeIds) {
ContentValues registeredValues = new ContentValues(1);
registeredValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
if (update(activeId, registeredValues)) {
markDirty(activeId, DirtyState.INSERT);
Recipient.live(activeId).refresh();
}
}
for (RecipientId inactiveId : inactiveIds) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
if (update(inactiveId, contentValues)) {
markDirty(inactiveId, DirtyState.DELETE);
Recipient.live(inactiveId).refresh();
}
}
}
/**
* Handles inserts the (e164, UUID) pairs, which could result in merges. Does not mark users as
* registered.
*
* @return A mapping of (RecipientId, UUID)
*/
public @NonNull Map<RecipientId, String> bulkProcessCdsResult(@NonNull Map<String, UUID> mapping) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
HashMap<RecipientId, String> uuidMap = new HashMap<>();
db.beginTransaction();
try {
for (Map.Entry<String, UUID> entry : mapping.entrySet()) {
String e164 = entry.getKey();
UUID uuid = entry.getValue();
Optional<RecipientId> 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<RecipientId> getUninvitedRecipientsForInsights() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
final String[] args = new String[]{String.valueOf(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31))};
try (Cursor cursor = db.rawQuery(INSIGHTS_INVITEE_LIST, args)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public @NonNull List<RecipientId> getRegistered() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, REGISTERED + " = ?", new String[] {"1"}, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public List<RecipientId> getSystemContacts() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, SYSTEM_DISPLAY_NAME + " IS NOT NULL AND " + SYSTEM_DISPLAY_NAME + " != \"\"", null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public void updateSystemContactColors(@NonNull ColorUpdater updater) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Map<RecipientId, MaterialColor> updates = new HashMap<>();
db.beginTransaction();
try (Cursor cursor = db.query(TABLE_NAME, new String[] {ID, COLOR, SYSTEM_DISPLAY_NAME}, SYSTEM_DISPLAY_NAME + " IS NOT NULL AND " + SYSTEM_DISPLAY_NAME + " != \"\"", null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
MaterialColor newColor = updater.update(cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME)),
cursor.getString(cursor.getColumnIndexOrThrow(COLOR)));
ContentValues contentValues = new ContentValues(1);
contentValues.put(COLOR, newColor.serialize());
db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] { String.valueOf(id) });
updates.put(RecipientId.from(id), newColor);
}
} finally {
db.setTransactionSuccessful();
db.endTransaction();
Stream.of(updates.entrySet()).forEach(entry -> Recipient.live(entry.getKey()).refresh());
}
}
public @Nullable Cursor getSignalContacts(boolean includeSelf) {
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
"(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " +
"(" + SORT_NAME + " NOT NULL OR " + USERNAME + " NOT NULL)";
String[] args;
if (includeSelf) {
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1" };
} else {
selection += " AND " + ID + " != ?";
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", Recipient.self().getId().serialize() };
}
String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + USERNAME + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
}
public @Nullable Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
query = buildCaseInsensitiveGlobPattern(query);
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
"(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " +
"(" +
PHONE + " GLOB ? OR " +
SORT_NAME + " GLOB ? OR " +
USERNAME + " GLOB ?" +
")";
String[] args;
if (includeSelf) {
args = new String[]{"0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query};
} else {
selection += " AND " + ID + " != ?";
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query, String.valueOf(Recipient.self().getId().toLong()) };
}
String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
}
public @Nullable Cursor getNonSignalContacts() {
String selection = BLOCKED + " = ? AND " +
REGISTERED + " != ? AND " +
GROUP_ID + " IS NULL AND " +
SYSTEM_DISPLAY_NAME + " NOT NULL AND " +
"(" + PHONE + " NOT NULL OR " + EMAIL + " NOT NULL)";
String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()) };
String orderBy = SYSTEM_DISPLAY_NAME + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
}
public @Nullable Cursor queryNonSignalContacts(@NonNull String query) {
query = buildCaseInsensitiveGlobPattern(query);
String selection = BLOCKED + " = ? AND " +
REGISTERED + " != ? AND " +
GROUP_ID + " IS NULL AND " +
SYSTEM_DISPLAY_NAME + " NOT NULL AND " +
"(" + PHONE + " NOT NULL OR " + EMAIL + " NOT NULL) AND " +
"(" +
PHONE + " GLOB ? OR " +
EMAIL + " GLOB ? OR " +
SYSTEM_DISPLAY_NAME + " GLOB ?" +
")";
String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query };
String orderBy = SYSTEM_DISPLAY_NAME + ", " + PHONE;
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy);
}
public @Nullable Cursor queryAllContacts(@NonNull String query) {
query = buildCaseInsensitiveGlobPattern(query);
String selection = BLOCKED + " = ? AND " +
"(" +
SORT_NAME + " GLOB ? OR " +
USERNAME + " GLOB ? OR " +
PHONE + " GLOB ? OR " +
EMAIL + " GLOB ?" +
")";
String[] args = new String[] { "0", query, query, query, query };
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null);
}
public @NonNull List<Recipient> queryRecipientsForMentions(@NonNull String query) {
return queryRecipientsForMentions(query, null);
}
public @NonNull List<Recipient> queryRecipientsForMentions(@NonNull String query, @Nullable List<RecipientId> recipientIds) {
query = buildCaseInsensitiveGlobPattern(query);
String ids = null;
if (Util.hasItems(recipientIds)) {
ids = TextUtils.join(",", Stream.of(recipientIds).map(RecipientId::serialize).toList());
}
String selection = BLOCKED + " = 0 AND " +
(ids != null ? ID + " IN (" + ids + ") AND " : "") +
SORT_NAME + " GLOB ?";
List<Recipient> recipients = new ArrayList<>();
try (RecipientDatabase.RecipientReader reader = new RecipientReader(databaseHelper.getReadableDatabase().query(TABLE_NAME, MENTION_SEARCH_PROJECTION, selection, SqlUtil.buildArgs(query), null, null, SORT_NAME))) {
Recipient recipient;
while ((recipient = reader.getNext()) != null) {
recipients.add(recipient);
}
}
return recipients;
}
/**
* Builds a case-insensitive GLOB pattern for fuzzy text queries. Works with all unicode
* characters.
*
* Ex:
* cat -> [cC][aA][tT]
*/
private static String buildCaseInsensitiveGlobPattern(@NonNull String query) {
if (TextUtils.isEmpty(query)) {
return "*";
}
StringBuilder pattern = new StringBuilder();
for (int i = 0, len = query.codePointCount(0, query.length()); i < len; i++) {
String point = StringUtil.codePointToString(query.codePointAt(i));
pattern.append("[");
pattern.append(point.toLowerCase());
pattern.append(point.toUpperCase());
pattern.append("]");
}
return "*" + pattern.toString() + "*";
}
public @NonNull List<Recipient> getRecipientsForMultiDeviceSync() {
String subquery = "SELECT " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " FROM " + ThreadDatabase.TABLE_NAME;
String selection = REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
ID + " != ? AND " +
"(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + ID + " IN (" + subquery + "))";
String[] args = new String[] { String.valueOf(RegisteredState.REGISTERED.getId()), Recipient.self().getId().serialize() };
List<Recipient> recipients = new ArrayList<>();
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, ID_PROJECTION, selection, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
recipients.add(Recipient.resolved(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))));
}
}
return recipients;
}
/**
* @param lastInteractionThreshold Only include contacts that have been interacted with since this time.
* @param lastProfileFetchThreshold Only include contacts that haven't their profile fetched after this time.
* @param limit Only return at most this many contact.
*/
public List<RecipientId> getRecipientsForRoutineProfileFetch(long lastInteractionThreshold, long lastProfileFetchThreshold, int limit) {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
Set<Recipient> recipientsWithinInteractionThreshold = new LinkedHashSet<>();
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false))) {
ThreadRecord record;
while ((record = reader.getNext()) != null && record.getDate() > lastInteractionThreshold) {
Recipient recipient = Recipient.resolved(record.getRecipient().getId());
if (recipient.isGroup()) {
recipientsWithinInteractionThreshold.addAll(recipient.getParticipants());
} else {
recipientsWithinInteractionThreshold.add(recipient);
}
}
}
return Stream.of(recipientsWithinInteractionThreshold)
.filterNot(Recipient::isLocalNumber)
.filter(r -> r.getLastProfileFetchTime() < lastProfileFetchThreshold)
.limit(limit)
.map(Recipient::getId)
.toList();
}
public void markProfilesFetched(@NonNull Collection<RecipientId> ids, long time) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentValues values = new ContentValues(1);
values.put(LAST_PROFILE_FETCH, time);
for (RecipientId id : ids) {
db.update(TABLE_NAME, values, ID_WHERE, new String[] { id.serialize() });
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public void applyBlockedUpdate(@NonNull List<SignalServiceAddress> blocked, List<byte[]> groupIds) {
List<String> blockedE164 = Stream.of(blocked)
.filter(b -> b.getNumber().isPresent())
.map(b -> b.getNumber().get())
.toList();
List<String> blockedUuid = Stream.of(blocked)
.filter(b -> b.getUuid().isPresent())
.map(b -> b.getUuid().get().toString().toLowerCase())
.toList();
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentValues resetBlocked = new ContentValues();
resetBlocked.put(BLOCKED, 0);
db.update(TABLE_NAME, resetBlocked, null, null);
ContentValues setBlocked = new ContentValues();
setBlocked.put(BLOCKED, 1);
setBlocked.put(PROFILE_SHARING, 0);
for (String e164 : blockedE164) {
db.update(TABLE_NAME, setBlocked, PHONE + " = ?", new String[] { e164 });
}
for (String uuid : blockedUuid) {
db.update(TABLE_NAME, setBlocked, UUID + " = ?", new String[] { uuid });
}
List<GroupId.V1> groupIdStrings = Stream.of(groupIds).map(GroupId::v1orThrow).toList();
for (GroupId.V1 groupId : groupIdStrings) {
db.update(TABLE_NAME, setBlocked, GROUP_ID + " = ?", new String[] { groupId.toString() });
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
ApplicationDependencies.getRecipientCache().clear();
}
public void updateStorageKeys(@NonNull Map<RecipientId, byte[]> keys) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (Map.Entry<RecipientId, byte[]> entry : keys.entrySet()) {
ContentValues values = new ContentValues();
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue()));
db.update(TABLE_NAME, values, ID_WHERE, new String[] { entry.getKey().serialize() });
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
for (RecipientId id : keys.keySet()) {
Recipient.live(id).refresh();
}
}
public void clearDirtyState(@NonNull List<RecipientId> recipients) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentValues values = new ContentValues();
values.put(DIRTY, DirtyState.CLEAN.getId());
for (RecipientId id : recipients) {
Optional<RecipientId> 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();
} finally {
db.endTransaction();
}
}
void markDirty(@NonNull RecipientId recipientId, @NonNull DirtyState dirtyState) {
Log.d(TAG, "Attempting to mark " + recipientId + " with dirty state " + dirtyState);
ContentValues contentValues = new ContentValues(1);
contentValues.put(DIRTY, dirtyState.getId());
String query = ID + " = ? AND (" + UUID + " NOT NULL OR " + PHONE + " NOT NULL OR " + GROUP_ID + " NOT NULL) AND ";
String[] args = new String[] { recipientId.serialize(), String.valueOf(dirtyState.id) };
switch (dirtyState) {
case INSERT:
query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)";
args = SqlUtil.appendArg(args, String.valueOf(DirtyState.DELETE.getId()));
contentValues.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()));
break;
case DELETE:
query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)";
args = SqlUtil.appendArg(args, String.valueOf(DirtyState.INSERT.getId()));
break;
default:
query += DIRTY + " < ?";
}
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, query, args);
}
/**
* Will update the database with the content values you specified. It will make an intelligent
* query such that this will only return true if a row was *actually* updated.
*/
private boolean update(@NonNull RecipientId id, @NonNull ContentValues contentValues) {
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(id), contentValues);
return update(updateQuery, contentValues);
}
/**
* Will update the database with the {@param contentValues} you specified.
* <p>
* This will only return true if a row was *actually* updated with respect to the where clause of the {@param updateQuery}.
*/
private boolean update(@NonNull SqlUtil.Query updateQuery, @NonNull ContentValues contentValues) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
return database.update(TABLE_NAME, contentValues, updateQuery.getWhere(), updateQuery.getWhereArgs()) > 0;
}
private @NonNull Optional<RecipientId> getByColumn(@NonNull String column, String value) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String query = column + " = ?";
String[] args = new String[] { value };
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return Optional.of(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
} else {
return Optional.absent();
}
}
}
private @NonNull GetOrInsertResult getOrInsertByColumn(@NonNull String column, String value) {
if (TextUtils.isEmpty(value)) {
throw new AssertionError(column + " cannot be empty.");
}
Optional<RecipientId> existing = getByColumn(column, value);
if (existing.isPresent()) {
return new GetOrInsertResult(existing.get(), false);
} else {
ContentValues values = new ContentValues();
values.put(column, value);
long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values);
if (id < 0) {
existing = getByColumn(column, value);
if (existing.isPresent()) {
return new GetOrInsertResult(existing.get(), false);
} else {
throw new AssertionError("Failed to insert recipient!");
}
} else {
return new GetOrInsertResult(RecipientId.from(id), true);
}
}
}
/**
* 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());
uuidValues.put(MENTION_SETTING, uuidSettings.getMentionSetting() != MentionSetting.ALWAYS_NOTIFY ? uuidSettings.getMentionSetting().getId() : e164Settings.getMentionSetting().getId());
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, true)) {
List<RecipientId> 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.");
}
// Mentions
ContentValues mentionRecipientValues = new ContentValues();
mentionRecipientValues.put(MentionDatabase.RECIPIENT_ID, byUuid.serialize());
db.update(MentionDatabase.TABLE_NAME, mentionRecipientValues, MentionDatabase.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(byE164));
if (threadMerge.neededMerge) {
ContentValues mentionThreadValues = new ContentValues();
mentionThreadValues.put(MentionDatabase.THREAD_ID, threadMerge.threadId);
db.update(MentionDatabase.TABLE_NAME, mentionThreadValues, MentionDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(threadMerge.previousThreadId));
}
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;
private final Map<RecipientId, PendingContactInfo> pendingContactInfoMap = new HashMap<>();
BulkOperationsHandle(SQLiteDatabase database) {
this.database = database;
}
public void setSystemContactInfo(@NonNull RecipientId id,
@Nullable String displayName,
@Nullable String photoUri,
@Nullable String systemPhoneLabel,
int systemPhoneType,
@Nullable String systemContactUri)
{
ContentValues dirtyQualifyingValues = new ContentValues();
dirtyQualifyingValues.put(SYSTEM_DISPLAY_NAME, displayName);
if (update(id, dirtyQualifyingValues)) {
markDirty(id, DirtyState.UPDATE);
}
ContentValues refreshQualifyingValues = new ContentValues();
refreshQualifyingValues.put(SYSTEM_PHOTO_URI, photoUri);
refreshQualifyingValues.put(SYSTEM_PHONE_LABEL, systemPhoneLabel);
refreshQualifyingValues.put(SYSTEM_PHONE_TYPE, systemPhoneType);
refreshQualifyingValues.put(SYSTEM_CONTACT_URI, systemContactUri);
boolean updatedValues = update(id, refreshQualifyingValues);
boolean updatedColor = displayName != null && setColorIfNotSetInternal(id, ContactColors.generateFor(displayName));
if (updatedValues || updatedColor) {
pendingContactInfoMap.put(id, new PendingContactInfo(displayName, photoUri, systemPhoneLabel, systemContactUri));
}
ContentValues otherValues = new ContentValues();
otherValues.put(SYSTEM_INFO_PENDING, 0);
update(id, otherValues);
}
public void finish() {
markAllRelevantEntriesDirty();
clearSystemDataForPendingInfo();
database.setTransactionSuccessful();
database.endTransaction();
Stream.of(pendingContactInfoMap.entrySet()).forEach(entry -> Recipient.live(entry.getKey()).refresh());
}
private void markAllRelevantEntriesDirty() {
String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " < ?";
String[] args = new String[] { "1", String.valueOf(DirtyState.UPDATE.getId()) };
ContentValues values = new ContentValues(1);
values.put(DIRTY, DirtyState.UPDATE.getId());
database.update(TABLE_NAME, values, query, args);
}
private void clearSystemDataForPendingInfo() {
String query = SYSTEM_INFO_PENDING + " = ?";
String[] args = new String[] { "1" };
ContentValues values = new ContentValues(5);
values.put(SYSTEM_INFO_PENDING, 0);
values.put(SYSTEM_DISPLAY_NAME, (String) null);
values.put(SYSTEM_PHOTO_URI, (String) null);
values.put(SYSTEM_PHONE_LABEL, (String) null);
values.put(SYSTEM_CONTACT_URI, (String) null);
database.update(TABLE_NAME, values, query, args);
}
}
private static @NonNull String nullIfEmpty(String column) {
return "NULLIF(" + column + ", '')";
}
private static @NonNull String removeWhitespace(@NonNull String column) {
return "REPLACE(" + column + ", ' ', '')";
}
public interface ColorUpdater {
MaterialColor update(@NonNull String name, @Nullable String color);
}
public static class RecipientSettings {
private final RecipientId id;
private final UUID uuid;
private final String username;
private final String e164;
private final String email;
private final GroupId groupId;
private final GroupType groupType;
private final boolean blocked;
private final long muteUntil;
private final VibrateState messageVibrateState;
private final VibrateState callVibrateState;
private final Uri messageRingtone;
private final Uri callRingtone;
private final MaterialColor color;
private final int defaultSubscriptionId;
private final int expireMessages;
private final RegisteredState registered;
private final byte[] profileKey;
private final byte[] profileKeyCredential;
private final String systemDisplayName;
private final String systemContactPhoto;
private final String systemPhoneLabel;
private final String systemContactUri;
private final ProfileName signalProfileName;
private final String signalProfileAvatar;
private final boolean hasProfileImage;
private final boolean profileSharing;
private final long lastProfileFetch;
private final String notificationChannel;
private final UnidentifiedAccessMode unidentifiedAccessMode;
private final boolean forceSmsSelection;
private final Recipient.Capability uuidCapability;
private final Recipient.Capability groupsV2Capability;
private final InsightsBannerTier insightsBannerTier;
private final byte[] storageId;
private final MentionSetting mentionSetting;
private final SyncExtras syncExtras;
RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid,
@Nullable String username,
@Nullable String e164,
@Nullable String email,
@Nullable GroupId groupId,
@NonNull GroupType groupType,
boolean blocked,
long muteUntil,
@NonNull VibrateState messageVibrateState,
@NonNull VibrateState callVibrateState,
@Nullable Uri messageRingtone,
@Nullable Uri callRingtone,
@Nullable MaterialColor color,
int defaultSubscriptionId,
int expireMessages,
@NonNull RegisteredState registered,
@Nullable byte[] profileKey,
@Nullable byte[] profileKeyCredential,
@Nullable String systemDisplayName,
@Nullable String systemContactPhoto,
@Nullable String systemPhoneLabel,
@Nullable String systemContactUri,
@NonNull ProfileName signalProfileName,
@Nullable String signalProfileAvatar,
boolean hasProfileImage,
boolean profileSharing,
long lastProfileFetch,
@Nullable String notificationChannel,
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
boolean forceSmsSelection,
Recipient.Capability uuidCapability,
Recipient.Capability groupsV2Capability,
@NonNull InsightsBannerTier insightsBannerTier,
@Nullable byte[] storageId,
@NonNull MentionSetting mentionSetting,
@NonNull SyncExtras syncExtras)
{
this.id = id;
this.uuid = uuid;
this.username = username;
this.e164 = e164;
this.email = email;
this.groupId = groupId;
this.groupType = groupType;
this.blocked = blocked;
this.muteUntil = muteUntil;
this.messageVibrateState = messageVibrateState;
this.callVibrateState = callVibrateState;
this.messageRingtone = messageRingtone;
this.callRingtone = callRingtone;
this.color = color;
this.defaultSubscriptionId = defaultSubscriptionId;
this.expireMessages = expireMessages;
this.registered = registered;
this.profileKey = profileKey;
this.profileKeyCredential = profileKeyCredential;
this.systemDisplayName = systemDisplayName;
this.systemContactPhoto = systemContactPhoto;
this.systemPhoneLabel = systemPhoneLabel;
this.systemContactUri = systemContactUri;
this.signalProfileName = signalProfileName;
this.signalProfileAvatar = signalProfileAvatar;
this.hasProfileImage = hasProfileImage;
this.profileSharing = profileSharing;
this.lastProfileFetch = lastProfileFetch;
this.notificationChannel = notificationChannel;
this.unidentifiedAccessMode = unidentifiedAccessMode;
this.forceSmsSelection = forceSmsSelection;
this.uuidCapability = uuidCapability;
this.groupsV2Capability = groupsV2Capability;
this.insightsBannerTier = insightsBannerTier;
this.storageId = storageId;
this.mentionSetting = mentionSetting;
this.syncExtras = syncExtras;
}
public RecipientId getId() {
return id;
}
public @Nullable UUID getUuid() {
return uuid;
}
public @Nullable String getUsername() {
return username;
}
public @Nullable String getE164() {
return e164;
}
public @Nullable String getEmail() {
return email;
}
public @Nullable GroupId getGroupId() {
return groupId;
}
public @NonNull GroupType getGroupType() {
return groupType;
}
public @Nullable MaterialColor getColor() {
return color;
}
public boolean isBlocked() {
return blocked;
}
public long getMuteUntil() {
return muteUntil;
}
public @NonNull VibrateState getMessageVibrateState() {
return messageVibrateState;
}
public @NonNull VibrateState getCallVibrateState() {
return callVibrateState;
}
public @Nullable Uri getMessageRingtone() {
return messageRingtone;
}
public @Nullable Uri getCallRingtone() {
return callRingtone;
}
public @NonNull InsightsBannerTier getInsightsBannerTier() {
return insightsBannerTier;
}
public Optional<Integer> getDefaultSubscriptionId() {
return defaultSubscriptionId != -1 ? Optional.of(defaultSubscriptionId) : Optional.absent();
}
public int getExpireMessages() {
return expireMessages;
}
public RegisteredState getRegistered() {
return registered;
}
public @Nullable byte[] getProfileKey() {
return profileKey;
}
public @Nullable byte[] getProfileKeyCredential() {
return profileKeyCredential;
}
public @Nullable String getSystemDisplayName() {
return systemDisplayName;
}
public @Nullable String getSystemContactPhotoUri() {
return systemContactPhoto;
}
public @Nullable String getSystemPhoneLabel() {
return systemPhoneLabel;
}
public @Nullable String getSystemContactUri() {
return systemContactUri;
}
public @NonNull ProfileName getProfileName() {
return signalProfileName;
}
public @Nullable String getProfileAvatar() {
return signalProfileAvatar;
}
public boolean hasProfileImage() {
return hasProfileImage;
}
public boolean isProfileSharing() {
return profileSharing;
}
public long getLastProfileFetch() {
return lastProfileFetch;
}
public @Nullable String getNotificationChannel() {
return notificationChannel;
}
public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() {
return unidentifiedAccessMode;
}
public boolean isForceSmsSelection() {
return forceSmsSelection;
}
public Recipient.Capability getUuidCapability() {
return uuidCapability;
}
public Recipient.Capability getGroupsV2Capability() {
return groupsV2Capability;
}
public @Nullable byte[] getStorageId() {
return storageId;
}
public @NonNull MentionSetting getMentionSetting() {
return mentionSetting;
}
public @NonNull SyncExtras getSyncExtras() {
return syncExtras;
}
/**
* A bundle of data that's only necessary when syncing to storage service, not for a
* {@link Recipient}.
*/
public static class SyncExtras {
private final byte[] storageProto;
private final GroupMasterKey groupMasterKey;
private final byte[] identityKey;
private final VerifiedStatus identityStatus;
private final boolean archived;
private final boolean forcedUnread;
public SyncExtras(@Nullable byte[] storageProto,
@Nullable GroupMasterKey groupMasterKey,
@Nullable byte[] identityKey,
@NonNull VerifiedStatus identityStatus,
boolean archived,
boolean forcedUnread)
{
this.storageProto = storageProto;
this.groupMasterKey = groupMasterKey;
this.identityKey = identityKey;
this.identityStatus = identityStatus;
this.archived = archived;
this.forcedUnread = forcedUnread;
}
public @Nullable byte[] getStorageProto() {
return storageProto;
}
public @Nullable GroupMasterKey getGroupMasterKey() {
return groupMasterKey;
}
public boolean isArchived() {
return archived;
}
public @Nullable byte[] getIdentityKey() {
return identityKey;
}
public @NonNull VerifiedStatus getIdentityStatus() {
return identityStatus;
}
public boolean isForcedUnread() {
return forcedUnread;
}
}
}
public static class RecipientReader implements Closeable {
private final Cursor cursor;
RecipientReader(Cursor cursor) {
this.cursor = cursor;
}
public @NonNull Recipient getCurrent() {
RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID)));
return Recipient.resolved(id);
}
public @Nullable Recipient getNext() {
if (cursor != null && !cursor.moveToNext()) {
return null;
}
return getCurrent();
}
public void close() {
cursor.close();
}
}
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;
private final String photoUri;
private final String phoneLabel;
private final String contactUri;
private PendingContactInfo(String displayName, String photoUri, String phoneLabel, String contactUri) {
this.displayName = displayName;
this.photoUri = photoUri;
this.phoneLabel = phoneLabel;
this.contactUri = contactUri;
}
}
public static class MissingRecipientException extends IllegalStateException {
public MissingRecipientException(@Nullable RecipientId id) {
super("Failed to find recipient with ID: " + id);
}
}
private static class GetOrInsertResult {
final RecipientId recipientId;
final boolean neededInsert;
private GetOrInsertResult(@NonNull RecipientId recipientId, boolean neededInsert) {
this.recipientId = recipientId;
this.neededInsert = neededInsert;
}
}
}