mirror of
https://github.com/oxen-io/session-android.git
synced 2025-12-29 03:36:49 +00:00
restructure and unite service android/java to libsignal
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import org.thoughtcrime.securesms.util.DelimiterUtil;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.NumberUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class Address implements Parcelable, Comparable<Address> {
|
||||
|
||||
public static final Parcelable.Creator<Address> CREATOR = new Parcelable.Creator<Address>() {
|
||||
public Address createFromParcel(Parcel in) {
|
||||
return new Address(in);
|
||||
}
|
||||
|
||||
public Address[] newArray(int size) {
|
||||
return new Address[size];
|
||||
}
|
||||
};
|
||||
|
||||
public static final Address UNKNOWN = new Address("Unknown");
|
||||
|
||||
private static final String TAG = Address.class.getSimpleName();
|
||||
|
||||
private static final AtomicReference<Pair<String, ExternalAddressFormatter>> cachedFormatter = new AtomicReference<>();
|
||||
|
||||
private final String address;
|
||||
|
||||
private Address(@NonNull String address) {
|
||||
if (address == null) throw new AssertionError(address);
|
||||
this.address = address.toLowerCase();
|
||||
}
|
||||
|
||||
public Address(Parcel in) {
|
||||
this(in.readString());
|
||||
}
|
||||
|
||||
public static @NonNull Address fromSerialized(@NonNull String serialized) {
|
||||
return new Address(serialized);
|
||||
}
|
||||
|
||||
public static Address fromExternal(@NonNull Context context, @Nullable String external) {
|
||||
return Address.fromSerialized(external);
|
||||
}
|
||||
|
||||
public static @NonNull List<Address> fromSerializedList(@NonNull String serialized, char delimiter) {
|
||||
String[] escapedAddresses = DelimiterUtil.split(serialized, delimiter);
|
||||
List<Address> addresses = new LinkedList<>();
|
||||
|
||||
for (String escapedAddress : escapedAddresses) {
|
||||
addresses.add(Address.fromSerialized(DelimiterUtil.unescape(escapedAddress, delimiter)));
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
public static @NonNull String toSerializedList(@NonNull List<Address> addresses, char delimiter) {
|
||||
Collections.sort(addresses);
|
||||
|
||||
List<String> escapedAddresses = new LinkedList<>();
|
||||
|
||||
for (Address address : addresses) {
|
||||
escapedAddresses.add(DelimiterUtil.escape(address.serialize(), delimiter));
|
||||
}
|
||||
|
||||
return Util.join(escapedAddresses, delimiter + "");
|
||||
}
|
||||
|
||||
public boolean isGroup() { return GroupUtil.isEncodedGroup(address); }
|
||||
|
||||
public boolean isClosedGroup() { return GroupUtil.isClosedGroup(address); }
|
||||
|
||||
public boolean isOpenGroup() { return GroupUtil.isOpenGroup(address); }
|
||||
|
||||
public boolean isRSSFeed() { return GroupUtil.isRSSFeed(address); }
|
||||
|
||||
public boolean isMmsGroup() { return GroupUtil.isMmsGroup(address); }
|
||||
|
||||
public boolean isEmail() {
|
||||
return NumberUtil.isValidEmail(address);
|
||||
}
|
||||
|
||||
public boolean isPhone() {
|
||||
return !isGroup() && !isEmail();
|
||||
}
|
||||
|
||||
public @NonNull String toGroupString() {
|
||||
if (!isGroup()) throw new AssertionError("Not group");
|
||||
return address;
|
||||
}
|
||||
|
||||
public @NonNull String toPhoneString() {
|
||||
if (!isPhone() && !isOpenGroup()) {
|
||||
if (isEmail()) throw new AssertionError("Not e164, is email");
|
||||
if (isGroup()) throw new AssertionError("Not e164, is group");
|
||||
throw new AssertionError("Not e164, unknown");
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
public @NonNull String toEmailString() {
|
||||
if (!isEmail()) throw new AssertionError("Not email");
|
||||
return address;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public String serialize() {
|
||||
return address;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
if (other == null || !(other instanceof Address)) return false;
|
||||
return address.equals(((Address) other).address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return address.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NonNull Address other) {
|
||||
return address.compareTo(other.address);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static class ExternalAddressFormatter {
|
||||
|
||||
private static final String TAG = ExternalAddressFormatter.class.getSimpleName();
|
||||
|
||||
private static final Set<String> SHORT_COUNTRIES = new HashSet<String>() {{
|
||||
add("NU");
|
||||
add("TK");
|
||||
add("NC");
|
||||
add("AC");
|
||||
}};
|
||||
|
||||
private static final Pattern US_NO_AREACODE = Pattern.compile("^(\\d{7})$");
|
||||
private static final Pattern BR_NO_AREACODE = Pattern.compile("^(9?\\d{8})$");
|
||||
|
||||
private final Optional<PhoneNumber> localNumber;
|
||||
private final String localCountryCode;
|
||||
|
||||
private final Pattern ALPHA_PATTERN = Pattern.compile("[a-zA-Z]");
|
||||
|
||||
ExternalAddressFormatter(@NonNull String localCountryCode, boolean countryCode) {
|
||||
this.localNumber = Optional.absent();
|
||||
this.localCountryCode = localCountryCode;
|
||||
}
|
||||
|
||||
public String format(@Nullable String number) {
|
||||
if (number == null) return "Unknown";
|
||||
return number;
|
||||
}
|
||||
|
||||
private @Nullable String parseAreaCode(@NonNull String e164Number, int countryCode) {
|
||||
switch (countryCode) {
|
||||
case 1:
|
||||
return e164Number.substring(2, 5);
|
||||
case 55:
|
||||
return e164Number.substring(3, 5);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private @NonNull String applyAreaCodeRules(@NonNull Optional<PhoneNumber> localNumber, @NonNull String testNumber) {
|
||||
if (!localNumber.isPresent() || !localNumber.get().getAreaCode().isPresent()) {
|
||||
return testNumber;
|
||||
}
|
||||
|
||||
Matcher matcher;
|
||||
switch (localNumber.get().getCountryCode()) {
|
||||
case 1:
|
||||
matcher = US_NO_AREACODE.matcher(testNumber);
|
||||
if (matcher.matches()) {
|
||||
return localNumber.get().getAreaCode() + matcher.group();
|
||||
}
|
||||
break;
|
||||
|
||||
case 55:
|
||||
matcher = BR_NO_AREACODE.matcher(testNumber);
|
||||
if (matcher.matches()) {
|
||||
return localNumber.get().getAreaCode() + matcher.group();
|
||||
}
|
||||
}
|
||||
return testNumber;
|
||||
}
|
||||
|
||||
private static class PhoneNumber {
|
||||
private final String e164Number;
|
||||
private final int countryCode;
|
||||
private final Optional<String> areaCode;
|
||||
|
||||
PhoneNumber(String e164Number, int countryCode, @Nullable String areaCode) {
|
||||
this.e164Number = e164Number;
|
||||
this.countryCode = countryCode;
|
||||
this.areaCode = Optional.fromNullable(areaCode);
|
||||
}
|
||||
|
||||
String getE164Number() {
|
||||
return e164Number;
|
||||
}
|
||||
|
||||
int getCountryCode() {
|
||||
return countryCode;
|
||||
}
|
||||
|
||||
Optional<String> getAreaCode() {
|
||||
return areaCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Copyright (C) 2014 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetManager;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
import android.text.TextUtils;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.LegacyMmsConnection.Apn;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Database to query APN and MMSC information
|
||||
*/
|
||||
public class ApnDatabase {
|
||||
private static final String TAG = ApnDatabase.class.getSimpleName();
|
||||
|
||||
private final SQLiteDatabase db;
|
||||
private final Context context;
|
||||
|
||||
private static final String DATABASE_NAME = "apns.db";
|
||||
private static final String ASSET_PATH = "databases" + File.separator + DATABASE_NAME;
|
||||
|
||||
private static final String TABLE_NAME = "apns";
|
||||
private static final String ID_COLUMN = "_id";
|
||||
private static final String MCC_MNC_COLUMN = "mccmnc";
|
||||
private static final String MCC_COLUMN = "mcc";
|
||||
private static final String MNC_COLUMN = "mnc";
|
||||
private static final String CARRIER_COLUMN = "carrier";
|
||||
private static final String APN_COLUMN = "apn";
|
||||
private static final String MMSC_COLUMN = "mmsc";
|
||||
private static final String PORT_COLUMN = "port";
|
||||
private static final String TYPE_COLUMN = "type";
|
||||
private static final String PROTOCOL_COLUMN = "protocol";
|
||||
private static final String BEARER_COLUMN = "bearer";
|
||||
private static final String ROAMING_PROTOCOL_COLUMN = "roaming_protocol";
|
||||
private static final String CARRIER_ENABLED_COLUMN = "carrier_enabled";
|
||||
private static final String MMS_PROXY_COLUMN = "mmsproxy";
|
||||
private static final String MMS_PORT_COLUMN = "mmsport";
|
||||
private static final String PROXY_COLUMN = "proxy";
|
||||
private static final String MVNO_MATCH_DATA_COLUMN = "mvno_match_data";
|
||||
private static final String MVNO_TYPE_COLUMN = "mvno";
|
||||
private static final String AUTH_TYPE_COLUMN = "authtype";
|
||||
private static final String USER_COLUMN = "user";
|
||||
private static final String PASSWORD_COLUMN = "password";
|
||||
private static final String SERVER_COLUMN = "server";
|
||||
|
||||
private static final String BASE_SELECTION = MCC_MNC_COLUMN + " = ?";
|
||||
|
||||
private static ApnDatabase instance = null;
|
||||
|
||||
public synchronized static ApnDatabase getInstance(Context context) throws IOException {
|
||||
if (instance == null) instance = new ApnDatabase(context.getApplicationContext());
|
||||
return instance;
|
||||
}
|
||||
|
||||
private ApnDatabase(final Context context) throws IOException {
|
||||
this.context = context;
|
||||
|
||||
File dbFile = context.getDatabasePath(DATABASE_NAME);
|
||||
|
||||
if (!dbFile.getParentFile().exists() && !dbFile.getParentFile().mkdir()) {
|
||||
throw new IOException("couldn't make databases directory");
|
||||
}
|
||||
|
||||
Util.copy(context.getAssets().open(ASSET_PATH, AssetManager.ACCESS_STREAMING),
|
||||
new FileOutputStream(dbFile));
|
||||
|
||||
try {
|
||||
this.db = SQLiteDatabase.openDatabase(context.getDatabasePath(DATABASE_NAME).getPath(),
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY | SQLiteDatabase.NO_LOCALIZED_COLLATORS);
|
||||
} catch (SQLiteException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Apn getCustomApnParameters() {
|
||||
String mmsc = TextSecurePreferences.getMmscUrl(context).trim();
|
||||
|
||||
if (!TextUtils.isEmpty(mmsc) && !mmsc.startsWith("http"))
|
||||
mmsc = "http://" + mmsc;
|
||||
|
||||
String proxy = TextSecurePreferences.getMmscProxy(context);
|
||||
String port = TextSecurePreferences.getMmscProxyPort(context);
|
||||
String user = TextSecurePreferences.getMmscUsername(context);
|
||||
String pass = TextSecurePreferences.getMmscPassword(context);
|
||||
|
||||
return new Apn(mmsc, proxy, port, user, pass);
|
||||
}
|
||||
|
||||
public Apn getDefaultApnParameters(String mccmnc, String apn) {
|
||||
if (mccmnc == null) {
|
||||
Log.w(TAG, "mccmnc was null, returning null");
|
||||
return Apn.EMPTY;
|
||||
}
|
||||
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
if (apn != null) {
|
||||
Log.d(TAG, "Querying table for MCC+MNC " + mccmnc + " and APN name " + apn);
|
||||
cursor = db.query(TABLE_NAME, null,
|
||||
BASE_SELECTION + " AND " + APN_COLUMN + " = ?",
|
||||
new String[] {mccmnc, apn},
|
||||
null, null, null);
|
||||
}
|
||||
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
if (cursor != null) cursor.close();
|
||||
Log.d(TAG, "Querying table for MCC+MNC " + mccmnc + " without APN name");
|
||||
cursor = db.query(TABLE_NAME, null,
|
||||
BASE_SELECTION,
|
||||
new String[] {mccmnc},
|
||||
null, null, null);
|
||||
}
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
Apn params = new Apn(cursor.getString(cursor.getColumnIndexOrThrow(MMSC_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(MMS_PROXY_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(MMS_PORT_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(USER_COLUMN)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(PASSWORD_COLUMN)));
|
||||
Log.d(TAG, "Returning preferred APN " + params);
|
||||
return params;
|
||||
}
|
||||
|
||||
Log.w(TAG, "No matching APNs found, returning null");
|
||||
|
||||
return Apn.EMPTY;
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public Optional<Apn> getMmsConnectionParameters(String mccmnc, String apn) {
|
||||
Apn customApn = getCustomApnParameters();
|
||||
Apn defaultApn = getDefaultApnParameters(mccmnc, apn);
|
||||
Apn result = new Apn(customApn, defaultApn,
|
||||
TextSecurePreferences.getUseCustomMmsc(context),
|
||||
TextSecurePreferences.getUseCustomMmscProxy(context),
|
||||
TextSecurePreferences.getUseCustomMmscProxyPort(context),
|
||||
TextSecurePreferences.getUseCustomMmscUsername(context),
|
||||
TextSecurePreferences.getUseCustomMmscPassword(context));
|
||||
|
||||
if (TextUtils.isEmpty(result.getMmsc())) return Optional.absent();
|
||||
else return Optional.of(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentAudioExtras;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MediaStream;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.ExternalStorageUtil;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.video.EncryptedMediaDataSource;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import kotlin.jvm.Synchronized;
|
||||
|
||||
public class AttachmentDatabase extends Database {
|
||||
|
||||
private static final String TAG = AttachmentDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TABLE_NAME = "part";
|
||||
public static final String ROW_ID = "_id";
|
||||
static final String ATTACHMENT_JSON_ALIAS = "attachment_json";
|
||||
public static final String MMS_ID = "mid";
|
||||
static final String CONTENT_TYPE = "ct";
|
||||
static final String NAME = "name";
|
||||
static final String CONTENT_DISPOSITION = "cd";
|
||||
static final String CONTENT_LOCATION = "cl";
|
||||
public static final String DATA = "_data";
|
||||
static final String TRANSFER_STATE = "pending_push";
|
||||
public static final String SIZE = "data_size";
|
||||
static final String FILE_NAME = "file_name";
|
||||
public static final String THUMBNAIL = "thumbnail";
|
||||
static final String THUMBNAIL_ASPECT_RATIO = "aspect_ratio";
|
||||
public static final String UNIQUE_ID = "unique_id";
|
||||
static final String DIGEST = "digest";
|
||||
static final String VOICE_NOTE = "voice_note";
|
||||
static final String QUOTE = "quote";
|
||||
public static final String STICKER_PACK_ID = "sticker_pack_id";
|
||||
public static final String STICKER_PACK_KEY = "sticker_pack_key";
|
||||
static final String STICKER_ID = "sticker_id";
|
||||
static final String FAST_PREFLIGHT_ID = "fast_preflight_id";
|
||||
public static final String DATA_RANDOM = "data_random";
|
||||
private static final String THUMBNAIL_RANDOM = "thumbnail_random";
|
||||
static final String WIDTH = "width";
|
||||
static final String HEIGHT = "height";
|
||||
static final String CAPTION = "caption";
|
||||
public static final String URL = "url";
|
||||
public static final String DIRECTORY = "parts";
|
||||
// "audio/*" mime type only related columns.
|
||||
static final String AUDIO_VISUAL_SAMPLES = "audio_visual_samples"; // Small amount of audio byte samples to visualise the content (e.g. draw waveform).
|
||||
static final String AUDIO_DURATION = "audio_duration"; // Duration of the audio track in milliseconds.
|
||||
|
||||
public static final int TRANSFER_PROGRESS_DONE = 0;
|
||||
public static final int TRANSFER_PROGRESS_STARTED = 1;
|
||||
public static final int TRANSFER_PROGRESS_PENDING = 2;
|
||||
public static final int TRANSFER_PROGRESS_FAILED = 3;
|
||||
|
||||
private static final String PART_ID_WHERE = ROW_ID + " = ? AND " + UNIQUE_ID + " = ?";
|
||||
private static final String PART_AUDIO_ONLY_WHERE = CONTENT_TYPE + " LIKE \"audio/%\"";
|
||||
|
||||
private static final String[] PROJECTION = new String[] {ROW_ID,
|
||||
MMS_ID, CONTENT_TYPE, NAME, CONTENT_DISPOSITION,
|
||||
CONTENT_LOCATION, DATA, THUMBNAIL, TRANSFER_STATE,
|
||||
SIZE, FILE_NAME, THUMBNAIL, THUMBNAIL_ASPECT_RATIO,
|
||||
UNIQUE_ID, DIGEST, FAST_PREFLIGHT_ID, VOICE_NOTE,
|
||||
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
|
||||
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, URL};
|
||||
|
||||
private static final String[] PROJECTION_AUDIO_EXTRAS = new String[] {AUDIO_VISUAL_SAMPLES, AUDIO_DURATION};
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
|
||||
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
|
||||
CONTENT_TYPE + " TEXT, " + NAME + " TEXT, " + "chset" + " INTEGER, " +
|
||||
CONTENT_DISPOSITION + " TEXT, " + "fn" + " TEXT, " + "cid" + " TEXT, " +
|
||||
CONTENT_LOCATION + " TEXT, " + "ctt_s" + " INTEGER, " +
|
||||
"ctt_t" + " TEXT, " + "encrypted" + " INTEGER, " +
|
||||
TRANSFER_STATE + " INTEGER, "+ DATA + " TEXT, " + SIZE + " INTEGER, " +
|
||||
FILE_NAME + " TEXT, " + THUMBNAIL + " TEXT, " + THUMBNAIL_ASPECT_RATIO + " REAL, " +
|
||||
UNIQUE_ID + " INTEGER NOT NULL, " + DIGEST + " BLOB, " + FAST_PREFLIGHT_ID + " TEXT, " +
|
||||
VOICE_NOTE + " INTEGER DEFAULT 0, " + DATA_RANDOM + " BLOB, " + THUMBNAIL_RANDOM + " BLOB, " +
|
||||
QUOTE + " INTEGER DEFAULT 0, " + WIDTH + " INTEGER DEFAULT 0, " + HEIGHT + " INTEGER DEFAULT 0, " +
|
||||
CAPTION + " TEXT DEFAULT NULL, " + URL + " TEXT, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
|
||||
STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1," +
|
||||
AUDIO_VISUAL_SAMPLES + " BLOB, " + AUDIO_DURATION + " INTEGER);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS pending_push_index ON " + TABLE_NAME + " (" + TRANSFER_STATE + ");",
|
||||
"CREATE INDEX IF NOT EXISTS part_sticker_pack_id_index ON " + TABLE_NAME + " (" + STICKER_PACK_ID + ");",
|
||||
};
|
||||
|
||||
private final ExecutorService thumbnailExecutor = Util.newSingleThreadedLifoExecutor();
|
||||
|
||||
private final AttachmentSecret attachmentSecret;
|
||||
|
||||
public AttachmentDatabase(Context context, SQLCipherOpenHelper databaseHelper, AttachmentSecret attachmentSecret) {
|
||||
super(context, databaseHelper);
|
||||
this.attachmentSecret = attachmentSecret;
|
||||
}
|
||||
|
||||
public @NonNull InputStream getAttachmentStream(AttachmentId attachmentId, long offset)
|
||||
throws IOException
|
||||
{
|
||||
InputStream dataStream = getDataStream(attachmentId, DATA, offset);
|
||||
|
||||
if (dataStream == null) throw new IOException("No stream for: " + attachmentId);
|
||||
else return dataStream;
|
||||
}
|
||||
|
||||
public @NonNull InputStream getThumbnailStream(@NonNull AttachmentId attachmentId)
|
||||
throws IOException
|
||||
{
|
||||
Log.d(TAG, "getThumbnailStream(" + attachmentId + ")");
|
||||
InputStream dataStream = getDataStream(attachmentId, THUMBNAIL, 0);
|
||||
|
||||
if (dataStream != null) {
|
||||
return dataStream;
|
||||
}
|
||||
|
||||
try {
|
||||
InputStream generatedStream = thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId)).get();
|
||||
|
||||
if (generatedStream == null) throw new FileNotFoundException("No thumbnail stream available: " + attachmentId);
|
||||
else return generatedStream;
|
||||
} catch (InterruptedException ie) {
|
||||
throw new AssertionError("interrupted");
|
||||
} catch (ExecutionException ee) {
|
||||
Log.w(TAG, ee);
|
||||
throw new IOException(ee);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean containsStickerPackId(@NonNull String stickerPackId) {
|
||||
String selection = STICKER_PACK_ID + " = ?";
|
||||
String[] args = new String[] { stickerPackId };
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, "1")) {
|
||||
return cursor != null && cursor.moveToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public void setTransferProgressFailed(AttachmentId attachmentId, long mmsId)
|
||||
throws MmsException
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_FAILED);
|
||||
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
|
||||
notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId));
|
||||
}
|
||||
|
||||
public @Nullable DatabaseAttachment getAttachment(@NonNull AttachmentId attachmentId)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, PROJECTION, PART_ID_WHERE, attachmentId.toStrings(), null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
List<DatabaseAttachment> list = getAttachment(cursor);
|
||||
|
||||
if (list != null && list.size() > 0) {
|
||||
return list.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull List<DatabaseAttachment> getAttachmentsForMessage(long mmsId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
List<DatabaseAttachment> results = new LinkedList<>();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, PROJECTION, MMS_ID + " = ?", new String[] {mmsId+""},
|
||||
null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
results.addAll(getAttachment(cursor));
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull List<DatabaseAttachment> getPendingAttachments() {
|
||||
final SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
final List<DatabaseAttachment> attachments = new LinkedList<>();
|
||||
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, PROJECTION, TRANSFER_STATE + " = ?", new String[] {String.valueOf(TRANSFER_PROGRESS_STARTED)}, null, null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
attachments.addAll(getAttachment(cursor));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
void deleteAttachmentsForMessage(long mmsId) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " = ?",
|
||||
new String[] {mmsId+""}, null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
database.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {mmsId + ""});
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
public void deleteAttachment(@NonNull AttachmentId id) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME,
|
||||
new String[]{DATA, THUMBNAIL, CONTENT_TYPE},
|
||||
PART_ID_WHERE,
|
||||
id.toStrings(),
|
||||
null,
|
||||
null,
|
||||
null))
|
||||
{
|
||||
if (cursor == null || !cursor.moveToNext()) {
|
||||
Log.w(TAG, "Tried to delete an attachment, but it didn't exist.");
|
||||
return;
|
||||
}
|
||||
String data = cursor.getString(0);
|
||||
String thumbnail = cursor.getString(1);
|
||||
String contentType = cursor.getString(2);
|
||||
|
||||
database.delete(TABLE_NAME, PART_ID_WHERE, id.toStrings());
|
||||
deleteAttachmentOnDisk(data, thumbnail, contentType);
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
void deleteAllAttachments() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.delete(TABLE_NAME, null, null);
|
||||
|
||||
File attachmentsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE);
|
||||
File[] attachments = attachmentsDirectory.listFiles();
|
||||
|
||||
for (File attachment : attachments) {
|
||||
attachment.delete();
|
||||
}
|
||||
|
||||
notifyAttachmentListeners();
|
||||
}
|
||||
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
private void deleteAttachmentOnDisk(@Nullable String data, @Nullable String thumbnail, @Nullable String contentType) {
|
||||
if (!TextUtils.isEmpty(data)) {
|
||||
new File(data).delete();
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(thumbnail)) {
|
||||
new File(thumbnail).delete();
|
||||
}
|
||||
|
||||
if (MediaUtil.isImageType(contentType) || thumbnail != null) {
|
||||
Glide.get(context).clearDiskCache();
|
||||
}
|
||||
}
|
||||
|
||||
public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId attachmentId, @NonNull InputStream inputStream)
|
||||
throws MmsException
|
||||
{
|
||||
DatabaseAttachment placeholder = getAttachment(attachmentId);
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
DataInfo dataInfo = setAttachmentData(inputStream);
|
||||
|
||||
if (placeholder != null && placeholder.isQuote() && !placeholder.getContentType().startsWith("image")) {
|
||||
values.put(THUMBNAIL, dataInfo.file.getAbsolutePath());
|
||||
values.put(THUMBNAIL_RANDOM, dataInfo.random);
|
||||
} else {
|
||||
values.put(DATA, dataInfo.file.getAbsolutePath());
|
||||
values.put(SIZE, dataInfo.length);
|
||||
values.put(DATA_RANDOM, dataInfo.random);
|
||||
}
|
||||
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
|
||||
values.put(CONTENT_LOCATION, (String)null);
|
||||
values.put(CONTENT_DISPOSITION, (String)null);
|
||||
values.put(DIGEST, (byte[])null);
|
||||
values.put(NAME, (String) null);
|
||||
values.put(FAST_PREFLIGHT_ID, (String)null);
|
||||
values.put(URL, "");
|
||||
|
||||
if (database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()) == 0) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
dataInfo.file.delete();
|
||||
} else {
|
||||
notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId));
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId));
|
||||
}
|
||||
|
||||
public void updateAttachmentAfterUploadSucceeded(@NonNull AttachmentId id, @NonNull Attachment attachment) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
|
||||
values.put(CONTENT_LOCATION, attachment.getLocation());
|
||||
values.put(DIGEST, attachment.getDigest());
|
||||
values.put(CONTENT_DISPOSITION, attachment.getKey());
|
||||
values.put(NAME, attachment.getRelay());
|
||||
values.put(SIZE, attachment.getSize());
|
||||
values.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId());
|
||||
values.put(URL, attachment.getUrl());
|
||||
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings());
|
||||
}
|
||||
|
||||
public void updateAttachmentAfterUploadFailed(@NonNull AttachmentId id) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_FAILED);
|
||||
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings());
|
||||
}
|
||||
|
||||
@NonNull Map<Attachment, AttachmentId> insertAttachmentsForMessage(long mmsId, @NonNull List<Attachment> attachments, @NonNull List<Attachment> quoteAttachment)
|
||||
throws MmsException
|
||||
{
|
||||
Log.d(TAG, "insertParts(" + attachments.size() + ")");
|
||||
|
||||
Map<Attachment, AttachmentId> insertedAttachments = new HashMap<>();
|
||||
|
||||
for (Attachment attachment : attachments) {
|
||||
AttachmentId attachmentId = insertAttachment(mmsId, attachment, attachment.isQuote());
|
||||
insertedAttachments.put(attachment, attachmentId);
|
||||
Log.i(TAG, "Inserted attachment at ID: " + attachmentId);
|
||||
}
|
||||
|
||||
for (Attachment attachment : quoteAttachment) {
|
||||
AttachmentId attachmentId = insertAttachment(mmsId, attachment, true);
|
||||
insertedAttachments.put(attachment, attachmentId);
|
||||
Log.i(TAG, "Inserted quoted attachment at ID: " + attachmentId);
|
||||
}
|
||||
|
||||
return insertedAttachments;
|
||||
}
|
||||
|
||||
public @NonNull Attachment updateAttachmentData(@NonNull Attachment attachment,
|
||||
@NonNull MediaStream mediaStream)
|
||||
throws MmsException
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
DatabaseAttachment databaseAttachment = (DatabaseAttachment) attachment;
|
||||
DataInfo dataInfo = getAttachmentDataFileInfo(databaseAttachment.getAttachmentId(), DATA);
|
||||
|
||||
if (dataInfo == null) {
|
||||
throw new MmsException("No attachment data found!");
|
||||
}
|
||||
|
||||
dataInfo = setAttachmentData(dataInfo.file, mediaStream.getStream());
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(SIZE, dataInfo.length);
|
||||
contentValues.put(CONTENT_TYPE, mediaStream.getMimeType());
|
||||
contentValues.put(WIDTH, mediaStream.getWidth());
|
||||
contentValues.put(HEIGHT, mediaStream.getHeight());
|
||||
contentValues.put(DATA_RANDOM, dataInfo.random);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, PART_ID_WHERE, databaseAttachment.getAttachmentId().toStrings());
|
||||
|
||||
return new DatabaseAttachment(databaseAttachment.getAttachmentId(),
|
||||
databaseAttachment.getMmsId(),
|
||||
databaseAttachment.hasData(),
|
||||
databaseAttachment.hasThumbnail(),
|
||||
mediaStream.getMimeType(),
|
||||
databaseAttachment.getTransferState(),
|
||||
dataInfo.length,
|
||||
databaseAttachment.getFileName(),
|
||||
databaseAttachment.getLocation(),
|
||||
databaseAttachment.getKey(),
|
||||
databaseAttachment.getRelay(),
|
||||
databaseAttachment.getDigest(),
|
||||
databaseAttachment.getFastPreflightId(),
|
||||
databaseAttachment.isVoiceNote(),
|
||||
mediaStream.getWidth(),
|
||||
mediaStream.getHeight(),
|
||||
databaseAttachment.isQuote(),
|
||||
databaseAttachment.getCaption(),
|
||||
databaseAttachment.getSticker(),
|
||||
databaseAttachment.getUrl());
|
||||
}
|
||||
|
||||
|
||||
public void updateAttachmentFileName(@NonNull AttachmentId attachmentId,
|
||||
@Nullable String fileName)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(FILE_NAME, ExternalStorageUtil.getCleanFileName(fileName));
|
||||
|
||||
database.update(TABLE_NAME, contentValues, PART_ID_WHERE, attachmentId.toStrings());
|
||||
}
|
||||
|
||||
public void markAttachmentUploaded(long messageId, Attachment attachment) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, ((DatabaseAttachment)attachment).getAttachmentId().toStrings());
|
||||
|
||||
notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId));
|
||||
}
|
||||
|
||||
public void setTransferState(long messageId, @NonNull Attachment attachment, int transferState) {
|
||||
if (!(attachment instanceof DatabaseAttachment)) {
|
||||
throw new AssertionError("Attempt to update attachment that doesn't belong to DB!");
|
||||
}
|
||||
|
||||
setTransferState(messageId, ((DatabaseAttachment) attachment).getAttachmentId(), transferState);
|
||||
}
|
||||
|
||||
public void setTransferState(long messageId, @NonNull AttachmentId attachmentId, int transferState) {
|
||||
final ContentValues values = new ContentValues(1);
|
||||
final SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
values.put(TRANSFER_STATE, transferState);
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
|
||||
notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns (pack_id, pack_key) pairs that are referenced in attachments but not in the stickers
|
||||
* database.
|
||||
*/
|
||||
public @Nullable Cursor getUnavailableStickerPacks() {
|
||||
String query = "SELECT DISTINCT " + STICKER_PACK_ID + ", " + STICKER_PACK_KEY + " FROM " + TABLE_NAME + " WHERE " + STICKER_PACK_ID + " NOT IN (" +
|
||||
"SELECT DISTINCT " + StickerDatabase.PACK_ID + " FROM " + StickerDatabase.TABLE_NAME +
|
||||
")";
|
||||
|
||||
return databaseHelper.getReadableDatabase().rawQuery(query, null);
|
||||
}
|
||||
|
||||
public boolean hasStickerAttachments() {
|
||||
String selection = STICKER_PACK_ID + " NOT NULL";
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, null, null, null, null, "1")) {
|
||||
return cursor != null && cursor.moveToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
@VisibleForTesting
|
||||
protected @Nullable InputStream getDataStream(AttachmentId attachmentId, String dataType, long offset)
|
||||
{
|
||||
DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, dataType);
|
||||
|
||||
if (dataInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dataInfo.random != null && dataInfo.random.length == 32) {
|
||||
return ModernDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.random, dataInfo.file, offset);
|
||||
} else {
|
||||
InputStream stream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.file);
|
||||
long skipped = stream.skip(offset);
|
||||
|
||||
if (skipped != offset) {
|
||||
Log.w(TAG, "Skip failed: " + skipped + " vs " + offset);
|
||||
return null;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable DataInfo getAttachmentDataFileInfo(@NonNull AttachmentId attachmentId, @NonNull String dataType)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
String randomColumn;
|
||||
|
||||
switch (dataType) {
|
||||
case DATA: randomColumn = DATA_RANDOM; break;
|
||||
case THUMBNAIL: randomColumn = THUMBNAIL_RANDOM; break;
|
||||
default:throw new AssertionError("Unknown data type: " + dataType);
|
||||
}
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[]{dataType, SIZE, randomColumn}, PART_ID_WHERE, attachmentId.toStrings(),
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
if (cursor.isNull(0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DataInfo(new File(cursor.getString(0)),
|
||||
cursor.getLong(1),
|
||||
cursor.getBlob(2));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private @NonNull DataInfo setAttachmentData(@NonNull Uri uri)
|
||||
throws MmsException
|
||||
{
|
||||
try {
|
||||
InputStream inputStream = PartAuthority.getAttachmentStream(context, uri);
|
||||
return setAttachmentData(inputStream);
|
||||
} catch (IOException e) {
|
||||
throw new MmsException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull DataInfo setAttachmentData(@NonNull InputStream in)
|
||||
throws MmsException
|
||||
{
|
||||
try {
|
||||
File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE);
|
||||
File dataFile = File.createTempFile("part", ".mms", partsDirectory);
|
||||
return setAttachmentData(dataFile, in);
|
||||
} catch (IOException e) {
|
||||
throw new MmsException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull DataInfo setAttachmentData(@NonNull File destination, @NonNull InputStream in)
|
||||
throws MmsException
|
||||
{
|
||||
try {
|
||||
Pair<byte[], OutputStream> out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, destination, false);
|
||||
long length = Util.copy(in, out.second);
|
||||
|
||||
return new DataInfo(destination, length, out.first);
|
||||
} catch (IOException e) {
|
||||
throw new MmsException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<DatabaseAttachment> getAttachment(@NonNull Cursor cursor) {
|
||||
try {
|
||||
if (cursor.getColumnIndex(AttachmentDatabase.ATTACHMENT_JSON_ALIAS) != -1) {
|
||||
if (cursor.isNull(cursor.getColumnIndexOrThrow(ATTACHMENT_JSON_ALIAS))) {
|
||||
return new LinkedList<>();
|
||||
}
|
||||
|
||||
List<DatabaseAttachment> result = new LinkedList<>();
|
||||
JSONArray array = new JSONArray(cursor.getString(cursor.getColumnIndexOrThrow(ATTACHMENT_JSON_ALIAS)));
|
||||
|
||||
for (int i=0;i<array.length();i++) {
|
||||
JsonUtils.SaneJSONObject object = new JsonUtils.SaneJSONObject(array.getJSONObject(i));
|
||||
|
||||
if (!object.isNull(ROW_ID)) {
|
||||
result.add(new DatabaseAttachment(new AttachmentId(object.getLong(ROW_ID), object.getLong(UNIQUE_ID)),
|
||||
object.getLong(MMS_ID),
|
||||
!TextUtils.isEmpty(object.getString(DATA)),
|
||||
!TextUtils.isEmpty(object.getString(THUMBNAIL)),
|
||||
object.getString(CONTENT_TYPE),
|
||||
object.getInt(TRANSFER_STATE),
|
||||
object.getLong(SIZE),
|
||||
object.getString(FILE_NAME),
|
||||
object.getString(CONTENT_LOCATION),
|
||||
object.getString(CONTENT_DISPOSITION),
|
||||
object.getString(NAME),
|
||||
null,
|
||||
object.getString(FAST_PREFLIGHT_ID),
|
||||
object.getInt(VOICE_NOTE) == 1,
|
||||
object.getInt(WIDTH),
|
||||
object.getInt(HEIGHT),
|
||||
object.getInt(QUOTE) == 1,
|
||||
object.getString(CAPTION),
|
||||
object.getInt(STICKER_ID) >= 0
|
||||
? new StickerLocator(object.getString(STICKER_PACK_ID),
|
||||
object.getString(STICKER_PACK_KEY),
|
||||
object.getInt(STICKER_ID))
|
||||
: null, "")); // TODO: Not sure if this will break something
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
int urlIndex = cursor.getColumnIndex(URL);
|
||||
return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID)),
|
||||
!cursor.isNull(cursor.getColumnIndexOrThrow(DATA)),
|
||||
!cursor.isNull(cursor.getColumnIndexOrThrow(THUMBNAIL)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(TRANSFER_STATE)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(SIZE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(FILE_NAME)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_LOCATION)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_DISPOSITION)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(NAME)),
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(DIGEST)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(FAST_PREFLIGHT_ID)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(VOICE_NOTE)) == 1,
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(WIDTH)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(HEIGHT)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1,
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(CAPTION)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)) >= 0
|
||||
? new StickerLocator(cursor.getString(cursor.getColumnIndexOrThrow(STICKER_PACK_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(STICKER_PACK_KEY)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)))
|
||||
: null,
|
||||
urlIndex > 0 ? cursor.getString(urlIndex) : ""));
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean quote)
|
||||
throws MmsException
|
||||
{
|
||||
Log.d(TAG, "Inserting attachment for mms id: " + mmsId);
|
||||
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
DataInfo dataInfo = null;
|
||||
long uniqueId = System.currentTimeMillis();
|
||||
|
||||
if (attachment.getDataUri() != null) {
|
||||
dataInfo = setAttachmentData(attachment.getDataUri());
|
||||
Log.d(TAG, "Wrote part to file: " + dataInfo.file.getAbsolutePath());
|
||||
}
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MMS_ID, mmsId);
|
||||
contentValues.put(CONTENT_TYPE, attachment.getContentType());
|
||||
contentValues.put(TRANSFER_STATE, attachment.getTransferState());
|
||||
contentValues.put(UNIQUE_ID, uniqueId);
|
||||
contentValues.put(CONTENT_LOCATION, attachment.getLocation());
|
||||
contentValues.put(DIGEST, attachment.getDigest());
|
||||
contentValues.put(CONTENT_DISPOSITION, attachment.getKey());
|
||||
contentValues.put(NAME, attachment.getRelay());
|
||||
contentValues.put(FILE_NAME, ExternalStorageUtil.getCleanFileName(attachment.getFileName()));
|
||||
contentValues.put(SIZE, attachment.getSize());
|
||||
contentValues.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId());
|
||||
contentValues.put(VOICE_NOTE, attachment.isVoiceNote() ? 1 : 0);
|
||||
contentValues.put(WIDTH, attachment.getWidth());
|
||||
contentValues.put(HEIGHT, attachment.getHeight());
|
||||
contentValues.put(QUOTE, quote);
|
||||
contentValues.put(CAPTION, attachment.getCaption());
|
||||
contentValues.put(URL, attachment.getUrl());
|
||||
|
||||
if (attachment.isSticker()) {
|
||||
contentValues.put(STICKER_PACK_ID, attachment.getSticker().getPackId());
|
||||
contentValues.put(STICKER_PACK_KEY, attachment.getSticker().getPackKey());
|
||||
contentValues.put(STICKER_ID, attachment.getSticker().getStickerId());
|
||||
}
|
||||
|
||||
if (dataInfo != null) {
|
||||
contentValues.put(DATA, dataInfo.file.getAbsolutePath());
|
||||
contentValues.put(SIZE, dataInfo.length);
|
||||
contentValues.put(DATA_RANDOM, dataInfo.random);
|
||||
}
|
||||
|
||||
boolean notifyPacks = attachment.isSticker() && !hasStickerAttachments();
|
||||
long rowId = database.insert(TABLE_NAME, null, contentValues);
|
||||
AttachmentId attachmentId = new AttachmentId(rowId, uniqueId);
|
||||
Uri thumbnailUri = attachment.getThumbnailUri();
|
||||
boolean hasThumbnail = false;
|
||||
|
||||
if (thumbnailUri != null) {
|
||||
try (InputStream attachmentStream = PartAuthority.getAttachmentStream(context, thumbnailUri)) {
|
||||
Pair<Integer, Integer> dimens;
|
||||
if (attachment.getContentType().equals(MediaUtil.IMAGE_GIF)) {
|
||||
dimens = new Pair<>(attachment.getWidth(), attachment.getHeight());
|
||||
} else {
|
||||
dimens = BitmapUtil.getDimensions(attachmentStream);
|
||||
}
|
||||
updateAttachmentThumbnail(attachmentId,
|
||||
PartAuthority.getAttachmentStream(context, thumbnailUri),
|
||||
(float) dimens.first / (float) dimens.second);
|
||||
hasThumbnail = true;
|
||||
} catch (IOException | BitmapDecodingException e) {
|
||||
Log.w(TAG, "Failed to save existing thumbnail.", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasThumbnail && dataInfo != null) {
|
||||
if (MediaUtil.hasVideoThumbnail(attachment.getDataUri())) {
|
||||
Bitmap bitmap = MediaUtil.getVideoThumbnail(context, attachment.getDataUri());
|
||||
|
||||
if (bitmap != null) {
|
||||
ThumbnailData thumbnailData = new ThumbnailData(bitmap);
|
||||
updateAttachmentThumbnail(attachmentId, thumbnailData.toDataStream(), thumbnailData.getAspectRatio());
|
||||
} else {
|
||||
Log.w(TAG, "Retrieving video thumbnail failed, submitting thumbnail generation job...");
|
||||
thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId));
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Submitting thumbnail generation job...");
|
||||
thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId));
|
||||
}
|
||||
}
|
||||
|
||||
if (notifyPacks) {
|
||||
notifyStickerPackListeners();
|
||||
}
|
||||
|
||||
return attachmentId;
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
@VisibleForTesting
|
||||
protected void updateAttachmentThumbnail(AttachmentId attachmentId, InputStream in, float aspectRatio)
|
||||
throws MmsException
|
||||
{
|
||||
Log.i(TAG, "updating part thumbnail for #" + attachmentId);
|
||||
|
||||
DataInfo thumbnailFile = setAttachmentData(in);
|
||||
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(2);
|
||||
|
||||
values.put(THUMBNAIL, thumbnailFile.file.getAbsolutePath());
|
||||
values.put(THUMBNAIL_ASPECT_RATIO, aspectRatio);
|
||||
values.put(THUMBNAIL_RANDOM, thumbnailFile.random);
|
||||
|
||||
database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
|
||||
|
||||
Cursor cursor = database.query(TABLE_NAME, new String[] {MMS_ID}, PART_ID_WHERE, attachmentId.toStrings(), null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID))));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the audio extra values associated with the attachment. Only "audio/*" mime type attachments are accepted.
|
||||
* @return the related audio extras or null in case any of the audio extra columns are empty or the attachment is not an audio.
|
||||
*/
|
||||
@Synchronized
|
||||
public @Nullable DatabaseAttachmentAudioExtras getAttachmentAudioExtras(@NonNull AttachmentId attachmentId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase()
|
||||
// We expect all the audio extra values to be present (not null) or reject the whole record.
|
||||
.query(TABLE_NAME,
|
||||
PROJECTION_AUDIO_EXTRAS,
|
||||
PART_ID_WHERE +
|
||||
" AND " + AUDIO_VISUAL_SAMPLES + " IS NOT NULL" +
|
||||
" AND " + AUDIO_DURATION + " IS NOT NULL" +
|
||||
" AND " + PART_AUDIO_ONLY_WHERE,
|
||||
attachmentId.toStrings(),
|
||||
null, null, null, "1")) {
|
||||
|
||||
if (cursor == null || !cursor.moveToFirst()) return null;
|
||||
|
||||
byte[] audioSamples = cursor.getBlob(cursor.getColumnIndexOrThrow(AUDIO_VISUAL_SAMPLES));
|
||||
long duration = cursor.getLong(cursor.getColumnIndexOrThrow(AUDIO_DURATION));
|
||||
|
||||
return new DatabaseAttachmentAudioExtras(attachmentId, audioSamples, duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates audio extra columns for the "audio/*" mime type attachments only.
|
||||
* @return true if the update operation was successful.
|
||||
*/
|
||||
@Synchronized
|
||||
public boolean setAttachmentAudioExtras(@NonNull DatabaseAttachmentAudioExtras extras) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(AUDIO_VISUAL_SAMPLES, extras.getVisualSamples());
|
||||
values.put(AUDIO_DURATION, extras.getDurationMs());
|
||||
|
||||
int alteredRows = databaseHelper.getWritableDatabase().update(TABLE_NAME,
|
||||
values,
|
||||
PART_ID_WHERE + " AND " + PART_AUDIO_ONLY_WHERE,
|
||||
extras.getAttachmentId().toStrings());
|
||||
|
||||
return alteredRows > 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class ThumbnailFetchCallable implements Callable<InputStream> {
|
||||
|
||||
private final AttachmentId attachmentId;
|
||||
|
||||
ThumbnailFetchCallable(AttachmentId attachmentId) {
|
||||
this.attachmentId = attachmentId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable InputStream call() throws Exception {
|
||||
Log.d(TAG, "Executing thumbnail job...");
|
||||
final InputStream stream = getDataStream(attachmentId, THUMBNAIL, 0);
|
||||
|
||||
if (stream != null) {
|
||||
return stream;
|
||||
}
|
||||
|
||||
DatabaseAttachment attachment = getAttachment(attachmentId);
|
||||
|
||||
if (attachment == null || !attachment.hasData()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ThumbnailData data = null;
|
||||
|
||||
if (MediaUtil.isVideoType(attachment.getContentType())) {
|
||||
data = generateVideoThumbnail(attachmentId);
|
||||
}
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
updateAttachmentThumbnail(attachmentId, data.toDataStream(), data.getAspectRatio());
|
||||
|
||||
return getDataStream(attachmentId, THUMBNAIL, 0);
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private ThumbnailData generateVideoThumbnail(AttachmentId attachmentId) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
Log.w(TAG, "Video thumbnails not supported...");
|
||||
return null;
|
||||
}
|
||||
|
||||
DataInfo dataInfo = getAttachmentDataFileInfo(attachmentId, DATA);
|
||||
|
||||
if (dataInfo == null) {
|
||||
Log.w(TAG, "No data file found for video thumbnail...");
|
||||
return null;
|
||||
}
|
||||
|
||||
EncryptedMediaDataSource dataSource = new EncryptedMediaDataSource(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length);
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
retriever.setDataSource(dataSource);
|
||||
|
||||
Bitmap bitmap = retriever.getFrameAtTime(1000);
|
||||
|
||||
Log.i(TAG, "Generated video thumbnail...");
|
||||
return new ThumbnailData(bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DataInfo {
|
||||
private final File file;
|
||||
private final long length;
|
||||
private final byte[] random;
|
||||
|
||||
private DataInfo(File file, long length, byte[] random) {
|
||||
this.file = file;
|
||||
this.length = length;
|
||||
this.random = random;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
|
||||
import com.google.android.mms.pdu_alt.EncodedStringValue;
|
||||
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
public class ContentValuesBuilder {
|
||||
|
||||
private final ContentValues contentValues;
|
||||
|
||||
public ContentValuesBuilder(ContentValues contentValues) {
|
||||
this.contentValues = contentValues;
|
||||
}
|
||||
|
||||
public void add(String key, String charsetKey, EncodedStringValue value) {
|
||||
if (value != null) {
|
||||
contentValues.put(key, Util.toIsoString(value.getTextString()));
|
||||
contentValues.put(charsetKey, value.getCharacterSet());
|
||||
}
|
||||
}
|
||||
|
||||
public void add(String contentKey, byte[] value) {
|
||||
if (value != null) {
|
||||
contentValues.put(contentKey, Util.toIsoString(value));
|
||||
}
|
||||
}
|
||||
|
||||
public void add(String contentKey, int b) {
|
||||
if (b != 0)
|
||||
contentValues.put(contentKey, b);
|
||||
}
|
||||
|
||||
public void add(String contentKey, long value) {
|
||||
if (value != -1L)
|
||||
contentValues.put(contentKey, value);
|
||||
}
|
||||
|
||||
public ContentValues getContentValues() {
|
||||
return contentValues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.ListIterator;
|
||||
|
||||
/**
|
||||
* A list backed by a {@link Cursor} that retrieves models using a provided {@link ModelBuilder}.
|
||||
* Allows you to abstract away the use of a {@link Cursor} while still getting the benefits of a
|
||||
* {@link Cursor} (e.g. windowing).
|
||||
*
|
||||
* The one special consideration that must be made is that because this contains a cursor, you must
|
||||
* call {@link #close()} when you are finished with it.
|
||||
*
|
||||
* Given that this is cursor-backed, it is effectively immutable.
|
||||
*/
|
||||
public class CursorList<T> implements List<T>, ObservableContent {
|
||||
|
||||
private final Cursor cursor;
|
||||
private final ModelBuilder<T> modelBuilder;
|
||||
|
||||
public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder<T> modelBuilder) {
|
||||
this.cursor = cursor;
|
||||
this.modelBuilder = modelBuilder;
|
||||
|
||||
forceQueryLoad();
|
||||
}
|
||||
|
||||
public static <T> CursorList<T> emptyList() {
|
||||
//noinspection ConstantConditions,unchecked
|
||||
return (CursorList<T>) new CursorList(emptyCursor(), null);
|
||||
}
|
||||
|
||||
private static Cursor emptyCursor() {
|
||||
return new MatrixCursor(new String[] { "a" }, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return cursor.getCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return size() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(Object o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Iterator<T> iterator() {
|
||||
return new Iterator<T>() {
|
||||
int index = 0;
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return cursor.getCount() > 0 && !cursor.isLast();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T next() {
|
||||
cursor.moveToPosition(index++);
|
||||
return modelBuilder.build(cursor);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Object[] toArray() {
|
||||
Object[] out = new Object[size()];
|
||||
for (int i = 0; i < cursor.getCount(); i++) {
|
||||
cursor.moveToPosition(i);
|
||||
out[i] = modelBuilder.build(cursor);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean add(T o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean remove(Object o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean addAll(@NonNull Collection collection) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean addAll(int i, @NonNull Collection collection) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T get(int i) {
|
||||
cursor.moveToPosition(i);
|
||||
return modelBuilder.build(cursor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T set(int i, T o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add(int i, T o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T remove(int i) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int indexOf(Object o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int lastIndexOf(Object o) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ListIterator<T> listIterator() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ListIterator<T> listIterator(int i) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<T> subList(int i, int i1) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retainAll(@NonNull Collection collection) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeAll(@NonNull Collection collection) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean containsAll(@NonNull Collection collection) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull T[] toArray(@Nullable Object[] objects) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!cursor.isClosed()) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerContentObserver(@NonNull ContentObserver observer) {
|
||||
cursor.registerContentObserver(observer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregisterContentObserver(@NonNull ContentObserver observer) {
|
||||
cursor.unregisterContentObserver(observer);
|
||||
}
|
||||
|
||||
private void forceQueryLoad() {
|
||||
cursor.getCount();
|
||||
}
|
||||
|
||||
public interface ModelBuilder<T> {
|
||||
T build(@NonNull Cursor cursor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.DataSetObserver;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
/**
|
||||
* RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView.
|
||||
*/
|
||||
public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
|
||||
private final Context context;
|
||||
private final DataSetObserver observer = new AdapterDataSetObserver();
|
||||
|
||||
@VisibleForTesting static final int HEADER_TYPE = Integer.MIN_VALUE;
|
||||
@VisibleForTesting static final int FOOTER_TYPE = Integer.MIN_VALUE + 1;
|
||||
@VisibleForTesting static final long HEADER_ID = Long.MIN_VALUE;
|
||||
@VisibleForTesting static final long FOOTER_ID = Long.MIN_VALUE + 1;
|
||||
|
||||
private Cursor cursor;
|
||||
private boolean valid;
|
||||
private @Nullable View header;
|
||||
private @Nullable View footer;
|
||||
|
||||
private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder {
|
||||
public HeaderFooterViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
}
|
||||
|
||||
protected CursorRecyclerViewAdapter(Context context, Cursor cursor) {
|
||||
this.context = context;
|
||||
this.cursor = cursor;
|
||||
if (cursor != null) {
|
||||
valid = true;
|
||||
cursor.registerDataSetObserver(observer);
|
||||
}
|
||||
}
|
||||
|
||||
protected @NonNull Context getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public @Nullable Cursor getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public void setHeaderView(@Nullable View header) {
|
||||
this.header = header;
|
||||
}
|
||||
|
||||
public View getHeaderView() {
|
||||
return this.header;
|
||||
}
|
||||
|
||||
public void setFooterView(@Nullable View footer) {
|
||||
this.footer = footer;
|
||||
}
|
||||
|
||||
public boolean hasHeaderView() {
|
||||
return header != null;
|
||||
}
|
||||
|
||||
public boolean hasFooterView() {
|
||||
return footer != null;
|
||||
}
|
||||
|
||||
public void changeCursor(Cursor cursor) {
|
||||
Cursor old = swapCursor(cursor);
|
||||
if (old != null) {
|
||||
old.close();
|
||||
}
|
||||
}
|
||||
|
||||
public Cursor swapCursor(Cursor newCursor) {
|
||||
if (newCursor == cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Cursor oldCursor = cursor;
|
||||
if (oldCursor != null) {
|
||||
oldCursor.unregisterDataSetObserver(observer);
|
||||
}
|
||||
|
||||
cursor = newCursor;
|
||||
if (cursor != null) {
|
||||
cursor.registerDataSetObserver(observer);
|
||||
}
|
||||
|
||||
valid = cursor != null;
|
||||
notifyDataSetChanged();
|
||||
return oldCursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
if (!isActiveCursor()) return 0;
|
||||
|
||||
return cursor.getCount()
|
||||
+ getFastAccessSize()
|
||||
+ (hasHeaderView() ? 1 : 0)
|
||||
+ (hasFooterView() ? 1 : 0);
|
||||
}
|
||||
|
||||
public int getCursorCount() {
|
||||
return cursor.getCount();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public final void onViewRecycled(@NonNull ViewHolder holder) {
|
||||
if (!(holder instanceof HeaderFooterViewHolder)) {
|
||||
onItemViewRecycled((VH)holder);
|
||||
}
|
||||
}
|
||||
|
||||
public void onItemViewRecycled(VH holder) {}
|
||||
|
||||
@Override
|
||||
public @NonNull final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
switch (viewType) {
|
||||
case HEADER_TYPE: return new HeaderFooterViewHolder(header);
|
||||
case FOOTER_TYPE: return new HeaderFooterViewHolder(footer);
|
||||
default: return onCreateItemViewHolder(parent, viewType);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract VH onCreateItemViewHolder(ViewGroup parent, int viewType);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
if (!isHeaderPosition(position) && !isFooterPosition(position)) {
|
||||
if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position);
|
||||
else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position));
|
||||
}
|
||||
}
|
||||
|
||||
public abstract void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor);
|
||||
|
||||
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public final int getItemViewType(int position) {
|
||||
if (isHeaderPosition(position)) return HEADER_TYPE;
|
||||
if (isFooterPosition(position)) return FOOTER_TYPE;
|
||||
if (isFastAccessPosition(position)) return getFastAccessItemViewType(position);
|
||||
return getItemViewType(getCursorAtPositionOrThrow(position));
|
||||
}
|
||||
|
||||
public int getItemViewType(@NonNull Cursor cursor) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final long getItemId(int position) {
|
||||
if (isHeaderPosition(position)) return HEADER_ID;
|
||||
else if (isFooterPosition(position)) return FOOTER_ID;
|
||||
else if (isFastAccessPosition(position)) return getFastAccessItemId(position);
|
||||
|
||||
long itemId = getItemId(getCursorAtPositionOrThrow(position));
|
||||
return itemId <= Long.MIN_VALUE + 1 ? itemId + 2 : itemId;
|
||||
}
|
||||
|
||||
public long getItemId(@NonNull Cursor cursor) {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow("_id"));
|
||||
}
|
||||
|
||||
protected @NonNull Cursor getCursorAtPositionOrThrow(final int position) {
|
||||
if (!isActiveCursor()) {
|
||||
throw new IllegalStateException("this should only be called when the cursor is valid");
|
||||
}
|
||||
if (!cursor.moveToPosition(getCursorPosition(position))) {
|
||||
throw new IllegalStateException("couldn't move cursor to position " + position + " (actual cursor position " + getCursorPosition(position) + ")");
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
protected boolean isActiveCursor() {
|
||||
return valid && cursor != null;
|
||||
}
|
||||
|
||||
protected boolean isFooterPosition(int position) {
|
||||
return hasFooterView() && position == getItemCount() - 1;
|
||||
}
|
||||
|
||||
protected boolean isHeaderPosition(int position) {
|
||||
return hasHeaderView() && position == 0;
|
||||
}
|
||||
|
||||
private int getCursorPosition(int position) {
|
||||
if (hasHeaderView()) {
|
||||
position -= 1;
|
||||
}
|
||||
|
||||
return position - getFastAccessSize();
|
||||
}
|
||||
|
||||
protected int getFastAccessItemViewType(int position) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected boolean isFastAccessPosition(int position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected long getFastAccessItemId(int position) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected int getFastAccessSize() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private class AdapterDataSetObserver extends DataSetObserver {
|
||||
@Override
|
||||
public void onChanged() {
|
||||
super.onChanged();
|
||||
valid = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvalidated() {
|
||||
super.onInvalidated();
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class Database {
|
||||
|
||||
protected static final String ID_WHERE = "_id = ?";
|
||||
|
||||
protected SQLCipherOpenHelper databaseHelper;
|
||||
protected final Context context;
|
||||
|
||||
public Database(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
this.context = context;
|
||||
this.databaseHelper = databaseHelper;
|
||||
}
|
||||
|
||||
protected void notifyConversationListeners(Set<Long> threadIds) {
|
||||
for (long threadId : threadIds)
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
|
||||
protected void notifyConversationListeners(long threadId) {
|
||||
context.getContentResolver().notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadId), null);
|
||||
}
|
||||
|
||||
protected void notifyConversationListListeners() {
|
||||
context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null);
|
||||
}
|
||||
|
||||
protected void notifyStickerListeners() {
|
||||
context.getContentResolver().notifyChange(DatabaseContentProviders.Sticker.CONTENT_URI, null);
|
||||
}
|
||||
|
||||
protected void notifyStickerPackListeners() {
|
||||
context.getContentResolver().notifyChange(DatabaseContentProviders.StickerPack.CONTENT_URI, null);
|
||||
}
|
||||
|
||||
protected void setNotifyConverationListeners(Cursor cursor, long threadId) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId));
|
||||
}
|
||||
|
||||
protected void setNotifyConverationListListeners(Cursor cursor) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI);
|
||||
}
|
||||
|
||||
protected void setNotifyStickerListeners(Cursor cursor) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Sticker.CONTENT_URI);
|
||||
}
|
||||
|
||||
protected void setNotifyStickerPackListeners(Cursor cursor) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.StickerPack.CONTENT_URI);
|
||||
}
|
||||
|
||||
protected void registerAttachmentListeners(@NonNull ContentObserver observer) {
|
||||
context.getContentResolver().registerContentObserver(DatabaseContentProviders.Attachment.CONTENT_URI,
|
||||
true,
|
||||
observer);
|
||||
}
|
||||
|
||||
protected void notifyAttachmentListeners() {
|
||||
context.getContentResolver().notifyChange(DatabaseContentProviders.Attachment.CONTENT_URI, null);
|
||||
}
|
||||
|
||||
public void reset(SQLCipherOpenHelper databaseHelper) {
|
||||
this.databaseHelper = databaseHelper;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Starting in API 26, a {@link ContentProvider} needs to be defined for each authority you wish to
|
||||
* observe changes on. These classes essentially do nothing except exist so Android doesn't complain.
|
||||
*/
|
||||
public class DatabaseContentProviders {
|
||||
|
||||
public static class ConversationList extends NoopContentProvider {
|
||||
public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.conversationlist");
|
||||
}
|
||||
|
||||
public static class Conversation extends NoopContentProvider {
|
||||
private static final String CONTENT_URI_STRING = "content://network.loki.securesms.database.conversation/";
|
||||
|
||||
public static Uri getUriForThread(long threadId) {
|
||||
return Uri.parse(CONTENT_URI_STRING + threadId);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Attachment extends NoopContentProvider {
|
||||
public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.attachment");
|
||||
}
|
||||
|
||||
public static class Sticker extends NoopContentProvider {
|
||||
public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.sticker");
|
||||
}
|
||||
|
||||
public static class StickerPack extends NoopContentProvider {
|
||||
public static final Uri CONTENT_URI = Uri.parse("content://network.loki.securesms.database.stickerpack");
|
||||
}
|
||||
|
||||
private static abstract class NoopContentProvider extends ContentProvider {
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.DatabaseUpgradeActivity;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsDatabase;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiPreKeyBundleDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiPreKeyRecordDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiUserDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.SharedSenderKeysDatabase;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
public class DatabaseFactory {
|
||||
|
||||
private static final Object lock = new Object();
|
||||
|
||||
private static DatabaseFactory instance;
|
||||
|
||||
private final SQLCipherOpenHelper databaseHelper;
|
||||
private final SmsDatabase sms;
|
||||
private final MmsDatabase mms;
|
||||
private final AttachmentDatabase attachments;
|
||||
private final MediaDatabase media;
|
||||
private final ThreadDatabase thread;
|
||||
private final MmsSmsDatabase mmsSmsDatabase;
|
||||
private final IdentityDatabase identityDatabase;
|
||||
private final DraftDatabase draftDatabase;
|
||||
private final PushDatabase pushDatabase;
|
||||
private final GroupDatabase groupDatabase;
|
||||
private final RecipientDatabase recipientDatabase;
|
||||
private final ContactsDatabase contactsDatabase;
|
||||
private final GroupReceiptDatabase groupReceiptDatabase;
|
||||
private final OneTimePreKeyDatabase preKeyDatabase;
|
||||
private final SignedPreKeyDatabase signedPreKeyDatabase;
|
||||
private final SessionDatabase sessionDatabase;
|
||||
private final SearchDatabase searchDatabase;
|
||||
private final JobDatabase jobDatabase;
|
||||
private final StickerDatabase stickerDatabase;
|
||||
|
||||
// Loki
|
||||
private final LokiAPIDatabase lokiAPIDatabase;
|
||||
private final LokiPreKeyRecordDatabase lokiContactPreKeyDatabase;
|
||||
private final LokiPreKeyBundleDatabase lokiPreKeyBundleDatabase;
|
||||
private final LokiMessageDatabase lokiMessageDatabase;
|
||||
private final LokiThreadDatabase lokiThreadDatabase;
|
||||
private final LokiUserDatabase lokiUserDatabase;
|
||||
private final LokiBackupFilesDatabase lokiBackupFilesDatabase;
|
||||
private final SharedSenderKeysDatabase sskDatabase;
|
||||
|
||||
public static DatabaseFactory getInstance(Context context) {
|
||||
synchronized (lock) {
|
||||
if (instance == null)
|
||||
instance = new DatabaseFactory(context.getApplicationContext());
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public static MmsSmsDatabase getMmsSmsDatabase(Context context) {
|
||||
return getInstance(context).mmsSmsDatabase;
|
||||
}
|
||||
|
||||
public static ThreadDatabase getThreadDatabase(Context context) {
|
||||
return getInstance(context).thread;
|
||||
}
|
||||
|
||||
public static SmsDatabase getSmsDatabase(Context context) {
|
||||
return getInstance(context).sms;
|
||||
}
|
||||
|
||||
public static MmsDatabase getMmsDatabase(Context context) {
|
||||
return getInstance(context).mms;
|
||||
}
|
||||
|
||||
public static AttachmentDatabase getAttachmentDatabase(Context context) {
|
||||
return getInstance(context).attachments;
|
||||
}
|
||||
|
||||
public static MediaDatabase getMediaDatabase(Context context) {
|
||||
return getInstance(context).media;
|
||||
}
|
||||
|
||||
public static IdentityDatabase getIdentityDatabase(Context context) {
|
||||
return getInstance(context).identityDatabase;
|
||||
}
|
||||
|
||||
public static DraftDatabase getDraftDatabase(Context context) {
|
||||
return getInstance(context).draftDatabase;
|
||||
}
|
||||
|
||||
public static PushDatabase getPushDatabase(Context context) {
|
||||
return getInstance(context).pushDatabase;
|
||||
}
|
||||
|
||||
public static GroupDatabase getGroupDatabase(Context context) {
|
||||
return getInstance(context).groupDatabase;
|
||||
}
|
||||
|
||||
public static RecipientDatabase getRecipientDatabase(Context context) {
|
||||
return getInstance(context).recipientDatabase;
|
||||
}
|
||||
|
||||
public static ContactsDatabase getContactsDatabase(Context context) {
|
||||
return getInstance(context).contactsDatabase;
|
||||
}
|
||||
|
||||
public static GroupReceiptDatabase getGroupReceiptDatabase(Context context) {
|
||||
return getInstance(context).groupReceiptDatabase;
|
||||
}
|
||||
|
||||
public static OneTimePreKeyDatabase getPreKeyDatabase(Context context) {
|
||||
return getInstance(context).preKeyDatabase;
|
||||
}
|
||||
|
||||
public static SignedPreKeyDatabase getSignedPreKeyDatabase(Context context) {
|
||||
return getInstance(context).signedPreKeyDatabase;
|
||||
}
|
||||
|
||||
public static SessionDatabase getSessionDatabase(Context context) {
|
||||
return getInstance(context).sessionDatabase;
|
||||
}
|
||||
|
||||
public static SearchDatabase getSearchDatabase(Context context) {
|
||||
return getInstance(context).searchDatabase;
|
||||
}
|
||||
|
||||
public static JobDatabase getJobDatabase(Context context) {
|
||||
return getInstance(context).jobDatabase;
|
||||
}
|
||||
|
||||
public static StickerDatabase getStickerDatabase(Context context) {
|
||||
return getInstance(context).stickerDatabase;
|
||||
}
|
||||
|
||||
public static SQLiteDatabase getBackupDatabase(Context context) {
|
||||
return getInstance(context).databaseHelper.getReadableDatabase();
|
||||
}
|
||||
|
||||
// region Loki
|
||||
public static LokiAPIDatabase getLokiAPIDatabase(Context context) {
|
||||
return getInstance(context).lokiAPIDatabase;
|
||||
}
|
||||
|
||||
public static LokiPreKeyRecordDatabase getLokiPreKeyRecordDatabase(Context context) {
|
||||
return getInstance(context).lokiContactPreKeyDatabase;
|
||||
}
|
||||
|
||||
public static LokiPreKeyBundleDatabase getLokiPreKeyBundleDatabase(Context context) {
|
||||
return getInstance(context).lokiPreKeyBundleDatabase;
|
||||
}
|
||||
|
||||
public static LokiMessageDatabase getLokiMessageDatabase(Context context) {
|
||||
return getInstance(context).lokiMessageDatabase;
|
||||
}
|
||||
|
||||
public static LokiThreadDatabase getLokiThreadDatabase(Context context) {
|
||||
return getInstance(context).lokiThreadDatabase;
|
||||
}
|
||||
|
||||
public static LokiUserDatabase getLokiUserDatabase(Context context) {
|
||||
return getInstance(context).lokiUserDatabase;
|
||||
}
|
||||
|
||||
public static LokiBackupFilesDatabase getLokiBackupFilesDatabase(Context context) {
|
||||
return getInstance(context).lokiBackupFilesDatabase;
|
||||
}
|
||||
|
||||
public static SharedSenderKeysDatabase getSSKDatabase(Context context) {
|
||||
return getInstance(context).sskDatabase;
|
||||
}
|
||||
// endregion
|
||||
|
||||
public static void upgradeRestored(Context context, SQLiteDatabase database){
|
||||
getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1);
|
||||
getInstance(context).databaseHelper.markCurrent(database);
|
||||
}
|
||||
|
||||
private DatabaseFactory(@NonNull Context context) {
|
||||
SQLiteDatabase.loadLibs(context);
|
||||
|
||||
DatabaseSecret databaseSecret = new DatabaseSecretProvider(context).getOrCreateDatabaseSecret();
|
||||
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
|
||||
|
||||
this.databaseHelper = new SQLCipherOpenHelper(context, databaseSecret);
|
||||
this.sms = new SmsDatabase(context, databaseHelper);
|
||||
this.mms = new MmsDatabase(context, databaseHelper);
|
||||
this.attachments = new AttachmentDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.media = new MediaDatabase(context, databaseHelper);
|
||||
this.thread = new ThreadDatabase(context, databaseHelper);
|
||||
this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper);
|
||||
this.identityDatabase = new IdentityDatabase(context, databaseHelper);
|
||||
this.draftDatabase = new DraftDatabase(context, databaseHelper);
|
||||
this.pushDatabase = new PushDatabase(context, databaseHelper);
|
||||
this.groupDatabase = new GroupDatabase(context, databaseHelper);
|
||||
this.recipientDatabase = new RecipientDatabase(context, databaseHelper);
|
||||
this.groupReceiptDatabase = new GroupReceiptDatabase(context, databaseHelper);
|
||||
this.contactsDatabase = new ContactsDatabase(context);
|
||||
this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper);
|
||||
this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper);
|
||||
this.sessionDatabase = new SessionDatabase(context, databaseHelper);
|
||||
this.searchDatabase = new SearchDatabase(context, databaseHelper);
|
||||
this.jobDatabase = new JobDatabase(context, databaseHelper);
|
||||
this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.lokiAPIDatabase = new LokiAPIDatabase(context, databaseHelper);
|
||||
this.lokiContactPreKeyDatabase = new LokiPreKeyRecordDatabase(context, databaseHelper);
|
||||
this.lokiPreKeyBundleDatabase = new LokiPreKeyBundleDatabase(context, databaseHelper);
|
||||
this.lokiMessageDatabase = new LokiMessageDatabase(context, databaseHelper);
|
||||
this.lokiThreadDatabase = new LokiThreadDatabase(context, databaseHelper);
|
||||
this.lokiUserDatabase = new LokiUserDatabase(context, databaseHelper);
|
||||
this.lokiBackupFilesDatabase = new LokiBackupFilesDatabase(context, databaseHelper);
|
||||
this.sskDatabase = new SharedSenderKeysDatabase(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
||||
int fromVersion, DatabaseUpgradeActivity.DatabaseUpgradeListener listener)
|
||||
{
|
||||
databaseHelper.getWritableDatabase();
|
||||
|
||||
ClassicOpenHelper legacyOpenHelper = null;
|
||||
|
||||
if (fromVersion < DatabaseUpgradeActivity.ASYMMETRIC_MASTER_SECRET_FIX_VERSION) {
|
||||
legacyOpenHelper = new ClassicOpenHelper(context);
|
||||
legacyOpenHelper.onApplicationLevelUpgrade(context, masterSecret, fromVersion, listener);
|
||||
}
|
||||
|
||||
if (fromVersion < DatabaseUpgradeActivity.SQLCIPHER && TextSecurePreferences.getNeedsSqlCipherMigration(context)) {
|
||||
if (legacyOpenHelper == null) {
|
||||
legacyOpenHelper = new ClassicOpenHelper(context);
|
||||
}
|
||||
|
||||
SQLCipherMigrationHelper.migrateCiphertext(context, masterSecret,
|
||||
legacyOpenHelper.getWritableDatabase(),
|
||||
databaseHelper.getWritableDatabase(),
|
||||
listener);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class DraftDatabase extends Database {
|
||||
|
||||
public static final String TABLE_NAME = "drafts";
|
||||
public static final String ID = "_id";
|
||||
public static final String THREAD_ID = "thread_id";
|
||||
public static final String DRAFT_TYPE = "type";
|
||||
public static final String DRAFT_VALUE = "value";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS draft_thread_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
|
||||
};
|
||||
|
||||
public DraftDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void insertDrafts(long threadId, List<Draft> drafts) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
for (Draft draft : drafts) {
|
||||
ContentValues values = new ContentValues(3);
|
||||
values.put(THREAD_ID, threadId);
|
||||
values.put(DRAFT_TYPE, draft.getType());
|
||||
values.put(DRAFT_VALUE, draft.getValue());
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearDrafts(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
|
||||
}
|
||||
|
||||
void clearDrafts(Set<Long> threadIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
StringBuilder where = new StringBuilder();
|
||||
List<String> arguments = new LinkedList<>();
|
||||
|
||||
for (long threadId : threadIds) {
|
||||
where.append(" OR ")
|
||||
.append(THREAD_ID)
|
||||
.append(" = ?");
|
||||
|
||||
arguments.add(String.valueOf(threadId));
|
||||
}
|
||||
|
||||
db.delete(TABLE_NAME, where.toString().substring(4), arguments.toArray(new String[0]));
|
||||
}
|
||||
|
||||
void clearAllDrafts() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
}
|
||||
|
||||
public List<Draft> getDrafts(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<Draft> results = new LinkedList<>();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String type = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE));
|
||||
String value = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE));
|
||||
|
||||
results.add(new Draft(type, value));
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static class Draft {
|
||||
public static final String TEXT = "text";
|
||||
public static final String IMAGE = "image";
|
||||
public static final String VIDEO = "video";
|
||||
public static final String AUDIO = "audio";
|
||||
public static final String LOCATION = "location";
|
||||
public static final String QUOTE = "quote";
|
||||
|
||||
private final String type;
|
||||
private final String value;
|
||||
|
||||
public Draft(String type, String value) {
|
||||
this.type = type;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
String getSnippet(Context context) {
|
||||
switch (type) {
|
||||
case TEXT: return value;
|
||||
case IMAGE: return context.getString(R.string.DraftDatabase_Draft_image_snippet);
|
||||
case VIDEO: return context.getString(R.string.DraftDatabase_Draft_video_snippet);
|
||||
case AUDIO: return context.getString(R.string.DraftDatabase_Draft_audio_snippet);
|
||||
case LOCATION: return context.getString(R.string.DraftDatabase_Draft_location_snippet);
|
||||
case QUOTE: return context.getString(R.string.DraftDatabase_Draft_quote_snippet);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Drafts extends LinkedList<Draft> {
|
||||
private Draft getDraftOfType(String type) {
|
||||
for (Draft draft : this) {
|
||||
if (type.equals(draft.getType())) {
|
||||
return draft;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getSnippet(Context context) {
|
||||
Draft textDraft = getDraftOfType(Draft.TEXT);
|
||||
if (textDraft != null) {
|
||||
return textDraft.getSnippet(context);
|
||||
} else if (size() > 0) {
|
||||
return get(0).getSnippet(context);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable Uri getUriSnippet() {
|
||||
Draft imageDraft = getDraftOfType(Draft.IMAGE);
|
||||
|
||||
if (imageDraft != null && imageDraft.getValue() != null) {
|
||||
return Uri.parse(imageDraft.getValue());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.LRUCache;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class EarlyReceiptCache {
|
||||
|
||||
private static final String TAG = EarlyReceiptCache.class.getSimpleName();
|
||||
|
||||
private final LRUCache<Long, Map<Address, Long>> cache = new LRUCache<>(100);
|
||||
|
||||
public synchronized void increment(long timestamp, Address origin) {
|
||||
Log.i(TAG, this+"");
|
||||
Log.i(TAG, String.format("Early receipt: (%d, %s)", timestamp, origin.serialize()));
|
||||
|
||||
Map<Address, Long> receipts = cache.get(timestamp);
|
||||
|
||||
if (receipts == null) {
|
||||
receipts = new HashMap<>();
|
||||
}
|
||||
|
||||
Long count = receipts.get(origin);
|
||||
|
||||
if (count != null) {
|
||||
receipts.put(origin, ++count);
|
||||
} else {
|
||||
receipts.put(origin, 1L);
|
||||
}
|
||||
|
||||
cache.put(timestamp, receipts);
|
||||
}
|
||||
|
||||
public synchronized Map<Address, Long> remove(long timestamp) {
|
||||
Map<Address, Long> receipts = cache.remove(timestamp);
|
||||
|
||||
Log.i(TAG, this+"");
|
||||
Log.i(TAG, String.format("Checking early receipts (%d): %d", timestamp, receipts == null ? 0 : receipts.size()));
|
||||
|
||||
return receipts != null ? receipts : new HashMap<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class FastCursorRecyclerViewAdapter<VH extends RecyclerView.ViewHolder, T>
|
||||
extends CursorRecyclerViewAdapter<VH>
|
||||
{
|
||||
private static final String TAG = FastCursorRecyclerViewAdapter.class.getSimpleName();
|
||||
|
||||
private final LinkedList<T> fastRecords = new LinkedList<>();
|
||||
private final List<Long> releasedRecordIds = new LinkedList<>();
|
||||
|
||||
protected FastCursorRecyclerViewAdapter(Context context, Cursor cursor) {
|
||||
super(context, cursor);
|
||||
}
|
||||
|
||||
public void addFastRecord(@NonNull T record) {
|
||||
fastRecords.addFirst(record);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void releaseFastRecord(long id) {
|
||||
synchronized (releasedRecordIds) {
|
||||
releasedRecordIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
protected void cleanFastRecords() {
|
||||
synchronized (releasedRecordIds) {
|
||||
Iterator<Long> releaseIdIterator = releasedRecordIds.iterator();
|
||||
|
||||
while (releaseIdIterator.hasNext()) {
|
||||
long releasedId = releaseIdIterator.next();
|
||||
Iterator<T> fastRecordIterator = fastRecords.iterator();
|
||||
|
||||
while (fastRecordIterator.hasNext()) {
|
||||
if (isRecordForId(fastRecordIterator.next(), releasedId)) {
|
||||
fastRecordIterator.remove();
|
||||
releaseIdIterator.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract T getRecordFromCursor(@NonNull Cursor cursor);
|
||||
protected abstract void onBindItemViewHolder(VH viewHolder, @NonNull T record);
|
||||
protected abstract long getItemId(@NonNull T record);
|
||||
protected abstract int getItemViewType(@NonNull T record);
|
||||
protected abstract boolean isRecordForId(@NonNull T record, long id);
|
||||
|
||||
@Override
|
||||
public int getItemViewType(@NonNull Cursor cursor) {
|
||||
T record = getRecordFromCursor(cursor);
|
||||
return getItemViewType(record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor) {
|
||||
T record = getRecordFromCursor(cursor);
|
||||
onBindItemViewHolder(viewHolder, record);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
|
||||
int calculatedPosition = getCalculatedPosition(position);
|
||||
onBindItemViewHolder(viewHolder, fastRecords.get(calculatedPosition));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getFastAccessSize() {
|
||||
return fastRecords.size();
|
||||
}
|
||||
|
||||
protected T getRecordForPositionOrThrow(int position) {
|
||||
if (isFastAccessPosition(position)) {
|
||||
return fastRecords.get(getCalculatedPosition(position));
|
||||
} else {
|
||||
Cursor cursor = getCursorAtPositionOrThrow(position);
|
||||
return getRecordFromCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
protected int getFastAccessItemViewType(int position) {
|
||||
return getItemViewType(fastRecords.get(getCalculatedPosition(position)));
|
||||
}
|
||||
|
||||
protected boolean isFastAccessPosition(int position) {
|
||||
position = getCalculatedPosition(position);
|
||||
return position >= 0 && position < fastRecords.size();
|
||||
}
|
||||
|
||||
protected long getFastAccessItemId(int position) {
|
||||
return getItemId(fastRecords.get(getCalculatedPosition(position)));
|
||||
}
|
||||
|
||||
private int getCalculatedPosition(int position) {
|
||||
return hasHeaderView() ? position - 1 : position;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Bitmap;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
import org.whispersystems.signalservice.loki.database.LokiOpenGroupDatabaseProtocol;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProtocol {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = GroupDatabase.class.getSimpleName();
|
||||
|
||||
static final String TABLE_NAME = "groups";
|
||||
private static final String ID = "_id";
|
||||
static final String GROUP_ID = "group_id";
|
||||
private static final String TITLE = "title";
|
||||
private static final String MEMBERS = "members";
|
||||
private static final String AVATAR = "avatar";
|
||||
private static final String AVATAR_ID = "avatar_id";
|
||||
private static final String AVATAR_KEY = "avatar_key";
|
||||
private static final String AVATAR_CONTENT_TYPE = "avatar_content_type";
|
||||
private static final String AVATAR_RELAY = "avatar_relay";
|
||||
private static final String AVATAR_DIGEST = "avatar_digest";
|
||||
private static final String TIMESTAMP = "timestamp";
|
||||
private static final String ACTIVE = "active";
|
||||
private static final String MMS = "mms";
|
||||
|
||||
// Loki
|
||||
private static final String AVATAR_URL = "avatar_url";
|
||||
private static final String ADMINS = "admins";
|
||||
|
||||
public static final String CREATE_TABLE =
|
||||
"CREATE TABLE " + TABLE_NAME +
|
||||
" (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
GROUP_ID + " TEXT, " +
|
||||
TITLE + " TEXT, " +
|
||||
MEMBERS + " TEXT, " +
|
||||
AVATAR + " BLOB, " +
|
||||
AVATAR_ID + " INTEGER, " +
|
||||
AVATAR_KEY + " BLOB, " +
|
||||
AVATAR_CONTENT_TYPE + " TEXT, " +
|
||||
AVATAR_RELAY + " TEXT, " +
|
||||
TIMESTAMP + " INTEGER, " +
|
||||
ACTIVE + " INTEGER DEFAULT 1, " +
|
||||
AVATAR_DIGEST + " BLOB, " +
|
||||
AVATAR_URL + " TEXT, " +
|
||||
ADMINS + " TEXT, " +
|
||||
MMS + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");",
|
||||
};
|
||||
|
||||
private static final String[] GROUP_PROJECTION = {
|
||||
GROUP_ID, TITLE, MEMBERS, AVATAR, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
|
||||
TIMESTAMP, ACTIVE, MMS, AVATAR_URL, ADMINS
|
||||
};
|
||||
|
||||
static final List<String> TYPED_GROUP_PROJECTION = Stream.of(GROUP_PROJECTION).map(columnName -> TABLE_NAME + "." + columnName).toList();
|
||||
|
||||
public GroupDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public Optional<GroupRecord> getGroup(String groupId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?",
|
||||
new String[] {groupId},
|
||||
null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return getGroup(cursor);
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
Optional<GroupRecord> getGroup(Cursor cursor) {
|
||||
Reader reader = new Reader(cursor);
|
||||
return Optional.fromNullable(reader.getCurrent());
|
||||
}
|
||||
|
||||
public boolean isUnknownGroup(String groupId) {
|
||||
return !getGroup(groupId).isPresent();
|
||||
}
|
||||
|
||||
public Reader getGroupsFilteredByTitle(String constraint) {
|
||||
@SuppressLint("Recycle")
|
||||
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, TITLE + " LIKE ?",
|
||||
new String[]{"%" + constraint + "%"},
|
||||
null, null, null);
|
||||
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
public String getOrCreateGroupForMembers(List<Address> members, boolean mms, List<Address> admins) {
|
||||
Collections.sort(members);
|
||||
Collections.sort(admins);
|
||||
|
||||
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {GROUP_ID},
|
||||
MEMBERS + " = ? AND " + MMS + " = ?",
|
||||
new String[] {Address.toSerializedList(members, ','), mms ? "1" : "0"},
|
||||
null, null, null);
|
||||
try {
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID));
|
||||
} else {
|
||||
String groupId = GroupUtil.getEncodedId(allocateGroupId(), mms);
|
||||
create(groupId, null, members, null, null, admins);
|
||||
return groupId;
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public Reader getGroups() {
|
||||
@SuppressLint("Recycle")
|
||||
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null);
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
public @NonNull List<Recipient> getGroupMembers(String groupId, boolean includeSelf) {
|
||||
List<Address> members = getCurrentMembers(groupId);
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
for (Address member : members) {
|
||||
if (!includeSelf && Util.isOwnNumber(context, member))
|
||||
continue;
|
||||
|
||||
if (member.isPhone()) {
|
||||
recipients.add(Recipient.from(context, member, false));
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
public boolean isClosedGroupMember(String hexEncodedPublicKey) {
|
||||
try {
|
||||
Address address = Address.fromSerialized(hexEncodedPublicKey);
|
||||
Reader reader = DatabaseFactory.getGroupDatabase(context).getGroups();
|
||||
|
||||
GroupRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
if (record.isClosedGroup() && record.members.contains(address)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void create(@NonNull String groupId, @Nullable String title, @NonNull List<Address> members,
|
||||
@Nullable SignalServiceAttachmentPointer avatar, @Nullable String relay, @Nullable List<Address> admins)
|
||||
{
|
||||
Collections.sort(members);
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(GROUP_ID, groupId);
|
||||
contentValues.put(TITLE, title);
|
||||
contentValues.put(MEMBERS, Address.toSerializedList(members, ','));
|
||||
|
||||
if (avatar != null) {
|
||||
contentValues.put(AVATAR_ID, avatar.getId());
|
||||
contentValues.put(AVATAR_KEY, avatar.getKey());
|
||||
contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType());
|
||||
contentValues.put(AVATAR_DIGEST, avatar.getDigest().orNull());
|
||||
contentValues.put(AVATAR_URL, avatar.getUrl());
|
||||
}
|
||||
|
||||
contentValues.put(AVATAR_RELAY, relay);
|
||||
contentValues.put(TIMESTAMP, System.currentTimeMillis());
|
||||
contentValues.put(ACTIVE, 1);
|
||||
contentValues.put(MMS, GroupUtil.isMmsGroup(groupId));
|
||||
|
||||
if (admins != null) {
|
||||
contentValues.put(ADMINS, Address.toSerializedList(admins, ','));
|
||||
}
|
||||
|
||||
databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues);
|
||||
|
||||
Recipient.applyCached(Address.fromSerialized(groupId), recipient -> {
|
||||
recipient.setName(title);
|
||||
recipient.setGroupAvatarId(avatar != null ? avatar.getId() : null);
|
||||
recipient.setParticipants(Stream.of(members).map(memberAddress -> Recipient.from(context, memberAddress, true)).toList());
|
||||
});
|
||||
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public boolean delete(@NonNull String groupId) {
|
||||
int result = databaseHelper.getWritableDatabase().delete(TABLE_NAME, GROUP_ID + " = ?", new String[]{groupId});
|
||||
|
||||
if (result > 0) {
|
||||
Recipient.removeCached(Address.fromSerialized(groupId));
|
||||
notifyConversationListListeners();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void update(String groupId, String title, SignalServiceAttachmentPointer avatar) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
if (title != null) contentValues.put(TITLE, title);
|
||||
|
||||
if (avatar != null) {
|
||||
contentValues.put(AVATAR_ID, avatar.getId());
|
||||
contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType());
|
||||
contentValues.put(AVATAR_KEY, avatar.getKey());
|
||||
contentValues.put(AVATAR_DIGEST, avatar.getDigest().orNull());
|
||||
contentValues.put(AVATAR_URL, avatar.getUrl());
|
||||
}
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
|
||||
GROUP_ID + " = ?",
|
||||
new String[] {groupId});
|
||||
|
||||
Recipient.applyCached(Address.fromSerialized(groupId), recipient -> {
|
||||
recipient.setName(title);
|
||||
recipient.setGroupAvatarId(avatar != null ? avatar.getId() : null);
|
||||
});
|
||||
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTitle(String groupID, String newValue) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(TITLE, newValue);
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
|
||||
new String[] {groupID});
|
||||
|
||||
Recipient recipient = Recipient.from(context, Address.fromSerialized(groupID), false);
|
||||
recipient.setName(newValue);
|
||||
}
|
||||
|
||||
public void updateProfilePicture(String groupID, Bitmap newValue) {
|
||||
updateProfilePicture(groupID, BitmapUtil.toByteArray(newValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateProfilePicture(String groupID, byte[] newValue) {
|
||||
long avatarId;
|
||||
|
||||
if (newValue != null) avatarId = Math.abs(new SecureRandom().nextLong());
|
||||
else avatarId = 0;
|
||||
|
||||
|
||||
ContentValues contentValues = new ContentValues(2);
|
||||
contentValues.put(AVATAR, newValue);
|
||||
contentValues.put(AVATAR_ID, avatarId);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
|
||||
new String[] {groupID});
|
||||
|
||||
Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(avatarId == 0 ? null : avatarId));
|
||||
}
|
||||
|
||||
public void updateMembers(String groupId, List<Address> members) {
|
||||
Collections.sort(members);
|
||||
|
||||
ContentValues contents = new ContentValues();
|
||||
contents.put(MEMBERS, Address.toSerializedList(members, ','));
|
||||
contents.put(ACTIVE, 1);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?",
|
||||
new String[] {groupId});
|
||||
|
||||
Recipient.applyCached(Address.fromSerialized(groupId), recipient -> {
|
||||
recipient.setParticipants(Stream.of(members).map(a -> Recipient.from(context, a, false)).toList());
|
||||
});
|
||||
}
|
||||
|
||||
public void updateAdmins(String groupId, List<Address> admins) {
|
||||
Collections.sort(admins);
|
||||
|
||||
ContentValues contents = new ContentValues();
|
||||
contents.put(ADMINS, Address.toSerializedList(admins, ','));
|
||||
contents.put(ACTIVE, 1);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", new String[] {groupId});
|
||||
}
|
||||
|
||||
public void removeMember(String groupId, Address source) {
|
||||
List<Address> currentMembers = getCurrentMembers(groupId);
|
||||
currentMembers.remove(source);
|
||||
|
||||
ContentValues contents = new ContentValues();
|
||||
contents.put(MEMBERS, Address.toSerializedList(currentMembers, ','));
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?",
|
||||
new String[] {groupId});
|
||||
|
||||
Recipient.applyCached(Address.fromSerialized(groupId), recipient -> {
|
||||
List<Recipient> current = recipient.getParticipants();
|
||||
Recipient removal = Recipient.from(context, source, false);
|
||||
|
||||
current.remove(removal);
|
||||
recipient.setParticipants(current);
|
||||
});
|
||||
}
|
||||
|
||||
private List<Address> getCurrentMembers(String groupId) {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {MEMBERS},
|
||||
GROUP_ID + " = ?",
|
||||
new String[] {groupId},
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS));
|
||||
return Address.fromSerializedList(serializedMembers, ',');
|
||||
}
|
||||
|
||||
return new LinkedList<>();
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive(String groupId) {
|
||||
Optional<GroupRecord> record = getGroup(groupId);
|
||||
return record.isPresent() && record.get().isActive();
|
||||
}
|
||||
|
||||
public void setActive(String groupId, boolean active) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(ACTIVE, active ? 1 : 0);
|
||||
database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId});
|
||||
}
|
||||
|
||||
public byte[] allocateGroupId() {
|
||||
byte[] groupId = new byte[16];
|
||||
new SecureRandom().nextBytes(groupId);
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public boolean hasGroup(@NonNull String groupId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().rawQuery(
|
||||
"SELECT 1 FROM " + TABLE_NAME + " WHERE " + GROUP_ID + " = ? LIMIT 1",
|
||||
new String[]{groupId}
|
||||
)) {
|
||||
return cursor.getCount() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Reader implements Closeable {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
public Reader(Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public @Nullable GroupRecord getNext() {
|
||||
if (cursor == null || !cursor.moveToNext()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public @Nullable GroupRecord getCurrent() {
|
||||
if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GroupRecord(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(TITLE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)),
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(AVATAR_ID)),
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_KEY)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_CONTENT_TYPE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_RELAY)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(ACTIVE)) == 1,
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_DIGEST)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(MMS)) == 1,
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_URL)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ADMINS)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (this.cursor != null)
|
||||
this.cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupRecord {
|
||||
|
||||
private final String id;
|
||||
private final String title;
|
||||
private final List<Address> members;
|
||||
private final byte[] avatar;
|
||||
private final long avatarId;
|
||||
private final byte[] avatarKey;
|
||||
private final byte[] avatarDigest;
|
||||
private final String avatarContentType;
|
||||
private final String relay;
|
||||
private final boolean active;
|
||||
private final boolean mms;
|
||||
private final String url;
|
||||
private final List<Address> admins;
|
||||
|
||||
public GroupRecord(String id, String title, String members, byte[] avatar,
|
||||
long avatarId, byte[] avatarKey, String avatarContentType,
|
||||
String relay, boolean active, byte[] avatarDigest, boolean mms, String url, String admins)
|
||||
{
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.avatar = avatar;
|
||||
this.avatarId = avatarId;
|
||||
this.avatarKey = avatarKey;
|
||||
this.avatarDigest = avatarDigest;
|
||||
this.avatarContentType = avatarContentType;
|
||||
this.relay = relay;
|
||||
this.active = active;
|
||||
this.mms = mms;
|
||||
this.url = url;
|
||||
|
||||
if (!TextUtils.isEmpty(members)) this.members = Address.fromSerializedList(members, ',');
|
||||
else this.members = new LinkedList<>();
|
||||
|
||||
if (!TextUtils.isEmpty(admins)) this.admins = Address.fromSerializedList(admins, ',');
|
||||
else this.admins = new LinkedList<>();
|
||||
}
|
||||
|
||||
public byte[] getId() {
|
||||
try {
|
||||
return GroupUtil.getDecodedId(id);
|
||||
} catch (IOException ioe) {
|
||||
throw new AssertionError(ioe);
|
||||
}
|
||||
}
|
||||
|
||||
public String getEncodedId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public List<Address> getMembers() {
|
||||
return members;
|
||||
}
|
||||
|
||||
public byte[] getAvatar() {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public long getAvatarId() {
|
||||
return avatarId;
|
||||
}
|
||||
|
||||
public byte[] getAvatarKey() {
|
||||
return avatarKey;
|
||||
}
|
||||
|
||||
public byte[] getAvatarDigest() {
|
||||
return avatarDigest;
|
||||
}
|
||||
|
||||
public String getAvatarContentType() {
|
||||
return avatarContentType;
|
||||
}
|
||||
|
||||
public String getRelay() {
|
||||
return relay;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public boolean isMms() {
|
||||
return mms;
|
||||
}
|
||||
|
||||
public boolean isOpenGroup() { return Address.fromSerialized(id).isOpenGroup(); }
|
||||
|
||||
public boolean isRSSFeed() { return Address.fromSerialized(id).isRSSFeed(); }
|
||||
|
||||
public boolean isClosedGroup() { return Address.fromSerialized(id).isClosedGroup(); }
|
||||
|
||||
public String getUrl() { return url; }
|
||||
|
||||
public List<Address> getAdmins() { return admins; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class GroupReceiptDatabase extends Database {
|
||||
|
||||
public static final String TABLE_NAME = "group_receipts";
|
||||
|
||||
private static final String ID = "_id";
|
||||
public static final String MMS_ID = "mms_id";
|
||||
private static final String ADDRESS = "address";
|
||||
private static final String STATUS = "status";
|
||||
private static final String TIMESTAMP = "timestamp";
|
||||
private static final String UNIDENTIFIED = "unidentified";
|
||||
|
||||
public static final int STATUS_UNKNOWN = -1;
|
||||
public static final int STATUS_UNDELIVERED = 0;
|
||||
public static final int STATUS_DELIVERED = 1;
|
||||
public static final int STATUS_READ = 2;
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
MMS_ID + " INTEGER, " + ADDRESS + " TEXT, " + STATUS + " INTEGER, " + TIMESTAMP + " INTEGER, " + UNIDENTIFIED + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXES = {
|
||||
"CREATE INDEX IF NOT EXISTS group_receipt_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
|
||||
};
|
||||
|
||||
public GroupReceiptDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void insert(List<Address> addresses, long mmsId, int status, long timestamp) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
for (Address address : addresses) {
|
||||
ContentValues values = new ContentValues(4);
|
||||
values.put(MMS_ID, mmsId);
|
||||
values.put(ADDRESS, address.serialize());
|
||||
values.put(STATUS, status);
|
||||
values.put(TIMESTAMP, timestamp);
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
}
|
||||
|
||||
public void update(Address address, long mmsId, int status, long timestamp) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(STATUS, status);
|
||||
values.put(TIMESTAMP, timestamp);
|
||||
|
||||
db.update(TABLE_NAME, values, MMS_ID + " = ? AND " + ADDRESS + " = ? AND " + STATUS + " < ?",
|
||||
new String[] {String.valueOf(mmsId), address.serialize(), String.valueOf(status)});
|
||||
}
|
||||
|
||||
public void setUnidentified(Address address, long mmsId, boolean unidentified) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(UNIDENTIFIED, unidentified ? 1 : 0);
|
||||
|
||||
db.update(TABLE_NAME, values, MMS_ID + " = ? AND " + ADDRESS + " = ?",
|
||||
new String[] {String.valueOf(mmsId), address.serialize()});
|
||||
|
||||
}
|
||||
|
||||
public @NonNull List<GroupReceiptInfo> getGroupReceiptInfo(long mmsId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<GroupReceiptInfo> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
results.add(new GroupReceiptInfo(Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
void deleteRowsForMessage(long mmsId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)});
|
||||
}
|
||||
|
||||
void deleteAllRows() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
}
|
||||
|
||||
public static class GroupReceiptInfo {
|
||||
private final Address address;
|
||||
private final int status;
|
||||
private final long timestamp;
|
||||
private final boolean unidentified;
|
||||
|
||||
GroupReceiptInfo(Address address, int status, long timestamp, boolean unidentified) {
|
||||
this.address = address;
|
||||
this.status = status;
|
||||
this.timestamp = timestamp;
|
||||
this.unidentified = unidentified;
|
||||
}
|
||||
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public boolean isUnidentified() {
|
||||
return unidentified;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class IdentityDatabase extends Database {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = IdentityDatabase.class.getSimpleName();
|
||||
|
||||
private static final String TABLE_NAME = "identities";
|
||||
private static final String ID = "_id";
|
||||
private static final String ADDRESS = "address";
|
||||
private static final String IDENTITY_KEY = "key";
|
||||
private static final String TIMESTAMP = "timestamp";
|
||||
private static final String FIRST_USE = "first_use";
|
||||
private static final String NONBLOCKING_APPROVAL = "nonblocking_approval";
|
||||
private static final String VERIFIED = "verified";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME +
|
||||
" (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
ADDRESS + " TEXT UNIQUE, " +
|
||||
IDENTITY_KEY + " TEXT, " +
|
||||
FIRST_USE + " INTEGER DEFAULT 0, " +
|
||||
TIMESTAMP + " INTEGER DEFAULT 0, " +
|
||||
VERIFIED + " INTEGER DEFAULT 0, " +
|
||||
NONBLOCKING_APPROVAL + " INTEGER DEFAULT 0);";
|
||||
|
||||
public enum VerifiedStatus {
|
||||
DEFAULT, VERIFIED, UNVERIFIED;
|
||||
|
||||
public int toInt() {
|
||||
if (this == DEFAULT) return 0;
|
||||
else if (this == VERIFIED) return 1;
|
||||
else if (this == UNVERIFIED) return 2;
|
||||
else throw new AssertionError();
|
||||
}
|
||||
|
||||
public static VerifiedStatus forState(int state) {
|
||||
if (state == 0) return DEFAULT;
|
||||
else if (state == 1) return VERIFIED;
|
||||
else if (state == 2) return UNVERIFIED;
|
||||
else throw new AssertionError("No such state: " + state);
|
||||
}
|
||||
}
|
||||
|
||||
IdentityDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public Cursor getIdentities() {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
return database.query(TABLE_NAME, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public @Nullable IdentityReader readerFor(@Nullable Cursor cursor) {
|
||||
if (cursor == null) return null;
|
||||
return new IdentityReader(cursor);
|
||||
}
|
||||
|
||||
public Optional<IdentityRecord> getIdentity(Address address) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?",
|
||||
new String[] {address.serialize()}, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return Optional.of(getIdentityRecord(cursor));
|
||||
}
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
throw new AssertionError(e);
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
public void saveIdentity(Address address, IdentityKey identityKey, VerifiedStatus verifiedStatus,
|
||||
boolean firstUse, long timestamp, boolean nonBlockingApproval)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String identityKeyString = Base64.encodeBytes(identityKey.serialize());
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
contentValues.put(IDENTITY_KEY, identityKeyString);
|
||||
contentValues.put(TIMESTAMP, timestamp);
|
||||
contentValues.put(VERIFIED, verifiedStatus.toInt());
|
||||
contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval ? 1 : 0);
|
||||
contentValues.put(FIRST_USE, firstUse ? 1 : 0);
|
||||
|
||||
database.replace(TABLE_NAME, null, contentValues);
|
||||
|
||||
EventBus.getDefault().post(new IdentityRecord(address, identityKey, verifiedStatus,
|
||||
firstUse, timestamp, nonBlockingApproval));
|
||||
}
|
||||
|
||||
public void setApproval(Address address, boolean nonBlockingApproval) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
ContentValues contentValues = new ContentValues(2);
|
||||
contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, ADDRESS + " = ?", new String[] {address.serialize()});
|
||||
}
|
||||
|
||||
public void setVerified(Address address, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(VERIFIED, verifiedStatus.toInt());
|
||||
|
||||
int updated = database.update(TABLE_NAME, contentValues, ADDRESS + " = ? AND " + IDENTITY_KEY + " = ?",
|
||||
new String[] {address.serialize(), Base64.encodeBytes(identityKey.serialize())});
|
||||
|
||||
if (updated > 0) {
|
||||
Optional<IdentityRecord> record = getIdentity(address);
|
||||
if (record.isPresent()) EventBus.getDefault().post(record.get());
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityRecord getIdentityRecord(@NonNull Cursor cursor) throws IOException, InvalidKeyException {
|
||||
String address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS));
|
||||
String serializedIdentity = cursor.getString(cursor.getColumnIndexOrThrow(IDENTITY_KEY));
|
||||
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP));
|
||||
int verifiedStatus = cursor.getInt(cursor.getColumnIndexOrThrow(VERIFIED));
|
||||
boolean nonblockingApproval = cursor.getInt(cursor.getColumnIndexOrThrow(NONBLOCKING_APPROVAL)) == 1;
|
||||
boolean firstUse = cursor.getInt(cursor.getColumnIndexOrThrow(FIRST_USE)) == 1;
|
||||
IdentityKey identity = new IdentityKey(Base64.decode(serializedIdentity), 0);
|
||||
|
||||
return new IdentityRecord(Address.fromSerialized(address), identity, VerifiedStatus.forState(verifiedStatus), firstUse, timestamp, nonblockingApproval);
|
||||
}
|
||||
|
||||
public static class IdentityRecord {
|
||||
|
||||
private final Address address;
|
||||
private final IdentityKey identitykey;
|
||||
private final VerifiedStatus verifiedStatus;
|
||||
private final boolean firstUse;
|
||||
private final long timestamp;
|
||||
private final boolean nonblockingApproval;
|
||||
|
||||
private IdentityRecord(Address address,
|
||||
IdentityKey identitykey, VerifiedStatus verifiedStatus,
|
||||
boolean firstUse, long timestamp, boolean nonblockingApproval)
|
||||
{
|
||||
this.address = address;
|
||||
this.identitykey = identitykey;
|
||||
this.verifiedStatus = verifiedStatus;
|
||||
this.firstUse = firstUse;
|
||||
this.timestamp = timestamp;
|
||||
this.nonblockingApproval = nonblockingApproval;
|
||||
}
|
||||
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
return identitykey;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public VerifiedStatus getVerifiedStatus() {
|
||||
return verifiedStatus;
|
||||
}
|
||||
|
||||
public boolean isApprovedNonBlocking() {
|
||||
return nonblockingApproval;
|
||||
}
|
||||
|
||||
public boolean isFirstUse() {
|
||||
return firstUse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return "{address: " + address + ", identityKey: " + identitykey + ", verifiedStatus: " + verifiedStatus + ", firstUse: " + firstUse + "}";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class IdentityReader {
|
||||
private final Cursor cursor;
|
||||
|
||||
IdentityReader(@NonNull Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public @Nullable IdentityRecord getNext() {
|
||||
if (cursor.moveToNext()) {
|
||||
try {
|
||||
return getIdentityRecord(cursor);
|
||||
} catch (IOException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec;
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class JobDatabase extends Database {
|
||||
|
||||
public static final String[] CREATE_TABLE = new String[] { Jobs.CREATE_TABLE,
|
||||
Constraints.CREATE_TABLE,
|
||||
Dependencies.CREATE_TABLE };
|
||||
|
||||
public static final class Jobs {
|
||||
public static final String TABLE_NAME = "job_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String FACTORY_KEY = "factory_key";
|
||||
private static final String QUEUE_KEY = "queue_key";
|
||||
private static final String CREATE_TIME = "create_time";
|
||||
private static final String NEXT_RUN_ATTEMPT_TIME = "next_run_attempt_time";
|
||||
private static final String RUN_ATTEMPT = "run_attempt";
|
||||
private static final String MAX_ATTEMPTS = "max_attempts";
|
||||
private static final String MAX_BACKOFF = "max_backoff";
|
||||
private static final String MAX_INSTANCES = "max_instances";
|
||||
private static final String LIFESPAN = "lifespan";
|
||||
private static final String SERIALIZED_DATA = "serialized_data";
|
||||
private static final String IS_RUNNING = "is_running";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT UNIQUE, " +
|
||||
FACTORY_KEY + " TEXT, " +
|
||||
QUEUE_KEY + " TEXT, " +
|
||||
CREATE_TIME + " INTEGER, " +
|
||||
NEXT_RUN_ATTEMPT_TIME + " INTEGER, " +
|
||||
RUN_ATTEMPT + " INTEGER, " +
|
||||
MAX_ATTEMPTS + " INTEGER, " +
|
||||
MAX_BACKOFF + " INTEGER, " +
|
||||
MAX_INSTANCES + " INTEGER, " +
|
||||
LIFESPAN + " INTEGER, " +
|
||||
SERIALIZED_DATA + " TEXT, " +
|
||||
IS_RUNNING + " INTEGER)";
|
||||
}
|
||||
|
||||
public static final class Constraints {
|
||||
public static final String TABLE_NAME = "constraint_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String FACTORY_KEY = "factory_key";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT, " +
|
||||
FACTORY_KEY + " TEXT, " +
|
||||
"UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))";
|
||||
}
|
||||
|
||||
public static final class Dependencies {
|
||||
public static final String TABLE_NAME = "dependency_spec";
|
||||
private static final String ID = "_id";
|
||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||
private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id";
|
||||
|
||||
private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
JOB_SPEC_ID + " TEXT, " +
|
||||
DEPENDS_ON_JOB_SPEC_ID + " TEXT, " +
|
||||
"UNIQUE(" + JOB_SPEC_ID + ", " + DEPENDS_ON_JOB_SPEC_ID + "))";
|
||||
}
|
||||
|
||||
|
||||
public JobDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (FullSpec fullSpec : fullSpecs) {
|
||||
insertJobSpec(db, fullSpec.getJobSpec());
|
||||
insertConstraintSpecs(db, fullSpec.getConstraintSpecs());
|
||||
insertDependencySpecs(db, fullSpec.getDependencySpecs());
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<JobSpec> getAllJobSpecs() {
|
||||
List<JobSpec> jobs = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Jobs.TABLE_NAME, null, null, null, null, null, Jobs.CREATE_TIME + ", " + Jobs.ID + " ASC")) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
jobs.add(jobSpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
|
||||
|
||||
String query = Jobs.JOB_SPEC_ID + " = ?";
|
||||
String[] args = new String[]{ id };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0);
|
||||
contentValues.put(Jobs.RUN_ATTEMPT, runAttempt);
|
||||
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, nextRunAttemptTime);
|
||||
|
||||
String query = Jobs.JOB_SPEC_ID + " = ?";
|
||||
String[] args = new String[]{ id };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
public synchronized void updateAllJobsToBePending() {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.IS_RUNNING, 0);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null);
|
||||
}
|
||||
|
||||
public synchronized void deleteJobs(@NonNull List<String> jobIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (String jobId : jobIds) {
|
||||
String[] arg = new String[]{jobId};
|
||||
|
||||
db.delete(Jobs.TABLE_NAME, Jobs.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Constraints.TABLE_NAME, Constraints.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Dependencies.TABLE_NAME, Dependencies.JOB_SPEC_ID + " = ?", arg);
|
||||
db.delete(Dependencies.TABLE_NAME, Dependencies.DEPENDS_ON_JOB_SPEC_ID + " = ?", arg);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() {
|
||||
List<ConstraintSpec> constraints = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Constraints.TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
constraints.add(constraintSpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
public synchronized @NonNull List<DependencySpec> getAllDependencySpecs() {
|
||||
List<DependencySpec> dependencies = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(Dependencies.TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
dependencies.add(dependencySpecFromCursor(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private void insertJobSpec(@NonNull SQLiteDatabase db, @NonNull JobSpec job) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Jobs.JOB_SPEC_ID, job.getId());
|
||||
contentValues.put(Jobs.FACTORY_KEY, job.getFactoryKey());
|
||||
contentValues.put(Jobs.QUEUE_KEY, job.getQueueKey());
|
||||
contentValues.put(Jobs.CREATE_TIME, job.getCreateTime());
|
||||
contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime());
|
||||
contentValues.put(Jobs.RUN_ATTEMPT, job.getRunAttempt());
|
||||
contentValues.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts());
|
||||
contentValues.put(Jobs.MAX_BACKOFF, job.getMaxBackoff());
|
||||
contentValues.put(Jobs.MAX_INSTANCES, job.getMaxInstances());
|
||||
contentValues.put(Jobs.LIFESPAN, job.getLifespan());
|
||||
contentValues.put(Jobs.SERIALIZED_DATA, job.getSerializedData());
|
||||
contentValues.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0);
|
||||
|
||||
db.insertWithOnConflict(Jobs.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
|
||||
private void insertConstraintSpecs(@NonNull SQLiteDatabase db, @NonNull List<ConstraintSpec> constraints) {
|
||||
for (ConstraintSpec constraintSpec : constraints) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Constraints.JOB_SPEC_ID, constraintSpec.getJobSpecId());
|
||||
contentValues.put(Constraints.FACTORY_KEY, constraintSpec.getFactoryKey());
|
||||
db.insertWithOnConflict(Constraints.TABLE_NAME, null ,contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertDependencySpecs(@NonNull SQLiteDatabase db, @NonNull List<DependencySpec> dependencies) {
|
||||
for (DependencySpec dependencySpec : dependencies) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(Dependencies.JOB_SPEC_ID, dependencySpec.getJobId());
|
||||
contentValues.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, dependencySpec.getDependsOnJobId());
|
||||
db.insertWithOnConflict(Dependencies.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull JobSpec jobSpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new JobSpec(cursor.getString(cursor.getColumnIndexOrThrow(Jobs.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.FACTORY_KEY)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.QUEUE_KEY)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.CREATE_TIME)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.NEXT_RUN_ATTEMPT_TIME)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.RUN_ATTEMPT)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_ATTEMPTS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.MAX_BACKOFF)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.LIFESPAN)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_INSTANCES)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_DATA)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.IS_RUNNING)) == 1);
|
||||
}
|
||||
|
||||
private @NonNull ConstraintSpec constraintSpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new ConstraintSpec(cursor.getString(cursor.getColumnIndexOrThrow(Constraints.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Constraints.FACTORY_KEY)));
|
||||
}
|
||||
|
||||
private @NonNull DependencySpec dependencySpecFromCursor(@NonNull Cursor cursor) {
|
||||
return new DependencySpec(cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.JOB_SPEC_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.DEPENDS_ON_JOB_SPEC_ID)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class MediaDatabase extends Database {
|
||||
|
||||
private static final String BASE_MEDIA_QUERY = "SELECT " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " AS " + AttachmentDatabase.ROW_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL_ASPECT_RATIO + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DIGEST + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + ", "
|
||||
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.ADDRESS + " "
|
||||
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
|
||||
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
|
||||
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
|
||||
+ " FROM " + MmsDatabase.TABLE_NAME
|
||||
+ " WHERE " + MmsDatabase.THREAD_ID + " = ?) AND (%s) AND "
|
||||
+ AttachmentDatabase.DATA + " IS NOT NULL AND "
|
||||
+ AttachmentDatabase.QUOTE + " = 0 AND "
|
||||
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL "
|
||||
+ "ORDER BY " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC";
|
||||
|
||||
private static final String GALLERY_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'image/%' OR " + AttachmentDatabase.CONTENT_TYPE + " LIKE 'video/%'");
|
||||
private static final String DOCUMENT_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'image/%' AND " +
|
||||
AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'video/%' AND " +
|
||||
AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'audio/%' AND " +
|
||||
AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'text/x-signal-plain'");
|
||||
|
||||
MediaDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public Cursor getGalleryMediaForThread(long threadId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""});
|
||||
setNotifyConverationListeners(cursor, threadId);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public void subscribeToMediaChanges(@NonNull ContentObserver observer) {
|
||||
registerAttachmentListeners(observer);
|
||||
}
|
||||
|
||||
public void unsubscribeToMediaChanges(@NonNull ContentObserver observer) {
|
||||
context.getContentResolver().unregisterContentObserver(observer);
|
||||
}
|
||||
|
||||
public Cursor getDocumentMediaForThread(long threadId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""});
|
||||
setNotifyConverationListeners(cursor, threadId);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public static class MediaRecord {
|
||||
|
||||
private final DatabaseAttachment attachment;
|
||||
private final Address address;
|
||||
private final long date;
|
||||
private final boolean outgoing;
|
||||
|
||||
private MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) {
|
||||
this.attachment = attachment;
|
||||
this.address = address;
|
||||
this.date = date;
|
||||
this.outgoing = outgoing;
|
||||
}
|
||||
|
||||
public static MediaRecord from(@NonNull Context context, @NonNull Cursor cursor) {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
List<DatabaseAttachment> attachments = attachmentDatabase.getAttachment(cursor);
|
||||
String serializedAddress = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS));
|
||||
boolean outgoing = MessagingDatabase.Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)));
|
||||
Address address = null;
|
||||
|
||||
if (serializedAddress != null) {
|
||||
address = Address.fromSerialized(serializedAddress);
|
||||
}
|
||||
|
||||
long date;
|
||||
|
||||
if (MmsDatabase.Types.isPushType(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)))) {
|
||||
date = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.DATE_SENT));
|
||||
} else {
|
||||
date = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.DATE_RECEIVED));
|
||||
}
|
||||
|
||||
return new MediaRecord(attachments != null && attachments.size() > 0 ? attachments.get(0) : null, address, date, outgoing);
|
||||
}
|
||||
|
||||
public DatabaseAttachment getAttachment() {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return attachment.getContentType();
|
||||
}
|
||||
|
||||
public @Nullable Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public long getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public boolean isOutgoing() {
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.documents.Document;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class MessagingDatabase extends Database implements MmsSmsColumns {
|
||||
|
||||
private static final String TAG = MessagingDatabase.class.getSimpleName();
|
||||
|
||||
public MessagingDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
protected abstract String getTableName();
|
||||
|
||||
public abstract void markExpireStarted(long messageId);
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
|
||||
public abstract void markAsSent(long messageId, boolean secure);
|
||||
public abstract void markUnidentified(long messageId, boolean unidentified);
|
||||
|
||||
public void setMismatchedIdentity(long messageId, final Address address, final IdentityKey identityKey) {
|
||||
List<IdentityKeyMismatch> items = new ArrayList<IdentityKeyMismatch>() {{
|
||||
add(new IdentityKeyMismatch(address, identityKey));
|
||||
}};
|
||||
|
||||
IdentityKeyMismatchList document = new IdentityKeyMismatchList(items);
|
||||
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
|
||||
try {
|
||||
setDocument(database, messageId, MISMATCHED_IDENTITIES, document);
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void addMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
|
||||
try {
|
||||
addToDocument(messageId, MISMATCHED_IDENTITIES,
|
||||
new IdentityKeyMismatch(address, identityKey),
|
||||
IdentityKeyMismatchList.class);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeMismatchedIdentity(long messageId, Address address, IdentityKey identityKey) {
|
||||
try {
|
||||
removeFromDocument(messageId, MISMATCHED_IDENTITIES,
|
||||
new IdentityKeyMismatch(address, identityKey),
|
||||
IdentityKeyMismatchList.class);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
protected <D extends Document<I>, I> void removeFromDocument(long messageId, String column, I object, Class<D> clazz) throws IOException {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
|
||||
try {
|
||||
D document = getDocument(database, messageId, column, clazz);
|
||||
Iterator<I> iterator = document.getList().iterator();
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
I item = iterator.next();
|
||||
|
||||
if (item.equals(object)) {
|
||||
iterator.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setDocument(database, messageId, column, document);
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
protected <T extends Document<I>, I> void addToDocument(long messageId, String column, final I object, Class<T> clazz) throws IOException {
|
||||
List<I> list = new ArrayList<I>() {{
|
||||
add(object);
|
||||
}};
|
||||
|
||||
addToDocument(messageId, column, list, clazz);
|
||||
}
|
||||
|
||||
protected <T extends Document<I>, I> void addToDocument(long messageId, String column, List<I> objects, Class<T> clazz) throws IOException {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
|
||||
try {
|
||||
T document = getDocument(database, messageId, column, clazz);
|
||||
document.getList().addAll(objects);
|
||||
setDocument(database, messageId, column, document);
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
private void setDocument(SQLiteDatabase database, long messageId, String column, Document document) throws IOException {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
|
||||
if (document == null || document.size() == 0) {
|
||||
contentValues.put(column, (String)null);
|
||||
} else {
|
||||
contentValues.put(column, JsonUtils.toJson(document));
|
||||
}
|
||||
|
||||
database.update(getTableName(), contentValues, ID_WHERE, new String[] {String.valueOf(messageId)});
|
||||
}
|
||||
|
||||
private <D extends Document> D getDocument(SQLiteDatabase database, long messageId,
|
||||
String column, Class<D> clazz)
|
||||
{
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(getTableName(), new String[] {column},
|
||||
ID_WHERE, new String[] {String.valueOf(messageId)},
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
String document = cursor.getString(cursor.getColumnIndexOrThrow(column));
|
||||
|
||||
try {
|
||||
if (!TextUtils.isEmpty(document)) {
|
||||
return JsonUtils.fromJson(document, clazz);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return clazz.newInstance();
|
||||
} catch (InstantiationException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static class SyncMessageId {
|
||||
|
||||
private final Address address;
|
||||
private final long timetamp;
|
||||
|
||||
public SyncMessageId(Address address, long timetamp) {
|
||||
this.address = address;
|
||||
this.timetamp = timetamp;
|
||||
}
|
||||
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public long getTimetamp() {
|
||||
return timetamp;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExpirationInfo {
|
||||
|
||||
private final long id;
|
||||
private final long expiresIn;
|
||||
private final long expireStarted;
|
||||
private final boolean mms;
|
||||
|
||||
public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) {
|
||||
this.id = id;
|
||||
this.expiresIn = expiresIn;
|
||||
this.expireStarted = expireStarted;
|
||||
this.mms = mms;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public long getExpireStarted() {
|
||||
return expireStarted;
|
||||
}
|
||||
|
||||
public boolean isMms() {
|
||||
return mms;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MarkedMessageInfo {
|
||||
|
||||
private final SyncMessageId syncMessageId;
|
||||
private final ExpirationInfo expirationInfo;
|
||||
|
||||
public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) {
|
||||
this.syncMessageId = syncMessageId;
|
||||
this.expirationInfo = expirationInfo;
|
||||
}
|
||||
|
||||
public SyncMessageId getSyncMessageId() {
|
||||
return syncMessageId;
|
||||
}
|
||||
|
||||
public ExpirationInfo getExpirationInfo() {
|
||||
return expirationInfo;
|
||||
}
|
||||
}
|
||||
|
||||
public static class InsertResult {
|
||||
private final long messageId;
|
||||
private final long threadId;
|
||||
|
||||
public InsertResult(long messageId, long threadId) {
|
||||
this.messageId = messageId;
|
||||
this.threadId = threadId;
|
||||
}
|
||||
|
||||
public long getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public long getThreadId() {
|
||||
return threadId;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
@SuppressWarnings("UnnecessaryInterfaceModifier")
|
||||
public interface MmsSmsColumns {
|
||||
|
||||
public static final String ID = "_id";
|
||||
public static final String NORMALIZED_DATE_SENT = "date_sent";
|
||||
public static final String NORMALIZED_DATE_RECEIVED = "date_received";
|
||||
public static final String THREAD_ID = "thread_id";
|
||||
public static final String READ = "read";
|
||||
public static final String BODY = "body";
|
||||
public static final String ADDRESS = "address";
|
||||
public static final String ADDRESS_DEVICE_ID = "address_device_id";
|
||||
public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count";
|
||||
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
|
||||
public static final String MISMATCHED_IDENTITIES = "mismatched_identities";
|
||||
public static final String UNIQUE_ROW_ID = "unique_row_id";
|
||||
public static final String SUBSCRIPTION_ID = "subscription_id";
|
||||
public static final String EXPIRES_IN = "expires_in";
|
||||
public static final String EXPIRE_STARTED = "expire_started";
|
||||
public static final String NOTIFIED = "notified";
|
||||
public static final String UNIDENTIFIED = "unidentified";
|
||||
|
||||
public static class Types {
|
||||
protected static final long TOTAL_MASK = 0xFFFFFFFF;
|
||||
|
||||
// Base Types
|
||||
protected static final long BASE_TYPE_MASK = 0x1F;
|
||||
|
||||
protected static final long INCOMING_CALL_TYPE = 1;
|
||||
protected static final long OUTGOING_CALL_TYPE = 2;
|
||||
protected static final long MISSED_CALL_TYPE = 3;
|
||||
protected static final long JOINED_TYPE = 4;
|
||||
|
||||
protected static final long BASE_INBOX_TYPE = 20;
|
||||
protected static final long BASE_OUTBOX_TYPE = 21;
|
||||
protected static final long BASE_SENDING_TYPE = 22;
|
||||
protected static final long BASE_SENT_TYPE = 23;
|
||||
protected static final long BASE_SENT_FAILED_TYPE = 24;
|
||||
protected static final long BASE_PENDING_SECURE_SMS_FALLBACK = 25;
|
||||
protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26;
|
||||
public static final long BASE_DRAFT_TYPE = 27;
|
||||
|
||||
protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE,
|
||||
BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE,
|
||||
BASE_PENDING_SECURE_SMS_FALLBACK,
|
||||
BASE_PENDING_INSECURE_SMS_FALLBACK,
|
||||
OUTGOING_CALL_TYPE};
|
||||
|
||||
// Message attributes
|
||||
protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0;
|
||||
protected static final long MESSAGE_FORCE_SMS_BIT = 0x40;
|
||||
|
||||
// Key Exchange Information
|
||||
protected static final long KEY_EXCHANGE_MASK = 0xFF00;
|
||||
protected static final long KEY_EXCHANGE_BIT = 0x8000;
|
||||
protected static final long KEY_EXCHANGE_IDENTITY_VERIFIED_BIT = 0x4000;
|
||||
protected static final long KEY_EXCHANGE_IDENTITY_DEFAULT_BIT = 0x2000;
|
||||
protected static final long KEY_EXCHANGE_CORRUPTED_BIT = 0x1000;
|
||||
protected static final long KEY_EXCHANGE_INVALID_VERSION_BIT = 0x800;
|
||||
protected static final long KEY_EXCHANGE_BUNDLE_BIT = 0x400;
|
||||
protected static final long KEY_EXCHANGE_IDENTITY_UPDATE_BIT = 0x200;
|
||||
protected static final long KEY_EXCHANGE_CONTENT_FORMAT = 0x100;
|
||||
|
||||
// Secure Message Information
|
||||
protected static final long SECURE_MESSAGE_BIT = 0x800000;
|
||||
protected static final long END_SESSION_BIT = 0x400000;
|
||||
protected static final long PUSH_MESSAGE_BIT = 0x200000;
|
||||
|
||||
// Group Message Information
|
||||
protected static final long GROUP_UPDATE_BIT = 0x10000;
|
||||
protected static final long GROUP_QUIT_BIT = 0x20000;
|
||||
protected static final long EXPIRATION_TIMER_UPDATE_BIT = 0x40000;
|
||||
|
||||
// Encrypted Storage Information XXX
|
||||
public static final long ENCRYPTION_MASK = 0xFF000000;
|
||||
// public static final long ENCRYPTION_SYMMETRIC_BIT = 0x80000000; Deprecated
|
||||
// protected static final long ENCRYPTION_ASYMMETRIC_BIT = 0x40000000; Deprecated
|
||||
protected static final long ENCRYPTION_REMOTE_BIT = 0x20000000;
|
||||
protected static final long ENCRYPTION_REMOTE_FAILED_BIT = 0x10000000;
|
||||
protected static final long ENCRYPTION_REMOTE_NO_SESSION_BIT = 0x08000000;
|
||||
protected static final long ENCRYPTION_REMOTE_DUPLICATE_BIT = 0x04000000;
|
||||
protected static final long ENCRYPTION_REMOTE_LEGACY_BIT = 0x02000000;
|
||||
|
||||
// Loki
|
||||
protected static final long ENCRYPTION_LOKI_SESSION_RESTORE_SENT_BIT = 0x01000000;
|
||||
protected static final long ENCRYPTION_LOKI_SESSION_RESTORE_DONE_BIT = 0x00100000;
|
||||
|
||||
public static boolean isDraftMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isFailedMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isOutgoingMessageType(long type) {
|
||||
for (long outgoingType : OUTGOING_MESSAGE_TYPES) {
|
||||
if ((type & BASE_TYPE_MASK) == outgoingType)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static long getOutgoingEncryptedMessageType() {
|
||||
return Types.BASE_SENDING_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT;
|
||||
}
|
||||
|
||||
public static long getOutgoingSmsMessageType() {
|
||||
return Types.BASE_SENDING_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isForcedSms(long type) {
|
||||
return (type & MESSAGE_FORCE_SMS_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isPendingMessageType(long type) {
|
||||
return
|
||||
(type & BASE_TYPE_MASK) == BASE_OUTBOX_TYPE ||
|
||||
(type & BASE_TYPE_MASK) == BASE_SENDING_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isSentType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_SENT_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isPendingSmsFallbackType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_PENDING_INSECURE_SMS_FALLBACK ||
|
||||
(type & BASE_TYPE_MASK) == BASE_PENDING_SECURE_SMS_FALLBACK;
|
||||
}
|
||||
|
||||
public static boolean isPendingSecureSmsFallbackType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_PENDING_SECURE_SMS_FALLBACK;
|
||||
}
|
||||
|
||||
public static boolean isPendingInsecureSmsFallbackType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_PENDING_INSECURE_SMS_FALLBACK;
|
||||
}
|
||||
|
||||
public static boolean isInboxType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_INBOX_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isJoinedType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == JOINED_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isSecureType(long type) {
|
||||
return (type & SECURE_MESSAGE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isPushType(long type) {
|
||||
return (type & PUSH_MESSAGE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isEndSessionType(long type) {
|
||||
return (type & END_SESSION_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isKeyExchangeType(long type) {
|
||||
return (type & KEY_EXCHANGE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isIdentityVerified(long type) {
|
||||
return (type & KEY_EXCHANGE_IDENTITY_VERIFIED_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isIdentityDefault(long type) {
|
||||
return (type & KEY_EXCHANGE_IDENTITY_DEFAULT_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isCorruptedKeyExchange(long type) {
|
||||
return (type & KEY_EXCHANGE_CORRUPTED_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isInvalidVersionKeyExchange(long type) {
|
||||
return (type & KEY_EXCHANGE_INVALID_VERSION_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isBundleKeyExchange(long type) {
|
||||
return (type & KEY_EXCHANGE_BUNDLE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isContentBundleKeyExchange(long type) {
|
||||
return (type & KEY_EXCHANGE_CONTENT_FORMAT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isIdentityUpdate(long type) {
|
||||
return (type & KEY_EXCHANGE_IDENTITY_UPDATE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isCallLog(long type) {
|
||||
return type == INCOMING_CALL_TYPE || type == OUTGOING_CALL_TYPE || type == MISSED_CALL_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isExpirationTimerUpdate(long type) {
|
||||
return (type & EXPIRATION_TIMER_UPDATE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isIncomingCall(long type) {
|
||||
return type == INCOMING_CALL_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isOutgoingCall(long type) {
|
||||
return type == OUTGOING_CALL_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isMissedCall(long type) {
|
||||
return type == MISSED_CALL_TYPE;
|
||||
}
|
||||
|
||||
public static boolean isGroupUpdate(long type) {
|
||||
return (type & GROUP_UPDATE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isGroupQuit(long type) {
|
||||
return (type & GROUP_QUIT_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isFailedDecryptType(long type) {
|
||||
return (type & ENCRYPTION_REMOTE_FAILED_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isDuplicateMessageType(long type) {
|
||||
return (type & ENCRYPTION_REMOTE_DUPLICATE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isDecryptInProgressType(long type) {
|
||||
return (type & 0x40000000) != 0; // Inline deprecated asymmetric encryption type
|
||||
}
|
||||
|
||||
public static boolean isNoRemoteSessionType(long type) {
|
||||
return (type & ENCRYPTION_REMOTE_NO_SESSION_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isLokiSessionRestoreSentType(long type) {
|
||||
return (type & ENCRYPTION_LOKI_SESSION_RESTORE_SENT_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isLokiSessionRestoreDoneType(long type) {
|
||||
return (type & ENCRYPTION_LOKI_SESSION_RESTORE_DONE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static boolean isLegacyType(long type) {
|
||||
return (type & ENCRYPTION_REMOTE_LEGACY_BIT) != 0 ||
|
||||
(type & ENCRYPTION_REMOTE_BIT) != 0;
|
||||
}
|
||||
|
||||
public static long translateFromSystemBaseType(long theirType) {
|
||||
// public static final int NONE_TYPE = 0;
|
||||
// public static final int INBOX_TYPE = 1;
|
||||
// public static final int SENT_TYPE = 2;
|
||||
// public static final int SENT_PENDING = 4;
|
||||
// public static final int FAILED_TYPE = 5;
|
||||
|
||||
switch ((int)theirType) {
|
||||
case 1: return BASE_INBOX_TYPE;
|
||||
case 2: return BASE_SENT_TYPE;
|
||||
case 3: return BASE_DRAFT_TYPE;
|
||||
case 4: return BASE_OUTBOX_TYPE;
|
||||
case 5: return BASE_SENT_FAILED_TYPE;
|
||||
case 6: return BASE_OUTBOX_TYPE;
|
||||
}
|
||||
|
||||
return BASE_INBOX_TYPE;
|
||||
}
|
||||
|
||||
public static int translateToSystemBaseType(long type) {
|
||||
if (isInboxType(type)) return 1;
|
||||
else if (isOutgoingMessageType(type)) return 2;
|
||||
else if (isFailedMessageType(type)) return 5;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
// public static final int NONE_TYPE = 0;
|
||||
// public static final int INBOX_TYPE = 1;
|
||||
// public static final int SENT_TYPE = 2;
|
||||
// public static final int SENT_PENDING = 4;
|
||||
// public static final int FAILED_TYPE = 5;
|
||||
//
|
||||
// public static final int OUTBOX_TYPE = 43; // Messages are stored local encrypted and need delivery.
|
||||
//
|
||||
//
|
||||
// public static final int ENCRYPTING_TYPE = 42; // Messages are stored local encrypted and need async encryption and delivery.
|
||||
// public static final int SECURE_SENT_TYPE = 44; // Messages were sent with async encryption.
|
||||
// public static final int SECURE_RECEIVED_TYPE = 45; // Messages were received with async decryption.
|
||||
// public static final int FAILED_DECRYPT_TYPE = 46; // Messages were received with async encryption and failed to decrypt.
|
||||
// public static final int DECRYPTING_TYPE = 47; // Messages are in the process of being asymmetricaly decrypted.
|
||||
// public static final int NO_SESSION_TYPE = 48; // Messages were received with async encryption but there is no session yet.
|
||||
//
|
||||
// public static final int OUTGOING_KEY_EXCHANGE_TYPE = 49;
|
||||
// public static final int INCOMING_KEY_EXCHANGE_TYPE = 50;
|
||||
// public static final int STALE_KEY_EXCHANGE_TYPE = 51;
|
||||
// public static final int PROCESSED_KEY_EXCHANGE_TYPE = 52;
|
||||
//
|
||||
// public static final int[] OUTGOING_MESSAGE_TYPES = {SENT_TYPE, SENT_PENDING, ENCRYPTING_TYPE,
|
||||
// OUTBOX_TYPE, SECURE_SENT_TYPE,
|
||||
// FAILED_TYPE, OUTGOING_KEY_EXCHANGE_TYPE};
|
||||
//
|
||||
// public static boolean isFailedMessageType(long type) {
|
||||
// return type == FAILED_TYPE;
|
||||
// }
|
||||
//
|
||||
// public static boolean isOutgoingMessageType(long type) {
|
||||
// for (int outgoingType : OUTGOING_MESSAGE_TYPES) {
|
||||
// if (type == outgoingType)
|
||||
// return true;
|
||||
// }
|
||||
//
|
||||
// return false;
|
||||
// }
|
||||
//
|
||||
// public static boolean isPendingMessageType(long type) {
|
||||
// return type == SENT_PENDING || type == ENCRYPTING_TYPE || type == OUTBOX_TYPE;
|
||||
// }
|
||||
//
|
||||
// public static boolean isSecureType(long type) {
|
||||
// return
|
||||
// type == SECURE_SENT_TYPE || type == ENCRYPTING_TYPE ||
|
||||
// type == SECURE_RECEIVED_TYPE || type == DECRYPTING_TYPE;
|
||||
// }
|
||||
//
|
||||
// public static boolean isKeyExchangeType(long type) {
|
||||
// return type == OUTGOING_KEY_EXCHANGE_TYPE || type == INCOMING_KEY_EXCHANGE_TYPE;
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteQueryBuilder;
|
||||
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class MmsSmsDatabase extends Database {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = MmsSmsDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TRANSPORT = "transport_type";
|
||||
public static final String MMS_TRANSPORT = "mms";
|
||||
public static final String SMS_TRANSPORT = "sms";
|
||||
|
||||
private static final String[] PROJECTION = {MmsSmsColumns.ID, MmsSmsColumns.UNIQUE_ROW_ID,
|
||||
SmsDatabase.BODY, SmsDatabase.TYPE,
|
||||
MmsSmsColumns.THREAD_ID,
|
||||
SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT,
|
||||
MmsSmsColumns.NORMALIZED_DATE_SENT,
|
||||
MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
|
||||
MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX,
|
||||
SmsDatabase.STATUS,
|
||||
MmsSmsColumns.UNIDENTIFIED,
|
||||
MmsDatabase.PART_COUNT,
|
||||
MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID,
|
||||
MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY,
|
||||
MmsDatabase.STATUS,
|
||||
MmsSmsColumns.DELIVERY_RECEIPT_COUNT,
|
||||
MmsSmsColumns.READ_RECEIPT_COUNT,
|
||||
MmsSmsColumns.MISMATCHED_IDENTITIES,
|
||||
MmsDatabase.NETWORK_FAILURE,
|
||||
MmsSmsColumns.SUBSCRIPTION_ID,
|
||||
MmsSmsColumns.EXPIRES_IN,
|
||||
MmsSmsColumns.EXPIRE_STARTED,
|
||||
MmsSmsColumns.NOTIFIED,
|
||||
TRANSPORT,
|
||||
AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
|
||||
MmsDatabase.QUOTE_ID,
|
||||
MmsDatabase.QUOTE_AUTHOR,
|
||||
MmsDatabase.QUOTE_BODY,
|
||||
MmsDatabase.QUOTE_MISSING,
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS};
|
||||
|
||||
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public @Nullable MessageRecord getMessageFor(long timestamp, Address author) {
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
|
||||
try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) {
|
||||
MmsSmsDatabase.Reader reader = db.readerFor(cursor);
|
||||
|
||||
MessageRecord messageRecord;
|
||||
|
||||
while ((messageRecord = reader.getNext()) != null) {
|
||||
if ((Util.isOwnNumber(context, author) && messageRecord.isOutgoing()) ||
|
||||
(!Util.isOwnNumber(context, author) && messageRecord.getIndividualRecipient().getAddress().equals(author)))
|
||||
{
|
||||
return messageRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Cursor getConversation(long threadId, long offset, long limit) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
|
||||
|
||||
Cursor cursor = queryTables(PROJECTION, selection, order, limitStr);
|
||||
setNotifyConverationListeners(cursor, threadId);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public Cursor getConversation(long threadId) {
|
||||
return getConversation(threadId, 0, 0);
|
||||
}
|
||||
|
||||
public Cursor getIdentityConflictMessagesForThread(long threadId) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.MISMATCHED_IDENTITIES + " IS NOT NULL";
|
||||
|
||||
Cursor cursor = queryTables(PROJECTION, selection, order, null);
|
||||
setNotifyConverationListeners(cursor, threadId);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public Cursor getConversationSnippet(long threadId) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
return queryTables(PROJECTION, selection, order, "1");
|
||||
}
|
||||
|
||||
public Cursor getUnread() {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC";
|
||||
String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0";
|
||||
|
||||
return queryTables(PROJECTION, selection, order, null);
|
||||
}
|
||||
|
||||
public int getUnreadCount(long threadId) {
|
||||
String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
Cursor cursor = queryTables(PROJECTION, selection, null, null);
|
||||
|
||||
try {
|
||||
return cursor != null ? cursor.getCount() : 0;
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();;
|
||||
}
|
||||
}
|
||||
|
||||
public int getConversationCount(long threadId) {
|
||||
int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId);
|
||||
count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) {
|
||||
DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, true, false);
|
||||
DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, true, false);
|
||||
}
|
||||
|
||||
public void incrementReadReceiptCount(SyncMessageId syncMessageId, long timestamp) {
|
||||
DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, false, true);
|
||||
DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, false, true);
|
||||
}
|
||||
|
||||
public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull Address address) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) {
|
||||
String serializedAddress = address.serialize();
|
||||
boolean isOwnNumber = Util.isOwnNumber(context, address);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
boolean quoteIdMatches = cursor.getLong(0) == quoteId;
|
||||
boolean addressMatches = serializedAddress.equals(cursor.getString(1));
|
||||
|
||||
if (quoteIdMatches && (addressMatches || isOwnNumber)) {
|
||||
return cursor.getPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull Address address) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.ADDRESS }, selection, order, null)) {
|
||||
String serializedAddress = address.serialize();
|
||||
boolean isOwnNumber = Util.isOwnNumber(context, address);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
boolean timestampMatches = cursor.getLong(0) == receivedTimestamp;
|
||||
boolean addressMatches = serializedAddress.equals(cursor.getString(1));
|
||||
|
||||
if (timestampMatches && (addressMatches || isOwnNumber)) {
|
||||
return cursor.getPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the position of the message with the provided timestamp in the query results you'd
|
||||
* get from calling {@link #getConversation(long)}.
|
||||
*
|
||||
* Note: This could give back incorrect results in the situation where multiple messages have the
|
||||
* same received timestamp. However, because this was designed to determine where to scroll to,
|
||||
* you'll still wind up in about the right spot.
|
||||
*/
|
||||
public int getMessagePositionInConversation(long threadId, long receivedTimestamp) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " > " + receivedTimestamp;
|
||||
|
||||
try (Cursor cursor = queryTables(new String[]{ "COUNT(*)" }, selection, order, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private Cursor queryTables(String[] projection, String selection, String order, String limit) {
|
||||
String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
|
||||
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
|
||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID,
|
||||
"'MMS::' || " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID
|
||||
+ " || '::' || " + MmsDatabase.DATE_SENT
|
||||
+ " AS " + MmsSmsColumns.UNIQUE_ROW_ID,
|
||||
"json_group_array(json_object(" +
|
||||
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
|
||||
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
|
||||
"'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + "," +
|
||||
"'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " +
|
||||
"'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " +
|
||||
"'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " +
|
||||
"'" + AttachmentDatabase.THUMBNAIL + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", " +
|
||||
"'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " +
|
||||
"'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " +
|
||||
"'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", " +
|
||||
"'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", " +
|
||||
"'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", " +
|
||||
"'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", " +
|
||||
"'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " +
|
||||
"'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " +
|
||||
"'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " +
|
||||
"'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " +
|
||||
"'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " +
|
||||
"'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " +
|
||||
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
|
||||
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID +
|
||||
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
|
||||
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
|
||||
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
|
||||
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
|
||||
MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID,
|
||||
MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS,
|
||||
MmsDatabase.UNIDENTIFIED,
|
||||
MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT,
|
||||
MmsSmsColumns.MISMATCHED_IDENTITIES,
|
||||
MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED,
|
||||
MmsSmsColumns.NOTIFIED,
|
||||
MmsDatabase.NETWORK_FAILURE, TRANSPORT,
|
||||
MmsDatabase.QUOTE_ID,
|
||||
MmsDatabase.QUOTE_AUTHOR,
|
||||
MmsDatabase.QUOTE_BODY,
|
||||
MmsDatabase.QUOTE_MISSING,
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS};
|
||||
|
||||
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
|
||||
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
|
||||
MmsSmsColumns.ID,
|
||||
"'SMS::' || " + MmsSmsColumns.ID
|
||||
+ " || '::' || " + SmsDatabase.DATE_SENT
|
||||
+ " AS " + MmsSmsColumns.UNIQUE_ROW_ID,
|
||||
"NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
|
||||
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
|
||||
SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,
|
||||
MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT,
|
||||
MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID,
|
||||
MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS,
|
||||
MmsDatabase.UNIDENTIFIED,
|
||||
MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT,
|
||||
MmsSmsColumns.MISMATCHED_IDENTITIES,
|
||||
MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED,
|
||||
MmsSmsColumns.NOTIFIED,
|
||||
MmsDatabase.NETWORK_FAILURE, TRANSPORT,
|
||||
MmsDatabase.QUOTE_ID,
|
||||
MmsDatabase.QUOTE_AUTHOR,
|
||||
MmsDatabase.QUOTE_BODY,
|
||||
MmsDatabase.QUOTE_MISSING,
|
||||
MmsDatabase.QUOTE_ATTACHMENT,
|
||||
MmsDatabase.SHARED_CONTACTS,
|
||||
MmsDatabase.LINK_PREVIEWS};
|
||||
|
||||
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
|
||||
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
|
||||
|
||||
mmsQueryBuilder.setDistinct(true);
|
||||
smsQueryBuilder.setDistinct(true);
|
||||
|
||||
smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME);
|
||||
mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + " LEFT OUTER JOIN " +
|
||||
AttachmentDatabase.TABLE_NAME +
|
||||
" ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID);
|
||||
|
||||
|
||||
Set<String> mmsColumnsPresent = new HashSet<>();
|
||||
mmsColumnsPresent.add(MmsSmsColumns.ID);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.READ);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.THREAD_ID);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.BODY);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.ADDRESS);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN);
|
||||
mmsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED);
|
||||
mmsColumnsPresent.add(MmsDatabase.MESSAGE_TYPE);
|
||||
mmsColumnsPresent.add(MmsDatabase.MESSAGE_BOX);
|
||||
mmsColumnsPresent.add(MmsDatabase.DATE_SENT);
|
||||
mmsColumnsPresent.add(MmsDatabase.DATE_RECEIVED);
|
||||
mmsColumnsPresent.add(MmsDatabase.PART_COUNT);
|
||||
mmsColumnsPresent.add(MmsDatabase.CONTENT_LOCATION);
|
||||
mmsColumnsPresent.add(MmsDatabase.TRANSACTION_ID);
|
||||
mmsColumnsPresent.add(MmsDatabase.MESSAGE_SIZE);
|
||||
mmsColumnsPresent.add(MmsDatabase.EXPIRY);
|
||||
mmsColumnsPresent.add(MmsDatabase.NOTIFIED);
|
||||
mmsColumnsPresent.add(MmsDatabase.STATUS);
|
||||
mmsColumnsPresent.add(MmsDatabase.UNIDENTIFIED);
|
||||
mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE);
|
||||
|
||||
mmsColumnsPresent.add(AttachmentDatabase.ROW_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.MMS_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.SIZE);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.FILE_NAME);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.DATA);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.THUMBNAIL);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.CONTENT_TYPE);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.CONTENT_LOCATION);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.DIGEST);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.FAST_PREFLIGHT_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.VOICE_NOTE);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.WIDTH);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.HEIGHT);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.QUOTE);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.STICKER_PACK_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.STICKER_PACK_KEY);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.STICKER_ID);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.CAPTION);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.CONTENT_DISPOSITION);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.NAME);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.TRANSFER_STATE);
|
||||
mmsColumnsPresent.add(AttachmentDatabase.ATTACHMENT_JSON_ALIAS);
|
||||
mmsColumnsPresent.add(MmsDatabase.QUOTE_ID);
|
||||
mmsColumnsPresent.add(MmsDatabase.QUOTE_AUTHOR);
|
||||
mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY);
|
||||
mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING);
|
||||
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
|
||||
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
|
||||
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
|
||||
|
||||
Set<String> smsColumnsPresent = new HashSet<>();
|
||||
smsColumnsPresent.add(MmsSmsColumns.ID);
|
||||
smsColumnsPresent.add(MmsSmsColumns.BODY);
|
||||
smsColumnsPresent.add(MmsSmsColumns.ADDRESS);
|
||||
smsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID);
|
||||
smsColumnsPresent.add(MmsSmsColumns.READ);
|
||||
smsColumnsPresent.add(MmsSmsColumns.THREAD_ID);
|
||||
smsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT);
|
||||
smsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT);
|
||||
smsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES);
|
||||
smsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID);
|
||||
smsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN);
|
||||
smsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED);
|
||||
smsColumnsPresent.add(MmsSmsColumns.NOTIFIED);
|
||||
smsColumnsPresent.add(SmsDatabase.TYPE);
|
||||
smsColumnsPresent.add(SmsDatabase.SUBJECT);
|
||||
smsColumnsPresent.add(SmsDatabase.DATE_SENT);
|
||||
smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED);
|
||||
smsColumnsPresent.add(SmsDatabase.STATUS);
|
||||
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null);
|
||||
@SuppressWarnings("deprecation")
|
||||
String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 4, SMS_TRANSPORT, selection, null, null, null);
|
||||
|
||||
SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder();
|
||||
String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit);
|
||||
|
||||
SQLiteQueryBuilder outerQueryBuilder = new SQLiteQueryBuilder();
|
||||
outerQueryBuilder.setTables("(" + unionQuery + ")");
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
String query = outerQueryBuilder.buildQuery(projection, null, null, null, null, null, null);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
return db.rawQuery(query, null);
|
||||
}
|
||||
|
||||
public Reader readerFor(@NonNull Cursor cursor) {
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
public class Reader {
|
||||
|
||||
private final Cursor cursor;
|
||||
private SmsDatabase.Reader smsReader;
|
||||
private MmsDatabase.Reader mmsReader;
|
||||
|
||||
public Reader(Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
private SmsDatabase.Reader getSmsReader() {
|
||||
if (smsReader == null) {
|
||||
smsReader = DatabaseFactory.getSmsDatabase(context).readerFor(cursor);
|
||||
}
|
||||
|
||||
return smsReader;
|
||||
}
|
||||
|
||||
private MmsDatabase.Reader getMmsReader() {
|
||||
if (mmsReader == null) {
|
||||
mmsReader = DatabaseFactory.getMmsDatabase(context).readerFor(cursor);
|
||||
}
|
||||
|
||||
return mmsReader;
|
||||
}
|
||||
|
||||
public MessageRecord getNext() {
|
||||
if (cursor == null || !cursor.moveToNext())
|
||||
return null;
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public MessageRecord getCurrent() {
|
||||
String type = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT));
|
||||
|
||||
if (MmsSmsDatabase.MMS_TRANSPORT.equals(type)) return getMmsReader().getCurrent();
|
||||
else if (MmsSmsDatabase.SMS_TRANSPORT.equals(type)) return getSmsReader().getCurrent();
|
||||
else throw new AssertionError("Bad type: " + type);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
public class NoExternalStorageException extends Exception {
|
||||
|
||||
public NoExternalStorageException() {
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
public NoExternalStorageException(String detailMessage) {
|
||||
super(detailMessage);
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
public NoExternalStorageException(Throwable throwable) {
|
||||
super(throwable);
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
public NoExternalStorageException(String detailMessage, Throwable throwable) {
|
||||
super(detailMessage, throwable);
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
public class NoSuchMessageException extends Exception {
|
||||
public NoSuchMessageException(String s) {super(s);}
|
||||
public NoSuchMessageException(Exception e) {super(e);}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
public class NotInDirectoryException extends Throwable {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.database.ContentObserver;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
public interface ObservableContent extends Closeable {
|
||||
void registerContentObserver(@NonNull ContentObserver observer);
|
||||
void unregisterContentObserver(@NonNull ContentObserver observer);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.Nullable;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECKeyPair;
|
||||
import org.whispersystems.libsignal.ecc.ECPrivateKey;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class OneTimePreKeyDatabase extends Database {
|
||||
|
||||
private static final String TAG = OneTimePreKeyDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TABLE_NAME = "one_time_prekeys";
|
||||
private static final String ID = "_id";
|
||||
public static final String KEY_ID = "key_id";
|
||||
public static final String PUBLIC_KEY = "public_key";
|
||||
public static final String PRIVATE_KEY = "private_key";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME +
|
||||
" (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
KEY_ID + " INTEGER UNIQUE, " +
|
||||
PUBLIC_KEY + " TEXT NOT NULL, " +
|
||||
PRIVATE_KEY + " TEXT NOT NULL);";
|
||||
|
||||
OneTimePreKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public @Nullable PreKeyRecord getPreKey(int keyId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, KEY_ID + " = ?",
|
||||
new String[] {String.valueOf(keyId)},
|
||||
null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
try {
|
||||
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PUBLIC_KEY))), 0);
|
||||
ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PRIVATE_KEY))));
|
||||
|
||||
return new PreKeyRecord(keyId, new ECKeyPair(publicKey, privateKey));
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void insertPreKey(int keyId, PreKeyRecord record) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(KEY_ID, keyId);
|
||||
contentValues.put(PUBLIC_KEY, Base64.encodeBytes(record.getKeyPair().getPublicKey().serialize()));
|
||||
contentValues.put(PRIVATE_KEY, Base64.encodeBytes(record.getKeyPair().getPrivateKey().serialize()));
|
||||
|
||||
database.replace(TABLE_NAME, null, contentValues);
|
||||
}
|
||||
|
||||
public void removePreKey(int keyId) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.delete(TABLE_NAME, KEY_ID + " = ?", new String[] {String.valueOf(keyId)});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class PushDatabase extends Database {
|
||||
|
||||
private static final String TAG = PushDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TABLE_NAME = "push";
|
||||
public static final String ID = "_id";
|
||||
public static final String TYPE = "type";
|
||||
public static final String SOURCE = "source";
|
||||
public static final String DEVICE_ID = "device_id";
|
||||
public static final String LEGACY_MSG = "body";
|
||||
public static final String CONTENT = "content";
|
||||
public static final String TIMESTAMP = "timestamp";
|
||||
public static final String SERVER_TIMESTAMP = "server_timestamp";
|
||||
public static final String SERVER_GUID = "server_guid";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
TYPE + " INTEGER, " + SOURCE + " TEXT, " + DEVICE_ID + " INTEGER, " + LEGACY_MSG + " TEXT, " + CONTENT + " TEXT, " + TIMESTAMP + " INTEGER, " +
|
||||
SERVER_TIMESTAMP + " INTEGER DEFAULT 0, " + SERVER_GUID + " TEXT DEFAULT NULL);";
|
||||
|
||||
public PushDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public long insert(@NonNull SignalServiceEnvelope envelope) {
|
||||
Optional<Long> messageId = find(envelope);
|
||||
|
||||
if (messageId.isPresent()) {
|
||||
return messageId.get();
|
||||
} else {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(TYPE, envelope.getType());
|
||||
values.put(SOURCE, envelope.getSource());
|
||||
values.put(DEVICE_ID, envelope.getSourceDevice());
|
||||
values.put(LEGACY_MSG, envelope.hasLegacyMessage() ? Base64.encodeBytes(envelope.getLegacyMessage()) : "");
|
||||
values.put(CONTENT, envelope.hasContent() ? Base64.encodeBytes(envelope.getContent()) : "");
|
||||
values.put(TIMESTAMP, envelope.getTimestamp());
|
||||
values.put(SERVER_TIMESTAMP, envelope.getServerTimestamp());
|
||||
values.put(SERVER_GUID, envelope.getUuid());
|
||||
|
||||
return databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values);
|
||||
}
|
||||
}
|
||||
|
||||
public SignalServiceEnvelope get(long id) throws NoSuchMessageException {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, ID_WHERE,
|
||||
new String[] {String.valueOf(id)},
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
String legacyMessage = cursor.getString(cursor.getColumnIndexOrThrow(LEGACY_MSG));
|
||||
String content = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT));
|
||||
|
||||
return new SignalServiceEnvelope(cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(SOURCE)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE_ID)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP)),
|
||||
Util.isEmpty(legacyMessage) ? null : Base64.decode(legacyMessage),
|
||||
Util.isEmpty(content) ? null : Base64.decode(content),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(SERVER_TIMESTAMP)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(SERVER_GUID)));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new NoSuchMessageException(e);
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
throw new NoSuchMessageException("Not found");
|
||||
}
|
||||
|
||||
public Cursor getPending() {
|
||||
return databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public void delete(long id) {
|
||||
databaseHelper.getWritableDatabase().delete(TABLE_NAME, ID_WHERE, new String[] {id+""});
|
||||
}
|
||||
|
||||
public Reader readerFor(Cursor cursor) {
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
private Optional<Long> find(SignalServiceEnvelope envelope) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, null, TYPE + " = ? AND " + SOURCE + " = ? AND " +
|
||||
DEVICE_ID + " = ? AND " + LEGACY_MSG + " = ? AND " +
|
||||
CONTENT + " = ? AND " + TIMESTAMP + " = ?" ,
|
||||
new String[] {String.valueOf(envelope.getType()),
|
||||
envelope.getSource(),
|
||||
String.valueOf(envelope.getSourceDevice()),
|
||||
envelope.hasLegacyMessage() ? Base64.encodeBytes(envelope.getLegacyMessage()) : "",
|
||||
envelope.hasContent() ? Base64.encodeBytes(envelope.getContent()) : "",
|
||||
String.valueOf(envelope.getTimestamp())},
|
||||
null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return Optional.of(cursor.getLong(cursor.getColumnIndexOrThrow(ID)));
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static class Reader {
|
||||
private final Cursor cursor;
|
||||
|
||||
public Reader(Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public SignalServiceEnvelope getNext() {
|
||||
try {
|
||||
if (cursor == null || !cursor.moveToNext())
|
||||
return null;
|
||||
|
||||
int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE));
|
||||
String source = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE));
|
||||
int deviceId = cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE_ID));
|
||||
String legacyMessage = cursor.getString(cursor.getColumnIndexOrThrow(LEGACY_MSG));
|
||||
String content = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT));
|
||||
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP));
|
||||
long serverTimestamp = cursor.getLong(cursor.getColumnIndexOrThrow(SERVER_TIMESTAMP));
|
||||
String serverGuid = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_GUID));
|
||||
|
||||
return new SignalServiceEnvelope(type, source, deviceId, timestamp,
|
||||
legacyMessage != null ? Base64.decode(legacyMessage) : null,
|
||||
content != null ? Base64.decode(content) : null,
|
||||
serverTimestamp, serverGuid);
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void close() {
|
||||
this.cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class RecipientDatabase extends Database {
|
||||
|
||||
private static final String TAG = RecipientDatabase.class.getSimpleName();
|
||||
|
||||
static final String TABLE_NAME = "recipient_preferences";
|
||||
private static final String ID = "_id";
|
||||
public static final String ADDRESS = "recipient_ids";
|
||||
private static final String BLOCK = "block";
|
||||
private static final String NOTIFICATION = "notification";
|
||||
private static final String VIBRATE = "vibrate";
|
||||
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 EXPIRE_MESSAGES = "expire_messages";
|
||||
private static final String REGISTERED = "registered";
|
||||
private static final String PROFILE_KEY = "profile_key";
|
||||
private static final String SYSTEM_DISPLAY_NAME = "system_display_name";
|
||||
private static final String SYSTEM_PHOTO_URI = "system_contact_photo";
|
||||
private static final String SYSTEM_PHONE_LABEL = "system_phone_label";
|
||||
private static final String SYSTEM_CONTACT_URI = "system_contact_uri";
|
||||
private static final String SIGNAL_PROFILE_NAME = "signal_profile_name";
|
||||
private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar";
|
||||
private static final String PROFILE_SHARING = "profile_sharing_approval";
|
||||
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 UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
|
||||
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
|
||||
|
||||
private static final String[] RECIPIENT_PROJECTION = new String[] {
|
||||
BLOCK, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
|
||||
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
|
||||
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
|
||||
UNIDENTIFIED_ACCESS_MODE,
|
||||
FORCE_SMS_SELECTION,
|
||||
};
|
||||
|
||||
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
|
||||
.map(columnName -> TABLE_NAME + "." + columnName)
|
||||
.toList();
|
||||
|
||||
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 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 static final String CREATE_TABLE =
|
||||
"CREATE TABLE " + TABLE_NAME +
|
||||
" (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
ADDRESS + " TEXT UNIQUE, " +
|
||||
BLOCK + " INTEGER DEFAULT 0," +
|
||||
NOTIFICATION + " TEXT DEFAULT NULL, " +
|
||||
VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
|
||||
MUTE_UNTIL + " INTEGER DEFAULT 0, " +
|
||||
COLOR + " TEXT DEFAULT NULL, " +
|
||||
SEEN_INVITE_REMINDER + " INTEGER DEFAULT 0, " +
|
||||
DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
|
||||
EXPIRE_MESSAGES + " INTEGER DEFAULT 0, " +
|
||||
REGISTERED + " INTEGER DEFAULT 0, " +
|
||||
SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " +
|
||||
SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " +
|
||||
SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " +
|
||||
SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " +
|
||||
PROFILE_KEY + " TEXT DEFAULT NULL, " +
|
||||
SIGNAL_PROFILE_NAME + " TEXT DEFAULT NULL, " +
|
||||
SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
|
||||
PROFILE_SHARING + " INTEGER DEFAULT 0, " +
|
||||
CALL_RINGTONE + " TEXT DEFAULT NULL, " +
|
||||
CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " +
|
||||
NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " +
|
||||
UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " +
|
||||
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0);";
|
||||
|
||||
public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public Cursor getBlocked() {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
|
||||
return database.query(TABLE_NAME, new String[] {ID, ADDRESS}, BLOCK + " = 1",
|
||||
null, null, null, null, null);
|
||||
}
|
||||
|
||||
public RecipientReader readerForBlocked(Cursor cursor) {
|
||||
return new RecipientReader(context, cursor);
|
||||
}
|
||||
|
||||
public RecipientReader getRecipientsWithNotificationChannels() {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS}, NOTIFICATION_CHANNEL + " NOT NULL",
|
||||
null, null, null, null, null);
|
||||
|
||||
return new RecipientReader(context, cursor);
|
||||
}
|
||||
|
||||
public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return getRecipientSettings(cursor);
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
Optional<RecipientSettings> getRecipientSettings(@NonNull Cursor cursor) {
|
||||
boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCK)) == 1;
|
||||
String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION));
|
||||
String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE));
|
||||
int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE));
|
||||
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
|
||||
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
|
||||
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
|
||||
boolean seenInviteReminder = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_INVITE_REMINDER)) == 1;
|
||||
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
|
||||
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(EXPIRE_MESSAGES));
|
||||
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
|
||||
String profileKeyString = cursor.getString(cursor.getColumnIndexOrThrow(PROFILE_KEY));
|
||||
String systemDisplayName = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_DISPLAY_NAME));
|
||||
String systemContactPhoto = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHOTO_URI));
|
||||
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
|
||||
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
|
||||
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
|
||||
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR));
|
||||
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
|
||||
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
|
||||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
|
||||
MaterialColor color;
|
||||
byte[] profileKey = 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;
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.of(new RecipientSettings(blocked, muteUntil,
|
||||
VibrateState.fromId(messageVibrateState),
|
||||
VibrateState.fromId(callVibrateState),
|
||||
Util.uri(messageRingtone), Util.uri(callRingtone),
|
||||
color, seenInviteReminder,
|
||||
defaultSubscriptionId, expireMessages,
|
||||
RegisteredState.fromId(registeredState),
|
||||
profileKey, systemDisplayName, systemContactPhoto,
|
||||
systemPhoneLabel, systemContactUri,
|
||||
signalProfileName, signalProfileAvatar, profileSharing,
|
||||
notificationChannel, UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
|
||||
forceSmsSelection));
|
||||
}
|
||||
|
||||
public BulkOperationsHandle resetAllSystemContactInfo() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SYSTEM_DISPLAY_NAME, (String)null);
|
||||
contentValues.put(SYSTEM_PHOTO_URI, (String)null);
|
||||
contentValues.put(SYSTEM_PHONE_LABEL, (String)null);
|
||||
contentValues.put(SYSTEM_CONTACT_URI, (String)null);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, null, null);
|
||||
|
||||
return new BulkOperationsHandle(database);
|
||||
}
|
||||
|
||||
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(COLOR, color.serialize());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setColor(color);
|
||||
}
|
||||
|
||||
public void setDefaultSubscriptionId(@NonNull Recipient recipient, int defaultSubscriptionId) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(DEFAULT_SUBSCRIPTION_ID, defaultSubscriptionId);
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setDefaultSubscriptionId(Optional.of(defaultSubscriptionId));
|
||||
}
|
||||
|
||||
public void setForceSmsSelection(@NonNull Recipient recipient, boolean forceSmsSelection) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(FORCE_SMS_SELECTION, forceSmsSelection ? 1 : 0);
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.resolve().setForceSmsSelection(forceSmsSelection);
|
||||
}
|
||||
|
||||
public void setBlocked(@NonNull Recipient recipient, boolean blocked) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(BLOCK, blocked ? 1 : 0);
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setBlocked(blocked);
|
||||
}
|
||||
|
||||
public void setMessageRingtone(@NonNull Recipient recipient, @Nullable Uri notification) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(NOTIFICATION, notification == null ? null : notification.toString());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setMessageRingtone(notification);
|
||||
}
|
||||
|
||||
public void setCallRingtone(@NonNull Recipient recipient, @Nullable Uri ringtone) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(CALL_RINGTONE, ringtone == null ? null : ringtone.toString());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setCallRingtone(ringtone);
|
||||
}
|
||||
|
||||
public void setMessageVibrate(@NonNull Recipient recipient, @NonNull VibrateState enabled) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(VIBRATE, enabled.getId());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setMessageVibrate(enabled);
|
||||
}
|
||||
|
||||
public void setCallVibrate(@NonNull Recipient recipient, @NonNull VibrateState enabled) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(CALL_VIBRATE, enabled.getId());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setCallVibrate(enabled);
|
||||
}
|
||||
|
||||
public void setMuted(@NonNull Recipient recipient, long until) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MUTE_UNTIL, until);
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setMuted(until);
|
||||
}
|
||||
|
||||
public void setSeenInviteReminder(@NonNull Recipient recipient, @SuppressWarnings("SameParameterValue") boolean seen) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(SEEN_INVITE_REMINDER, seen ? 1 : 0);
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setHasSeenInviteReminder(seen);
|
||||
}
|
||||
|
||||
public void setExpireMessages(@NonNull Recipient recipient, int expiration) {
|
||||
recipient.setExpireMessages(expiration);
|
||||
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(EXPIRE_MESSAGES, expiration);
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setExpireMessages(expiration);
|
||||
}
|
||||
|
||||
public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setUnidentifiedAccessMode(unidentifiedAccessMode);
|
||||
}
|
||||
|
||||
public void setProfileKey(@NonNull Recipient recipient, @Nullable byte[] profileKey) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(PROFILE_KEY, profileKey == null ? null : Base64.encodeBytes(profileKey));
|
||||
updateOrInsert(recipient.getAddress(), values);
|
||||
recipient.resolve().setProfileKey(profileKey);
|
||||
}
|
||||
|
||||
public void setProfileName(@NonNull Recipient recipient, @Nullable String profileName) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SIGNAL_PROFILE_NAME, profileName);
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.resolve().setProfileName(profileName);
|
||||
}
|
||||
|
||||
public void setProfileAvatar(@NonNull Recipient recipient, @Nullable String profileAvatar) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar);
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.resolve().setProfileAvatar(profileAvatar);
|
||||
}
|
||||
|
||||
public void setProfileSharing(@NonNull Recipient recipient, @SuppressWarnings("SameParameterValue") boolean enabled) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(PROFILE_SHARING, enabled ? 1 : 0);
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.setProfileSharing(enabled);
|
||||
}
|
||||
|
||||
public void setNotificationChannel(@NonNull Recipient recipient, @Nullable String notificationChannel) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(NOTIFICATION_CHANNEL, notificationChannel);
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.setNotificationChannel(notificationChannel);
|
||||
}
|
||||
|
||||
public Set<Address> getAllAddresses() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Set<Address> results = new HashSet<>();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, new String[] {ADDRESS}, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
results.add(Address.fromExternal(context, cursor.getString(0)));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void setRegistered(@NonNull Recipient recipient, RegisteredState registeredState) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(REGISTERED, registeredState.getId());
|
||||
updateOrInsert(recipient.getAddress(), contentValues);
|
||||
recipient.setRegistered(registeredState);
|
||||
}
|
||||
|
||||
public void setRegistered(@NonNull List<Address> activeAddresses,
|
||||
@NonNull List<Address> inactiveAddresses)
|
||||
{
|
||||
for (Address activeAddress : activeAddresses) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
|
||||
|
||||
updateOrInsert(activeAddress, contentValues);
|
||||
Recipient.applyCached(activeAddress, recipient -> recipient.setRegistered(RegisteredState.REGISTERED));
|
||||
}
|
||||
|
||||
for (Address inactiveAddress : inactiveAddresses) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
|
||||
|
||||
updateOrInsert(inactiveAddress, contentValues);
|
||||
Recipient.applyCached(inactiveAddress, recipient -> recipient.setRegistered(RegisteredState.NOT_REGISTERED));
|
||||
}
|
||||
}
|
||||
|
||||
public List<Address> getRegistered() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<Address> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, new String[] {ADDRESS}, REGISTERED + " = ?", new String[] {"1"}, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
results.add(Address.fromSerialized(cursor.getString(0)));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Address> getSystemContacts() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<Address> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, new String[] {ADDRESS}, SYSTEM_DISPLAY_NAME + " IS NOT NULL AND " + SYSTEM_DISPLAY_NAME + " != \"\"", null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
results.add(Address.fromSerialized(cursor.getString(0)));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void updateSystemContactColors(@NonNull ColorUpdater updater) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Map<Address, MaterialColor> updates = new HashMap<>();
|
||||
|
||||
db.beginTransaction();
|
||||
try (Cursor cursor = db.query(TABLE_NAME, new String[] {ADDRESS, COLOR, SYSTEM_DISPLAY_NAME}, SYSTEM_DISPLAY_NAME + " IS NOT NULL AND " + SYSTEM_DISPLAY_NAME + " != \"\"", null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)));
|
||||
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, ADDRESS + " = ?", new String[]{address.serialize()});
|
||||
|
||||
updates.put(address, newColor);
|
||||
}
|
||||
} finally {
|
||||
db.setTransactionSuccessful();
|
||||
db.endTransaction();
|
||||
|
||||
Stream.of(updates.entrySet()).forEach(entry -> {
|
||||
Recipient.applyCached(entry.getKey(), recipient -> {
|
||||
recipient.setColor(entry.getValue());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// XXX This shouldn't be here, and is just a temporary workaround
|
||||
public RegisteredState isRegistered(@NonNull Address address) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, new String[] {REGISTERED}, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) return RegisteredState.fromId(cursor.getInt(0));
|
||||
else return RegisteredState.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateOrInsert(Address address, ContentValues contentValues) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
database.beginTransaction();
|
||||
|
||||
int updated = database.update(TABLE_NAME, contentValues, ADDRESS + " = ?",
|
||||
new String[] {address.serialize()});
|
||||
|
||||
if (updated < 1) {
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
database.insert(TABLE_NAME, null, contentValues);
|
||||
}
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
public class BulkOperationsHandle {
|
||||
|
||||
private final SQLiteDatabase database;
|
||||
|
||||
private final Map<Address, PendingContactInfo> pendingContactInfoMap = new HashMap<>();
|
||||
|
||||
BulkOperationsHandle(SQLiteDatabase database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void setSystemContactInfo(@NonNull Address address, @Nullable String displayName, @Nullable String photoUri, @Nullable String systemPhoneLabel, @Nullable String systemContactUri) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SYSTEM_DISPLAY_NAME, displayName);
|
||||
contentValues.put(SYSTEM_PHOTO_URI, photoUri);
|
||||
contentValues.put(SYSTEM_PHONE_LABEL, systemPhoneLabel);
|
||||
contentValues.put(SYSTEM_CONTACT_URI, systemContactUri);
|
||||
|
||||
updateOrInsert(address, contentValues);
|
||||
pendingContactInfoMap.put(address, new PendingContactInfo(displayName, photoUri, systemPhoneLabel, systemContactUri));
|
||||
}
|
||||
|
||||
public void finish() {
|
||||
database.setTransactionSuccessful();
|
||||
database.endTransaction();
|
||||
|
||||
Stream.of(pendingContactInfoMap.entrySet())
|
||||
.forEach(entry -> Recipient.applyCached(entry.getKey(), recipient -> {
|
||||
recipient.setName(entry.getValue().displayName);
|
||||
recipient.setSystemContactPhoto(Util.uri(entry.getValue().photoUri));
|
||||
recipient.setCustomLabel(entry.getValue().phoneLabel);
|
||||
recipient.setContactUri(Util.uri(entry.getValue().contactUri));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public interface ColorUpdater {
|
||||
MaterialColor update(@NonNull String name, @Nullable String color);
|
||||
}
|
||||
|
||||
public static class RecipientSettings {
|
||||
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 boolean seenInviteReminder;
|
||||
private final int defaultSubscriptionId;
|
||||
private final int expireMessages;
|
||||
private final RegisteredState registered;
|
||||
private final byte[] profileKey;
|
||||
private final String systemDisplayName;
|
||||
private final String systemContactPhoto;
|
||||
private final String systemPhoneLabel;
|
||||
private final String systemContactUri;
|
||||
private final String signalProfileName;
|
||||
private final String signalProfileAvatar;
|
||||
private final boolean profileSharing;
|
||||
private final String notificationChannel;
|
||||
private final UnidentifiedAccessMode unidentifiedAccessMode;
|
||||
private final boolean forceSmsSelection;
|
||||
|
||||
RecipientSettings(boolean blocked, long muteUntil,
|
||||
@NonNull VibrateState messageVibrateState,
|
||||
@NonNull VibrateState callVibrateState,
|
||||
@Nullable Uri messageRingtone,
|
||||
@Nullable Uri callRingtone,
|
||||
@Nullable MaterialColor color,
|
||||
boolean seenInviteReminder,
|
||||
int defaultSubscriptionId,
|
||||
int expireMessages,
|
||||
@NonNull RegisteredState registered,
|
||||
@Nullable byte[] profileKey,
|
||||
@Nullable String systemDisplayName,
|
||||
@Nullable String systemContactPhoto,
|
||||
@Nullable String systemPhoneLabel,
|
||||
@Nullable String systemContactUri,
|
||||
@Nullable String signalProfileName,
|
||||
@Nullable String signalProfileAvatar,
|
||||
boolean profileSharing,
|
||||
@Nullable String notificationChannel,
|
||||
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
|
||||
boolean forceSmsSelection)
|
||||
{
|
||||
this.blocked = blocked;
|
||||
this.muteUntil = muteUntil;
|
||||
this.messageVibrateState = messageVibrateState;
|
||||
this.callVibrateState = callVibrateState;
|
||||
this.messageRingtone = messageRingtone;
|
||||
this.callRingtone = callRingtone;
|
||||
this.color = color;
|
||||
this.seenInviteReminder = seenInviteReminder;
|
||||
this.defaultSubscriptionId = defaultSubscriptionId;
|
||||
this.expireMessages = expireMessages;
|
||||
this.registered = registered;
|
||||
this.profileKey = profileKey;
|
||||
this.systemDisplayName = systemDisplayName;
|
||||
this.systemContactPhoto = systemContactPhoto;
|
||||
this.systemPhoneLabel = systemPhoneLabel;
|
||||
this.systemContactUri = systemContactUri;
|
||||
this.signalProfileName = signalProfileName;
|
||||
this.signalProfileAvatar = signalProfileAvatar;
|
||||
this.profileSharing = profileSharing;
|
||||
this.notificationChannel = notificationChannel;
|
||||
this.unidentifiedAccessMode = unidentifiedAccessMode;
|
||||
this.forceSmsSelection = forceSmsSelection;
|
||||
}
|
||||
|
||||
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 boolean hasSeenInviteReminder() {
|
||||
return seenInviteReminder;
|
||||
}
|
||||
|
||||
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 String getSystemDisplayName() {
|
||||
return systemDisplayName;
|
||||
}
|
||||
|
||||
public @Nullable String getSystemContactPhotoUri() {
|
||||
return systemContactPhoto;
|
||||
}
|
||||
|
||||
public @Nullable String getSystemPhoneLabel() {
|
||||
return systemPhoneLabel;
|
||||
}
|
||||
|
||||
public @Nullable String getSystemContactUri() {
|
||||
return systemContactUri;
|
||||
}
|
||||
|
||||
public @Nullable String getProfileName() {
|
||||
return signalProfileName;
|
||||
}
|
||||
|
||||
public @Nullable String getProfileAvatar() {
|
||||
return signalProfileAvatar;
|
||||
}
|
||||
|
||||
public boolean isProfileSharing() {
|
||||
return profileSharing;
|
||||
}
|
||||
|
||||
public @Nullable String getNotificationChannel() {
|
||||
return notificationChannel;
|
||||
}
|
||||
|
||||
public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() {
|
||||
return unidentifiedAccessMode;
|
||||
}
|
||||
|
||||
public boolean isForceSmsSelection() {
|
||||
return forceSmsSelection;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecipientReader implements Closeable {
|
||||
|
||||
private final Context context;
|
||||
private final Cursor cursor;
|
||||
|
||||
RecipientReader(Context context, Cursor cursor) {
|
||||
this.context = context;
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public @NonNull Recipient getCurrent() {
|
||||
String serialized = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS));
|
||||
return Recipient.from(context, Address.fromSerialized(serialized), false);
|
||||
}
|
||||
|
||||
public @Nullable Recipient getNext() {
|
||||
if (cursor != null && !cursor.moveToNext()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.Cursor;
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Contains all databases necessary for full-text search (FTS).
|
||||
*/
|
||||
public class SearchDatabase extends Database {
|
||||
|
||||
public static final String SMS_FTS_TABLE_NAME = "sms_fts";
|
||||
public static final String MMS_FTS_TABLE_NAME = "mms_fts";
|
||||
|
||||
public static final String ID = "rowid";
|
||||
public static final String BODY = MmsSmsColumns.BODY;
|
||||
public static final String THREAD_ID = MmsSmsColumns.THREAD_ID;
|
||||
public static final String SNIPPET = "snippet";
|
||||
public static final String CONVERSATION_ADDRESS = "conversation_address";
|
||||
public static final String MESSAGE_ADDRESS = "message_address";
|
||||
|
||||
public static final String[] CREATE_TABLE = {
|
||||
"CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");",
|
||||
|
||||
"CREATE TRIGGER sms_ai AFTER INSERT ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;",
|
||||
|
||||
|
||||
"CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");",
|
||||
|
||||
"CREATE TRIGGER mms_ai AFTER INSERT ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;"
|
||||
};
|
||||
|
||||
private static final String MESSAGES_QUERY =
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + SmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " +
|
||||
"UNION ALL " +
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + MmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " +
|
||||
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
|
||||
"LIMIT 500";
|
||||
|
||||
private static final String MESSAGES_FOR_THREAD_QUERY =
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + SmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? AND " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
|
||||
"UNION ALL " +
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + MmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? AND " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
|
||||
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
|
||||
"LIMIT 500";
|
||||
|
||||
public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public Cursor queryMessages(@NonNull String query) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String prefixQuery = adjustQuery(query);
|
||||
|
||||
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery });
|
||||
setNotifyConverationListListeners(cursor);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public Cursor queryMessages(@NonNull String query, long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String prefixQuery = adjustQuery(query);
|
||||
|
||||
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) });
|
||||
setNotifyConverationListListeners(cursor);
|
||||
return cursor;
|
||||
|
||||
}
|
||||
|
||||
private String adjustQuery(@NonNull String query) {
|
||||
List<String> tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList();
|
||||
String prefixQuery = Util.join(tokens, "* ");
|
||||
|
||||
prefixQuery += "*";
|
||||
|
||||
return prefixQuery;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class SessionDatabase extends Database {
|
||||
|
||||
private static final String TAG = SessionDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TABLE_NAME = "sessions";
|
||||
|
||||
private static final String ID = "_id";
|
||||
public static final String ADDRESS = "address";
|
||||
public static final String DEVICE = "device";
|
||||
public static final String RECORD = "record";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME +
|
||||
"(" + ID + " INTEGER PRIMARY KEY, " + ADDRESS + " TEXT NOT NULL, " +
|
||||
DEVICE + " INTEGER NOT NULL, " + RECORD + " BLOB NOT NULL, " +
|
||||
"UNIQUE(" + ADDRESS + "," + DEVICE + ") ON CONFLICT REPLACE);";
|
||||
|
||||
SessionDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void store(@NonNull Address address, int deviceId, @NonNull SessionRecord record) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(ADDRESS, address.serialize());
|
||||
values.put(DEVICE, deviceId);
|
||||
values.put(RECORD, record.serialize());
|
||||
|
||||
database.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
|
||||
}
|
||||
|
||||
public @Nullable SessionRecord load(@NonNull Address address, int deviceId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, new String[]{RECORD},
|
||||
ADDRESS + " = ? AND " + DEVICE + " = ?",
|
||||
new String[] {address.serialize(), String.valueOf(deviceId)},
|
||||
null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
try {
|
||||
return new SessionRecord(cursor.getBlob(cursor.getColumnIndexOrThrow(RECORD)));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public @NonNull List<SessionRow> getAllFor(@NonNull Address address) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
List<SessionRow> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null,
|
||||
ADDRESS + " = ?",
|
||||
new String[] {address.serialize()},
|
||||
null, null, null))
|
||||
{
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
try {
|
||||
results.add(new SessionRow(address,
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE)),
|
||||
new SessionRecord(cursor.getBlob(cursor.getColumnIndexOrThrow(RECORD)))));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public @NonNull List<SessionRow> getAll() {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
List<SessionRow> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
try {
|
||||
results.add(new SessionRow(Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE)),
|
||||
new SessionRecord(cursor.getBlob(cursor.getColumnIndexOrThrow(RECORD)))));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public @NonNull List<Integer> getSubDevices(@NonNull Address address) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
List<Integer> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, new String[] {DEVICE},
|
||||
ADDRESS + " = ?",
|
||||
new String[] {address.serialize()},
|
||||
null, null, null))
|
||||
{
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
int device = cursor.getInt(cursor.getColumnIndexOrThrow(DEVICE));
|
||||
|
||||
if (device != SignalServiceAddress.DEFAULT_DEVICE_ID) {
|
||||
results.add(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void delete(@NonNull Address address, int deviceId) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
database.delete(TABLE_NAME, ADDRESS + " = ? AND " + DEVICE + " = ?",
|
||||
new String[] {address.serialize(), String.valueOf(deviceId)});
|
||||
}
|
||||
|
||||
public void deleteAllFor(@NonNull Address address) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.delete(TABLE_NAME, ADDRESS + " = ?", new String[] {address.serialize()});
|
||||
}
|
||||
|
||||
public static final class SessionRow {
|
||||
private final Address address;
|
||||
private final int deviceId;
|
||||
private final SessionRecord record;
|
||||
|
||||
public SessionRow(Address address, int deviceId, SessionRecord record) {
|
||||
this.address = address;
|
||||
this.deviceId = deviceId;
|
||||
this.record = record;
|
||||
}
|
||||
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public int getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public SessionRecord getRecord() {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECKeyPair;
|
||||
import org.whispersystems.libsignal.ecc.ECPrivateKey;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class SignedPreKeyDatabase extends Database {
|
||||
|
||||
private static final String TAG = SignedPreKeyDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TABLE_NAME = "signed_prekeys";
|
||||
|
||||
private static final String ID = "_id";
|
||||
public static final String KEY_ID = "key_id";
|
||||
public static final String PUBLIC_KEY = "public_key";
|
||||
public static final String PRIVATE_KEY = "private_key";
|
||||
public static final String SIGNATURE = "signature";
|
||||
public static final String TIMESTAMP = "timestamp";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME +
|
||||
" (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
KEY_ID + " INTEGER UNIQUE, " +
|
||||
PUBLIC_KEY + " TEXT NOT NULL, " +
|
||||
PRIVATE_KEY + " TEXT NOT NULL, " +
|
||||
SIGNATURE + " TEXT NOT NULL, " +
|
||||
TIMESTAMP + " INTEGER DEFAULT 0);";
|
||||
|
||||
SignedPreKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public @Nullable SignedPreKeyRecord getSignedPreKey(int keyId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, KEY_ID + " = ?",
|
||||
new String[] {String.valueOf(keyId)},
|
||||
null, null, null))
|
||||
{
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
try {
|
||||
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PUBLIC_KEY))), 0);
|
||||
ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PRIVATE_KEY))));
|
||||
byte[] signature = Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(SIGNATURE)));
|
||||
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP));
|
||||
|
||||
return new SignedPreKeyRecord(keyId, timestamp, new ECKeyPair(publicKey, privateKey), signature);
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public @NonNull List<SignedPreKeyRecord> getAllSignedPreKeys() {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
List<SignedPreKeyRecord> results = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
try {
|
||||
int keyId = cursor.getInt(cursor.getColumnIndexOrThrow(KEY_ID));
|
||||
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PUBLIC_KEY))), 0);
|
||||
ECPrivateKey privateKey = Curve.decodePrivatePoint(Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(PRIVATE_KEY))));
|
||||
byte[] signature = Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(SIGNATURE)));
|
||||
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(TIMESTAMP));
|
||||
|
||||
results.add(new SignedPreKeyRecord(keyId, timestamp, new ECKeyPair(publicKey, privateKey), signature));
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void insertSignedPreKey(int keyId, SignedPreKeyRecord record) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(KEY_ID, keyId);
|
||||
contentValues.put(PUBLIC_KEY, Base64.encodeBytes(record.getKeyPair().getPublicKey().serialize()));
|
||||
contentValues.put(PRIVATE_KEY, Base64.encodeBytes(record.getKeyPair().getPrivateKey().serialize()));
|
||||
contentValues.put(SIGNATURE, Base64.encodeBytes(record.getSignature()));
|
||||
contentValues.put(TIMESTAMP, record.getTimestamp());
|
||||
|
||||
database.replace(TABLE_NAME, null, contentValues);
|
||||
}
|
||||
|
||||
|
||||
public void removeSignedPreKey(int keyId) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.delete(TABLE_NAME, KEY_ID + " = ? AND " + SIGNATURE + " IS NOT NULL", new String[] {String.valueOf(keyId)});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
* Copyright (C) 2013 - 2017 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteStatement;
|
||||
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.sms.IncomingGroupMessage;
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Database for storage of SMS messages.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class SmsDatabase extends MessagingDatabase {
|
||||
|
||||
private static final String TAG = SmsDatabase.class.getSimpleName();
|
||||
|
||||
public static final String TABLE_NAME = "sms";
|
||||
public static final String PERSON = "person";
|
||||
static final String DATE_RECEIVED = "date";
|
||||
static final String DATE_SENT = "date_sent";
|
||||
public static final String PROTOCOL = "protocol";
|
||||
public static final String STATUS = "status";
|
||||
public static final String TYPE = "type";
|
||||
public static final String REPLY_PATH_PRESENT = "reply_path_present";
|
||||
public static final String SUBJECT = "subject";
|
||||
public static final String SERVICE_CENTER = "service_center";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " integer PRIMARY KEY, " +
|
||||
THREAD_ID + " INTEGER, " + ADDRESS + " TEXT, " + ADDRESS_DEVICE_ID + " INTEGER DEFAULT 1, " + PERSON + " INTEGER, " +
|
||||
DATE_RECEIVED + " INTEGER, " + DATE_SENT + " INTEGER, " + PROTOCOL + " INTEGER, " + READ + " INTEGER DEFAULT 0, " +
|
||||
STATUS + " INTEGER DEFAULT -1," + TYPE + " INTEGER, " + REPLY_PATH_PRESENT + " INTEGER, " +
|
||||
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0," + SUBJECT + " TEXT, " + BODY + " TEXT, " +
|
||||
MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + SERVICE_CENTER + " TEXT, " + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
|
||||
EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " +
|
||||
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sms_read_index ON " + TABLE_NAME + " (" + READ + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sms_type_index ON " + TABLE_NAME + " (" + TYPE + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sms_date_sent_index ON " + TABLE_NAME + " (" + DATE_SENT + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sms_thread_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");"
|
||||
};
|
||||
|
||||
private static final String[] MESSAGE_PROJECTION = new String[] {
|
||||
ID, THREAD_ID, ADDRESS, ADDRESS_DEVICE_ID, PERSON,
|
||||
DATE_RECEIVED + " AS " + NORMALIZED_DATE_RECEIVED,
|
||||
DATE_SENT + " AS " + NORMALIZED_DATE_SENT,
|
||||
PROTOCOL, READ, STATUS, TYPE,
|
||||
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
|
||||
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED,
|
||||
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED
|
||||
};
|
||||
|
||||
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache();
|
||||
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache();
|
||||
|
||||
public SmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
protected String getTableName() {
|
||||
return TABLE_NAME;
|
||||
}
|
||||
|
||||
private void updateTypeBitmask(long id, long maskOff, long maskOn) {
|
||||
Log.i("MessageDatabase", "Updating ID: " + id + " to base type: " + maskOn);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.execSQL("UPDATE " + TABLE_NAME +
|
||||
" SET " + TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" +
|
||||
" WHERE " + ID + " = ?", new String[] {id+""});
|
||||
|
||||
long threadId = getThreadIdForMessage(id);
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
|
||||
public long getThreadIdForMessage(long id) {
|
||||
String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?";
|
||||
String[] sqlArgs = new String[] {id+""};
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.rawQuery(sql, sqlArgs);
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
return cursor.getLong(0);
|
||||
else
|
||||
return -1;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public int getMessageCount() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, null, null, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) return cursor.getInt(0);
|
||||
else return 0;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public int getMessageCountForThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?",
|
||||
new String[] {threadId+""}, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
return cursor.getInt(0);
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public long getIDForMessageAtIndex(long threadID, int index) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] { threadID + "" }, null, null, null);
|
||||
if (cursor != null && cursor.moveToPosition(index)) {
|
||||
return cursor.getLong(0);
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public Set<Long> getAllMessageIDs(long threadID) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
Set<Long> messageIDs = new HashSet<>();
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] { threadID + "" }, null, null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
messageIDs.add(cursor.getLong(0));
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return messageIDs;
|
||||
}
|
||||
|
||||
public void markAsEndSession(long id) {
|
||||
updateTypeBitmask(id, Types.KEY_EXCHANGE_MASK, Types.END_SESSION_BIT);
|
||||
}
|
||||
|
||||
public void markAsPreKeyBundle(long id) {
|
||||
updateTypeBitmask(id, Types.KEY_EXCHANGE_MASK, Types.KEY_EXCHANGE_BIT | Types.KEY_EXCHANGE_BUNDLE_BIT);
|
||||
}
|
||||
|
||||
public void markAsInvalidVersionKeyExchange(long id) {
|
||||
updateTypeBitmask(id, 0, Types.KEY_EXCHANGE_INVALID_VERSION_BIT);
|
||||
}
|
||||
|
||||
public void markAsSecure(long id) {
|
||||
updateTypeBitmask(id, 0, Types.SECURE_MESSAGE_BIT);
|
||||
}
|
||||
|
||||
public void markAsInsecure(long id) {
|
||||
updateTypeBitmask(id, Types.SECURE_MESSAGE_BIT, 0);
|
||||
}
|
||||
|
||||
public void markAsPush(long id) {
|
||||
updateTypeBitmask(id, 0, Types.PUSH_MESSAGE_BIT);
|
||||
}
|
||||
|
||||
public void markAsForcedSms(long id) {
|
||||
updateTypeBitmask(id, Types.PUSH_MESSAGE_BIT, Types.MESSAGE_FORCE_SMS_BIT);
|
||||
}
|
||||
|
||||
public void markAsDecryptFailed(long id) {
|
||||
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_FAILED_BIT);
|
||||
}
|
||||
|
||||
public void markAsDecryptDuplicate(long id) {
|
||||
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_DUPLICATE_BIT);
|
||||
}
|
||||
|
||||
public void markAsNoSession(long id) {
|
||||
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_NO_SESSION_BIT);
|
||||
}
|
||||
|
||||
public void markAsSentLokiSessionRestorationRequest(long id) {
|
||||
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_LOKI_SESSION_RESTORE_SENT_BIT);
|
||||
}
|
||||
|
||||
public void markAsLokiSessionRestorationDone(long id) {
|
||||
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_LOKI_SESSION_RESTORE_DONE_BIT);
|
||||
}
|
||||
|
||||
public void markAsLegacyVersion(long id) {
|
||||
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_LEGACY_BIT);
|
||||
}
|
||||
|
||||
public void markAsOutbox(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_OUTBOX_TYPE);
|
||||
}
|
||||
|
||||
public void markAsPendingInsecureSmsFallback(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_PENDING_INSECURE_SMS_FALLBACK);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAsSent(long id, boolean isSecure) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (isSecure ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0));
|
||||
}
|
||||
|
||||
public void markAsSending(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE);
|
||||
}
|
||||
|
||||
public void markAsMissedCall(long id) {
|
||||
updateTypeBitmask(id, Types.TOTAL_MASK, Types.MISSED_CALL_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markUnidentified(long id, boolean unidentified) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(UNIDENTIFIED, unidentified ? 1 : 0);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markExpireStarted(long id) {
|
||||
markExpireStarted(id, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markExpireStarted(long id, long startedAtTimestamp) {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(EXPIRE_STARTED, startedAtTimestamp);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)});
|
||||
|
||||
long threadId = getThreadIdForMessage(id);
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
|
||||
public void markStatus(long id, int status) {
|
||||
Log.i("MessageDatabase", "Updating ID: " + id + " to status: " + status);
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(STATUS, status);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {id+""});
|
||||
|
||||
long threadId = getThreadIdForMessage(id);
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
|
||||
public void markAsSentFailed(long id) {
|
||||
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_FAILED_TYPE);
|
||||
}
|
||||
|
||||
public void markAsNotified(long id) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
|
||||
contentValues.put(NOTIFIED, 1);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)});
|
||||
}
|
||||
|
||||
public boolean isOutgoingMessage(long timestamp) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
Cursor cursor = null;
|
||||
boolean isOutgoing = false;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] { ID, THREAD_ID, ADDRESS, TYPE },
|
||||
DATE_SENT + " = ?", new String[] { String.valueOf(timestamp) },
|
||||
null, null, null, null);
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(TYPE)))) {
|
||||
isOutgoing = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
return isOutgoing;
|
||||
}
|
||||
|
||||
public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryReceipt, boolean readReceipt) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
Cursor cursor = null;
|
||||
boolean foundMessage = false;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, ADDRESS, TYPE},
|
||||
DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())},
|
||||
null, null, null, null);
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(TYPE)))) {
|
||||
Address theirAddress = messageId.getAddress();
|
||||
Address ourAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)));
|
||||
String columnName = deliveryReceipt ? DELIVERY_RECEIPT_COUNT : READ_RECEIPT_COUNT;
|
||||
|
||||
if (ourAddress.equals(theirAddress)) {
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID));
|
||||
|
||||
database.execSQL("UPDATE " + TABLE_NAME +
|
||||
" SET " + columnName + " = " + columnName + " + 1 WHERE " +
|
||||
ID + " = ?",
|
||||
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
foundMessage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMessage) {
|
||||
if (deliveryReceipt) earlyDeliveryReceiptCache.increment(messageId.getTimetamp(), messageId.getAddress());
|
||||
if (readReceipt) earlyReadReceiptCache.increment(messageId.getTimetamp(), messageId.getAddress());
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Pair<Long, Long>> setTimestampRead(SyncMessageId messageId, long proposedExpireStarted) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
List<Pair<Long, Long>> expiring = new LinkedList<>();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] {ID, THREAD_ID, ADDRESS, TYPE, EXPIRES_IN, EXPIRE_STARTED},
|
||||
DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())},
|
||||
null, null, null, null);
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
Address theirAddress = messageId.getAddress();
|
||||
Address ourAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)));
|
||||
|
||||
if (ourAddress.equals(theirAddress)) {
|
||||
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID));
|
||||
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN));
|
||||
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED));
|
||||
|
||||
expireStarted = expireStarted > 0 ? Math.min(proposedExpireStarted, expireStarted) : proposedExpireStarted;
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(READ, 1);
|
||||
|
||||
if (expiresIn > 0) {
|
||||
contentValues.put(EXPIRE_STARTED, expireStarted);
|
||||
expiring.add(new Pair<>(id, expiresIn));
|
||||
}
|
||||
|
||||
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {cursor.getLong(cursor.getColumnIndexOrThrow(ID)) + ""});
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).updateReadState(threadId);
|
||||
DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId);
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
return expiring;
|
||||
}
|
||||
|
||||
public List<MarkedMessageInfo> setMessagesRead(long threadId) {
|
||||
return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0", new String[] {String.valueOf(threadId)});
|
||||
}
|
||||
|
||||
public List<MarkedMessageInfo> setAllMessagesRead() {
|
||||
return setMessagesRead(READ + " = 0", null);
|
||||
}
|
||||
|
||||
private List<MarkedMessageInfo> setMessagesRead(String where, String[] arguments) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
List<MarkedMessageInfo> results = new LinkedList<>();
|
||||
Cursor cursor = null;
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
if (Types.isSecureType(cursor.getLong(3))) {
|
||||
SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), cursor.getLong(2));
|
||||
ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false);
|
||||
|
||||
results.add(new MarkedMessageInfo(syncMessageId, expirationInfo));
|
||||
}
|
||||
}
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(READ, 1);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, where, arguments);
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Pair<Long, Long> updateBundleMessageBody(long messageId, String body) {
|
||||
long type = Types.BASE_INBOX_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT;
|
||||
return updateMessageBodyAndType(messageId, body, Types.TOTAL_MASK, type);
|
||||
}
|
||||
|
||||
public void updateMessageBody(long messageId, String body) {
|
||||
long type = 0;
|
||||
updateMessageBodyAndType(messageId, body, Types.ENCRYPTION_MASK, type);
|
||||
}
|
||||
|
||||
private Pair<Long, Long> updateMessageBodyAndType(long messageId, String body, long maskOff, long maskOn) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.execSQL("UPDATE " + TABLE_NAME + " SET " + BODY + " = ?, " +
|
||||
TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + ") " +
|
||||
"WHERE " + ID + " = ?",
|
||||
new String[] {body, messageId + ""});
|
||||
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, true);
|
||||
notifyConversationListeners(threadId);
|
||||
notifyConversationListListeners();
|
||||
|
||||
return new Pair<>(messageId, threadId);
|
||||
}
|
||||
|
||||
public Pair<Long, Long> copyMessageInbox(long messageId) {
|
||||
try {
|
||||
SmsMessageRecord record = getMessage(messageId);
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(TYPE, (record.getType() & ~Types.BASE_TYPE_MASK) | Types.BASE_INBOX_TYPE);
|
||||
contentValues.put(ADDRESS, record.getIndividualRecipient().getAddress().serialize());
|
||||
contentValues.put(ADDRESS_DEVICE_ID, record.getRecipientDeviceId());
|
||||
contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
|
||||
contentValues.put(DATE_SENT, record.getDateSent());
|
||||
contentValues.put(PROTOCOL, 31337);
|
||||
contentValues.put(READ, 0);
|
||||
contentValues.put(BODY, record.getBody());
|
||||
contentValues.put(THREAD_ID, record.getThreadId());
|
||||
contentValues.put(EXPIRES_IN, record.getExpiresIn());
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
long newMessageId = db.insert(TABLE_NAME, null, contentValues);
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).update(record.getThreadId(), true);
|
||||
notifyConversationListeners(record.getThreadId());
|
||||
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new TrimThreadJob(record.getThreadId()));
|
||||
|
||||
return new Pair<>(newMessageId, record.getThreadId());
|
||||
} catch (NoSuchMessageException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Pair<Long, Long> insertReceivedCall(@NonNull Address address) {
|
||||
return insertCallLog(address, Types.INCOMING_CALL_TYPE, false);
|
||||
}
|
||||
|
||||
public @NonNull Pair<Long, Long> insertOutgoingCall(@NonNull Address address) {
|
||||
return insertCallLog(address, Types.OUTGOING_CALL_TYPE, false);
|
||||
}
|
||||
|
||||
public @NonNull Pair<Long, Long> insertMissedCall(@NonNull Address address) {
|
||||
return insertCallLog(address, Types.MISSED_CALL_TYPE, true);
|
||||
}
|
||||
|
||||
private @NonNull Pair<Long, Long> insertCallLog(@NonNull Address address, long type, boolean unread) {
|
||||
Recipient recipient = Recipient.from(context, address, true);
|
||||
long threadId = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(recipient);
|
||||
|
||||
ContentValues values = new ContentValues(6);
|
||||
values.put(ADDRESS, address.serialize());
|
||||
values.put(ADDRESS_DEVICE_ID, 1);
|
||||
values.put(DATE_RECEIVED, System.currentTimeMillis());
|
||||
values.put(DATE_SENT, System.currentTimeMillis());
|
||||
values.put(READ, unread ? 0 : 1);
|
||||
values.put(TYPE, type);
|
||||
values.put(THREAD_ID, threadId);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
long messageId = db.insert(TABLE_NAME, null, values);
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, true);
|
||||
notifyConversationListeners(threadId);
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new TrimThreadJob(threadId));
|
||||
|
||||
if (unread) {
|
||||
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
|
||||
}
|
||||
|
||||
return new Pair<>(messageId, threadId);
|
||||
}
|
||||
|
||||
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp) {
|
||||
if (message.isJoined()) {
|
||||
type = (type & (Types.TOTAL_MASK - Types.BASE_TYPE_MASK)) | Types.JOINED_TYPE;
|
||||
} else if (message.isPreKeyBundle()) {
|
||||
type |= Types.KEY_EXCHANGE_BIT | Types.KEY_EXCHANGE_BUNDLE_BIT;
|
||||
} else if (message.isSecureMessage()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
} else if (message.isGroup()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
if (((IncomingGroupMessage)message).isUpdate()) type |= Types.GROUP_UPDATE_BIT;
|
||||
else if (((IncomingGroupMessage)message).isQuit()) type |= Types.GROUP_QUIT_BIT;
|
||||
} else if (message.isEndSession()) {
|
||||
type |= Types.SECURE_MESSAGE_BIT;
|
||||
type |= Types.END_SESSION_BIT;
|
||||
}
|
||||
|
||||
if (message.isPush()) type |= Types.PUSH_MESSAGE_BIT;
|
||||
if (message.isIdentityUpdate()) type |= Types.KEY_EXCHANGE_IDENTITY_UPDATE_BIT;
|
||||
if (message.isContentPreKeyBundle()) type |= Types.KEY_EXCHANGE_CONTENT_FORMAT;
|
||||
|
||||
if (message.isIdentityVerified()) type |= Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
|
||||
else if (message.isIdentityDefault()) type |= Types.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT;
|
||||
|
||||
Recipient recipient = Recipient.from(context, message.getSender(), true);
|
||||
|
||||
Recipient groupRecipient;
|
||||
|
||||
if (message.getGroupId() == null) {
|
||||
groupRecipient = null;
|
||||
} else {
|
||||
groupRecipient = Recipient.from(context, message.getGroupId(), true);
|
||||
}
|
||||
|
||||
boolean unread = (org.thoughtcrime.securesms.util.Util.isDefaultSmsProvider(context) ||
|
||||
message.isSecureMessage() || message.isGroup() || message.isPreKeyBundle()) &&
|
||||
!message.isIdentityUpdate() && !message.isIdentityDefault() && !message.isIdentityVerified();
|
||||
|
||||
long threadId;
|
||||
|
||||
if (groupRecipient == null) threadId = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(recipient);
|
||||
else threadId = DatabaseFactory.getThreadDatabase(context).getOrCreateThreadIdFor(groupRecipient);
|
||||
|
||||
ContentValues values = new ContentValues(6);
|
||||
values.put(ADDRESS, message.getSender().serialize());
|
||||
values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId());
|
||||
// In open groups messages should be sorted by their server timestamp
|
||||
long receivedTimestamp = serverTimestamp;
|
||||
if (serverTimestamp == 0) { receivedTimestamp = message.getSentTimestampMillis(); }
|
||||
values.put(DATE_RECEIVED, receivedTimestamp); // Loki - This is important due to how we handle GIFs
|
||||
values.put(DATE_SENT, message.getSentTimestampMillis());
|
||||
values.put(PROTOCOL, message.getProtocol());
|
||||
values.put(READ, unread ? 0 : 1);
|
||||
values.put(SUBSCRIPTION_ID, message.getSubscriptionId());
|
||||
values.put(EXPIRES_IN, message.getExpiresIn());
|
||||
values.put(UNIDENTIFIED, message.isUnidentified());
|
||||
|
||||
if (!TextUtils.isEmpty(message.getPseudoSubject()))
|
||||
values.put(SUBJECT, message.getPseudoSubject());
|
||||
|
||||
values.put(REPLY_PATH_PRESENT, message.isReplyPathPresent());
|
||||
values.put(SERVICE_CENTER, message.getServiceCenterAddress());
|
||||
values.put(BODY, message.getMessageBody());
|
||||
values.put(TYPE, type);
|
||||
values.put(THREAD_ID, threadId);
|
||||
|
||||
if (message.isPush() && isDuplicate(message, threadId)) {
|
||||
Log.w(TAG, "Duplicate message (" + message.getSentTimestampMillis() + "), ignoring...");
|
||||
return Optional.absent();
|
||||
} else {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
long messageId = db.insert(TABLE_NAME, null, values);
|
||||
|
||||
if (unread) {
|
||||
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
|
||||
}
|
||||
|
||||
if (!message.isIdentityUpdate() && !message.isIdentityVerified() && !message.isIdentityDefault()) {
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, true);
|
||||
}
|
||||
|
||||
if (message.getSubscriptionId() != -1) {
|
||||
DatabaseFactory.getRecipientDatabase(context).setDefaultSubscriptionId(recipient, message.getSubscriptionId());
|
||||
}
|
||||
|
||||
notifyConversationListeners(threadId);
|
||||
|
||||
if (!message.isIdentityUpdate() && !message.isIdentityVerified() && !message.isIdentityDefault()) {
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new TrimThreadJob(threadId));
|
||||
}
|
||||
|
||||
return Optional.of(new InsertResult(messageId, threadId));
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message) {
|
||||
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0);
|
||||
}
|
||||
|
||||
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp) {
|
||||
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp);
|
||||
}
|
||||
|
||||
public long insertMessageOutbox(long threadId, OutgoingTextMessage message,
|
||||
boolean forceSms, long date, InsertListener insertListener)
|
||||
{
|
||||
long type = Types.BASE_SENDING_TYPE;
|
||||
|
||||
if (message.isKeyExchange()) type |= Types.KEY_EXCHANGE_BIT;
|
||||
else if (message.isSecureMessage()) type |= (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT);
|
||||
else if (message.isEndSession()) type |= Types.END_SESSION_BIT;
|
||||
if (forceSms) type |= Types.MESSAGE_FORCE_SMS_BIT;
|
||||
|
||||
if (message.isIdentityVerified()) type |= Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
|
||||
else if (message.isIdentityDefault()) type |= Types.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT;
|
||||
|
||||
Address address = message.getRecipient().getAddress();
|
||||
Map<Address, Long> earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(date);
|
||||
Map<Address, Long> earlyReadReceipts = earlyReadReceiptCache.remove(date);
|
||||
|
||||
ContentValues contentValues = new ContentValues(6);
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
contentValues.put(THREAD_ID, threadId);
|
||||
contentValues.put(BODY, message.getMessageBody());
|
||||
contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
|
||||
contentValues.put(DATE_SENT, date);
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(TYPE, type);
|
||||
contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId());
|
||||
contentValues.put(EXPIRES_IN, message.getExpiresIn());
|
||||
contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum());
|
||||
contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum());
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
long messageId = db.insert(TABLE_NAME, ADDRESS, contentValues);
|
||||
|
||||
if (insertListener != null) {
|
||||
insertListener.onComplete();
|
||||
}
|
||||
|
||||
if (!message.isIdentityVerified() && !message.isIdentityDefault()) {
|
||||
DatabaseFactory.getThreadDatabase(context).update(threadId, true);
|
||||
DatabaseFactory.getThreadDatabase(context).setLastSeen(threadId);
|
||||
}
|
||||
|
||||
DatabaseFactory.getThreadDatabase(context).setHasSent(threadId, true);
|
||||
|
||||
notifyConversationListeners(threadId);
|
||||
|
||||
if (!message.isIdentityVerified() && !message.isIdentityDefault()) {
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new TrimThreadJob(threadId));
|
||||
}
|
||||
|
||||
return messageId;
|
||||
}
|
||||
|
||||
Cursor getMessages(int skip, int limit) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
return db.query(TABLE_NAME, MESSAGE_PROJECTION, null, null, null, null, ID, skip + "," + limit);
|
||||
}
|
||||
|
||||
Cursor getOutgoingMessages() {
|
||||
String outgoingSelection = TYPE + " & " + Types.BASE_TYPE_MASK + " = " + Types.BASE_OUTBOX_TYPE;
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
return db.query(TABLE_NAME, MESSAGE_PROJECTION, outgoingSelection, null, null, null, null);
|
||||
}
|
||||
|
||||
public Cursor getExpirationStartedMessages() {
|
||||
String where = EXPIRE_STARTED + " > 0";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
return db.query(TABLE_NAME, MESSAGE_PROJECTION, where, null, null, null, null);
|
||||
}
|
||||
|
||||
public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[]{messageId + ""}, null, null, null);
|
||||
Reader reader = new Reader(cursor);
|
||||
SmsMessageRecord record = reader.getNext();
|
||||
|
||||
reader.close();
|
||||
|
||||
if (record == null) throw new NoSuchMessageException("No message for ID: " + messageId);
|
||||
else return record;
|
||||
}
|
||||
|
||||
public Cursor getMessageCursor(long messageId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null);
|
||||
setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId));
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public boolean deleteMessage(long messageId) {
|
||||
Log.i("MessageDatabase", "Deleting: " + messageId);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
|
||||
boolean threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
public void ensureMigration() {
|
||||
databaseHelper.getWritableDatabase();
|
||||
}
|
||||
|
||||
private boolean isDuplicate(IncomingTextMessage message, long threadId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?",
|
||||
new String[]{String.valueOf(message.getSentTimestampMillis()), message.getSender().serialize(), String.valueOf(threadId)},
|
||||
null, null, null, "1");
|
||||
|
||||
try {
|
||||
return cursor != null && cursor.moveToFirst();
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
/*package */void deleteThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
|
||||
}
|
||||
|
||||
/*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
|
||||
|
||||
for (long outgoingType : Types.OUTGOING_MESSAGE_TYPES) {
|
||||
where += " WHEN " + outgoingType + " THEN " + DATE_SENT + " < " + date;
|
||||
}
|
||||
|
||||
where += (" ELSE " + DATE_RECEIVED + " < " + date + " END)");
|
||||
|
||||
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
|
||||
}
|
||||
|
||||
/*package*/ void deleteThreads(Set<Long> threadIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = "";
|
||||
|
||||
for (long threadId : threadIds) {
|
||||
where += THREAD_ID + " = '" + threadId + "' OR ";
|
||||
}
|
||||
|
||||
where = where.substring(0, where.length() - 4);
|
||||
|
||||
db.delete(TABLE_NAME, where, null);
|
||||
}
|
||||
|
||||
/*package */ void deleteAllThreads() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
}
|
||||
|
||||
/*package*/ SQLiteDatabase beginTransaction() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
return database;
|
||||
}
|
||||
|
||||
/*package*/ void endTransaction(SQLiteDatabase database) {
|
||||
database.setTransactionSuccessful();
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
/*package*/ SQLiteStatement createInsertStatement(SQLiteDatabase database) {
|
||||
return database.compileStatement("INSERT INTO " + TABLE_NAME + " (" + ADDRESS + ", " +
|
||||
PERSON + ", " +
|
||||
DATE_SENT + ", " +
|
||||
DATE_RECEIVED + ", " +
|
||||
PROTOCOL + ", " +
|
||||
READ + ", " +
|
||||
STATUS + ", " +
|
||||
TYPE + ", " +
|
||||
REPLY_PATH_PRESENT + ", " +
|
||||
SUBJECT + ", " +
|
||||
BODY + ", " +
|
||||
SERVICE_CENTER +
|
||||
", " + THREAD_ID + ") " +
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
}
|
||||
|
||||
public static class Status {
|
||||
public static final int STATUS_NONE = -1;
|
||||
public static final int STATUS_COMPLETE = 0;
|
||||
public static final int STATUS_PENDING = 0x20;
|
||||
public static final int STATUS_FAILED = 0x40;
|
||||
}
|
||||
|
||||
public Reader readerFor(Cursor cursor) {
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
public OutgoingMessageReader readerFor(OutgoingTextMessage message, long threadId) {
|
||||
return new OutgoingMessageReader(message, threadId);
|
||||
}
|
||||
|
||||
public class OutgoingMessageReader {
|
||||
|
||||
private final OutgoingTextMessage message;
|
||||
private final long id;
|
||||
private final long threadId;
|
||||
|
||||
public OutgoingMessageReader(OutgoingTextMessage message, long threadId) {
|
||||
this.message = message;
|
||||
this.threadId = threadId;
|
||||
this.id = new SecureRandom().nextLong();
|
||||
}
|
||||
|
||||
public MessageRecord getCurrent() {
|
||||
return new SmsMessageRecord(id, message.getMessageBody(),
|
||||
message.getRecipient(), message.getRecipient(),
|
||||
1, System.currentTimeMillis(), System.currentTimeMillis(),
|
||||
0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(),
|
||||
threadId, 0, new LinkedList<IdentityKeyMismatch>(),
|
||||
message.getSubscriptionId(), message.getExpiresIn(),
|
||||
System.currentTimeMillis(), 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
public class Reader {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
public Reader(Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public SmsMessageRecord getNext() {
|
||||
if (cursor == null || !cursor.moveToNext())
|
||||
return null;
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
if (cursor == null) return 0;
|
||||
else return cursor.getCount();
|
||||
}
|
||||
|
||||
public SmsMessageRecord getCurrent() {
|
||||
long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID));
|
||||
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS)));
|
||||
int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID));
|
||||
long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE));
|
||||
long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED));
|
||||
long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT));
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID));
|
||||
int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS));
|
||||
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT));
|
||||
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT));
|
||||
String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES));
|
||||
int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID));
|
||||
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN));
|
||||
long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED));
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY));
|
||||
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1;
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0;
|
||||
}
|
||||
|
||||
List<IdentityKeyMismatch> mismatches = getMismatches(mismatchDocument);
|
||||
Recipient recipient = Recipient.from(context, address, true);
|
||||
|
||||
return new SmsMessageRecord(messageId, body, recipient,
|
||||
recipient,
|
||||
addressDeviceId,
|
||||
dateSent, dateReceived, deliveryReceiptCount, type,
|
||||
threadId, status, mismatches, subscriptionId,
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified);
|
||||
}
|
||||
|
||||
private List<IdentityKeyMismatch> getMismatches(String document) {
|
||||
try {
|
||||
if (!TextUtils.isEmpty(document)) {
|
||||
return JsonUtils.fromJson(document, IdentityKeyMismatchList.class).getList();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
return new LinkedList<>();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public interface InsertListener {
|
||||
public void onComplete();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteStatement;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
public class SmsMigrator {
|
||||
|
||||
private static final String TAG = SmsMigrator.class.getSimpleName();
|
||||
|
||||
private static void addStringToStatement(SQLiteStatement statement, Cursor cursor,
|
||||
int index, String key)
|
||||
{
|
||||
int columnIndex = cursor.getColumnIndexOrThrow(key);
|
||||
|
||||
if (cursor.isNull(columnIndex)) {
|
||||
statement.bindNull(index);
|
||||
} else {
|
||||
statement.bindString(index, cursor.getString(columnIndex));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addIntToStatement(SQLiteStatement statement, Cursor cursor,
|
||||
int index, String key)
|
||||
{
|
||||
int columnIndex = cursor.getColumnIndexOrThrow(key);
|
||||
|
||||
if (cursor.isNull(columnIndex)) {
|
||||
statement.bindNull(index);
|
||||
} else {
|
||||
statement.bindLong(index, cursor.getLong(columnIndex));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private static void addTranslatedTypeToStatement(SQLiteStatement statement, Cursor cursor, int index, String key)
|
||||
{
|
||||
int columnIndex = cursor.getColumnIndexOrThrow(key);
|
||||
|
||||
if (cursor.isNull(columnIndex)) {
|
||||
statement.bindLong(index, SmsDatabase.Types.BASE_INBOX_TYPE);
|
||||
} else {
|
||||
long theirType = cursor.getLong(columnIndex);
|
||||
statement.bindLong(index, SmsDatabase.Types.translateFromSystemBaseType(theirType));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAppropriateTypeForMigration(Cursor cursor, int columnIndex) {
|
||||
long systemType = cursor.getLong(columnIndex);
|
||||
long ourType = SmsDatabase.Types.translateFromSystemBaseType(systemType);
|
||||
|
||||
return ourType == MmsSmsColumns.Types.BASE_INBOX_TYPE ||
|
||||
ourType == MmsSmsColumns.Types.BASE_SENT_TYPE ||
|
||||
ourType == MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE;
|
||||
}
|
||||
|
||||
private static void getContentValuesForRow(Context context, Cursor cursor,
|
||||
long threadId, SQLiteStatement statement)
|
||||
{
|
||||
String theirAddress = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS));
|
||||
statement.bindString(1, Address.fromExternal(context, theirAddress).serialize());
|
||||
|
||||
addIntToStatement(statement, cursor, 2, SmsDatabase.PERSON);
|
||||
addIntToStatement(statement, cursor, 3, SmsDatabase.DATE_RECEIVED);
|
||||
addIntToStatement(statement, cursor, 4, SmsDatabase.DATE_RECEIVED);
|
||||
addIntToStatement(statement, cursor, 5, SmsDatabase.PROTOCOL);
|
||||
addIntToStatement(statement, cursor, 6, SmsDatabase.READ);
|
||||
addIntToStatement(statement, cursor, 7, SmsDatabase.STATUS);
|
||||
addTranslatedTypeToStatement(statement, cursor, 8, SmsDatabase.TYPE);
|
||||
addIntToStatement(statement, cursor, 9, SmsDatabase.REPLY_PATH_PRESENT);
|
||||
addStringToStatement(statement, cursor, 10, SmsDatabase.SUBJECT);
|
||||
addStringToStatement(statement, cursor, 11, SmsDatabase.BODY);
|
||||
addStringToStatement(statement, cursor, 12, SmsDatabase.SERVICE_CENTER);
|
||||
|
||||
statement.bindLong(13, threadId);
|
||||
}
|
||||
|
||||
private static String getTheirCanonicalAddress(Context context, String theirRecipientId) {
|
||||
Uri uri = Uri.parse("content://mms-sms/canonical-address/" + theirRecipientId);
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(uri, null, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getString(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (IllegalStateException iae) {
|
||||
Log.w("SmsMigrator", iae);
|
||||
return null;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable Set<Recipient> getOurRecipients(Context context, String theirRecipients) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(theirRecipients.trim(), " ");
|
||||
Set<Recipient> recipientList = new HashSet<>();
|
||||
|
||||
while (tokenizer.hasMoreTokens()) {
|
||||
String theirRecipientId = tokenizer.nextToken();
|
||||
String address = getTheirCanonicalAddress(context, theirRecipientId);
|
||||
|
||||
if (address != null) {
|
||||
recipientList.add(Recipient.from(context, Address.fromExternal(context, address), true));
|
||||
}
|
||||
}
|
||||
|
||||
if (recipientList.isEmpty()) return null;
|
||||
else return recipientList;
|
||||
}
|
||||
|
||||
private static void migrateConversation(Context context, SmsMigrationProgressListener listener,
|
||||
ProgressDescription progress,
|
||||
long theirThreadId, long ourThreadId)
|
||||
{
|
||||
SmsDatabase ourSmsDatabase = DatabaseFactory.getSmsDatabase(context);
|
||||
Cursor cursor = null;
|
||||
SQLiteStatement statement = null;
|
||||
|
||||
try {
|
||||
Uri uri = Uri.parse("content://sms/conversations/" + theirThreadId);
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(uri, null, null, null, null);
|
||||
} catch (SQLiteException e) {
|
||||
/// Work around for weird sony-specific (?) bug: #4309
|
||||
Log.w(TAG, e);
|
||||
return;
|
||||
}
|
||||
|
||||
SQLiteDatabase transaction = ourSmsDatabase.beginTransaction();
|
||||
statement = ourSmsDatabase.createInsertStatement(transaction);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
int typeColumn = cursor.getColumnIndex(SmsDatabase.TYPE);
|
||||
|
||||
if (cursor.isNull(typeColumn) || isAppropriateTypeForMigration(cursor, typeColumn)) {
|
||||
getContentValuesForRow(context, cursor, ourThreadId, statement);
|
||||
statement.execute();
|
||||
}
|
||||
|
||||
listener.progressUpdate(new ProgressDescription(progress, cursor.getCount(), cursor.getPosition()));
|
||||
}
|
||||
|
||||
ourSmsDatabase.endTransaction(transaction);
|
||||
DatabaseFactory.getThreadDatabase(context).update(ourThreadId, true);
|
||||
DatabaseFactory.getThreadDatabase(context).notifyConversationListeners(ourThreadId);
|
||||
|
||||
} finally {
|
||||
if (statement != null)
|
||||
statement.close();
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrateDatabase(Context context, SmsMigrationProgressListener listener)
|
||||
{
|
||||
// if (context.getSharedPreferences("SecureSMS", Context.MODE_PRIVATE).getBoolean("migrated", false))
|
||||
// return;
|
||||
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
Uri threadListUri = Uri.parse("content://mms-sms/conversations?simple=true");
|
||||
cursor = context.getContentResolver().query(threadListUri, null, null, null, "date ASC");
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long theirThreadId = cursor.getLong(cursor.getColumnIndexOrThrow("_id"));
|
||||
String theirRecipients = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids"));
|
||||
Set<Recipient> ourRecipients = getOurRecipients(context, theirRecipients);
|
||||
ProgressDescription progress = new ProgressDescription(cursor.getCount(), cursor.getPosition(), 100, 0);
|
||||
|
||||
if (ourRecipients != null) {
|
||||
if (ourRecipients.size() == 1) {
|
||||
long ourThreadId = threadDatabase.getOrCreateThreadIdFor(ourRecipients.iterator().next());
|
||||
migrateConversation(context, listener, progress, theirThreadId, ourThreadId);
|
||||
} else if (ourRecipients.size() > 1) {
|
||||
ourRecipients.add(Recipient.from(context, Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)), true));
|
||||
|
||||
List<Address> memberAddresses = new LinkedList<>();
|
||||
|
||||
for (Recipient recipient : ourRecipients) {
|
||||
memberAddresses.add(recipient.getAddress());
|
||||
}
|
||||
|
||||
String ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(memberAddresses, true, null);
|
||||
Recipient ourGroupRecipient = Recipient.from(context, Address.fromSerialized(ourGroupId), true);
|
||||
long ourThreadId = threadDatabase.getOrCreateThreadIdFor(ourGroupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION);
|
||||
|
||||
migrateConversation(context, listener, progress, theirThreadId, ourThreadId);
|
||||
}
|
||||
}
|
||||
|
||||
progress.incrementPrimaryComplete();
|
||||
listener.progressUpdate(progress);
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
context.getSharedPreferences("SecureSMS", Context.MODE_PRIVATE).edit()
|
||||
.putBoolean("migrated", true).apply();
|
||||
}
|
||||
|
||||
public interface SmsMigrationProgressListener {
|
||||
void progressUpdate(ProgressDescription description);
|
||||
}
|
||||
|
||||
public static class ProgressDescription {
|
||||
public final int primaryTotal;
|
||||
public int primaryComplete;
|
||||
public final int secondaryTotal;
|
||||
public final int secondaryComplete;
|
||||
|
||||
ProgressDescription(int primaryTotal, int primaryComplete,
|
||||
int secondaryTotal, int secondaryComplete)
|
||||
{
|
||||
this.primaryTotal = primaryTotal;
|
||||
this.primaryComplete = primaryComplete;
|
||||
this.secondaryTotal = secondaryTotal;
|
||||
this.secondaryComplete = secondaryComplete;
|
||||
}
|
||||
|
||||
ProgressDescription(ProgressDescription that, int secondaryTotal, int secondaryComplete) {
|
||||
this.primaryComplete = that.primaryComplete;
|
||||
this.primaryTotal = that.primaryTotal;
|
||||
this.secondaryComplete = secondaryComplete;
|
||||
this.secondaryTotal = secondaryTotal;
|
||||
}
|
||||
|
||||
void incrementPrimaryComplete() {
|
||||
primaryComplete += 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.IncomingSticker;
|
||||
import org.thoughtcrime.securesms.database.model.StickerPackRecord;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.stickers.BlessedPacks;
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class StickerDatabase extends Database {
|
||||
|
||||
private static final String TAG = Log.tag(StickerDatabase.class);
|
||||
|
||||
public static final String TABLE_NAME = "sticker";
|
||||
public static final String _ID = "_id";
|
||||
static final String PACK_ID = "pack_id";
|
||||
private static final String PACK_KEY = "pack_key";
|
||||
private static final String PACK_TITLE = "pack_title";
|
||||
private static final String PACK_AUTHOR = "pack_author";
|
||||
private static final String STICKER_ID = "sticker_id";
|
||||
private static final String EMOJI = "emoji";
|
||||
private static final String COVER = "cover";
|
||||
private static final String INSTALLED = "installed";
|
||||
private static final String LAST_USED = "last_used";
|
||||
public static final String FILE_PATH = "file_path";
|
||||
public static final String FILE_LENGTH = "file_length";
|
||||
public static final String FILE_RANDOM = "file_random";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + _ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
PACK_ID + " TEXT NOT NULL, " +
|
||||
PACK_KEY + " TEXT NOT NULL, " +
|
||||
PACK_TITLE + " TEXT NOT NULL, " +
|
||||
PACK_AUTHOR + " TEXT NOT NULL, " +
|
||||
STICKER_ID + " INTEGER, " +
|
||||
COVER + " INTEGER, " +
|
||||
EMOJI + " TEXT NOT NULL, " +
|
||||
LAST_USED + " INTEGER, " +
|
||||
INSTALLED + " INTEGER," +
|
||||
FILE_PATH + " TEXT NOT NULL, " +
|
||||
FILE_LENGTH + " INTEGER, " +
|
||||
FILE_RANDOM + " BLOB, " +
|
||||
"UNIQUE(" + PACK_ID + ", " + STICKER_ID + ", " + COVER + ") ON CONFLICT IGNORE)";
|
||||
|
||||
public static final String[] CREATE_INDEXES = {
|
||||
"CREATE INDEX IF NOT EXISTS sticker_pack_id_index ON " + TABLE_NAME + " (" + PACK_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS sticker_sticker_id_index ON " + TABLE_NAME + " (" + STICKER_ID + ");"
|
||||
};
|
||||
|
||||
private static final String DIRECTORY = "stickers";
|
||||
|
||||
private final AttachmentSecret attachmentSecret;
|
||||
|
||||
public StickerDatabase(Context context, SQLCipherOpenHelper databaseHelper, AttachmentSecret attachmentSecret) {
|
||||
super(context, databaseHelper);
|
||||
this.attachmentSecret = attachmentSecret;
|
||||
}
|
||||
|
||||
public void insertSticker(@NonNull IncomingSticker sticker, @NonNull InputStream dataStream) throws IOException {
|
||||
FileInfo fileInfo = saveStickerImage(dataStream);
|
||||
ContentValues contentValues = new ContentValues();
|
||||
|
||||
contentValues.put(PACK_ID, sticker.getPackId());
|
||||
contentValues.put(PACK_KEY, sticker.getPackKey());
|
||||
contentValues.put(PACK_TITLE, sticker.getPackTitle());
|
||||
contentValues.put(PACK_AUTHOR, sticker.getPackAuthor());
|
||||
contentValues.put(STICKER_ID, sticker.getStickerId());
|
||||
contentValues.put(EMOJI, sticker.getEmoji());
|
||||
contentValues.put(COVER, sticker.isCover() ? 1 : 0);
|
||||
contentValues.put(INSTALLED, sticker.isInstalled() ? 1 : 0);
|
||||
contentValues.put(FILE_PATH, fileInfo.getFile().getAbsolutePath());
|
||||
contentValues.put(FILE_LENGTH, fileInfo.getLength());
|
||||
contentValues.put(FILE_RANDOM, fileInfo.getRandom());
|
||||
|
||||
long id = databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues);
|
||||
|
||||
if (id > 0) {
|
||||
notifyStickerListeners();
|
||||
|
||||
if (sticker.isCover()) {
|
||||
notifyStickerPackListeners();
|
||||
|
||||
if (sticker.isInstalled()) {
|
||||
broadcastInstallEvent(sticker.getPackId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable StickerRecord getSticker(@NonNull String packId, int stickerId, boolean isCover) {
|
||||
String selection = PACK_ID + " = ? AND " + STICKER_ID + " = ? AND " + COVER + " = ?";
|
||||
String[] args = new String[] { packId, String.valueOf(stickerId), String.valueOf(isCover ? 1 : 0) };
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, "1")) {
|
||||
return new StickerRecordReader(cursor).getNext();
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable StickerPackRecord getStickerPack(@NonNull String packId) {
|
||||
String query = PACK_ID + " = ? AND " + COVER + " = ?";
|
||||
String[] args = new String[] { packId, "1" };
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null, "1")) {
|
||||
return new StickerPackRecordReader(cursor).getNext();
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable Cursor getInstalledStickerPacks() {
|
||||
String selection = COVER + " = ? AND " + INSTALLED + " = ?";
|
||||
String[] args = new String[] { "1", "1" };
|
||||
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, null);
|
||||
|
||||
setNotifyStickerPackListeners(cursor);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public @Nullable Cursor getStickersByEmoji(@NonNull String emoji) {
|
||||
String selection = EMOJI + " LIKE ? AND " + COVER + " = ?";
|
||||
String[] args = new String[] { "%"+emoji+"%", "0" };
|
||||
|
||||
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, null);
|
||||
setNotifyStickerListeners(cursor);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public @Nullable Cursor getAllStickerPacks() {
|
||||
return getAllStickerPacks(null);
|
||||
}
|
||||
|
||||
public @Nullable Cursor getAllStickerPacks(@Nullable String limit) {
|
||||
String query = COVER + " = ?";
|
||||
String[] args = new String[] { "1" };
|
||||
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null, limit);
|
||||
setNotifyStickerPackListeners(cursor);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public @Nullable Cursor getStickersForPack(@NonNull String packId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String selection = PACK_ID + " = ? AND " + COVER + " = ?";
|
||||
String[] args = new String[] { packId, "0" };
|
||||
|
||||
Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, null);
|
||||
setNotifyStickerListeners(cursor);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public @Nullable Cursor getRecentlyUsedStickers(int limit) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String selection = LAST_USED + " > ? AND " + COVER + " = ?";
|
||||
String[] args = new String[] { "0", "0" };
|
||||
|
||||
Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, LAST_USED + " DESC", String.valueOf(limit));
|
||||
setNotifyStickerListeners(cursor);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public @Nullable InputStream getStickerStream(long rowId) throws IOException {
|
||||
String selection = _ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(rowId) };
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, selection, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
String path = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(FILE_RANDOM));
|
||||
|
||||
if (path != null) {
|
||||
return ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(path), 0);
|
||||
} else {
|
||||
Log.w(TAG, "getStickerStream("+rowId+") - No sticker data");
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "getStickerStream("+rowId+") - Sticker not found.");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isPackInstalled(@NonNull String packId) {
|
||||
StickerPackRecord record = getStickerPack(packId);
|
||||
|
||||
return (record != null && record.isInstalled());
|
||||
}
|
||||
|
||||
public boolean isPackAvailableAsReference(@NonNull String packId) {
|
||||
return getStickerPack(packId) != null;
|
||||
}
|
||||
|
||||
public void updateStickerLastUsedTime(long rowId, long lastUsed) {
|
||||
String selection = _ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(rowId) };
|
||||
ContentValues values = new ContentValues();
|
||||
|
||||
values.put(LAST_USED, lastUsed);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, selection, args);
|
||||
|
||||
notifyStickerListeners();
|
||||
notifyStickerPackListeners();
|
||||
}
|
||||
|
||||
public void markPackAsInstalled(@NonNull String packKey) {
|
||||
updatePackInstalled(databaseHelper.getWritableDatabase(), packKey, true);
|
||||
notifyStickerPackListeners();
|
||||
}
|
||||
|
||||
public void deleteOrphanedPacks() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String query = "SELECT " + PACK_ID + " FROM " + TABLE_NAME + " WHERE " + INSTALLED + " = ? AND " +
|
||||
PACK_ID + " NOT IN (" +
|
||||
"SELECT DISTINCT " + AttachmentDatabase.STICKER_PACK_ID + " FROM " + AttachmentDatabase.TABLE_NAME + " " +
|
||||
"WHERE " + AttachmentDatabase.STICKER_PACK_ID + " NOT NULL" +
|
||||
")";
|
||||
String[] args = new String[] { "0" };
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
boolean performedDelete = false;
|
||||
|
||||
try (Cursor cursor = db.rawQuery(query, args)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String packId = cursor.getString(cursor.getColumnIndexOrThrow(PACK_ID));
|
||||
|
||||
if (!BlessedPacks.contains(packId)) {
|
||||
deletePack(db, packId);
|
||||
performedDelete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
|
||||
if (performedDelete) {
|
||||
notifyStickerPackListeners();
|
||||
notifyStickerListeners();
|
||||
}
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void uninstallPack(@NonNull String packId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
|
||||
updatePackInstalled(db, packId, false);
|
||||
deleteStickersInPackExceptCover(db, packId);
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
notifyStickerPackListeners();
|
||||
notifyStickerListeners();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePackInstalled(@NonNull SQLiteDatabase db, @NonNull String packId, boolean installed) {
|
||||
StickerPackRecord existing = getStickerPack(packId);
|
||||
|
||||
if (existing != null && existing.isInstalled() == installed) {
|
||||
return;
|
||||
}
|
||||
|
||||
String selection = PACK_ID + " = ?";
|
||||
String[] args = new String[]{ packId };
|
||||
ContentValues values = new ContentValues(1);
|
||||
|
||||
values.put(INSTALLED, installed ? 1 : 0);
|
||||
db.update(TABLE_NAME, values, selection, args);
|
||||
|
||||
if (installed) {
|
||||
broadcastInstallEvent(packId);
|
||||
}
|
||||
}
|
||||
|
||||
private FileInfo saveStickerImage(@NonNull InputStream inputStream) throws IOException {
|
||||
File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE);
|
||||
File file = File.createTempFile("sticker", ".mms", partsDirectory);
|
||||
Pair<byte[], OutputStream> out = ModernEncryptingPartOutputStream.createFor(attachmentSecret, file, false);
|
||||
long length = Util.copy(inputStream, out.second);
|
||||
|
||||
return new FileInfo(file, length, out.first);
|
||||
}
|
||||
|
||||
private void deleteSticker(@NonNull SQLiteDatabase db, long rowId, @Nullable String filePath) {
|
||||
String selection = _ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(rowId) };
|
||||
|
||||
db.delete(TABLE_NAME, selection, args);
|
||||
|
||||
if (!TextUtils.isEmpty(filePath)) {
|
||||
new File(filePath).delete();
|
||||
}
|
||||
}
|
||||
|
||||
private void deletePack(@NonNull SQLiteDatabase db, @NonNull String packId) {
|
||||
String selection = PACK_ID + " = ?";
|
||||
String[] args = new String[] { packId };
|
||||
|
||||
db.delete(TABLE_NAME, selection, args);
|
||||
|
||||
deleteStickersInPack(db, packId);
|
||||
}
|
||||
|
||||
private void deleteStickersInPack(@NonNull SQLiteDatabase db, @NonNull String packId) {
|
||||
String selection = PACK_ID + " = ?";
|
||||
String[] args = new String[] { packId };
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String filePath = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH));
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(_ID));
|
||||
|
||||
deleteSticker(db, rowId, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
|
||||
db.delete(TABLE_NAME, selection, args);
|
||||
}
|
||||
|
||||
private void deleteStickersInPackExceptCover(@NonNull SQLiteDatabase db, @NonNull String packId) {
|
||||
String selection = PACK_ID + " = ? AND " + COVER + " = ?";
|
||||
String[] args = new String[] { packId, "0" };
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, selection, args, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(_ID));
|
||||
String filePath = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH));
|
||||
|
||||
deleteSticker(db, rowId, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastInstallEvent(@NonNull String packId) {
|
||||
StickerPackRecord pack = getStickerPack(packId);
|
||||
|
||||
if (pack != null) {
|
||||
EventBus.getDefault().postSticky(new StickerPackInstallEvent(new DecryptableUri(pack.getCover().getUri())));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FileInfo {
|
||||
private final File file;
|
||||
private final long length;
|
||||
private final byte[] random;
|
||||
|
||||
private FileInfo(@NonNull File file, long length, @NonNull byte[] random) {
|
||||
this.file = file;
|
||||
this.length = length;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public long getLength() {
|
||||
return length;
|
||||
}
|
||||
|
||||
public byte[] getRandom() {
|
||||
return random;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class StickerRecordReader implements Closeable {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
public StickerRecordReader(@Nullable Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public @Nullable StickerRecord getNext() {
|
||||
if (cursor == null || !cursor.moveToNext()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public @NonNull StickerRecord getCurrent() {
|
||||
return new StickerRecord(cursor.getLong(cursor.getColumnIndexOrThrow(_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(PACK_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(PACK_KEY)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(EMOJI)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(FILE_LENGTH)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(COVER)) == 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class StickerPackRecordReader implements Closeable {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
public StickerPackRecordReader(@Nullable Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public @Nullable StickerPackRecord getNext() {
|
||||
if (cursor == null || !cursor.moveToNext()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public @NonNull StickerPackRecord getCurrent() {
|
||||
StickerRecord cover = new StickerRecordReader(cursor).getCurrent();
|
||||
|
||||
return new StickerPackRecord(cursor.getString(cursor.getColumnIndexOrThrow(PACK_ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(PACK_KEY)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(PACK_TITLE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(PACK_AUTHOR)),
|
||||
cover,
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(INSTALLED)) == 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
/*
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
* Copyright (C) 2013-2017 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MergeCursor;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DelimiterUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import nl.komponents.kovenant.combine.Tuple2;
|
||||
|
||||
public class ThreadDatabase extends Database {
|
||||
|
||||
private static final String TAG = ThreadDatabase.class.getSimpleName();
|
||||
|
||||
private Map<Long, Address> addressCache = new HashMap<>();
|
||||
|
||||
public static final String TABLE_NAME = "thread";
|
||||
public static final String ID = "_id";
|
||||
public static final String DATE = "date";
|
||||
public static final String MESSAGE_COUNT = "message_count";
|
||||
public static final String ADDRESS = "recipient_ids";
|
||||
public static final String SNIPPET = "snippet";
|
||||
private static final String SNIPPET_CHARSET = "snippet_cs";
|
||||
public static final String READ = "read";
|
||||
public static final String UNREAD_COUNT = "unread_count";
|
||||
public static final String TYPE = "type";
|
||||
private static final String ERROR = "error";
|
||||
public static final String SNIPPET_TYPE = "snippet_type";
|
||||
public static final String SNIPPET_URI = "snippet_uri";
|
||||
public static final String ARCHIVED = "archived";
|
||||
public static final String STATUS = "status";
|
||||
public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count";
|
||||
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
|
||||
public static final String EXPIRES_IN = "expires_in";
|
||||
public static final String LAST_SEEN = "last_seen";
|
||||
private static final String HAS_SENT = "has_sent";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
|
||||
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +
|
||||
MESSAGE_COUNT + " INTEGER DEFAULT 0, " + ADDRESS + " TEXT, " + SNIPPET + " TEXT, " +
|
||||
SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " +
|
||||
TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
|
||||
SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " +
|
||||
ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " +
|
||||
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " +
|
||||
LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " +
|
||||
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + ADDRESS + ");",
|
||||
"CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");",
|
||||
};
|
||||
|
||||
private static final String[] THREAD_PROJECTION = {
|
||||
ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE,
|
||||
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT
|
||||
};
|
||||
|
||||
private static final List<String> TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION)
|
||||
.map(columnName -> TABLE_NAME + "." + columnName)
|
||||
.toList();
|
||||
|
||||
private static final List<String> COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION),
|
||||
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
|
||||
Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
|
||||
.toList();
|
||||
|
||||
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
private long createThreadForRecipient(Address address, boolean group, int distributionType) {
|
||||
ContentValues contentValues = new ContentValues(4);
|
||||
long date = System.currentTimeMillis();
|
||||
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
contentValues.put(ADDRESS, address.serialize());
|
||||
|
||||
if (group)
|
||||
contentValues.put(TYPE, distributionType);
|
||||
|
||||
contentValues.put(MESSAGE_COUNT, 0);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
return db.insert(TABLE_NAME, null, contentValues);
|
||||
}
|
||||
|
||||
private void updateThread(long threadId, long count, String body, @Nullable Uri attachment,
|
||||
long date, int status, int deliveryReceiptCount, long type, boolean unarchive,
|
||||
long expiresIn, int readReceiptCount)
|
||||
{
|
||||
ContentValues contentValues = new ContentValues(7);
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
contentValues.put(MESSAGE_COUNT, count);
|
||||
if (!body.isEmpty()) {
|
||||
contentValues.put(SNIPPET, body);
|
||||
}
|
||||
contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString());
|
||||
contentValues.put(SNIPPET_TYPE, type);
|
||||
contentValues.put(STATUS, status);
|
||||
contentValues.put(DELIVERY_RECEIPT_COUNT, deliveryReceiptCount);
|
||||
contentValues.put(READ_RECEIPT_COUNT, readReceiptCount);
|
||||
contentValues.put(EXPIRES_IN, expiresIn);
|
||||
|
||||
if (unarchive) {
|
||||
contentValues.put(ARCHIVED, 0);
|
||||
}
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""});
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) {
|
||||
ContentValues contentValues = new ContentValues(4);
|
||||
|
||||
contentValues.put(DATE, date - date % 1000);
|
||||
if (!snippet.isEmpty()) {
|
||||
contentValues.put(SNIPPET, snippet);
|
||||
}
|
||||
contentValues.put(SNIPPET_TYPE, type);
|
||||
contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString());
|
||||
|
||||
if (unarchive) {
|
||||
contentValues.put(ARCHIVED, 0);
|
||||
}
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""});
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
private void deleteThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""});
|
||||
addressCache.remove(threadId);
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
private void deleteThreads(Set<Long> threadIds) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
String where = "";
|
||||
|
||||
for (long threadId : threadIds) {
|
||||
where += ID + " = '" + threadId + "' OR ";
|
||||
}
|
||||
|
||||
where = where.substring(0, where.length() - 4);
|
||||
|
||||
db.delete(TABLE_NAME, where, null);
|
||||
for (long threadId: threadIds) {
|
||||
addressCache.remove(threadId);
|
||||
}
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
private void deleteAllThreads() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
addressCache.clear();
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public void trimAllThreads(int length, ProgressListener listener) {
|
||||
Cursor cursor = null;
|
||||
int threadCount = 0;
|
||||
int complete = 0;
|
||||
|
||||
try {
|
||||
cursor = this.getConversationList();
|
||||
|
||||
if (cursor != null)
|
||||
threadCount = cursor.getCount();
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
|
||||
trimThread(threadId, length);
|
||||
|
||||
listener.onProgress(++complete, threadCount);
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public void trimThread(long threadId, int length) {
|
||||
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " to: " + length);
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId);
|
||||
|
||||
if (cursor != null && length > 0 && cursor.getCount() > length) {
|
||||
Log.w("ThreadDatabase", "Cursor count is greater than length!");
|
||||
cursor.moveToPosition(length - 1);
|
||||
|
||||
long lastTweetDate = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
|
||||
|
||||
Log.i("ThreadDatabase", "Cut off tweet date: " + lastTweetDate);
|
||||
|
||||
DatabaseFactory.getSmsDatabase(context).deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
|
||||
DatabaseFactory.getMmsDatabase(context).deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
|
||||
|
||||
update(threadId, false);
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public List<MarkedMessageInfo> setAllThreadsRead() {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(UNREAD_COUNT, 0);
|
||||
|
||||
db.update(TABLE_NAME, contentValues, null, null);
|
||||
|
||||
final List<MarkedMessageInfo> smsRecords = DatabaseFactory.getSmsDatabase(context).setAllMessagesRead();
|
||||
final List<MarkedMessageInfo> mmsRecords = DatabaseFactory.getMmsDatabase(context).setAllMessagesRead();
|
||||
|
||||
notifyConversationListListeners();
|
||||
|
||||
return new LinkedList<MarkedMessageInfo>() {{
|
||||
addAll(smsRecords);
|
||||
addAll(mmsRecords);
|
||||
}};
|
||||
}
|
||||
|
||||
public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(READ, 1);
|
||||
contentValues.put(UNREAD_COUNT, 0);
|
||||
|
||||
if (lastSeen) {
|
||||
contentValues.put(LAST_SEEN, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
|
||||
|
||||
final List<MarkedMessageInfo> smsRecords = DatabaseFactory.getSmsDatabase(context).setMessagesRead(threadId);
|
||||
final List<MarkedMessageInfo> mmsRecords = DatabaseFactory.getMmsDatabase(context).setMessagesRead(threadId);
|
||||
|
||||
notifyConversationListListeners();
|
||||
|
||||
return new LinkedList<MarkedMessageInfo>() {{
|
||||
addAll(smsRecords);
|
||||
addAll(mmsRecords);
|
||||
}};
|
||||
}
|
||||
|
||||
public void incrementUnread(long threadId, int amount) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
|
||||
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ? WHERE " + ID + " = ?",
|
||||
new String[] {String.valueOf(amount),
|
||||
String.valueOf(threadId)});
|
||||
}
|
||||
|
||||
public void setDistributionType(long threadId, int distributionType) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(TYPE, distributionType);
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public int getDistributionType(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return cursor.getInt(cursor.getColumnIndexOrThrow(TYPE));
|
||||
}
|
||||
|
||||
return DistributionTypes.DEFAULT;
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public Cursor getFilteredConversationList(@Nullable List<Address> filter) {
|
||||
if (filter == null || filter.size() == 0)
|
||||
return null;
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<List<Address>> partitionedAddresses = Util.partition(filter, 900);
|
||||
List<Cursor> cursors = new LinkedList<>();
|
||||
|
||||
for (List<Address> addresses : partitionedAddresses) {
|
||||
String selection = TABLE_NAME + "." + ADDRESS + " = ?";
|
||||
String[] selectionArgs = new String[addresses.size()];
|
||||
|
||||
for (int i=0;i<addresses.size()-1;i++)
|
||||
selection += (" OR " + TABLE_NAME + "." + ADDRESS + " = ?");
|
||||
|
||||
int i= 0;
|
||||
for (Address address : addresses) {
|
||||
selectionArgs[i++] = DelimiterUtil.escape(address.serialize(), ' ');
|
||||
}
|
||||
|
||||
String query = createQuery(selection, 0);
|
||||
cursors.add(db.rawQuery(query, selectionArgs));
|
||||
}
|
||||
|
||||
Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0);
|
||||
setNotifyConverationListListeners(cursor);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public Cursor getRecentConversationList(int limit) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(MESSAGE_COUNT + " != 0", limit);
|
||||
|
||||
return db.rawQuery(query, null);
|
||||
}
|
||||
|
||||
public Cursor getConversationList() {
|
||||
return getConversationList("0");
|
||||
}
|
||||
|
||||
public Cursor getArchivedConversationList() {
|
||||
return getConversationList("1");
|
||||
}
|
||||
|
||||
private Cursor getConversationList(String archived) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0", 0);
|
||||
Cursor cursor = db.rawQuery(query, new String[]{archived});
|
||||
|
||||
setNotifyConverationListListeners(cursor);
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public Cursor getDirectShareList() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = createQuery(MESSAGE_COUNT + " != 0", 0);
|
||||
|
||||
return db.rawQuery(query, null);
|
||||
}
|
||||
|
||||
public int getArchivedConversationListCount() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, ARCHIVED + " = ?",
|
||||
new String[] {"1"}, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void archiveConversation(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(ARCHIVED, 1);
|
||||
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public void unarchiveConversation(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(ARCHIVED, 0);
|
||||
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public void setLastSeen(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(LAST_SEEN, System.currentTimeMillis());
|
||||
|
||||
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)});
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{LAST_SEEN, HAS_SENT}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return new Pair<>(cursor.getLong(0), cursor.getLong(1) == 1);
|
||||
}
|
||||
|
||||
return new Pair<>(-1L, false);
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteConversation(long threadId) {
|
||||
DatabaseFactory.getSmsDatabase(context).deleteThread(threadId);
|
||||
DatabaseFactory.getMmsDatabase(context).deleteThread(threadId);
|
||||
DatabaseFactory.getDraftDatabase(context).clearDrafts(threadId);
|
||||
deleteThread(threadId);
|
||||
notifyConversationListeners(threadId);
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public void deleteConversations(Set<Long> selectedConversations) {
|
||||
DatabaseFactory.getSmsDatabase(context).deleteThreads(selectedConversations);
|
||||
DatabaseFactory.getMmsDatabase(context).deleteThreads(selectedConversations);
|
||||
DatabaseFactory.getDraftDatabase(context).clearDrafts(selectedConversations);
|
||||
deleteThreads(selectedConversations);
|
||||
notifyConversationListeners(selectedConversations);
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public void deleteAllConversations() {
|
||||
DatabaseFactory.getSmsDatabase(context).deleteAllThreads();
|
||||
DatabaseFactory.getMmsDatabase(context).deleteAllThreads();
|
||||
DatabaseFactory.getDraftDatabase(context).clearAllDrafts();
|
||||
deleteAllThreads();
|
||||
}
|
||||
|
||||
public boolean hasThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = db.query(TABLE_NAME, new String[]{ ID }, ID_WHERE, new String[]{ String.valueOf(threadId) }, null, null, null);
|
||||
|
||||
try {
|
||||
if (cursor != null && cursor.moveToFirst()) { return true; }
|
||||
return false;
|
||||
} finally {
|
||||
if (cursor != null) cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public long getThreadIdIfExistsFor(Recipient recipient) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String where = ADDRESS + " = ?";
|
||||
String[] recipientsArg = new String[] {recipient.getAddress().serialize()};
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst())
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(ID));
|
||||
else
|
||||
return -1L;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public long getOrCreateThreadIdFor(Recipient recipient) {
|
||||
return getOrCreateThreadIdFor(recipient, DistributionTypes.DEFAULT);
|
||||
}
|
||||
|
||||
public long getOrCreateThreadIdFor(Recipient recipient, int distributionType) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String where = ADDRESS + " = ?";
|
||||
String[] recipientsArg = new String[]{recipient.getAddress().serialize()};
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(ID));
|
||||
} else {
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient, true);
|
||||
return createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType);
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable Recipient getRecipientForThreadId(long threadId) {
|
||||
// Loki - Cache the address
|
||||
if (addressCache.containsKey(threadId) && addressCache.get(threadId) != null) {
|
||||
return Recipient.from(context, addressCache.get(threadId), false);
|
||||
}
|
||||
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, null, ID + " = ?", new String[] {threadId+""}, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)));
|
||||
addressCache.put(threadId, address);
|
||||
return Recipient.from(context, address, false);
|
||||
}
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void setHasSent(long threadId, boolean hasSent) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(HAS_SENT, hasSent ? 1 : 0);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE,
|
||||
new String[] {String.valueOf(threadId)});
|
||||
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
|
||||
void updateReadState(long threadId) {
|
||||
int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId);
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(READ, unreadCount == 0);
|
||||
contentValues.put(UNREAD_COUNT, unreadCount);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,ID_WHERE,
|
||||
new String[] {String.valueOf(threadId)});
|
||||
|
||||
notifyConversationListListeners();
|
||||
}
|
||||
|
||||
public boolean update(long threadId, boolean unarchive) {
|
||||
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
long count = mmsSmsDatabase.getConversationCount(threadId);
|
||||
|
||||
if (count == 0) {
|
||||
deleteThread(threadId);
|
||||
notifyConversationListListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
MmsSmsDatabase.Reader reader = null;
|
||||
|
||||
try {
|
||||
reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
|
||||
MessageRecord record;
|
||||
|
||||
if (reader != null && (record = reader.getNext()) != null) {
|
||||
updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record),
|
||||
record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(),
|
||||
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
|
||||
notifyConversationListListeners();
|
||||
return false;
|
||||
} else {
|
||||
deleteThread(threadId);
|
||||
notifyConversationListListeners();
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
if (reader != null)
|
||||
reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lightweight utility method to retrieve the complete list of non-archived threads coupled with their recipient address.
|
||||
* @return a tuple with non-null values: thread id, recipient address.
|
||||
*/
|
||||
public @NonNull List<Tuple2<Long, String>> getConversationListQuick() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
|
||||
ArrayList<Tuple2<Long, String>> result = new ArrayList<>();
|
||||
|
||||
try (Cursor cursor = db.query(
|
||||
TABLE_NAME,
|
||||
new String[]{ID, ADDRESS},
|
||||
ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + ADDRESS + " IS NOT NULL",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
result.add(new Tuple2<>(
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.isMms()) {
|
||||
MmsMessageRecord record = (MmsMessageRecord) messageRecord;
|
||||
if (record.getSharedContacts().size() > 0) {
|
||||
Contact contact = ((MmsMessageRecord) messageRecord).getSharedContacts().get(0);
|
||||
return ContactUtil.getStringSummary(context, contact).toString();
|
||||
}
|
||||
String attachmentString = record.getSlideDeck().getBody();
|
||||
if (!attachmentString.isEmpty()) {
|
||||
if (!messageRecord.getBody().isEmpty()) {
|
||||
attachmentString = attachmentString + ": " + messageRecord.getBody();
|
||||
}
|
||||
return attachmentString;
|
||||
}
|
||||
}
|
||||
return messageRecord.getBody();
|
||||
}
|
||||
|
||||
private @Nullable Uri getAttachmentUriFor(MessageRecord record) {
|
||||
if (!record.isMms() || record.isMmsNotification() || record.isGroupAction()) return null;
|
||||
|
||||
SlideDeck slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
|
||||
Slide thumbnail = slideDeck.getThumbnailSlide();
|
||||
|
||||
if (thumbnail != null) {
|
||||
return thumbnail.getThumbnailUri();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private @NonNull String createQuery(@NonNull String where, int limit) {
|
||||
String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ",");
|
||||
String query =
|
||||
"SELECT " + projection + " FROM " + TABLE_NAME +
|
||||
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
|
||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
|
||||
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
|
||||
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID +
|
||||
" WHERE " + where +
|
||||
" ORDER BY " + TABLE_NAME + "." + DATE + " DESC";
|
||||
|
||||
if (limit > 0) {
|
||||
query += " LIMIT " + limit;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public interface ProgressListener {
|
||||
void onProgress(int complete, int total);
|
||||
}
|
||||
|
||||
public Reader readerFor(Cursor cursor) {
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
public static class DistributionTypes {
|
||||
public static final int DEFAULT = 2;
|
||||
public static final int BROADCAST = 1;
|
||||
public static final int CONVERSATION = 2;
|
||||
public static final int ARCHIVE = 3;
|
||||
public static final int INBOX_ZERO = 4;
|
||||
}
|
||||
|
||||
public class Reader implements Closeable {
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
public Reader(Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public ThreadRecord getNext() {
|
||||
if (cursor == null || !cursor.moveToNext())
|
||||
return null;
|
||||
|
||||
return getCurrent();
|
||||
}
|
||||
|
||||
public ThreadRecord getCurrent() {
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID));
|
||||
int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE));
|
||||
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.ADDRESS)));
|
||||
|
||||
Optional<RecipientSettings> settings;
|
||||
Optional<GroupRecord> groupRecord;
|
||||
|
||||
if (distributionType != DistributionTypes.ARCHIVE && distributionType != DistributionTypes.INBOX_ZERO) {
|
||||
settings = DatabaseFactory.getRecipientDatabase(context).getRecipientSettings(cursor);
|
||||
groupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(cursor);
|
||||
} else {
|
||||
settings = Optional.absent();
|
||||
groupRecord = Optional.absent();
|
||||
}
|
||||
|
||||
Recipient recipient = Recipient.from(context, address, settings, groupRecord, true);
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET));
|
||||
long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE));
|
||||
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
|
||||
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
|
||||
long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE));
|
||||
boolean archived = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.ARCHIVED)) != 0;
|
||||
int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS));
|
||||
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT));
|
||||
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT));
|
||||
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN));
|
||||
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
|
||||
Uri snippetUri = getSnippetUri(cursor);
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0;
|
||||
}
|
||||
|
||||
return new ThreadRecord(body, snippetUri, recipient, date, count,
|
||||
unreadCount, threadId, deliveryReceiptCount, status, type,
|
||||
distributionType, archived, expiresIn, lastSeen, readReceiptCount);
|
||||
}
|
||||
|
||||
private @Nullable Uri getSnippetUri(Cursor cursor) {
|
||||
if (cursor.isNull(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_URI))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Uri.parse(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_URI)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
import org.xmlpull.v1.XmlPullParserFactory;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class XmlBackup {
|
||||
|
||||
private static final String PROTOCOL = "protocol";
|
||||
private static final String ADDRESS = "address";
|
||||
private static final String CONTACT_NAME = "contact_name";
|
||||
private static final String DATE = "date";
|
||||
private static final String READABLE_DATE = "readable_date";
|
||||
private static final String TYPE = "type";
|
||||
private static final String SUBJECT = "subject";
|
||||
private static final String BODY = "body";
|
||||
private static final String SERVICE_CENTER = "service_center";
|
||||
private static final String READ = "read";
|
||||
private static final String STATUS = "status";
|
||||
private static final String TOA = "toa";
|
||||
private static final String SC_TOA = "sc_toa";
|
||||
private static final String LOCKED = "locked";
|
||||
|
||||
private static final SimpleDateFormat dateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z");
|
||||
|
||||
private final XmlPullParser parser;
|
||||
|
||||
public XmlBackup(String path) throws XmlPullParserException, FileNotFoundException {
|
||||
this.parser = XmlPullParserFactory.newInstance().newPullParser();
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
|
||||
parser.setInput(new FileInputStream(path), null);
|
||||
}
|
||||
|
||||
public XmlBackupItem getNext() throws IOException, XmlPullParserException {
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.getEventType() != XmlPullParser.START_TAG) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = parser.getName();
|
||||
|
||||
if (!name.equalsIgnoreCase("sms")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int attributeCount = parser.getAttributeCount();
|
||||
|
||||
if (attributeCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
XmlBackupItem item = new XmlBackupItem();
|
||||
|
||||
for (int i=0;i<attributeCount;i++) {
|
||||
String attributeName = parser.getAttributeName(i);
|
||||
|
||||
if (attributeName.equals(PROTOCOL )) item.protocol = Integer.parseInt(parser.getAttributeValue(i));
|
||||
else if (attributeName.equals(ADDRESS )) item.address = parser.getAttributeValue(i);
|
||||
else if (attributeName.equals(CONTACT_NAME )) item.contactName = parser.getAttributeValue(i);
|
||||
else if (attributeName.equals(DATE )) item.date = Long.parseLong(parser.getAttributeValue(i));
|
||||
else if (attributeName.equals(READABLE_DATE )) item.readableDate = parser.getAttributeValue(i);
|
||||
else if (attributeName.equals(TYPE )) item.type = Integer.parseInt(parser.getAttributeValue(i));
|
||||
else if (attributeName.equals(SUBJECT )) item.subject = parser.getAttributeValue(i);
|
||||
else if (attributeName.equals(BODY )) item.body = parser.getAttributeValue(i);
|
||||
else if (attributeName.equals(SERVICE_CENTER)) item.serviceCenter = parser.getAttributeValue(i);
|
||||
else if (attributeName.equals(READ )) item.read = Integer.parseInt(parser.getAttributeValue(i));
|
||||
else if (attributeName.equals(STATUS )) item.status = Integer.parseInt(parser.getAttributeValue(i));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class XmlBackupItem {
|
||||
private int protocol;
|
||||
private String address;
|
||||
private String contactName;
|
||||
private long date;
|
||||
private String readableDate;
|
||||
private int type;
|
||||
private String subject;
|
||||
private String body;
|
||||
private String serviceCenter;
|
||||
private int read;
|
||||
private int status;
|
||||
|
||||
public XmlBackupItem() {}
|
||||
|
||||
public XmlBackupItem(int protocol, String address, String contactName, long date, int type,
|
||||
String subject, String body, String serviceCenter, int read, int status)
|
||||
{
|
||||
this.protocol = protocol;
|
||||
this.address = address;
|
||||
this.contactName = contactName;
|
||||
this.date = date;
|
||||
this.readableDate = dateFormatter.format(date);
|
||||
this.type = type;
|
||||
this.subject = subject;
|
||||
this.body = body;
|
||||
this.serviceCenter = serviceCenter;
|
||||
this.read = read;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getProtocol() {
|
||||
return protocol;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public String getContactName() {
|
||||
return contactName;
|
||||
}
|
||||
|
||||
public long getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public String getReadableDate() {
|
||||
return readableDate;
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getSubject() {
|
||||
return subject;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public String getServiceCenter() {
|
||||
return serviceCenter;
|
||||
}
|
||||
|
||||
public int getRead() {
|
||||
return read;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Writer {
|
||||
|
||||
private static final String XML_HEADER = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?>";
|
||||
private static final String CREATED_BY = "<!-- File Created By Signal -->";
|
||||
private static final String OPEN_TAG_SMSES = "<smses count=\"%d\">";
|
||||
private static final String CLOSE_TAG_SMSES = "</smses>";
|
||||
private static final String OPEN_TAG_SMS = " <sms ";
|
||||
private static final String CLOSE_EMPTYTAG = "/>";
|
||||
private static final String OPEN_ATTRIBUTE = "=\"";
|
||||
private static final String CLOSE_ATTRIBUTE = "\" ";
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("[^\u0020-\uD7FF]");
|
||||
|
||||
private final BufferedWriter bufferedWriter;
|
||||
|
||||
public Writer(String path, int count) throws IOException {
|
||||
bufferedWriter = new BufferedWriter(new FileWriter(path, false));
|
||||
|
||||
bufferedWriter.write(XML_HEADER);
|
||||
bufferedWriter.newLine();
|
||||
bufferedWriter.write(CREATED_BY);
|
||||
bufferedWriter.newLine();
|
||||
bufferedWriter.write(String.format(Locale.ROOT, OPEN_TAG_SMSES, count));
|
||||
}
|
||||
|
||||
public void writeItem(XmlBackupItem item) throws IOException {
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
stringBuilder.append(OPEN_TAG_SMS);
|
||||
appendAttribute(stringBuilder, PROTOCOL, item.getProtocol());
|
||||
appendAttribute(stringBuilder, ADDRESS, escapeXML(item.getAddress()));
|
||||
appendAttribute(stringBuilder, CONTACT_NAME, escapeXML(item.getContactName()));
|
||||
appendAttribute(stringBuilder, DATE, item.getDate());
|
||||
appendAttribute(stringBuilder, READABLE_DATE, item.getReadableDate());
|
||||
appendAttribute(stringBuilder, TYPE, item.getType());
|
||||
appendAttribute(stringBuilder, SUBJECT, escapeXML(item.getSubject()));
|
||||
appendAttribute(stringBuilder, BODY, escapeXML(item.getBody()));
|
||||
appendAttribute(stringBuilder, TOA, "null");
|
||||
appendAttribute(stringBuilder, SC_TOA, "null");
|
||||
appendAttribute(stringBuilder, SERVICE_CENTER, item.getServiceCenter());
|
||||
appendAttribute(stringBuilder, READ, item.getRead());
|
||||
appendAttribute(stringBuilder, STATUS, item.getStatus());
|
||||
appendAttribute(stringBuilder, LOCKED, 0);
|
||||
stringBuilder.append(CLOSE_EMPTYTAG);
|
||||
|
||||
bufferedWriter.newLine();
|
||||
bufferedWriter.write(stringBuilder.toString());
|
||||
}
|
||||
|
||||
private <T> void appendAttribute(StringBuilder stringBuilder, String name, T value) {
|
||||
stringBuilder.append(name).append(OPEN_ATTRIBUTE).append(value).append(CLOSE_ATTRIBUTE);
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
bufferedWriter.newLine();
|
||||
bufferedWriter.write(CLOSE_TAG_SMSES);
|
||||
bufferedWriter.close();
|
||||
}
|
||||
|
||||
private String escapeXML(String s) {
|
||||
if (TextUtils.isEmpty(s)) return s;
|
||||
|
||||
Matcher matcher = PATTERN.matcher( s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'"));
|
||||
StringBuffer st = new StringBuffer();
|
||||
|
||||
while (matcher.find()) {
|
||||
String escaped="";
|
||||
for (char ch: matcher.group(0).toCharArray()) {
|
||||
escaped += ("&#" + ((int) ch) + ";");
|
||||
}
|
||||
matcher.appendReplacement(st, escaped);
|
||||
}
|
||||
matcher.appendTail(st);
|
||||
return st.toString();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.database.documents;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface Document<T> {
|
||||
|
||||
public int size();
|
||||
public List<T> getList();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.thoughtcrime.securesms.database.documents;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class IdentityKeyMismatch {
|
||||
|
||||
private static final String TAG = IdentityKeyMismatch.class.getSimpleName();
|
||||
|
||||
@JsonProperty(value = "a")
|
||||
private String address;
|
||||
|
||||
@JsonProperty(value = "k")
|
||||
@JsonSerialize(using = IdentityKeySerializer.class)
|
||||
@JsonDeserialize(using = IdentityKeyDeserializer.class)
|
||||
private IdentityKey identityKey;
|
||||
|
||||
public IdentityKeyMismatch() {}
|
||||
|
||||
public IdentityKeyMismatch(Address address, IdentityKey identityKey) {
|
||||
this.address = address.serialize();
|
||||
this.identityKey = identityKey;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Address getAddress() {
|
||||
return Address.fromSerialized(address);
|
||||
}
|
||||
|
||||
public IdentityKey getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other == null || !(other instanceof IdentityKeyMismatch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
IdentityKeyMismatch that = (IdentityKeyMismatch)other;
|
||||
return that.address.equals(this.address) && that.identityKey.equals(this.identityKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return address.hashCode() ^ identityKey.hashCode();
|
||||
}
|
||||
|
||||
private static class IdentityKeySerializer extends JsonSerializer<IdentityKey> {
|
||||
@Override
|
||||
public void serialize(IdentityKey value, JsonGenerator jsonGenerator, SerializerProvider serializers)
|
||||
throws IOException
|
||||
{
|
||||
jsonGenerator.writeString(Base64.encodeBytes(value.serialize()));
|
||||
}
|
||||
}
|
||||
|
||||
private static class IdentityKeyDeserializer extends JsonDeserializer<IdentityKey> {
|
||||
@Override
|
||||
public IdentityKey deserialize(JsonParser jsonParser, DeserializationContext ctxt)
|
||||
throws IOException
|
||||
{
|
||||
try {
|
||||
return new IdentityKey(Base64.decode(jsonParser.getValueAsString()), 0);
|
||||
} catch (InvalidKeyException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.database.documents;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class IdentityKeyMismatchList implements Document<IdentityKeyMismatch> {
|
||||
|
||||
@JsonProperty(value = "m")
|
||||
private List<IdentityKeyMismatch> mismatches;
|
||||
|
||||
public IdentityKeyMismatchList() {
|
||||
this.mismatches = new LinkedList<>();
|
||||
}
|
||||
|
||||
public IdentityKeyMismatchList(List<IdentityKeyMismatch> mismatches) {
|
||||
this.mismatches = mismatches;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
if (mismatches == null) return 0;
|
||||
else return mismatches.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public List<IdentityKeyMismatch> getList() {
|
||||
return mismatches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.database.documents;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
|
||||
public class NetworkFailure {
|
||||
|
||||
@JsonProperty(value = "a")
|
||||
private String address;
|
||||
|
||||
public NetworkFailure(Address address) {
|
||||
this.address = address.serialize();
|
||||
}
|
||||
|
||||
public NetworkFailure() {}
|
||||
|
||||
@JsonIgnore
|
||||
public Address getAddress() {
|
||||
return Address.fromSerialized(address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other == null || !(other instanceof NetworkFailure)) return false;
|
||||
|
||||
NetworkFailure that = (NetworkFailure)other;
|
||||
return this.address.equals(that.address);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return address.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.database.documents;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class NetworkFailureList implements Document<NetworkFailure> {
|
||||
|
||||
@JsonProperty(value = "l")
|
||||
private List<NetworkFailure> failures;
|
||||
|
||||
public NetworkFailureList() {
|
||||
this.failures = new LinkedList<>();
|
||||
}
|
||||
|
||||
public NetworkFailureList(List<NetworkFailure> failures) {
|
||||
this.failures = failures;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
if (failures == null) return 0;
|
||||
else return failures.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public List<NetworkFailure> getList() {
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
package org.thoughtcrime.securesms.database.helpers;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.libsignal.InvalidMessageException;
|
||||
import org.whispersystems.libsignal.state.PreKeyRecord;
|
||||
import org.whispersystems.libsignal.state.SignedPreKeyRecord;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
|
||||
class PreKeyMigrationHelper {
|
||||
|
||||
private static final String PREKEY_DIRECTORY = "prekeys";
|
||||
private static final String SIGNED_PREKEY_DIRECTORY = "signed_prekeys";
|
||||
|
||||
private static final int PLAINTEXT_VERSION = 2;
|
||||
private static final int CURRENT_VERSION_MARKER = 2;
|
||||
|
||||
private static final String TAG = PreKeyMigrationHelper.class.getSimpleName();
|
||||
|
||||
static boolean migratePreKeys(Context context, SQLiteDatabase database) {
|
||||
File[] preKeyFiles = getPreKeyDirectory(context).listFiles();
|
||||
boolean clean = true;
|
||||
|
||||
if (preKeyFiles != null) {
|
||||
for (File preKeyFile : preKeyFiles) {
|
||||
if (!"index.dat".equals(preKeyFile.getName())) {
|
||||
try {
|
||||
PreKeyRecord preKey = new PreKeyRecord(loadSerializedRecord(preKeyFile));
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(OneTimePreKeyDatabase.KEY_ID, preKey.getId());
|
||||
contentValues.put(OneTimePreKeyDatabase.PUBLIC_KEY, Base64.encodeBytes(preKey.getKeyPair().getPublicKey().serialize()));
|
||||
contentValues.put(OneTimePreKeyDatabase.PRIVATE_KEY, Base64.encodeBytes(preKey.getKeyPair().getPrivateKey().serialize()));
|
||||
database.insert(OneTimePreKeyDatabase.TABLE_NAME, null, contentValues);
|
||||
Log.i(TAG, "Migrated one-time prekey: " + preKey.getId());
|
||||
} catch (IOException | InvalidMessageException e) {
|
||||
Log.w(TAG, e);
|
||||
clean = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File[] signedPreKeyFiles = getSignedPreKeyDirectory(context).listFiles();
|
||||
|
||||
if (signedPreKeyFiles != null) {
|
||||
for (File signedPreKeyFile : signedPreKeyFiles) {
|
||||
if (!"index.dat".equals(signedPreKeyFile.getName())) {
|
||||
try {
|
||||
SignedPreKeyRecord signedPreKey = new SignedPreKeyRecord(loadSerializedRecord(signedPreKeyFile));
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(SignedPreKeyDatabase.KEY_ID, signedPreKey.getId());
|
||||
contentValues.put(SignedPreKeyDatabase.PUBLIC_KEY, Base64.encodeBytes(signedPreKey.getKeyPair().getPublicKey().serialize()));
|
||||
contentValues.put(SignedPreKeyDatabase.PRIVATE_KEY, Base64.encodeBytes(signedPreKey.getKeyPair().getPrivateKey().serialize()));
|
||||
contentValues.put(SignedPreKeyDatabase.SIGNATURE, Base64.encodeBytes(signedPreKey.getSignature()));
|
||||
contentValues.put(SignedPreKeyDatabase.TIMESTAMP, signedPreKey.getTimestamp());
|
||||
database.insert(SignedPreKeyDatabase.TABLE_NAME, null, contentValues);
|
||||
Log.i(TAG, "Migrated signed prekey: " + signedPreKey.getId());
|
||||
} catch (IOException | InvalidMessageException e) {
|
||||
Log.w(TAG, e);
|
||||
clean = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File oneTimePreKeyIndex = new File(getPreKeyDirectory(context), PreKeyIndex.FILE_NAME);
|
||||
File signedPreKeyIndex = new File(getSignedPreKeyDirectory(context), SignedPreKeyIndex.FILE_NAME);
|
||||
|
||||
if (oneTimePreKeyIndex.exists()) {
|
||||
try {
|
||||
InputStreamReader reader = new InputStreamReader(new FileInputStream(oneTimePreKeyIndex));
|
||||
PreKeyIndex index = JsonUtils.fromJson(reader, PreKeyIndex.class);
|
||||
reader.close();
|
||||
|
||||
Log.i(TAG, "Setting next prekey id: " + index.nextPreKeyId);
|
||||
TextSecurePreferences.setNextPreKeyId(context, index.nextPreKeyId);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (signedPreKeyIndex.exists()) {
|
||||
try {
|
||||
InputStreamReader reader = new InputStreamReader(new FileInputStream(signedPreKeyIndex));
|
||||
SignedPreKeyIndex index = JsonUtils.fromJson(reader, SignedPreKeyIndex.class);
|
||||
reader.close();
|
||||
|
||||
Log.i(TAG, "Setting next signed prekey id: " + index.nextSignedPreKeyId);
|
||||
Log.i(TAG, "Setting active signed prekey id: " + index.activeSignedPreKeyId);
|
||||
TextSecurePreferences.setNextSignedPreKeyId(context, index.nextSignedPreKeyId);
|
||||
TextSecurePreferences.setActiveSignedPreKeyId(context, index.activeSignedPreKeyId);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
static void cleanUpPreKeys(@NonNull Context context) {
|
||||
File preKeyDirectory = getPreKeyDirectory(context);
|
||||
File[] preKeyFiles = preKeyDirectory.listFiles();
|
||||
|
||||
if (preKeyFiles != null) {
|
||||
for (File preKeyFile : preKeyFiles) {
|
||||
Log.i(TAG, "Deleting: " + preKeyFile.getAbsolutePath());
|
||||
preKeyFile.delete();
|
||||
}
|
||||
|
||||
Log.i(TAG, "Deleting: " + preKeyDirectory.getAbsolutePath());
|
||||
preKeyDirectory.delete();
|
||||
}
|
||||
|
||||
File signedPreKeyDirectory = getSignedPreKeyDirectory(context);
|
||||
File[] signedPreKeyFiles = signedPreKeyDirectory.listFiles();
|
||||
|
||||
if (signedPreKeyFiles != null) {
|
||||
for (File signedPreKeyFile : signedPreKeyFiles) {
|
||||
Log.i(TAG, "Deleting: " + signedPreKeyFile.getAbsolutePath());
|
||||
signedPreKeyFile.delete();
|
||||
}
|
||||
|
||||
Log.i(TAG, "Deleting: " + signedPreKeyDirectory.getAbsolutePath());
|
||||
signedPreKeyDirectory.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] loadSerializedRecord(File recordFile)
|
||||
throws IOException, InvalidMessageException
|
||||
{
|
||||
FileInputStream fin = new FileInputStream(recordFile);
|
||||
int recordVersion = readInteger(fin);
|
||||
|
||||
if (recordVersion > CURRENT_VERSION_MARKER) {
|
||||
throw new IOException("Invalid version: " + recordVersion);
|
||||
}
|
||||
|
||||
byte[] serializedRecord = readBlob(fin);
|
||||
|
||||
if (recordVersion < PLAINTEXT_VERSION) {
|
||||
throw new IOException("Migration didn't happen! " + recordFile.getAbsolutePath() + ", " + recordVersion);
|
||||
}
|
||||
|
||||
fin.close();
|
||||
return serializedRecord;
|
||||
}
|
||||
|
||||
private static File getPreKeyDirectory(Context context) {
|
||||
return getRecordsDirectory(context, PREKEY_DIRECTORY);
|
||||
}
|
||||
|
||||
private static File getSignedPreKeyDirectory(Context context) {
|
||||
return getRecordsDirectory(context, SIGNED_PREKEY_DIRECTORY);
|
||||
}
|
||||
|
||||
private static File getRecordsDirectory(Context context, String directoryName) {
|
||||
File directory = new File(context.getFilesDir(), directoryName);
|
||||
|
||||
if (!directory.exists()) {
|
||||
if (!directory.mkdirs()) {
|
||||
Log.w(TAG, "PreKey directory creation failed!");
|
||||
}
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static byte[] readBlob(FileInputStream in) throws IOException {
|
||||
int length = readInteger(in);
|
||||
byte[] blobBytes = new byte[length];
|
||||
|
||||
in.read(blobBytes, 0, blobBytes.length);
|
||||
return blobBytes;
|
||||
}
|
||||
|
||||
private static int readInteger(FileInputStream in) throws IOException {
|
||||
byte[] integer = new byte[4];
|
||||
in.read(integer, 0, integer.length);
|
||||
return Conversions.byteArrayToInt(integer);
|
||||
}
|
||||
|
||||
private static class PreKeyIndex {
|
||||
static final String FILE_NAME = "index.dat";
|
||||
|
||||
@JsonProperty
|
||||
private int nextPreKeyId;
|
||||
|
||||
public PreKeyIndex() {}
|
||||
}
|
||||
|
||||
private static class SignedPreKeyIndex {
|
||||
static final String FILE_NAME = "index.dat";
|
||||
|
||||
@JsonProperty
|
||||
private int nextSignedPreKeyId;
|
||||
|
||||
@JsonProperty
|
||||
private int activeSignedPreKeyId = -1;
|
||||
|
||||
public SignedPreKeyIndex() {}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package org.thoughtcrime.securesms.database.helpers;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.text.TextUtils;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import android.util.Pair;
|
||||
|
||||
import com.annimon.stream.function.BiFunction;
|
||||
|
||||
import org.thoughtcrime.securesms.DatabaseUpgradeActivity;
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.crypto.AsymmetricMasterCipher;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.MasterCipher;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.service.GenericForegroundService;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.libsignal.InvalidMessageException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class SQLCipherMigrationHelper {
|
||||
|
||||
private static final String TAG = SQLCipherMigrationHelper.class.getSimpleName();
|
||||
|
||||
private static final long ENCRYPTION_SYMMETRIC_BIT = 0x80000000;
|
||||
private static final long ENCRYPTION_ASYMMETRIC_BIT = 0x40000000;
|
||||
|
||||
static void migratePlaintext(@NonNull Context context,
|
||||
@NonNull android.database.sqlite.SQLiteDatabase legacyDb,
|
||||
@NonNull net.sqlcipher.database.SQLiteDatabase modernDb)
|
||||
{
|
||||
modernDb.beginTransaction();
|
||||
try {
|
||||
GenericForegroundService.startForegroundTask(context, context.getString(R.string.SQLCipherMigrationHelper_migrating_signal_database));
|
||||
copyTable("identities", legacyDb, modernDb, null);
|
||||
copyTable("push", legacyDb, modernDb, null);
|
||||
copyTable("groups", legacyDb, modernDb, null);
|
||||
copyTable("recipient_preferences", legacyDb, modernDb, null);
|
||||
copyTable("group_receipts", legacyDb, modernDb, null);
|
||||
modernDb.setTransactionSuccessful();
|
||||
} finally {
|
||||
modernDb.endTransaction();
|
||||
GenericForegroundService.stopForegroundTask(context);
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrateCiphertext(@NonNull Context context,
|
||||
@NonNull MasterSecret masterSecret,
|
||||
@NonNull android.database.sqlite.SQLiteDatabase legacyDb,
|
||||
@NonNull net.sqlcipher.database.SQLiteDatabase modernDb,
|
||||
@Nullable DatabaseUpgradeActivity.DatabaseUpgradeListener listener)
|
||||
{
|
||||
MasterCipher legacyCipher = new MasterCipher(masterSecret);
|
||||
AsymmetricMasterCipher legacyAsymmetricCipher = new AsymmetricMasterCipher(MasterSecretUtil.getAsymmetricMasterSecret(context, masterSecret));
|
||||
|
||||
modernDb.beginTransaction();
|
||||
|
||||
try {
|
||||
GenericForegroundService.startForegroundTask(context, context.getString(R.string.SQLCipherMigrationHelper_migrating_signal_database));
|
||||
int total = 5000;
|
||||
|
||||
copyTable("sms", legacyDb, modernDb, (row, progress) -> {
|
||||
Pair<Long, String> plaintext = getPlaintextBody(legacyCipher, legacyAsymmetricCipher,
|
||||
row.getAsLong("type"),
|
||||
row.getAsString("body"));
|
||||
|
||||
row.put("body", plaintext.second);
|
||||
row.put("type", plaintext.first);
|
||||
|
||||
if (listener != null && (progress.first % 1000 == 0)) {
|
||||
listener.setProgress(getTotalProgress(0, progress.first, progress.second), total);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
copyTable("mms", legacyDb, modernDb, (row, progress) -> {
|
||||
Pair<Long, String> plaintext = getPlaintextBody(legacyCipher, legacyAsymmetricCipher,
|
||||
row.getAsLong("msg_box"),
|
||||
row.getAsString("body"));
|
||||
|
||||
row.put("body", plaintext.second);
|
||||
row.put("msg_box", plaintext.first);
|
||||
|
||||
if (listener != null && (progress.first % 1000 == 0)) {
|
||||
listener.setProgress(getTotalProgress(1000, progress.first, progress.second), total);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
copyTable("part", legacyDb, modernDb, (row, progress) -> {
|
||||
String fileName = row.getAsString("file_name");
|
||||
String mediaKey = row.getAsString("cd");
|
||||
|
||||
try {
|
||||
if (!TextUtils.isEmpty(fileName)) {
|
||||
row.put("file_name", legacyCipher.decryptBody(fileName));
|
||||
}
|
||||
} catch (InvalidMessageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!TextUtils.isEmpty(mediaKey)) {
|
||||
byte[] plaintext;
|
||||
|
||||
if (mediaKey.startsWith("?ASYNC-")) {
|
||||
plaintext = legacyAsymmetricCipher.decryptBytes(Base64.decode(mediaKey.substring("?ASYNC-".length())));
|
||||
} else {
|
||||
plaintext = legacyCipher.decryptBytes(Base64.decode(mediaKey));
|
||||
}
|
||||
|
||||
row.put("cd", Base64.encodeBytes(plaintext));
|
||||
}
|
||||
} catch (IOException | InvalidMessageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
if (listener != null && (progress.first % 1000 == 0)) {
|
||||
listener.setProgress(getTotalProgress(2000, progress.first, progress.second), total);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
copyTable("thread", legacyDb, modernDb, (row, progress) -> {
|
||||
Long snippetType = row.getAsLong("snippet_type");
|
||||
if (snippetType == null) snippetType = 0L;
|
||||
|
||||
Pair<Long, String> plaintext = getPlaintextBody(legacyCipher, legacyAsymmetricCipher,
|
||||
snippetType, row.getAsString("snippet"));
|
||||
|
||||
row.put("snippet", plaintext.second);
|
||||
row.put("snippet_type", plaintext.first);
|
||||
|
||||
if (listener != null && (progress.first % 1000 == 0)) {
|
||||
listener.setProgress(getTotalProgress(3000, progress.first, progress.second), total);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
|
||||
copyTable("drafts", legacyDb, modernDb, (row, progress) -> {
|
||||
String draftType = row.getAsString("type");
|
||||
String draft = row.getAsString("value");
|
||||
|
||||
try {
|
||||
if (!TextUtils.isEmpty(draftType)) row.put("type", legacyCipher.decryptBody(draftType));
|
||||
if (!TextUtils.isEmpty(draft)) row.put("value", legacyCipher.decryptBody(draft));
|
||||
} catch (InvalidMessageException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
if (listener != null && (progress.first % 1000 == 0)) {
|
||||
listener.setProgress(getTotalProgress(4000, progress.first, progress.second), total);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
AttachmentSecretProvider.getInstance(context).setClassicKey(context, masterSecret.getEncryptionKey().getEncoded(), masterSecret.getMacKey().getEncoded());
|
||||
TextSecurePreferences.setNeedsSqlCipherMigration(context, false);
|
||||
modernDb.setTransactionSuccessful();
|
||||
} finally {
|
||||
modernDb.endTransaction();
|
||||
GenericForegroundService.stopForegroundTask(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyTable(@NonNull String tableName,
|
||||
@NonNull android.database.sqlite.SQLiteDatabase legacyDb,
|
||||
@NonNull net.sqlcipher.database.SQLiteDatabase modernDb,
|
||||
@Nullable BiFunction<ContentValues, Pair<Integer, Integer>, ContentValues> transformer)
|
||||
{
|
||||
Set<String> destinationColumns = getTableColumns(tableName, modernDb);
|
||||
|
||||
try (Cursor cursor = legacyDb.query(tableName, null, null, null, null, null, null)) {
|
||||
int count = (cursor != null) ? cursor.getCount() : 0;
|
||||
int progress = 1;
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
ContentValues row = new ContentValues();
|
||||
|
||||
for (int i=0;i<cursor.getColumnCount();i++) {
|
||||
String columnName = cursor.getColumnName(i);
|
||||
|
||||
if (destinationColumns.contains(columnName)) {
|
||||
switch (cursor.getType(i)) {
|
||||
case Cursor.FIELD_TYPE_STRING: row.put(columnName, cursor.getString(i)); break;
|
||||
case Cursor.FIELD_TYPE_FLOAT: row.put(columnName, cursor.getFloat(i)); break;
|
||||
case Cursor.FIELD_TYPE_INTEGER: row.put(columnName, cursor.getLong(i)); break;
|
||||
case Cursor.FIELD_TYPE_BLOB: row.put(columnName, cursor.getBlob(i)); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transformer != null) {
|
||||
row = transformer.apply(row, new Pair<>(progress++, count));
|
||||
}
|
||||
|
||||
modernDb.insert(tableName, null, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Pair<Long, String> getPlaintextBody(@NonNull MasterCipher legacyCipher,
|
||||
@NonNull AsymmetricMasterCipher legacyAsymmetricCipher,
|
||||
long type,
|
||||
@Nullable String body)
|
||||
{
|
||||
try {
|
||||
if (!TextUtils.isEmpty(body)) {
|
||||
if ((type & ENCRYPTION_SYMMETRIC_BIT) != 0) body = legacyCipher.decryptBody(body);
|
||||
else if ((type & ENCRYPTION_ASYMMETRIC_BIT) != 0) body = legacyAsymmetricCipher.decryptBody(body);
|
||||
}
|
||||
} catch (InvalidMessageException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
type &= ~(ENCRYPTION_SYMMETRIC_BIT);
|
||||
type &= ~(ENCRYPTION_ASYMMETRIC_BIT);
|
||||
|
||||
return new Pair<>(type, body);
|
||||
}
|
||||
|
||||
private static Set<String> getTableColumns(String tableName, net.sqlcipher.database.SQLiteDatabase database) {
|
||||
Set<String> results = new HashSet<>();
|
||||
|
||||
try (Cursor cursor = database.rawQuery("PRAGMA table_info(" + tableName + ")", null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
results.add(cursor.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static int getTotalProgress(int sectionOffset, int sectionProgress, int sectionTotal) {
|
||||
double percentOfSectionComplete = ((double)sectionProgress) / ((double)sectionTotal);
|
||||
return sectionOffset + (int)(((double)1000) * percentOfSectionComplete);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
package org.thoughtcrime.securesms.database.helpers;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.SystemClock;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
import net.sqlcipher.database.SQLiteDatabaseHook;
|
||||
import net.sqlcipher.database.SQLiteOpenHelper;
|
||||
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.JobDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.PushDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.SessionDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiMessageDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiPreKeyBundleDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiPreKeyRecordDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.LokiUserDatabase;
|
||||
import org.thoughtcrime.securesms.loki.database.SharedSenderKeysDatabase;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.loki.api.opengroups.PublicChat;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = SQLCipherOpenHelper.class.getSimpleName();
|
||||
|
||||
private static final int RECIPIENT_CALL_RINGTONE_VERSION = 2;
|
||||
private static final int MIGRATE_PREKEYS_VERSION = 3;
|
||||
private static final int MIGRATE_SESSIONS_VERSION = 4;
|
||||
private static final int NO_MORE_IMAGE_THUMBNAILS_VERSION = 5;
|
||||
private static final int ATTACHMENT_DIMENSIONS = 6;
|
||||
private static final int QUOTED_REPLIES = 7;
|
||||
private static final int SHARED_CONTACTS = 8;
|
||||
private static final int FULL_TEXT_SEARCH = 9;
|
||||
private static final int BAD_IMPORT_CLEANUP = 10;
|
||||
private static final int QUOTE_MISSING = 11;
|
||||
private static final int NOTIFICATION_CHANNELS = 12;
|
||||
private static final int SECRET_SENDER = 13;
|
||||
private static final int ATTACHMENT_CAPTIONS = 14;
|
||||
private static final int ATTACHMENT_CAPTIONS_FIX = 15;
|
||||
private static final int PREVIEWS = 16;
|
||||
private static final int CONVERSATION_SEARCH = 17;
|
||||
private static final int SELF_ATTACHMENT_CLEANUP = 18;
|
||||
private static final int RECIPIENT_FORCE_SMS_SELECTION = 19;
|
||||
private static final int JOBMANAGER_STRIKES_BACK = 20;
|
||||
private static final int STICKERS = 21;
|
||||
private static final int lokiV1 = 22;
|
||||
private static final int lokiV2 = 23;
|
||||
private static final int lokiV3 = 24;
|
||||
private static final int lokiV4 = 25;
|
||||
private static final int lokiV5 = 26;
|
||||
private static final int lokiV6 = 27;
|
||||
private static final int lokiV7 = 28;
|
||||
private static final int lokiV8 = 29;
|
||||
private static final int lokiV9 = 30;
|
||||
private static final int lokiV10 = 31;
|
||||
private static final int lokiV11 = 32;
|
||||
private static final int lokiV12 = 33;
|
||||
private static final int lokiV13 = 34;
|
||||
private static final int lokiV14_BACKUP_FILES = 35;
|
||||
private static final int lokiV15 = 36;
|
||||
private static final int lokiV16 = 37;
|
||||
private static final int lokiV17 = 38;
|
||||
private static final int lokiV18_CLEAR_BG_POLL_JOBS = 39;
|
||||
|
||||
private static final int DATABASE_VERSION = lokiV18_CLEAR_BG_POLL_JOBS; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
|
||||
private final Context context;
|
||||
private final DatabaseSecret databaseSecret;
|
||||
|
||||
public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) {
|
||||
super(context, DATABASE_NAME, null, DATABASE_VERSION, new SQLiteDatabaseHook() {
|
||||
@Override
|
||||
public void preKey(SQLiteDatabase db) {
|
||||
db.rawExecSQL("PRAGMA cipher_default_kdf_iter = 1;");
|
||||
db.rawExecSQL("PRAGMA cipher_default_page_size = 4096;");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postKey(SQLiteDatabase db) {
|
||||
db.rawExecSQL("PRAGMA kdf_iter = '1';");
|
||||
db.rawExecSQL("PRAGMA cipher_page_size = 4096;");
|
||||
}
|
||||
});
|
||||
|
||||
this.context = context.getApplicationContext();
|
||||
this.databaseSecret = databaseSecret;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(SmsDatabase.CREATE_TABLE);
|
||||
db.execSQL(MmsDatabase.CREATE_TABLE);
|
||||
db.execSQL(AttachmentDatabase.CREATE_TABLE);
|
||||
db.execSQL(ThreadDatabase.CREATE_TABLE);
|
||||
db.execSQL(IdentityDatabase.CREATE_TABLE);
|
||||
db.execSQL(DraftDatabase.CREATE_TABLE);
|
||||
db.execSQL(PushDatabase.CREATE_TABLE);
|
||||
db.execSQL(GroupDatabase.CREATE_TABLE);
|
||||
db.execSQL(RecipientDatabase.CREATE_TABLE);
|
||||
db.execSQL(GroupReceiptDatabase.CREATE_TABLE);
|
||||
db.execSQL(OneTimePreKeyDatabase.CREATE_TABLE);
|
||||
db.execSQL(SignedPreKeyDatabase.CREATE_TABLE);
|
||||
db.execSQL(SessionDatabase.CREATE_TABLE);
|
||||
for (String sql : SearchDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
for (String sql : JobDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
db.execSQL(StickerDatabase.CREATE_TABLE);
|
||||
|
||||
db.execSQL(LokiAPIDatabase.getCreateSnodePoolTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOnionRequestPathTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSwarmTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateLastMessageHashValueTable2Command());
|
||||
db.execSQL(LokiAPIDatabase.getCreateReceivedMessageHashValuesTable3Command());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupAuthTokenTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateLastMessageServerIDTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateLastDeletionServerIDTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateDeviceLinkCacheCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateUserCountTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestTimestampCacheCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestSentTimestampTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestProcessedTimestampTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupPublicKeyTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
|
||||
db.execSQL(LokiPreKeyBundleDatabase.getCreateTableCommand());
|
||||
db.execSQL(LokiPreKeyRecordDatabase.getCreateTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateMessageIDTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand());
|
||||
db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand());
|
||||
db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand());
|
||||
db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand());
|
||||
db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand());
|
||||
db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand());
|
||||
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
|
||||
db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand());
|
||||
db.execSQL(SharedSenderKeysDatabase.getCreateCurrentClosedGroupRatchetTableCommand());
|
||||
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
|
||||
|
||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, MmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, AttachmentDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, ThreadDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, DraftDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, StickerDatabase.CREATE_INDEXES);
|
||||
|
||||
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
|
||||
ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context);
|
||||
android.database.sqlite.SQLiteDatabase legacyDb = legacyHelper.getWritableDatabase();
|
||||
|
||||
SQLCipherMigrationHelper.migratePlaintext(context, legacyDb, db);
|
||||
|
||||
MasterSecret masterSecret = KeyCachingService.getMasterSecret(context);
|
||||
|
||||
if (masterSecret != null) SQLCipherMigrationHelper.migrateCiphertext(context, masterSecret, legacyDb, db, null);
|
||||
else TextSecurePreferences.setNeedsSqlCipherMigration(context, true);
|
||||
|
||||
if (!PreKeyMigrationHelper.migratePreKeys(context, db)) {
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new RefreshPreKeysJob());
|
||||
}
|
||||
|
||||
SessionStoreMigrationHelper.migrateSessions(context, db);
|
||||
PreKeyMigrationHelper.cleanUpPreKeys(context);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigure(SQLiteDatabase db) {
|
||||
super.onConfigure(db);
|
||||
// Loki - Enable write ahead logging mode and increase the cache size.
|
||||
// This should be disabled if we ever run into serious race condition bugs.
|
||||
db.enableWriteAheadLogging();
|
||||
db.execSQL("PRAGMA cache_size = 10000");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
Log.i(TAG, "Upgrading database: " + oldVersion + ", " + newVersion);
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
|
||||
if (oldVersion < RECIPIENT_CALL_RINGTONE_VERSION) {
|
||||
db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN call_ringtone TEXT DEFAULT NULL");
|
||||
db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN call_vibrate INTEGER DEFAULT " + RecipientDatabase.VibrateState.DEFAULT.getId());
|
||||
}
|
||||
|
||||
if (oldVersion < MIGRATE_PREKEYS_VERSION) {
|
||||
db.execSQL("CREATE TABLE signed_prekeys (_id INTEGER PRIMARY KEY, key_id INTEGER UNIQUE, public_key TEXT NOT NULL, private_key TEXT NOT NULL, signature TEXT NOT NULL, timestamp INTEGER DEFAULT 0)");
|
||||
db.execSQL("CREATE TABLE one_time_prekeys (_id INTEGER PRIMARY KEY, key_id INTEGER UNIQUE, public_key TEXT NOT NULL, private_key TEXT NOT NULL)");
|
||||
|
||||
if (!PreKeyMigrationHelper.migratePreKeys(context, db)) {
|
||||
ApplicationContext.getInstance(context).getJobManager().add(new RefreshPreKeysJob());
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < MIGRATE_SESSIONS_VERSION) {
|
||||
db.execSQL("CREATE TABLE sessions (_id INTEGER PRIMARY KEY, address TEXT NOT NULL, device INTEGER NOT NULL, record BLOB NOT NULL, UNIQUE(address, device) ON CONFLICT REPLACE)");
|
||||
SessionStoreMigrationHelper.migrateSessions(context, db);
|
||||
}
|
||||
|
||||
if (oldVersion < NO_MORE_IMAGE_THUMBNAILS_VERSION) {
|
||||
ContentValues update = new ContentValues();
|
||||
update.put("thumbnail", (String)null);
|
||||
update.put("aspect_ratio", (String)null);
|
||||
update.put("thumbnail_random", (String)null);
|
||||
|
||||
try (Cursor cursor = db.query("part", new String[] {"_id", "ct", "thumbnail"}, "thumbnail IS NOT NULL", null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long id = cursor.getLong(cursor.getColumnIndexOrThrow("_id"));
|
||||
String contentType = cursor.getString(cursor.getColumnIndexOrThrow("ct"));
|
||||
|
||||
if (contentType != null && !contentType.startsWith("video")) {
|
||||
String thumbnailPath = cursor.getString(cursor.getColumnIndexOrThrow("thumbnail"));
|
||||
File thumbnailFile = new File(thumbnailPath);
|
||||
thumbnailFile.delete();
|
||||
|
||||
db.update("part", update, "_id = ?", new String[] {String.valueOf(id)});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < ATTACHMENT_DIMENSIONS) {
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN width INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN height INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
if (oldVersion < QUOTED_REPLIES) {
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN quote_id INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN quote_author TEXT");
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN quote_body TEXT");
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN quote_attachment INTEGER DEFAULT -1");
|
||||
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN quote INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
if (oldVersion < SHARED_CONTACTS) {
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN shared_contacts TEXT");
|
||||
}
|
||||
|
||||
if (oldVersion < FULL_TEXT_SEARCH) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, content=sms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(rowid, body) VALUES (new._id, new.body);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
" INSERT INTO sms_fts(rowid, body) VALUES(new._id, new.body);\n" +
|
||||
"END;");
|
||||
|
||||
db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, content=mms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(rowid, body) VALUES (new._id, new.body);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
" INSERT INTO mms_fts(rowid, body) VALUES(new._id, new.body);\n" +
|
||||
"END;");
|
||||
|
||||
Log.i(TAG, "Beginning to build search index.");
|
||||
long start = SystemClock.elapsedRealtime();
|
||||
|
||||
db.execSQL("INSERT INTO sms_fts (rowid, body) SELECT _id, body FROM sms");
|
||||
|
||||
long smsFinished = SystemClock.elapsedRealtime();
|
||||
Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms");
|
||||
|
||||
db.execSQL("INSERT INTO mms_fts (rowid, body) SELECT _id, body FROM mms");
|
||||
|
||||
long mmsFinished = SystemClock.elapsedRealtime();
|
||||
Log.i(TAG, "Indexing MMS completed in " + (mmsFinished - smsFinished) + " ms");
|
||||
Log.i(TAG, "Indexing finished. Total time: " + (mmsFinished - start) + " ms");
|
||||
}
|
||||
|
||||
if (oldVersion < BAD_IMPORT_CLEANUP) {
|
||||
String trimmedCondition = " NOT IN (SELECT _id FROM mms)";
|
||||
|
||||
db.delete("group_receipts", "mms_id" + trimmedCondition, null);
|
||||
|
||||
String[] columns = new String[] { "_id", "unique_id", "_data", "thumbnail"};
|
||||
|
||||
try (Cursor cursor = db.query("part", columns, "mid" + trimmedCondition, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
db.delete("part", "_id = ? AND unique_id = ?", new String[] { String.valueOf(cursor.getLong(0)), String.valueOf(cursor.getLong(1)) });
|
||||
|
||||
String data = cursor.getString(2);
|
||||
String thumbnail = cursor.getString(3);
|
||||
|
||||
if (!TextUtils.isEmpty(data)) {
|
||||
new File(data).delete();
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(thumbnail)) {
|
||||
new File(thumbnail).delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This column only being checked due to upgrade issues as described in #8184
|
||||
if (oldVersion < QUOTE_MISSING && !columnExists(db, "mms", "quote_missing")) {
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN quote_missing INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
// Note: The column only being checked due to upgrade issues as described in #8184
|
||||
if (oldVersion < NOTIFICATION_CHANNELS && !columnExists(db, "recipient_preferences", "notification_channel")) {
|
||||
db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN notification_channel TEXT DEFAULT NULL");
|
||||
NotificationChannels.create(context);
|
||||
|
||||
try (Cursor cursor = db.rawQuery("SELECT recipient_ids, system_display_name, signal_profile_name, notification, vibrate FROM recipient_preferences WHERE notification NOT NULL OR vibrate != 0", null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String addressString = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids"));
|
||||
Address address = Address.fromExternal(context, addressString);
|
||||
String systemName = cursor.getString(cursor.getColumnIndexOrThrow("system_display_name"));
|
||||
String profileName = cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_name"));
|
||||
String messageSound = cursor.getString(cursor.getColumnIndexOrThrow("notification"));
|
||||
Uri messageSoundUri = messageSound != null ? Uri.parse(messageSound) : null;
|
||||
int vibrateState = cursor.getInt(cursor.getColumnIndexOrThrow("vibrate"));
|
||||
String displayName = NotificationChannels.getChannelDisplayNameFor(context, systemName, profileName, address);
|
||||
boolean vibrateEnabled = vibrateState == 0 ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == 1;
|
||||
|
||||
if (address.isGroup()) {
|
||||
try(Cursor groupCursor = db.rawQuery("SELECT title FROM groups WHERE group_id = ?", new String[] { address.toGroupString() })) {
|
||||
if (groupCursor != null && groupCursor.moveToFirst()) {
|
||||
String title = groupCursor.getString(groupCursor.getColumnIndexOrThrow("title"));
|
||||
|
||||
if (!TextUtils.isEmpty(title)) {
|
||||
displayName = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String channelId = NotificationChannels.createChannelFor(context, address, displayName, messageSoundUri, vibrateEnabled);
|
||||
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put("notification_channel", channelId);
|
||||
db.update("recipient_preferences", values, "recipient_ids = ?", new String[] { addressString });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < SECRET_SENDER) {
|
||||
db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN unidentified_access_mode INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE push ADD COLUMN server_timestamp INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE push ADD COLUMN server_guid TEXT DEFAULT NULL");
|
||||
db.execSQL("ALTER TABLE group_receipts ADD COLUMN unidentified INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN unidentified INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE sms ADD COLUMN unidentified INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
if (oldVersion < ATTACHMENT_CAPTIONS) {
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN caption TEXT DEFAULT NULL");
|
||||
}
|
||||
|
||||
// 4.30.8 included a migration, but not a correct CREATE_TABLE statement, so we need to add
|
||||
// this column if it isn't present.
|
||||
if (oldVersion < ATTACHMENT_CAPTIONS_FIX) {
|
||||
if (!columnExists(db, "part", "caption")) {
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN caption TEXT DEFAULT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < PREVIEWS) {
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN previews TEXT");
|
||||
}
|
||||
|
||||
if (oldVersion < CONVERSATION_SEARCH) {
|
||||
db.execSQL("DROP TABLE sms_fts");
|
||||
db.execSQL("DROP TABLE mms_fts");
|
||||
db.execSQL("DROP TRIGGER sms_ai");
|
||||
db.execSQL("DROP TRIGGER sms_au");
|
||||
db.execSQL("DROP TRIGGER sms_ad");
|
||||
db.execSQL("DROP TRIGGER mms_ai");
|
||||
db.execSQL("DROP TRIGGER mms_au");
|
||||
db.execSQL("DROP TRIGGER mms_ad");
|
||||
|
||||
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, thread_id UNINDEXED, content=sms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
" INSERT INTO sms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
|
||||
db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, thread_id UNINDEXED, content=mms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
" INSERT INTO mms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
|
||||
Log.i(TAG, "Beginning to build search index.");
|
||||
long start = SystemClock.elapsedRealtime();
|
||||
|
||||
db.execSQL("INSERT INTO sms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM sms");
|
||||
|
||||
long smsFinished = SystemClock.elapsedRealtime();
|
||||
Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms");
|
||||
|
||||
db.execSQL("INSERT INTO mms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM mms");
|
||||
|
||||
long mmsFinished = SystemClock.elapsedRealtime();
|
||||
Log.i(TAG, "Indexing MMS completed in " + (mmsFinished - smsFinished) + " ms");
|
||||
Log.i(TAG, "Indexing finished. Total time: " + (mmsFinished - start) + " ms");
|
||||
}
|
||||
|
||||
if (oldVersion < SELF_ATTACHMENT_CLEANUP) {
|
||||
String localNumber = TextSecurePreferences.getLocalNumber(context);
|
||||
|
||||
if (!TextUtils.isEmpty(localNumber)) {
|
||||
try (Cursor threadCursor = db.rawQuery("SELECT _id FROM thread WHERE recipient_ids = ?", new String[]{ localNumber })) {
|
||||
if (threadCursor != null && threadCursor.moveToFirst()) {
|
||||
long threadId = threadCursor.getLong(0);
|
||||
ContentValues updateValues = new ContentValues(1);
|
||||
|
||||
updateValues.put("pending_push", 0);
|
||||
|
||||
int count = db.update("part", updateValues, "mid IN (SELECT _id FROM mms WHERE thread_id = ?)", new String[]{ String.valueOf(threadId) });
|
||||
Log.i(TAG, "Updated " + count + " self-sent attachments.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldVersion < RECIPIENT_FORCE_SMS_SELECTION) {
|
||||
db.execSQL("ALTER TABLE recipient_preferences ADD COLUMN force_sms_selection INTEGER DEFAULT 0");
|
||||
}
|
||||
|
||||
if (oldVersion < JOBMANAGER_STRIKES_BACK) {
|
||||
db.execSQL("CREATE TABLE job_spec(_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
"job_spec_id TEXT UNIQUE, " +
|
||||
"factory_key TEXT, " +
|
||||
"queue_key TEXT, " +
|
||||
"create_time INTEGER, " +
|
||||
"next_run_attempt_time INTEGER, " +
|
||||
"run_attempt INTEGER, " +
|
||||
"max_attempts INTEGER, " +
|
||||
"max_backoff INTEGER, " +
|
||||
"max_instances INTEGER, " +
|
||||
"lifespan INTEGER, " +
|
||||
"serialized_data TEXT, " +
|
||||
"is_running INTEGER)");
|
||||
|
||||
db.execSQL("CREATE TABLE constraint_spec(_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
"job_spec_id TEXT, " +
|
||||
"factory_key TEXT, " +
|
||||
"UNIQUE(job_spec_id, factory_key))");
|
||||
|
||||
db.execSQL("CREATE TABLE dependency_spec(_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
"job_spec_id TEXT, " +
|
||||
"depends_on_job_spec_id TEXT, " +
|
||||
"UNIQUE(job_spec_id, depends_on_job_spec_id))");
|
||||
}
|
||||
|
||||
if (oldVersion < STICKERS) {
|
||||
db.execSQL("CREATE TABLE sticker (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
"pack_id TEXT NOT NULL, " +
|
||||
"pack_key TEXT NOT NULL, " +
|
||||
"pack_title TEXT NOT NULL, " +
|
||||
"pack_author TEXT NOT NULL, " +
|
||||
"sticker_id INTEGER, " +
|
||||
"cover INTEGER, " +
|
||||
"emoji TEXT NOT NULL, " +
|
||||
"last_used INTEGER, " +
|
||||
"installed INTEGER," +
|
||||
"file_path TEXT NOT NULL, " +
|
||||
"file_length INTEGER, " +
|
||||
"file_random BLOB, " +
|
||||
"UNIQUE(pack_id, sticker_id, cover) ON CONFLICT IGNORE)");
|
||||
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS sticker_pack_id_index ON sticker (pack_id);");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS sticker_sticker_id_index ON sticker (sticker_id);");
|
||||
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN sticker_pack_id TEXT");
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN sticker_pack_key TEXT");
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN sticker_id INTEGER DEFAULT -1");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS part_sticker_pack_id_index ON part (sticker_pack_id)");
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV1) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupAuthTokenTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateLastMessageServerIDTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateLastDeletionServerIDTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV2) {
|
||||
db.execSQL(LokiUserDatabase.getCreateServerDisplayNameTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV3) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateDeviceLinkCacheCommand());
|
||||
db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand());
|
||||
|
||||
db.execSQL("ALTER TABLE groups ADD COLUMN avatar_url TEXT");
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN url TEXT");
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV4) {
|
||||
db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV5) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateUserCountTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV6) {
|
||||
// Migrate public chats from __textsecure_group__ to __loki_public_chat_group__
|
||||
try (Cursor lokiPublicChatCursor = db.rawQuery("SELECT public_chat FROM loki_public_chat_database", null)) {
|
||||
while (lokiPublicChatCursor != null && lokiPublicChatCursor.moveToNext()) {
|
||||
String chatString = lokiPublicChatCursor.getString(0);
|
||||
PublicChat publicChat = PublicChat.fromJSON(chatString);
|
||||
if (publicChat != null) {
|
||||
byte[] groupId = publicChat.getId().getBytes();
|
||||
String oldId = GroupUtil.getEncodedId(groupId, false);
|
||||
String newId = GroupUtil.getEncodedOpenGroupId(groupId);
|
||||
ContentValues threadUpdate = new ContentValues();
|
||||
threadUpdate.put("recipient_ids", newId);
|
||||
db.update("thread", threadUpdate, "recipient_ids = ?", new String[]{ oldId });
|
||||
ContentValues groupUpdate = new ContentValues();
|
||||
groupUpdate.put("group_id", newId);
|
||||
db.update("groups", groupUpdate,"group_id = ?", new String[] { oldId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate RSS feeds from __textsecure_group__ to __loki_rss_feed_group__
|
||||
String[] rssFeedIds = new String[] { "loki.network.feed", "loki.network.messenger-updates.feed" };
|
||||
for (String groupId : rssFeedIds) {
|
||||
String oldId = GroupUtil.getEncodedId(groupId.getBytes(), false);
|
||||
String newId = GroupUtil.getEncodedRSSFeedId(groupId.getBytes());
|
||||
ContentValues threadUpdate = new ContentValues();
|
||||
threadUpdate.put("recipient_ids", newId);
|
||||
db.update("thread", threadUpdate, "recipient_ids = ?", new String[]{ oldId });
|
||||
ContentValues groupUpdate = new ContentValues();
|
||||
groupUpdate.put("group_id", newId);
|
||||
db.update("groups", groupUpdate,"group_id = ?", new String[] { oldId });
|
||||
}
|
||||
|
||||
// Add admin field in groups
|
||||
db.execSQL("ALTER TABLE groups ADD COLUMN admins TEXT");
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV7) {
|
||||
db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV8) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestTimestampCacheCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV9) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateSnodePoolTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateOnionRequestPathTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV10) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestSentTimestampTableCommand());
|
||||
db.execSQL(LokiAPIDatabase.getCreateSessionRequestProcessedTimestampTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV11) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupPublicKeyTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV12) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateLastMessageHashValueTable2Command());
|
||||
db.execSQL(SharedSenderKeysDatabase.getCreateCurrentClosedGroupRatchetTableCommand());
|
||||
db.execSQL(SharedSenderKeysDatabase.getCreateClosedGroupPrivateKeyTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV13) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateReceivedMessageHashValuesTable3Command());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV14_BACKUP_FILES) {
|
||||
db.execSQL(LokiBackupFilesDatabase.getCreateTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV15) {
|
||||
db.execSQL(SharedSenderKeysDatabase.getCreateOldClosedGroupRatchetTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV16) {
|
||||
db.execSQL(LokiAPIDatabase.getCreateOpenGroupProfilePictureTableCommand());
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV17) {
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN audio_visual_samples BLOB");
|
||||
db.execSQL("ALTER TABLE part ADD COLUMN audio_duration INTEGER");
|
||||
}
|
||||
|
||||
if (oldVersion < lokiV18_CLEAR_BG_POLL_JOBS) {
|
||||
// BackgroundPollJob was replaced with BackgroundPollWorker. Clear all the scheduled job records.
|
||||
db.execSQL("DELETE FROM job_spec WHERE factory_key = 'BackgroundPollJob'");
|
||||
db.execSQL("DELETE FROM constraint_spec WHERE factory_key = 'BackgroundPollJob'");
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
|
||||
if (oldVersion < MIGRATE_PREKEYS_VERSION) {
|
||||
PreKeyMigrationHelper.cleanUpPreKeys(context);
|
||||
}
|
||||
}
|
||||
|
||||
public SQLiteDatabase getReadableDatabase() {
|
||||
return getReadableDatabase(databaseSecret.asString());
|
||||
}
|
||||
|
||||
public SQLiteDatabase getWritableDatabase() {
|
||||
return getWritableDatabase(databaseSecret.asString());
|
||||
}
|
||||
|
||||
public void markCurrent(SQLiteDatabase db) {
|
||||
db.setVersion(DATABASE_VERSION);
|
||||
}
|
||||
|
||||
private void executeStatements(SQLiteDatabase db, String[] statements) {
|
||||
for (String statement : statements)
|
||||
db.execSQL(statement);
|
||||
}
|
||||
|
||||
private static boolean columnExists(@NonNull SQLiteDatabase db, @NonNull String table, @NonNull String column) {
|
||||
try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) {
|
||||
int nameColumnIndex = cursor.getColumnIndexOrThrow("name");
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
String name = cursor.getString(nameColumnIndex);
|
||||
|
||||
if (name.equals(column)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.thoughtcrime.securesms.database.helpers;
|
||||
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.database.SessionDatabase;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
import org.whispersystems.libsignal.state.SessionRecord;
|
||||
import org.whispersystems.libsignal.state.SessionState;
|
||||
import org.whispersystems.libsignal.state.StorageProtos.SessionStructure;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
class SessionStoreMigrationHelper {
|
||||
|
||||
private static final String TAG = SessionStoreMigrationHelper.class.getSimpleName();
|
||||
|
||||
private static final String SESSIONS_DIRECTORY_V2 = "sessions-v2";
|
||||
private static final Object FILE_LOCK = new Object();
|
||||
|
||||
private static final int SINGLE_STATE_VERSION = 1;
|
||||
private static final int ARCHIVE_STATES_VERSION = 2;
|
||||
private static final int PLAINTEXT_VERSION = 3;
|
||||
private static final int CURRENT_VERSION = 3;
|
||||
|
||||
static void migrateSessions(Context context, SQLiteDatabase database) {
|
||||
File directory = new File(context.getFilesDir(), SESSIONS_DIRECTORY_V2);
|
||||
|
||||
if (directory.exists()) {
|
||||
File[] sessionFiles = directory.listFiles();
|
||||
|
||||
if (sessionFiles != null) {
|
||||
for (File sessionFile : sessionFiles) {
|
||||
try {
|
||||
String[] parts = sessionFile.getName().split("[.]");
|
||||
Address address = Address.fromSerialized(parts[0]);
|
||||
|
||||
int deviceId;
|
||||
|
||||
if (parts.length > 1) deviceId = Integer.parseInt(parts[1]);
|
||||
else deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID;
|
||||
|
||||
FileInputStream in = new FileInputStream(sessionFile);
|
||||
int versionMarker = readInteger(in);
|
||||
|
||||
if (versionMarker > CURRENT_VERSION) {
|
||||
throw new AssertionError("Unknown version: " + versionMarker + ", " + sessionFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
byte[] serialized = readBlob(in);
|
||||
in.close();
|
||||
|
||||
if (versionMarker < PLAINTEXT_VERSION) {
|
||||
throw new AssertionError("Not plaintext: " + versionMarker + ", " + sessionFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
SessionRecord sessionRecord;
|
||||
|
||||
if (versionMarker == SINGLE_STATE_VERSION) {
|
||||
Log.i(TAG, "Migrating single state version: " + sessionFile.getAbsolutePath());
|
||||
SessionStructure sessionStructure = SessionStructure.parseFrom(serialized);
|
||||
SessionState sessionState = new SessionState(sessionStructure);
|
||||
|
||||
sessionRecord = new SessionRecord(sessionState);
|
||||
} else if (versionMarker >= ARCHIVE_STATES_VERSION) {
|
||||
Log.i(TAG, "Migrating session: " + sessionFile.getAbsolutePath());
|
||||
sessionRecord = new SessionRecord(serialized);
|
||||
} else {
|
||||
throw new AssertionError("Unknown version: " + versionMarker + ", " + sessionFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(SessionDatabase.ADDRESS, address.serialize());
|
||||
contentValues.put(SessionDatabase.DEVICE, deviceId);
|
||||
contentValues.put(SessionDatabase.RECORD, sessionRecord.serialize());
|
||||
|
||||
database.insert(SessionDatabase.TABLE_NAME, null, contentValues);
|
||||
} catch (NumberFormatException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readBlob(FileInputStream in) throws IOException {
|
||||
int length = readInteger(in);
|
||||
byte[] blobBytes = new byte[length];
|
||||
|
||||
in.read(blobBytes, 0, blobBytes.length);
|
||||
return blobBytes;
|
||||
}
|
||||
|
||||
private static int readInteger(FileInputStream in) throws IOException {
|
||||
byte[] integer = new byte[4];
|
||||
in.read(integer, 0, integer.length);
|
||||
return Conversions.byteArrayToInt(integer);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.thoughtcrime.securesms.database.identity;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class IdentityRecordList {
|
||||
|
||||
private static final String TAG = IdentityRecordList.class.getSimpleName();
|
||||
|
||||
private final List<IdentityRecord> identityRecords = new LinkedList<>();
|
||||
|
||||
public void add(Optional<IdentityRecord> identityRecord) {
|
||||
if (identityRecord.isPresent()) {
|
||||
identityRecords.add(identityRecord.get());
|
||||
}
|
||||
}
|
||||
|
||||
public void replaceWith(IdentityRecordList identityRecordList) {
|
||||
identityRecords.clear();
|
||||
identityRecords.addAll(identityRecordList.identityRecords);
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (identityRecord.getVerifiedStatus() != VerifiedStatus.VERIFIED) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return identityRecords.size() > 0;
|
||||
}
|
||||
|
||||
public boolean isUnverified() {
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isUntrusted() {
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (isUntrusted(identityRecord)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<IdentityRecord> getUntrustedRecords() {
|
||||
List<IdentityRecord> results = new LinkedList<>();
|
||||
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (isUntrusted(identityRecord)) {
|
||||
results.add(identityRecord);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Recipient> getUntrustedRecipients(Context context) {
|
||||
List<Recipient> untrusted = new LinkedList<>();
|
||||
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (isUntrusted(identityRecord)) {
|
||||
untrusted.add(Recipient.from(context, identityRecord.getAddress(), false));
|
||||
}
|
||||
}
|
||||
|
||||
return untrusted;
|
||||
}
|
||||
|
||||
public List<IdentityRecord> getUnverifiedRecords() {
|
||||
List<IdentityRecord> results = new LinkedList<>();
|
||||
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) {
|
||||
results.add(identityRecord);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Recipient> getUnverifiedRecipients(Context context) {
|
||||
List<Recipient> unverified = new LinkedList<>();
|
||||
|
||||
for (IdentityRecord identityRecord : identityRecords) {
|
||||
if (identityRecord.getVerifiedStatus() == VerifiedStatus.UNVERIFIED) {
|
||||
unverified.add(Recipient.from(context, identityRecord.getAddress(), false));
|
||||
}
|
||||
}
|
||||
|
||||
return unverified;
|
||||
}
|
||||
|
||||
private boolean isUntrusted(IdentityRecord identityRecord) {
|
||||
return !identityRecord.isApprovedNonBlocking() &&
|
||||
System.currentTimeMillis() - identityRecord.getTimestamp() < TimeUnit.SECONDS.toMillis(5);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
|
||||
|
||||
public class BlockedContactsLoader extends AbstractCursorLoader {
|
||||
|
||||
public BlockedContactsLoader(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getCursor() {
|
||||
return DatabaseFactory.getRecipientDatabase(getContext())
|
||||
.getBlocked();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public class BucketedThreadMediaLoader extends AsyncTaskLoader<BucketedThreadMediaLoader.BucketedThreadMedia> {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = BucketedThreadMediaLoader.class.getSimpleName();
|
||||
|
||||
private final Address address;
|
||||
private final ContentObserver observer;
|
||||
|
||||
public BucketedThreadMediaLoader(@NonNull Context context, @NonNull Address address) {
|
||||
super(context);
|
||||
this.address = address;
|
||||
this.observer = new ForceLoadContentObserver();
|
||||
|
||||
onContentChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStartLoading() {
|
||||
if (takeContentChanged()) {
|
||||
forceLoad();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStopLoading() {
|
||||
cancelLoad();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAbandon() {
|
||||
DatabaseFactory.getMediaDatabase(getContext()).unsubscribeToMediaChanges(observer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BucketedThreadMedia loadInBackground() {
|
||||
BucketedThreadMedia result = new BucketedThreadMedia(getContext());
|
||||
long threadId = DatabaseFactory.getThreadDatabase(getContext()).getOrCreateThreadIdFor(Recipient.from(getContext(), address, true));
|
||||
|
||||
DatabaseFactory.getMediaDatabase(getContext()).subscribeToMediaChanges(observer);
|
||||
try (Cursor cursor = DatabaseFactory.getMediaDatabase(getContext()).getGalleryMediaForThread(threadId)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
result.add(MediaDatabase.MediaRecord.from(getContext(), cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static class BucketedThreadMedia {
|
||||
|
||||
private final TimeBucket TODAY;
|
||||
private final TimeBucket YESTERDAY;
|
||||
private final TimeBucket THIS_WEEK;
|
||||
private final TimeBucket THIS_MONTH;
|
||||
private final MonthBuckets OLDER;
|
||||
|
||||
private final TimeBucket[] TIME_SECTIONS;
|
||||
|
||||
public BucketedThreadMedia(@NonNull Context context) {
|
||||
this.TODAY = new TimeBucket(context.getString(R.string.BucketedThreadMedia_Today), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, 1000));
|
||||
this.YESTERDAY = new TimeBucket(context.getString(R.string.BucketedThreadMedia_Yesterday), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -1));
|
||||
this.THIS_WEEK = new TimeBucket(context.getString(R.string.BucketedThreadMedia_This_week), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -2));
|
||||
this.THIS_MONTH = new TimeBucket(context.getString(R.string.BucketedThreadMedia_This_month), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -30), TimeBucket.addToCalendar(Calendar.DAY_OF_YEAR, -7));
|
||||
this.TIME_SECTIONS = new TimeBucket[]{TODAY, YESTERDAY, THIS_WEEK, THIS_MONTH};
|
||||
this.OLDER = new MonthBuckets();
|
||||
}
|
||||
|
||||
|
||||
public void add(MediaDatabase.MediaRecord mediaRecord) {
|
||||
for (TimeBucket timeSection : TIME_SECTIONS) {
|
||||
if (timeSection.inRange(mediaRecord.getDate())) {
|
||||
timeSection.add(mediaRecord);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
OLDER.add(mediaRecord);
|
||||
}
|
||||
|
||||
public int getSectionCount() {
|
||||
return (int)Stream.of(TIME_SECTIONS)
|
||||
.filter(timeBucket -> !timeBucket.isEmpty())
|
||||
.count() +
|
||||
OLDER.getSectionCount();
|
||||
}
|
||||
|
||||
public int getSectionItemCount(int section) {
|
||||
List<TimeBucket> activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList();
|
||||
|
||||
if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItemCount();
|
||||
else return OLDER.getSectionItemCount(section - activeTimeBuckets.size());
|
||||
}
|
||||
|
||||
public MediaDatabase.MediaRecord get(int section, int item) {
|
||||
List<TimeBucket> activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList();
|
||||
|
||||
if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getItem(item);
|
||||
else return OLDER.getItem(section - activeTimeBuckets.size(), item);
|
||||
}
|
||||
|
||||
public String getName(int section, Locale locale) {
|
||||
List<TimeBucket> activeTimeBuckets = Stream.of(TIME_SECTIONS).filter(timeBucket -> !timeBucket.isEmpty()).toList();
|
||||
|
||||
if (section < activeTimeBuckets.size()) return activeTimeBuckets.get(section).getName();
|
||||
else return OLDER.getName(section - activeTimeBuckets.size(), locale);
|
||||
}
|
||||
|
||||
private static class TimeBucket {
|
||||
|
||||
private final List<MediaDatabase.MediaRecord> records = new LinkedList<>();
|
||||
|
||||
private final long startTime;
|
||||
private final long endtime;
|
||||
private final String name;
|
||||
|
||||
TimeBucket(String name, long startTime, long endtime) {
|
||||
this.name = name;
|
||||
this.startTime = startTime;
|
||||
this.endtime = endtime;
|
||||
}
|
||||
|
||||
void add(MediaDatabase.MediaRecord record) {
|
||||
this.records.add(record);
|
||||
}
|
||||
|
||||
boolean inRange(long timestamp) {
|
||||
return timestamp > startTime && timestamp <= endtime;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return records.isEmpty();
|
||||
}
|
||||
|
||||
int getItemCount() {
|
||||
return records.size();
|
||||
}
|
||||
|
||||
MediaDatabase.MediaRecord getItem(int position) {
|
||||
return records.get(position);
|
||||
}
|
||||
|
||||
String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
static long addToCalendar(int field, int amount) {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(field, amount);
|
||||
return calendar.getTimeInMillis();
|
||||
}
|
||||
}
|
||||
|
||||
private static class MonthBuckets {
|
||||
|
||||
private final Map<Date, List<MediaDatabase.MediaRecord>> months = new HashMap<>();
|
||||
|
||||
void add(MediaDatabase.MediaRecord record) {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTimeInMillis(record.getDate());
|
||||
|
||||
int year = calendar.get(Calendar.YEAR) - 1900;
|
||||
int month = calendar.get(Calendar.MONTH);
|
||||
Date date = new Date(year, month, 1);
|
||||
|
||||
if (months.containsKey(date)) {
|
||||
months.get(date).add(record);
|
||||
} else {
|
||||
List<MediaDatabase.MediaRecord> list = new LinkedList<>();
|
||||
list.add(record);
|
||||
months.put(date, list);
|
||||
}
|
||||
}
|
||||
|
||||
int getSectionCount() {
|
||||
return months.size();
|
||||
}
|
||||
|
||||
int getSectionItemCount(int section) {
|
||||
return months.get(getSection(section)).size();
|
||||
}
|
||||
|
||||
MediaDatabase.MediaRecord getItem(int section, int position) {
|
||||
return months.get(getSection(section)).get(position);
|
||||
}
|
||||
|
||||
Date getSection(int section) {
|
||||
ArrayList<Date> keys = new ArrayList<>(months.keySet());
|
||||
Collections.sort(keys, Collections.reverseOrder());
|
||||
|
||||
return keys.get(section);
|
||||
}
|
||||
|
||||
String getName(int section, Locale locale) {
|
||||
Date sectionDate = getSection(section);
|
||||
|
||||
return new SimpleDateFormat("MMMM, yyyy", locale).format(sectionDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.database.MergeCursor;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class ConversationListLoader extends AbstractCursorLoader {
|
||||
|
||||
private final String filter;
|
||||
private final boolean archived;
|
||||
|
||||
public ConversationListLoader(Context context, String filter, boolean archived) {
|
||||
super(context);
|
||||
this.filter = filter;
|
||||
this.archived = archived;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getCursor() {
|
||||
if (filter != null && filter.trim().length() != 0) return getFilteredConversationList(filter);
|
||||
else if (!archived) return getUnarchivedConversationList();
|
||||
else return getArchivedConversationList();
|
||||
}
|
||||
|
||||
private Cursor getUnarchivedConversationList() {
|
||||
List<Cursor> cursorList = new LinkedList<>();
|
||||
cursorList.add(DatabaseFactory.getThreadDatabase(context).getConversationList());
|
||||
|
||||
int archivedCount = DatabaseFactory.getThreadDatabase(context)
|
||||
.getArchivedConversationListCount();
|
||||
|
||||
if (archivedCount > 0) {
|
||||
MatrixCursor switchToArchiveCursor = new MatrixCursor(new String[] {
|
||||
ThreadDatabase.ID, ThreadDatabase.DATE, ThreadDatabase.MESSAGE_COUNT,
|
||||
ThreadDatabase.ADDRESS, ThreadDatabase.SNIPPET, ThreadDatabase.READ, ThreadDatabase.UNREAD_COUNT,
|
||||
ThreadDatabase.TYPE, ThreadDatabase.SNIPPET_TYPE, ThreadDatabase.SNIPPET_URI,
|
||||
ThreadDatabase.ARCHIVED, ThreadDatabase.STATUS, ThreadDatabase.DELIVERY_RECEIPT_COUNT,
|
||||
ThreadDatabase.EXPIRES_IN, ThreadDatabase.LAST_SEEN, ThreadDatabase.READ_RECEIPT_COUNT}, 1);
|
||||
|
||||
|
||||
if (cursorList.get(0).getCount() <= 0) {
|
||||
switchToArchiveCursor.addRow(new Object[] {-1L, System.currentTimeMillis(), archivedCount,
|
||||
"-1", null, 1, 0, ThreadDatabase.DistributionTypes.INBOX_ZERO,
|
||||
0, null, 0, -1, 0, 0, 0, -1});
|
||||
}
|
||||
|
||||
switchToArchiveCursor.addRow(new Object[] {-1L, System.currentTimeMillis(), archivedCount,
|
||||
"-1", null, 1, 0, ThreadDatabase.DistributionTypes.ARCHIVE,
|
||||
0, null, 0, -1, 0, 0, 0, -1});
|
||||
|
||||
cursorList.add(switchToArchiveCursor);
|
||||
}
|
||||
|
||||
return new MergeCursor(cursorList.toArray(new Cursor[0]));
|
||||
}
|
||||
|
||||
private Cursor getArchivedConversationList() {
|
||||
return DatabaseFactory.getThreadDatabase(context).getArchivedConversationList();
|
||||
}
|
||||
|
||||
private Cursor getFilteredConversationList(String filter) {
|
||||
List<String> numbers = ContactAccessor.getInstance().getNumbersForThreadSearchFilter(context, filter);
|
||||
List<Address> addresses = new LinkedList<>();
|
||||
|
||||
for (String number : numbers) {
|
||||
addresses.add(Address.fromExternal(context, number));
|
||||
}
|
||||
|
||||
return DatabaseFactory.getThreadDatabase(context).getFilteredConversationList(addresses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
public class ConversationLoader extends AbstractCursorLoader {
|
||||
private final long threadId;
|
||||
private int offset;
|
||||
private int limit;
|
||||
private long lastSeen;
|
||||
private boolean hasSent;
|
||||
|
||||
public ConversationLoader(Context context, long threadId, int offset, int limit, long lastSeen) {
|
||||
super(context);
|
||||
this.threadId = threadId;
|
||||
this.offset = offset;
|
||||
this.limit = limit;
|
||||
this.lastSeen = lastSeen;
|
||||
this.hasSent = true;
|
||||
}
|
||||
|
||||
public boolean hasLimit() {
|
||||
return limit > 0;
|
||||
}
|
||||
|
||||
public boolean hasOffset() {
|
||||
return offset > 0;
|
||||
}
|
||||
|
||||
public int getOffset() {
|
||||
return offset;
|
||||
}
|
||||
|
||||
public long getLastSeen() {
|
||||
return lastSeen;
|
||||
}
|
||||
|
||||
public boolean hasSent() {
|
||||
return hasSent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getCursor() {
|
||||
Pair<Long, Boolean> lastSeenAndHasSent = DatabaseFactory.getThreadDatabase(context).getLastSeenAndHasSent(threadId);
|
||||
|
||||
this.hasSent = lastSeenAndHasSent.second();
|
||||
|
||||
if (lastSeen == -1) {
|
||||
this.lastSeen = lastSeenAndHasSent.first();
|
||||
}
|
||||
|
||||
return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId, offset, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.loader.content.AsyncTaskLoader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
|
||||
public class CountryListLoader extends AsyncTaskLoader<ArrayList<Map<String, String>>> {
|
||||
|
||||
public CountryListLoader(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ArrayList<Map<String, String>> loadInBackground() {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private static class RegionComparator implements Comparator<Map<String, String>> {
|
||||
@Override
|
||||
public int compare(Map<String, String> lhs, Map<String, String> rhs) {
|
||||
return lhs.get("country_name").compareTo(rhs.get("country_name"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.devicelist.Device;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.AsyncLoader;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.loki.protocol.shelved.multidevice.MultiDeviceProtocol;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class DeviceListLoader extends AsyncLoader<List<Device>> {
|
||||
|
||||
private static final String TAG = DeviceListLoader.class.getSimpleName();
|
||||
|
||||
public DeviceListLoader(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Device> loadInBackground() {
|
||||
try {
|
||||
String userPublicKey = TextSecurePreferences.getLocalNumber(getContext());
|
||||
Set<String> slaveDevicePublicKeys = MultiDeviceProtocol.shared.getSlaveDevices(userPublicKey);
|
||||
List<Device> devices = Stream.of(slaveDevicePublicKeys).map(this::mapToDevice).toList();
|
||||
Collections.sort(devices, new DeviceComparator());
|
||||
return devices;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Device mapToDevice(@NonNull String hexEncodedPublicKey) {
|
||||
String shortId = "";
|
||||
String name = DatabaseFactory.getLokiUserDatabase(getContext()).getDisplayName(hexEncodedPublicKey);
|
||||
return new Device(hexEncodedPublicKey, shortId, name);
|
||||
}
|
||||
|
||||
private static class DeviceComparator implements Comparator<Device> {
|
||||
|
||||
@Override
|
||||
public int compare(Device lhs, Device rhs) {
|
||||
return lhs.getName().compareTo(rhs.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
|
||||
|
||||
public class MessageDetailsLoader extends AbstractCursorLoader {
|
||||
private final String type;
|
||||
private final long messageId;
|
||||
|
||||
public MessageDetailsLoader(Context context, String type, long messageId) {
|
||||
super(context);
|
||||
this.type = type;
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getCursor() {
|
||||
switch (type) {
|
||||
case MmsSmsDatabase.SMS_TRANSPORT:
|
||||
return DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageId);
|
||||
case MmsSmsDatabase.MMS_TRANSPORT:
|
||||
return DatabaseFactory.getMmsDatabase(context).getMessage(messageId);
|
||||
default:
|
||||
throw new AssertionError("no valid message type specified");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.util.Pair;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.AsyncLoader;
|
||||
|
||||
public class PagingMediaLoader extends AsyncLoader<Pair<Cursor, Integer>> {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = PagingMediaLoader.class.getSimpleName();
|
||||
|
||||
private final Recipient recipient;
|
||||
private final Uri uri;
|
||||
private final boolean leftIsRecent;
|
||||
|
||||
public PagingMediaLoader(@NonNull Context context, @NonNull Recipient recipient, @NonNull Uri uri, boolean leftIsRecent) {
|
||||
super(context);
|
||||
this.recipient = recipient;
|
||||
this.uri = uri;
|
||||
this.leftIsRecent = leftIsRecent;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Pair<Cursor, Integer> loadInBackground() {
|
||||
long threadId = DatabaseFactory.getThreadDatabase(getContext()).getOrCreateThreadIdFor(recipient);
|
||||
Cursor cursor = DatabaseFactory.getMediaDatabase(getContext()).getGalleryMediaForThread(threadId);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
AttachmentId attachmentId = new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID)), cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID)));
|
||||
Uri attachmentUri = PartAuthority.getAttachmentDataUri(attachmentId);
|
||||
|
||||
if (attachmentUri.equals(uri)) {
|
||||
return new Pair<>(cursor, leftIsRecent ? cursor.getPosition() : cursor.getCount() - 1 - cursor.getPosition());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
import androidx.loader.content.CursorLoader;
|
||||
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
|
||||
public class RecentPhotosLoader extends CursorLoader {
|
||||
|
||||
public static Uri BASE_URL = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
|
||||
|
||||
private static final String[] PROJECTION = new String[] {
|
||||
MediaStore.Images.ImageColumns._ID,
|
||||
MediaStore.Images.ImageColumns.DATE_TAKEN,
|
||||
MediaStore.Images.ImageColumns.DATE_MODIFIED,
|
||||
MediaStore.Images.ImageColumns.ORIENTATION,
|
||||
MediaStore.Images.ImageColumns.MIME_TYPE,
|
||||
MediaStore.Images.ImageColumns.BUCKET_ID,
|
||||
MediaStore.Images.ImageColumns.SIZE,
|
||||
MediaStore.Images.ImageColumns.WIDTH,
|
||||
MediaStore.Images.ImageColumns.HEIGHT
|
||||
};
|
||||
|
||||
private final Context context;
|
||||
|
||||
public RecentPhotosLoader(Context context) {
|
||||
super(context);
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor loadInBackground() {
|
||||
if (Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
|
||||
return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
PROJECTION, null, null,
|
||||
MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.database.loaders;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
|
||||
|
||||
public class ThreadMediaLoader extends AbstractCursorLoader {
|
||||
|
||||
private final Address address;
|
||||
private final boolean gallery;
|
||||
|
||||
public ThreadMediaLoader(@NonNull Context context, @NonNull Address address, boolean gallery) {
|
||||
super(context);
|
||||
this.address = address;
|
||||
this.gallery = gallery;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor getCursor() {
|
||||
long threadId = DatabaseFactory.getThreadDatabase(getContext()).getOrCreateThreadIdFor(Recipient.from(getContext(), address, true));
|
||||
|
||||
if (gallery) return DatabaseFactory.getMediaDatabase(getContext()).getGalleryMediaForThread(threadId);
|
||||
else return DatabaseFactory.getMediaDatabase(getContext()).getDocumentMediaForThread(threadId);
|
||||
}
|
||||
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Moxie Marlinspike
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
/**
|
||||
* The base class for all message record models. Encapsulates basic data
|
||||
* shared between ThreadRecord and MessageRecord.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
|
||||
public abstract class DisplayRecord {
|
||||
|
||||
protected final long type;
|
||||
|
||||
private final Recipient recipient;
|
||||
private final long dateSent;
|
||||
private final long dateReceived;
|
||||
private final long threadId;
|
||||
private final String body;
|
||||
private final int deliveryStatus;
|
||||
private final int deliveryReceiptCount;
|
||||
private final int readReceiptCount;
|
||||
|
||||
DisplayRecord(String body, Recipient recipient, long dateSent,
|
||||
long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount,
|
||||
long type, int readReceiptCount)
|
||||
{
|
||||
this.threadId = threadId;
|
||||
this.recipient = recipient;
|
||||
this.dateSent = dateSent;
|
||||
this.dateReceived = dateReceived;
|
||||
this.type = type;
|
||||
this.body = body;
|
||||
this.deliveryReceiptCount = deliveryReceiptCount;
|
||||
this.readReceiptCount = readReceiptCount;
|
||||
this.deliveryStatus = deliveryStatus;
|
||||
}
|
||||
|
||||
public @NonNull String getBody() {
|
||||
return body == null ? "" : body;
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
return
|
||||
MmsSmsColumns.Types.isFailedMessageType(type) ||
|
||||
MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) ||
|
||||
deliveryStatus >= SmsDatabase.Status.STATUS_FAILED;
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return MmsSmsColumns.Types.isPendingMessageType(type) &&
|
||||
!MmsSmsColumns.Types.isIdentityVerified(type) &&
|
||||
!MmsSmsColumns.Types.isIdentityDefault(type);
|
||||
}
|
||||
|
||||
public boolean isOutgoing() {
|
||||
return MmsSmsColumns.Types.isOutgoingMessageType(type);
|
||||
}
|
||||
|
||||
public abstract SpannableString getDisplayBody(@NonNull Context context);
|
||||
|
||||
public Recipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public long getDateSent() {
|
||||
return dateSent;
|
||||
}
|
||||
|
||||
public long getDateReceived() {
|
||||
return dateReceived;
|
||||
}
|
||||
|
||||
public long getThreadId() {
|
||||
return threadId;
|
||||
}
|
||||
|
||||
public boolean isKeyExchange() {
|
||||
return SmsDatabase.Types.isKeyExchangeType(type);
|
||||
}
|
||||
|
||||
public boolean isEndSession() { return SmsDatabase.Types.isEndSessionType(type); }
|
||||
|
||||
public boolean isLokiSessionRestoreSent() { return SmsDatabase.Types.isLokiSessionRestoreSentType(type); }
|
||||
|
||||
public boolean isLokiSessionRestoreDone() { return SmsDatabase.Types.isLokiSessionRestoreDoneType(type); }
|
||||
|
||||
public boolean isGroupUpdate() {
|
||||
return SmsDatabase.Types.isGroupUpdate(type);
|
||||
}
|
||||
|
||||
public boolean isGroupQuit() {
|
||||
return SmsDatabase.Types.isGroupQuit(type);
|
||||
}
|
||||
|
||||
public boolean isGroupAction() {
|
||||
return isGroupUpdate() || isGroupQuit();
|
||||
}
|
||||
|
||||
public boolean isExpirationTimerUpdate() {
|
||||
return SmsDatabase.Types.isExpirationTimerUpdate(type);
|
||||
}
|
||||
|
||||
public boolean isCallLog() {
|
||||
return SmsDatabase.Types.isCallLog(type);
|
||||
}
|
||||
|
||||
public boolean isJoined() {
|
||||
return SmsDatabase.Types.isJoinedType(type);
|
||||
}
|
||||
|
||||
public boolean isIncomingCall() {
|
||||
return SmsDatabase.Types.isIncomingCall(type);
|
||||
}
|
||||
|
||||
public boolean isOutgoingCall() {
|
||||
return SmsDatabase.Types.isOutgoingCall(type);
|
||||
}
|
||||
|
||||
public boolean isMissedCall() {
|
||||
return SmsDatabase.Types.isMissedCall(type);
|
||||
}
|
||||
|
||||
public boolean isVerificationStatusChange() {
|
||||
return SmsDatabase.Types.isIdentityDefault(type) || SmsDatabase.Types.isIdentityVerified(type);
|
||||
}
|
||||
|
||||
public int getDeliveryStatus() {
|
||||
return deliveryStatus;
|
||||
}
|
||||
|
||||
public int getDeliveryReceiptCount() {
|
||||
return deliveryReceiptCount;
|
||||
}
|
||||
|
||||
public int getReadReceiptCount() {
|
||||
return readReceiptCount;
|
||||
}
|
||||
|
||||
public boolean isDelivered() {
|
||||
return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE &&
|
||||
deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
|
||||
}
|
||||
|
||||
public boolean isRemoteRead() {
|
||||
return readReceiptCount > 0;
|
||||
}
|
||||
|
||||
public boolean isPendingInsecureSmsFallback() {
|
||||
return SmsDatabase.Types.isPendingInsecureSmsFallbackType(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class IncomingSticker {
|
||||
|
||||
private final String packKey;
|
||||
private final String packId;
|
||||
private final String packTitle;
|
||||
private final String packAuthor;
|
||||
private final int stickerId;
|
||||
private final String emoji;
|
||||
private final boolean isCover;
|
||||
private final boolean isInstalled;
|
||||
|
||||
public IncomingSticker(@NonNull String packId,
|
||||
@NonNull String packKey,
|
||||
@NonNull String packTitle,
|
||||
@NonNull String packAuthor,
|
||||
int stickerId,
|
||||
@NonNull String emoji,
|
||||
boolean isCover,
|
||||
boolean isInstalled)
|
||||
{
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
this.packTitle = packTitle;
|
||||
this.packAuthor = packAuthor;
|
||||
this.stickerId = stickerId;
|
||||
this.emoji = emoji;
|
||||
this.isCover = isCover;
|
||||
this.isInstalled = isInstalled;
|
||||
}
|
||||
|
||||
public @NonNull String getPackKey() {
|
||||
return packKey;
|
||||
}
|
||||
|
||||
public @NonNull String getPackId() {
|
||||
return packId;
|
||||
}
|
||||
|
||||
public @NonNull String getPackTitle() {
|
||||
return packTitle;
|
||||
}
|
||||
|
||||
public @NonNull String getPackAuthor() {
|
||||
return packAuthor;
|
||||
}
|
||||
|
||||
public int getStickerId() {
|
||||
return stickerId;
|
||||
}
|
||||
|
||||
public @NonNull String getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public boolean isCover() {
|
||||
return isCover;
|
||||
}
|
||||
|
||||
public boolean isInstalled() {
|
||||
return isInstalled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (C) 2012 Moxie Marlinspike
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents the message record model for MMS messages that contain
|
||||
* media (ie: they've been downloaded).
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
|
||||
public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
private final static String TAG = MediaMmsMessageRecord.class.getSimpleName();
|
||||
|
||||
private final int partCount;
|
||||
|
||||
public MediaMmsMessageRecord(long id, Recipient conversationRecipient,
|
||||
Recipient individualRecipient, int recipientDeviceId,
|
||||
long dateSent, long dateReceived, int deliveryReceiptCount,
|
||||
long threadId, String body,
|
||||
@NonNull SlideDeck slideDeck,
|
||||
int partCount, long mailbox,
|
||||
List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> failures, int subscriptionId,
|
||||
long expiresIn, long expireStarted, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
|
||||
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
||||
subscriptionId, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts,
|
||||
linkPreviews, unidentified);
|
||||
this.partCount = partCount;
|
||||
}
|
||||
|
||||
public int getPartCount() {
|
||||
return partCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMmsNotification() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
if (MmsDatabase.Types.isFailedDecryptType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MmsMessageRecord_bad_encrypted_mms_message));
|
||||
} else if (MmsDatabase.Types.isDuplicateMessageType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message));
|
||||
} else if (MmsDatabase.Types.isNoRemoteSessionType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MmsMessageRecord_mms_message_encrypted_for_non_existing_session));
|
||||
} else if (isLegacyMessage()) {
|
||||
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
|
||||
}
|
||||
|
||||
return super.getDisplayBody(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Moxie Marlinpsike
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The base class for message record models that are displayed in
|
||||
* conversations, as opposed to models that are displayed in a thread list.
|
||||
* Encapsulates the shared data between both SMS and MMS messages.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public abstract class MessageRecord extends DisplayRecord {
|
||||
|
||||
private final Recipient individualRecipient;
|
||||
private final int recipientDeviceId;
|
||||
public final long id;
|
||||
private final List<IdentityKeyMismatch> mismatches;
|
||||
private final List<NetworkFailure> networkFailures;
|
||||
private final int subscriptionId;
|
||||
private final long expiresIn;
|
||||
private final long expireStarted;
|
||||
private final boolean unidentified;
|
||||
|
||||
MessageRecord(long id, String body, Recipient conversationRecipient,
|
||||
Recipient individualRecipient, int recipientDeviceId,
|
||||
long dateSent, long dateReceived, long threadId,
|
||||
int deliveryStatus, int deliveryReceiptCount, long type,
|
||||
List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> networkFailures,
|
||||
int subscriptionId, long expiresIn, long expireStarted,
|
||||
int readReceiptCount, boolean unidentified)
|
||||
{
|
||||
super(body, conversationRecipient, dateSent, dateReceived,
|
||||
threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount);
|
||||
this.id = id;
|
||||
this.individualRecipient = individualRecipient;
|
||||
this.recipientDeviceId = recipientDeviceId;
|
||||
this.mismatches = mismatches;
|
||||
this.networkFailures = networkFailures;
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.expiresIn = expiresIn;
|
||||
this.expireStarted = expireStarted;
|
||||
this.unidentified = unidentified;
|
||||
}
|
||||
|
||||
public abstract boolean isMms();
|
||||
public abstract boolean isMmsNotification();
|
||||
|
||||
public boolean isSecure() {
|
||||
return MmsSmsColumns.Types.isSecureType(type);
|
||||
}
|
||||
|
||||
public boolean isLegacyMessage() {
|
||||
return MmsSmsColumns.Types.isLegacyType(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
if (isGroupUpdate() && isOutgoing()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_you_updated_group));
|
||||
} else if (isGroupUpdate()) {
|
||||
return new SpannableString(GroupUtil.getDescription(context, getBody()).toString(getIndividualRecipient()));
|
||||
} else if (isGroupQuit() && isOutgoing()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_left_group));
|
||||
} else if (isGroupQuit()) {
|
||||
return new SpannableString(context.getString(R.string.ConversationItem_group_action_left, getIndividualRecipient().toShortString()));
|
||||
} else if (isIncomingCall()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_s_called_you, getIndividualRecipient().toShortString()));
|
||||
} else if (isOutgoingCall()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_you_called));
|
||||
} else if (isMissedCall()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_missed_call));
|
||||
} else if (isJoined()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().toShortString()));
|
||||
} else if (isExpirationTimerUpdate()) {
|
||||
int seconds = (int)(getExpiresIn() / 1000);
|
||||
if (seconds <= 0) {
|
||||
return isOutgoing() ? new SpannableString(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages))
|
||||
: new SpannableString(context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, getIndividualRecipient().toShortString()));
|
||||
}
|
||||
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
|
||||
return isOutgoing() ? new SpannableString(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time))
|
||||
: new SpannableString(context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, getIndividualRecipient().toShortString(), time));
|
||||
} else if (isIdentityUpdate()) {
|
||||
return new SpannableString(context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, getIndividualRecipient().toShortString()));
|
||||
} else if (isIdentityVerified()) {
|
||||
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, getIndividualRecipient().toShortString()));
|
||||
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, getIndividualRecipient().toShortString()));
|
||||
} else if (isIdentityDefault()) {
|
||||
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, getIndividualRecipient().toShortString()));
|
||||
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, getIndividualRecipient().toShortString()));
|
||||
}
|
||||
|
||||
return new SpannableString(getBody());
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean isPush() {
|
||||
return SmsDatabase.Types.isPushType(type) && !SmsDatabase.Types.isForcedSms(type);
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
if (getRecipient().getAddress().isOpenGroup()) {
|
||||
return getDateReceived();
|
||||
}
|
||||
if (isPush() && getDateSent() < getDateReceived()) {
|
||||
return getDateSent();
|
||||
}
|
||||
return getDateReceived();
|
||||
}
|
||||
|
||||
public boolean isForcedSms() {
|
||||
return SmsDatabase.Types.isForcedSms(type);
|
||||
}
|
||||
|
||||
public boolean isIdentityVerified() {
|
||||
return SmsDatabase.Types.isIdentityVerified(type);
|
||||
}
|
||||
|
||||
public boolean isIdentityDefault() {
|
||||
return SmsDatabase.Types.isIdentityDefault(type);
|
||||
}
|
||||
|
||||
public boolean isIdentityMismatchFailure() {
|
||||
return mismatches != null && !mismatches.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isBundleKeyExchange() {
|
||||
return SmsDatabase.Types.isBundleKeyExchange(type);
|
||||
}
|
||||
|
||||
public boolean isContentBundleKeyExchange() {
|
||||
return SmsDatabase.Types.isContentBundleKeyExchange(type);
|
||||
}
|
||||
|
||||
public boolean isIdentityUpdate() {
|
||||
return SmsDatabase.Types.isIdentityUpdate(type);
|
||||
}
|
||||
|
||||
public boolean isCorruptedKeyExchange() {
|
||||
return SmsDatabase.Types.isCorruptedKeyExchange(type);
|
||||
}
|
||||
|
||||
public boolean isInvalidVersionKeyExchange() {
|
||||
return SmsDatabase.Types.isInvalidVersionKeyExchange(type);
|
||||
}
|
||||
|
||||
public boolean isUpdate() {
|
||||
return isGroupAction() || isJoined() || isExpirationTimerUpdate() || isCallLog() ||
|
||||
isEndSession() || isIdentityUpdate() || isIdentityVerified() || isIdentityDefault() || isLokiSessionRestoreSent() || isLokiSessionRestoreDone();
|
||||
}
|
||||
|
||||
public boolean isMediaPending() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public Recipient getIndividualRecipient() {
|
||||
return individualRecipient;
|
||||
}
|
||||
|
||||
public int getRecipientDeviceId() {
|
||||
return recipientDeviceId;
|
||||
}
|
||||
|
||||
public long getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public List<IdentityKeyMismatch> getIdentityKeyMismatches() {
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
public List<NetworkFailure> getNetworkFailures() {
|
||||
return networkFailures;
|
||||
}
|
||||
|
||||
public boolean hasNetworkFailures() {
|
||||
return networkFailures != null && !networkFailures.isEmpty();
|
||||
}
|
||||
|
||||
protected SpannableString emphasisAdded(String sequence) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public boolean equals(Object other) {
|
||||
return other != null &&
|
||||
other instanceof MessageRecord &&
|
||||
((MessageRecord) other).getId() == getId() &&
|
||||
((MessageRecord) other).isMms() == isMms();
|
||||
}
|
||||
|
||||
public int hashCode() {
|
||||
return (int)getId();
|
||||
}
|
||||
|
||||
public int getSubscriptionId() {
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public long getExpireStarted() {
|
||||
return expireStarted;
|
||||
}
|
||||
|
||||
public boolean isUnidentified() {
|
||||
return unidentified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class MmsMessageRecord extends MessageRecord {
|
||||
|
||||
private final @NonNull SlideDeck slideDeck;
|
||||
private final @Nullable Quote quote;
|
||||
private final @NonNull List<Contact> contacts = new LinkedList<>();
|
||||
private final @NonNull List<LinkPreview> linkPreviews = new LinkedList<>();
|
||||
|
||||
MmsMessageRecord(long id, String body, Recipient conversationRecipient,
|
||||
Recipient individualRecipient, int recipientDeviceId, long dateSent,
|
||||
long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount,
|
||||
long type, List<IdentityKeyMismatch> mismatches,
|
||||
List<NetworkFailure> networkFailures, int subscriptionId, long expiresIn,
|
||||
long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount,
|
||||
@Nullable Quote quote, @NonNull List<Contact> contacts,
|
||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified);
|
||||
|
||||
this.slideDeck = slideDeck;
|
||||
this.quote = quote;
|
||||
|
||||
this.contacts.addAll(contacts);
|
||||
this.linkPreviews.addAll(linkPreviews);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMms() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public SlideDeck getSlideDeck() {
|
||||
return slideDeck;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMediaPending() {
|
||||
for (Slide slide : getSlideDeck().getSlides()) {
|
||||
if (slide.isInProgress() || slide.isPendingDownload()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean containsMediaSlide() {
|
||||
return slideDeck.containsMediaSlide();
|
||||
}
|
||||
|
||||
public @Nullable Quote getQuote() {
|
||||
return quote;
|
||||
}
|
||||
|
||||
public @NonNull List<Contact> getSharedContacts() {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
public @NonNull List<LinkPreview> getLinkPreviews() {
|
||||
return linkPreviews;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Moxie Marlinspike
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
|
||||
/**
|
||||
* Represents the message record model for MMS messages that are
|
||||
* notifications (ie: they're pointers to undownloaded media).
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
|
||||
public class NotificationMmsMessageRecord extends MmsMessageRecord {
|
||||
|
||||
private final byte[] contentLocation;
|
||||
private final long messageSize;
|
||||
private final long expiry;
|
||||
private final int status;
|
||||
private final byte[] transactionId;
|
||||
|
||||
public NotificationMmsMessageRecord(long id, Recipient conversationRecipient,
|
||||
Recipient individualRecipient, int recipientDeviceId,
|
||||
long dateSent, long dateReceived, int deliveryReceiptCount,
|
||||
long threadId, byte[] contentLocation, long messageSize,
|
||||
long expiry, int status, byte[] transactionId, long mailbox,
|
||||
int subscriptionId, SlideDeck slideDeck, int readReceiptCount)
|
||||
{
|
||||
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
|
||||
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
|
||||
new LinkedList<IdentityKeyMismatch>(), new LinkedList<NetworkFailure>(), subscriptionId,
|
||||
0, 0, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false);
|
||||
|
||||
this.contentLocation = contentLocation;
|
||||
this.messageSize = messageSize;
|
||||
this.expiry = expiry;
|
||||
this.status = status;
|
||||
this.transactionId = transactionId;
|
||||
}
|
||||
|
||||
public byte[] getTransactionId() {
|
||||
return transactionId;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public byte[] getContentLocation() {
|
||||
return contentLocation;
|
||||
}
|
||||
|
||||
public long getMessageSize() {
|
||||
return (messageSize + 1023) / 1024;
|
||||
}
|
||||
|
||||
public long getExpiration() {
|
||||
return expiry * 1000;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOutgoing() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSecure() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPending() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMmsNotification() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMediaPending() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED) {
|
||||
return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_multimedia_message));
|
||||
} else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) {
|
||||
return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_downloading_mms_message));
|
||||
} else {
|
||||
return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_error_downloading_mms_message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
|
||||
public class Quote {
|
||||
|
||||
private final long id;
|
||||
private final Address author;
|
||||
private final String text;
|
||||
private final boolean missing;
|
||||
private final SlideDeck attachment;
|
||||
|
||||
public Quote(long id, @NonNull Address author, @Nullable String text, boolean missing, @NonNull SlideDeck attachment) {
|
||||
this.id = id;
|
||||
this.author = author;
|
||||
this.text = text;
|
||||
this.missing = missing;
|
||||
this.attachment = attachment;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public @NonNull Address getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public @Nullable String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public boolean isOriginalMissing() {
|
||||
return missing;
|
||||
}
|
||||
|
||||
public @NonNull SlideDeck getAttachment() {
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Moxie Marlinspike
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
/**
|
||||
* The message record model which represents standard SMS messages.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public class SmsMessageRecord extends MessageRecord {
|
||||
|
||||
public SmsMessageRecord(long id,
|
||||
String body, Recipient recipient,
|
||||
Recipient individualRecipient,
|
||||
int recipientDeviceId,
|
||||
long dateSent, long dateReceived,
|
||||
int deliveryReceiptCount,
|
||||
long type, long threadId,
|
||||
int status, List<IdentityKeyMismatch> mismatches,
|
||||
int subscriptionId, long expiresIn, long expireStarted,
|
||||
int readReceiptCount, boolean unidentified)
|
||||
{
|
||||
super(id, body, recipient, individualRecipient, recipientDeviceId,
|
||||
dateSent, dateReceived, threadId, status, deliveryReceiptCount, type,
|
||||
mismatches, new LinkedList<>(), subscriptionId,
|
||||
expiresIn, expireStarted, readReceiptCount, unidentified);
|
||||
}
|
||||
|
||||
public long getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
Recipient recipient = getRecipient();
|
||||
if (SmsDatabase.Types.isFailedDecryptType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message));
|
||||
} else if (isCorruptedKeyExchange()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_corrupted_key_exchange_message));
|
||||
} else if (isInvalidVersionKeyExchange()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_key_exchange_message_for_invalid_protocol_version));
|
||||
} else if (MmsSmsColumns.Types.isLegacyType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
|
||||
} else if (isBundleKeyExchange()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_message_with_new_safety_number_tap_to_process));
|
||||
} else if (isKeyExchange() && isOutgoing()) {
|
||||
return new SpannableString("");
|
||||
} else if (isKeyExchange() && !isOutgoing()) {
|
||||
return emphasisAdded(context.getString(R.string.ConversationItem_received_key_exchange_message_tap_to_process));
|
||||
} else if (SmsDatabase.Types.isDuplicateMessageType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message));
|
||||
} else if (SmsDatabase.Types.isNoRemoteSessionType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session));
|
||||
} else if (isLokiSessionRestoreSent()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset));
|
||||
} else if (isLokiSessionRestoreDone()) {
|
||||
return emphasisAdded(context.getString(R.string.view_reset_secure_session_done_message));
|
||||
} else if (isEndSession() && isOutgoing()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset));
|
||||
} else if (isEndSession()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset_s, getIndividualRecipient().toShortString()));
|
||||
} else {
|
||||
return super.getDisplayBody(context);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMms() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMmsNotification() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
|
||||
public class Sticker {
|
||||
|
||||
private final String packId;
|
||||
private final String packKey;
|
||||
private final int stickerId;
|
||||
private final Attachment attachment;
|
||||
|
||||
public Sticker(@NonNull String packId,
|
||||
@NonNull String packKey,
|
||||
int stickerId,
|
||||
@NonNull Attachment attachment)
|
||||
{
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
this.stickerId = stickerId;
|
||||
this.attachment = attachment;
|
||||
}
|
||||
|
||||
public @NonNull String getPackId() {
|
||||
return packId;
|
||||
}
|
||||
|
||||
public @NonNull String getPackKey() {
|
||||
return packKey;
|
||||
}
|
||||
|
||||
public int getStickerId() {
|
||||
return stickerId;
|
||||
}
|
||||
|
||||
public @NonNull Attachment getAttachment() {
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a record for a sticker pack in the {@link org.thoughtcrime.securesms.database.StickerDatabase}.
|
||||
*/
|
||||
public final class StickerPackRecord {
|
||||
|
||||
private final String packId;
|
||||
private final String packKey;
|
||||
private final Optional<String> title;
|
||||
private final Optional<String> author;
|
||||
private final StickerRecord cover;
|
||||
private final boolean installed;
|
||||
|
||||
public StickerPackRecord(@NonNull String packId,
|
||||
@NonNull String packKey,
|
||||
@NonNull String title,
|
||||
@NonNull String author,
|
||||
@NonNull StickerRecord cover,
|
||||
boolean installed)
|
||||
{
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
this.title = TextUtils.isEmpty(title) ? Optional.absent() : Optional.of(title);
|
||||
this.author = TextUtils.isEmpty(author) ? Optional.absent() : Optional.of(author);
|
||||
this.cover = cover;
|
||||
this.installed = installed;
|
||||
}
|
||||
|
||||
public @NonNull String getPackId() {
|
||||
return packId;
|
||||
}
|
||||
|
||||
public @NonNull String getPackKey() {
|
||||
return packKey;
|
||||
}
|
||||
|
||||
public @NonNull Optional<String> getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public @NonNull Optional<String> getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public @NonNull StickerRecord getCover() {
|
||||
return cover;
|
||||
}
|
||||
|
||||
public boolean isInstalled() {
|
||||
return installed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
StickerPackRecord record = (StickerPackRecord) o;
|
||||
return installed == record.installed &&
|
||||
packId.equals(record.packId) &&
|
||||
packKey.equals(record.packKey) &&
|
||||
title.equals(record.title) &&
|
||||
author.equals(record.author) &&
|
||||
cover.equals(record.cover);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(packId, packKey, title, author, cover, installed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a record for a sticker pack in the {@link org.thoughtcrime.securesms.database.StickerDatabase}.
|
||||
*/
|
||||
public final class StickerRecord {
|
||||
|
||||
private final long rowId;
|
||||
private final String packId;
|
||||
private final String packKey;
|
||||
private final int stickerId;
|
||||
private final String emoji;
|
||||
private final long size;
|
||||
private final boolean isCover;
|
||||
|
||||
public StickerRecord(long rowId,
|
||||
@NonNull String packId,
|
||||
@NonNull String packKey,
|
||||
int stickerId,
|
||||
@NonNull String emoji,
|
||||
long size,
|
||||
boolean isCover)
|
||||
{
|
||||
this.rowId = rowId;
|
||||
this.packId = packId;
|
||||
this.packKey = packKey;
|
||||
this.stickerId = stickerId;
|
||||
this.emoji = emoji;
|
||||
this.size = size;
|
||||
this.isCover = isCover;
|
||||
}
|
||||
|
||||
public long getRowId() {
|
||||
return rowId;
|
||||
}
|
||||
|
||||
public @NonNull String getPackId() {
|
||||
return packId;
|
||||
}
|
||||
|
||||
public @NonNull String getPackKey() {
|
||||
return packKey;
|
||||
}
|
||||
|
||||
public int getStickerId() {
|
||||
return stickerId;
|
||||
}
|
||||
|
||||
public @NonNull Uri getUri() {
|
||||
return PartAuthority.getStickerUri(rowId);
|
||||
}
|
||||
|
||||
public @NonNull String getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public boolean isCover() {
|
||||
return isCover;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
StickerRecord that = (StickerRecord) o;
|
||||
return rowId == that.rowId &&
|
||||
stickerId == that.stickerId &&
|
||||
size == that.size &&
|
||||
isCover == that.isCover &&
|
||||
packId.equals(that.packId) &&
|
||||
packKey.equals(that.packKey) &&
|
||||
emoji.equals(that.emoji);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(rowId, packId, packKey, stickerId, emoji, size, isCover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (C) 2012 Moxie Marlinspike
|
||||
* Copyright (C) 2013-2017 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.database.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
|
||||
import network.loki.messenger.R;
|
||||
|
||||
/**
|
||||
* The message record model which represents thread heading messages.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public class ThreadRecord extends DisplayRecord {
|
||||
|
||||
private @Nullable final Uri snippetUri;
|
||||
private final long count;
|
||||
private final int unreadCount;
|
||||
private final int distributionType;
|
||||
private final boolean archived;
|
||||
private final long expiresIn;
|
||||
private final long lastSeen;
|
||||
|
||||
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
|
||||
@NonNull Recipient recipient, long date, long count, int unreadCount,
|
||||
long threadId, int deliveryReceiptCount, int status, long snippetType,
|
||||
int distributionType, boolean archived, long expiresIn, long lastSeen,
|
||||
int readReceiptCount)
|
||||
{
|
||||
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
|
||||
this.snippetUri = snippetUri;
|
||||
this.count = count;
|
||||
this.unreadCount = unreadCount;
|
||||
this.distributionType = distributionType;
|
||||
this.archived = archived;
|
||||
this.expiresIn = expiresIn;
|
||||
this.lastSeen = lastSeen;
|
||||
}
|
||||
|
||||
public @Nullable Uri getSnippetUri() {
|
||||
return snippetUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpannableString getDisplayBody(@NonNull Context context) {
|
||||
Recipient recipient = getRecipient();
|
||||
if (isGroupUpdate()) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
|
||||
} else if (isGroupQuit()) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
|
||||
} else if (isKeyExchange()) {
|
||||
return emphasisAdded(context.getString(R.string.ConversationListItem_key_exchange_message));
|
||||
} else if (SmsDatabase.Types.isFailedDecryptType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message));
|
||||
} else if (SmsDatabase.Types.isNoRemoteSessionType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session));
|
||||
} else if (isLokiSessionRestoreSent()) {
|
||||
return emphasisAdded(context.getString(R.string.SmsMessageRecord_secure_session_reset));
|
||||
} else if (isLokiSessionRestoreDone()) {
|
||||
return emphasisAdded(context.getString(R.string.view_reset_secure_session_done_message));
|
||||
} else if (SmsDatabase.Types.isEndSessionType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_secure_session_reset));
|
||||
} else if (MmsSmsColumns.Types.isLegacyType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
|
||||
} else if (MmsSmsColumns.Types.isDraftMessageType(type)) {
|
||||
String draftText = context.getString(R.string.ThreadRecord_draft);
|
||||
return emphasisAdded(draftText + " " + getBody(), 0, draftText.length());
|
||||
} else if (SmsDatabase.Types.isOutgoingCall(type)) {
|
||||
return emphasisAdded(context.getString(network.loki.messenger.R.string.ThreadRecord_called));
|
||||
} else if (SmsDatabase.Types.isIncomingCall(type)) {
|
||||
return emphasisAdded(context.getString(network.loki.messenger.R.string.ThreadRecord_called_you));
|
||||
} else if (SmsDatabase.Types.isMissedCall(type)) {
|
||||
return emphasisAdded(context.getString(network.loki.messenger.R.string.ThreadRecord_missed_call));
|
||||
} else if (SmsDatabase.Types.isJoinedType(type)) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, getRecipient().toShortString()));
|
||||
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
|
||||
int seconds = (int)(getExpiresIn() / 1000);
|
||||
if (seconds <= 0) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_messages_disabled));
|
||||
}
|
||||
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_message_time_updated_to_s, time));
|
||||
} else if (SmsDatabase.Types.isIdentityUpdate(type)) {
|
||||
if (getRecipient().isGroupRecipient()) return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
|
||||
else return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, getRecipient().toShortString()));
|
||||
} else if (SmsDatabase.Types.isIdentityVerified(type)) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
|
||||
} else if (SmsDatabase.Types.isIdentityDefault(type)) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified));
|
||||
} else {
|
||||
if (TextUtils.isEmpty(getBody())) {
|
||||
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_media_message)));
|
||||
} else {
|
||||
return new SpannableString(getBody());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SpannableString emphasisAdded(String sequence) {
|
||||
return emphasisAdded(sequence, 0, sequence.length());
|
||||
}
|
||||
|
||||
private SpannableString emphasisAdded(String sequence, int start, int end) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
|
||||
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
}
|
||||
|
||||
public long getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public int getUnreadCount() {
|
||||
return unreadCount;
|
||||
}
|
||||
|
||||
public long getDate() {
|
||||
return getDateReceived();
|
||||
}
|
||||
|
||||
public boolean isArchived() {
|
||||
return archived;
|
||||
}
|
||||
|
||||
public int getDistributionType() {
|
||||
return distributionType;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public long getLastSeen() {
|
||||
return lastSeen;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user