Move all files to natural position.

This commit is contained in:
Alan Evans
2020-01-06 10:52:48 -05:00
parent 0df36047e7
commit 9ebe920195
3016 changed files with 6 additions and 36 deletions

View File

@@ -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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,254 @@
/**
* 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 android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ViewHolder;
/**
* 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 @NonNull 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 @Nullable 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(@NonNull Context context, @Nullable 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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://org.thoughtcrime.securesms.database.conversationlist");
}
public static class Conversation extends NoopContentProvider {
private static final String CONTENT_URI_STRING = "content://org.thoughtcrime.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://org.thoughtcrime.securesms.database.attachment");
}
public static class Sticker extends NoopContentProvider {
public static final Uri CONTENT_URI = Uri.parse("content://org.thoughtcrime.securesms.database.sticker");
}
public static class StickerPack extends NoopContentProvider {
public static final Uri CONTENT_URI = Uri.parse("content://org.thoughtcrime.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;
}
}
}

View File

@@ -0,0 +1,219 @@
/*
* 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.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.migrations.LegacyMigrationJob;
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;
private final StorageKeyDatabase storageKeyDatabase;
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 StorageKeyDatabase getStorageKeyDatabase(Context context) {
return getInstance(context).storageKeyDatabase;
}
public static SQLiteDatabase getBackupDatabase(Context context) {
return getInstance(context).databaseHelper.getReadableDatabase();
}
public static void upgradeRestored(Context context, SQLiteDatabase database){
getInstance(context).databaseHelper.onUpgrade(database, database.getVersion(), -1);
getInstance(context).databaseHelper.markCurrent(database);
getInstance(context).mms.trimEntriesForExpiredMessages();
}
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.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper);
}
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
int fromVersion, LegacyMigrationJob.DatabaseUpgradeListener listener)
{
databaseHelper.getWritableDatabase();
ClassicOpenHelper legacyOpenHelper = null;
if (fromVersion < LegacyMigrationJob.ASYMMETRIC_MASTER_SECRET_FIX_VERSION) {
legacyOpenHelper = new ClassicOpenHelper(context);
legacyOpenHelper.onApplicationLevelUpgrade(context, masterSecret, fromVersion, listener);
}
if (fromVersion < LegacyMigrationJob.SQLCIPHER && TextSecurePreferences.getNeedsSqlCipherMigration(context)) {
if (legacyOpenHelper == null) {
legacyOpenHelper = new ClassicOpenHelper(context);
}
SQLCipherMigrationHelper.migrateCiphertext(context, masterSecret,
legacyOpenHelper.getWritableDatabase(),
databaseHelper.getWritableDatabase(),
listener);
}
}
public void triggerDatabaseAccess() {
databaseHelper.getWritableDatabase();
}
}

View File

@@ -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 org.thoughtcrime.securesms.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 {
private 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;
}
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.database;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.LRUCache;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
public class EarlyReceiptCache {
private static final String TAG = EarlyReceiptCache.class.getSimpleName();
private final LRUCache<Long, Map<RecipientId, Long>> cache = new LRUCache<>(100);
private final String name;
public EarlyReceiptCache(@NonNull String name) {
this.name = name;
}
public synchronized void increment(long timestamp, @NonNull RecipientId origin) {
Log.i(TAG, String.format(Locale.US, "[%s] Timestamp: %d, Recipient: %s", name, timestamp, origin.serialize()));
Map<RecipientId, 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<RecipientId, Long> remove(long timestamp) {
Map<RecipientId, Long> receipts = cache.remove(timestamp);
Log.i(TAG, this+"");
Log.i(TAG, String.format(Locale.US, "Checking early receipts (%d): %d", timestamp, receipts == null ? 0 : receipts.size()));
return receipts != null ? receipts : new HashMap<>();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,492 @@
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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
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 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 {
@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";
static final String RECIPIENT_ID = "recipient_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";
static final String ACTIVE = "active";
static final String MMS = "mms";
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME +
" (" + ID + " INTEGER PRIMARY KEY, " +
GROUP_ID + " TEXT, " +
RECIPIENT_ID + " INTEGER, " +
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, " +
MMS + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = {
"CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");",
"CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");",
};
private static final String[] GROUP_PROJECTION = {
GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, AVATAR, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
TIMESTAMP, ACTIVE, MMS
};
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(RecipientId recipientId) {
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return getGroup(cursor);
}
return Optional.absent();
}
}
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) {
Optional<GroupRecord> group = getGroup(groupId);
if (!group.isPresent()) {
return true;
}
boolean noMetadata = group.get().getAvatar() == null && TextUtils.isEmpty(group.get().getTitle());
boolean noMembers = group.get().getMembers().isEmpty() || (group.get().getMembers().size() == 1 && group.get().getMembers().contains(Recipient.self().getId()));
return noMetadata && noMembers;
}
public Reader getGroupsFilteredByTitle(String constraint, boolean includeInactive) {
String query;
String[] queryArgs;
if (includeInactive) {
query = TITLE + " LIKE ? AND (" + ACTIVE + " = ? OR " + RECIPIENT_ID + " IN (SELECT " + ThreadDatabase.RECIPIENT_ID + " FROM " + ThreadDatabase.TABLE_NAME + "))";
queryArgs = new String[]{"%" + constraint + "%", "1"};
} else {
query = TITLE + " LIKE ? AND " + ACTIVE + " = ?";
queryArgs = new String[]{"%" + constraint + "%", "1"};
}
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, queryArgs, null, null, TITLE + " COLLATE NOCASE ASC");
return new Reader(cursor);
}
public String getOrCreateGroupForMembers(List<RecipientId> members, boolean mms) {
Collections.sort(members);
Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {GROUP_ID},
MEMBERS + " = ? AND " + MMS + " = ?",
new String[] {RecipientId.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);
return groupId;
}
} finally {
if (cursor != null) cursor.close();
}
}
public List<String> getGroupNamesContainingMember(RecipientId recipientId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
List<String> groupNames = new LinkedList<>();
String[] projection = new String[]{TITLE, MEMBERS};
String query = MEMBERS + " LIKE ?";
String[] args = new String[]{"%" + recipientId.serialize() + "%"};
try (Cursor cursor = database.query(TABLE_NAME, projection, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
List<String> members = Util.split(cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), ",");
if (members.contains(recipientId.serialize())) {
groupNames.add(cursor.getString(cursor.getColumnIndexOrThrow(TITLE)));
}
}
}
return groupNames;
}
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<RecipientId> members = getCurrentMembers(groupId);
List<Recipient> recipients = new LinkedList<>();
for (RecipientId member : members) {
if (!includeSelf && Recipient.resolved(member).isLocalNumber()) {
continue;
}
recipients.add(Recipient.resolved(member));
}
return recipients;
}
public void create(@NonNull String groupId, @Nullable String title, @NonNull List<RecipientId> members,
@Nullable SignalServiceAttachmentPointer avatar, @Nullable String relay)
{
Collections.sort(members);
ContentValues contentValues = new ContentValues();
contentValues.put(RECIPIENT_ID, DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId).serialize());
contentValues.put(GROUP_ID, groupId);
contentValues.put(TITLE, title);
contentValues.put(MEMBERS, RecipientId.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_RELAY, relay);
contentValues.put(TIMESTAMP, System.currentTimeMillis());
contentValues.put(ACTIVE, 1);
contentValues.put(MMS, GroupUtil.isMmsGroup(groupId));
databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues);
RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient.live(groupRecipient).refresh();
notifyConversationListListeners();
}
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());
}
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
GROUP_ID + " = ?",
new String[] {groupId});
RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient.live(groupRecipient).refresh();
notifyConversationListListeners();
}
public void updateTitle(String groupId, String title) {
ContentValues contentValues = new ContentValues();
contentValues.put(TITLE, title);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
new String[] {groupId});
RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient.live(groupRecipient).refresh();
}
public void updateAvatar(String groupId, Bitmap avatar) {
updateAvatar(groupId, BitmapUtil.toByteArray(avatar));
}
public void updateAvatar(String groupId, byte[] avatar) {
long avatarId;
if (avatar != null) avatarId = Math.abs(new SecureRandom().nextLong());
else avatarId = 0;
ContentValues contentValues = new ContentValues(2);
contentValues.put(AVATAR, avatar);
contentValues.put(AVATAR_ID, avatarId);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?",
new String[] {groupId});
RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient.live(groupRecipient).refresh();
}
public void updateMembers(String groupId, List<RecipientId> members) {
Collections.sort(members);
ContentValues contents = new ContentValues();
contents.put(MEMBERS, RecipientId.toSerializedList(members));
contents.put(ACTIVE, 1);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?",
new String[] {groupId});
RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient.live(groupRecipient).refresh();
}
public void remove(String groupId, RecipientId source) {
List<RecipientId> currentMembers = getCurrentMembers(groupId);
currentMembers.remove(source);
ContentValues contents = new ContentValues();
contents.put(MEMBERS, RecipientId.toSerializedList(currentMembers));
databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?",
new String[] {groupId});
RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient.live(groupRecipient).refresh();
}
private List<RecipientId> 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 RecipientId.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 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)),
RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_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);
}
@Override
public void close() {
if (this.cursor != null)
this.cursor.close();
}
}
public static class GroupRecord {
private final String id;
private final RecipientId recipientId;
private final String title;
private final List<RecipientId> 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;
public GroupRecord(String id, @NonNull RecipientId recipientId, String title, String members, byte[] avatar,
long avatarId, byte[] avatarKey, String avatarContentType,
String relay, boolean active, byte[] avatarDigest, boolean mms)
{
this.id = id;
this.recipientId = recipientId;
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;
if (!TextUtils.isEmpty(members)) this.members = RecipientId.fromSerializedList(members);
else this.members = new LinkedList<>();
}
public byte[] getId() {
try {
return GroupUtil.getDecodedId(id);
} catch (IOException ioe) {
throw new AssertionError(ioe);
}
}
public @NonNull RecipientId getRecipientId() {
return recipientId;
}
public String getEncodedId() {
return id;
}
public String getTitle() {
return title;
}
public List<RecipientId> 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;
}
}
}

View File

@@ -0,0 +1,133 @@
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.recipients.RecipientId;
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 RECIPIENT_ID = "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, " + RECIPIENT_ID + " INTEGER, " + 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<RecipientId> recipientIds, long mmsId, int status, long timestamp) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
for (RecipientId recipientId : recipientIds) {
ContentValues values = new ContentValues(4);
values.put(MMS_ID, mmsId);
values.put(RECIPIENT_ID, recipientId.serialize());
values.put(STATUS, status);
values.put(TIMESTAMP, timestamp);
db.insert(TABLE_NAME, null, values);
}
}
public void update(@NonNull RecipientId recipientId, 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 " + RECIPIENT_ID + " = ? AND " + STATUS + " < ?",
new String[] {String.valueOf(mmsId), recipientId.serialize(), String.valueOf(status)});
}
public void setUnidentified(RecipientId recipientId, 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 " + RECIPIENT_ID + " = ?",
new String[] {String.valueOf(mmsId), recipientId.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(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))),
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 RecipientId recipientId;
private final int status;
private final long timestamp;
private final boolean unidentified;
GroupReceiptInfo(@NonNull RecipientId recipientId, int status, long timestamp, boolean unidentified) {
this.recipientId = recipientId;
this.status = status;
this.timestamp = timestamp;
this.unidentified = unidentified;
}
public @NonNull RecipientId getRecipientId() {
return recipientId;
}
public int getStatus() {
return status;
}
public long getTimestamp() {
return timestamp;
}
public boolean isUnidentified() {
return unidentified;
}
}
}

View File

@@ -0,0 +1,273 @@
/*
* 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.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
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();
static final String TABLE_NAME = "identities";
private static final String ID = "_id";
static final String RECIPIENT_ID = "address";
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";
static final String VERIFIED = "verified";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME +
" (" + ID + " INTEGER PRIMARY KEY, " +
RECIPIENT_ID + " INTEGER 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(@NonNull RecipientId recipientId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try {
cursor = database.query(TABLE_NAME, null, RECIPIENT_ID + " = ?",
new String[] {recipientId.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(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus,
boolean firstUse, long timestamp, boolean nonBlockingApproval)
{
saveIdentityInternal(recipientId, identityKey, verifiedStatus, firstUse, timestamp, nonBlockingApproval);
DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE);
}
public void setApproval(@NonNull RecipientId recipientId, boolean nonBlockingApproval) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(2);
contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval);
database.update(TABLE_NAME, contentValues, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()});
DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE);
}
public void setVerified(@NonNull RecipientId recipientId, 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, RECIPIENT_ID + " = ? AND " + IDENTITY_KEY + " = ?",
new String[] {recipientId.serialize(), Base64.encodeBytes(identityKey.serialize())});
if (updated > 0) {
Optional<IdentityRecord> record = getIdentity(recipientId);
if (record.isPresent()) EventBus.getDefault().post(record.get());
DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE);
}
}
public void updateIdentityAfterSync(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
if (!hasMatchingKey(id, identityKey, verifiedStatus)) {
saveIdentityInternal(id, identityKey, verifiedStatus, false, System.currentTimeMillis(), true);
Optional<IdentityRecord> record = getIdentity(id);
if (record.isPresent()) EventBus.getDefault().post(record.get());
}
}
private boolean hasMatchingKey(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = RECIPIENT_ID + " = ? AND " + IDENTITY_KEY + " = ? AND " + VERIFIED + " = ?";
String[] args = new String[]{id.serialize(), Base64.encodeBytes(identityKey.serialize()), String.valueOf(verifiedStatus.toInt())};
try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) {
return cursor != null && cursor.moveToFirst();
}
}
private IdentityRecord getIdentityRecord(@NonNull Cursor cursor) throws IOException, InvalidKeyException {
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID));
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(RecipientId.from(recipientId), identity, VerifiedStatus.forState(verifiedStatus), firstUse, timestamp, nonblockingApproval);
}
private void saveIdentityInternal(@NonNull RecipientId recipientId, 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(RECIPIENT_ID, recipientId.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(recipientId, identityKey, verifiedStatus,
firstUse, timestamp, nonBlockingApproval));
}
public static class IdentityRecord {
private final RecipientId recipientId;
private final IdentityKey identitykey;
private final VerifiedStatus verifiedStatus;
private final boolean firstUse;
private final long timestamp;
private final boolean nonblockingApproval;
private IdentityRecord(@NonNull RecipientId recipientId,
IdentityKey identitykey, VerifiedStatus verifiedStatus,
boolean firstUse, long timestamp, boolean nonblockingApproval)
{
this.recipientId = recipientId;
this.identitykey = identitykey;
this.verifiedStatus = verifiedStatus;
this.firstUse = firstUse;
this.timestamp = timestamp;
this.nonblockingApproval = nonblockingApproval;
}
public RecipientId getRecipientId() {
return recipientId;
}
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 "{recipientId: " + recipientId + ", 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();
}
}
}

View File

@@ -0,0 +1,287 @@
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 String JOBS_TABLE_NAME = "job_spec";
public static String CONSTRAINTS_TABLE_NAME = "constraint_spec";
public static String DEPENDENCIES_TABLE_NAME = "dependency_spec";
public static final String[] CREATE_TABLE = new String[] { Jobs.CREATE_TABLE,
Constraints.CREATE_TABLE,
Dependencies.CREATE_TABLE };
private static final class Jobs {
private static final String TABLE_NAME = JOBS_TABLE_NAME;
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)";
}
private static final class Constraints {
private static final String TABLE_NAME = CONSTRAINTS_TABLE_NAME;
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 + "))";
}
private static final class Dependencies {
private static final String TABLE_NAME = DEPENDENCIES_TABLE_NAME;
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, @NonNull String serializedData) {
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);
contentValues.put(Jobs.SERIALIZED_DATA, serializedData);
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 updateJobs(@NonNull List<JobSpec> jobs) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (JobSpec job : jobs) {
ContentValues values = new ContentValues();
values.put(Jobs.JOB_SPEC_ID, job.getId());
values.put(Jobs.FACTORY_KEY, job.getFactoryKey());
values.put(Jobs.QUEUE_KEY, job.getQueueKey());
values.put(Jobs.CREATE_TIME, job.getCreateTime());
values.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime());
values.put(Jobs.RUN_ATTEMPT, job.getRunAttempt());
values.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts());
values.put(Jobs.MAX_BACKOFF, job.getMaxBackoff());
values.put(Jobs.MAX_INSTANCES, job.getMaxInstances());
values.put(Jobs.LIFESPAN, job.getLifespan());
values.put(Jobs.SERIALIZED_DATA, job.getSerializedData());
values.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0);
String query = Jobs.JOB_SPEC_ID + " = ?";
String[] args = new String[]{ job.getId() };
db.update(Jobs.TABLE_NAME, values, query, args);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
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)));
}
}

View File

@@ -0,0 +1,291 @@
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 org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.List;
public class MediaDatabase extends Database {
public static final int ALL_THREADS = -1;
private static final String THREAD_RECIPIENT_ID = "THREAD_RECIPIENT_ID";
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.BLUR_HASH + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", "
+ 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.THREAD_ID + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.RECIPIENT_ID + ", "
+ ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID + " as " + THREAD_RECIPIENT_ID + " "
+ "FROM " + AttachmentDatabase.TABLE_NAME + " LEFT JOIN " + MmsDatabase.TABLE_NAME
+ " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " "
+ "LEFT JOIN " + ThreadDatabase.TABLE_NAME
+ " ON " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.THREAD_ID + " "
+ "WHERE " + AttachmentDatabase.MMS_ID + " IN (SELECT " + MmsSmsColumns.ID
+ " FROM " + MmsDatabase.TABLE_NAME
+ " WHERE " + MmsDatabase.THREAD_ID + " __EQUALITY__ ?) AND (%s) AND "
+ MmsDatabase.VIEW_ONCE + " = 0 AND "
+ AttachmentDatabase.DATA + " IS NOT NULL AND "
+ AttachmentDatabase.QUOTE + " = 0 AND "
+ AttachmentDatabase.STICKER_PACK_ID + " IS NULL ";
private static final String UNIQUE_MEDIA_QUERY = "SELECT "
+ "MAX(" + AttachmentDatabase.SIZE + ") as " + AttachmentDatabase.SIZE + ", "
+ AttachmentDatabase.CONTENT_TYPE + " "
+ "FROM " + AttachmentDatabase.TABLE_NAME + " "
+ "WHERE " + AttachmentDatabase.STICKER_PACK_ID + " IS NULL "
+ "GROUP BY " + AttachmentDatabase.DATA;
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 AUDIO_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " LIKE 'audio/%'");
private static final String ALL_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, AttachmentDatabase.CONTENT_TYPE + " NOT LIKE 'text/x-signal-plain'");
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 @NonNull Cursor getGalleryMediaForThread(long threadId, @NonNull Sorting sorting) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String query = sorting.applyToQuery(applyEqualityOperator(threadId, GALLERY_MEDIA_QUERY));
String[] args = {threadId + ""};
Cursor cursor = database.rawQuery(query, args);
setNotifyConverationListeners(cursor, threadId);
return cursor;
}
public @NonNull Cursor getDocumentMediaForThread(long threadId, @NonNull Sorting sorting) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String query = sorting.applyToQuery(applyEqualityOperator(threadId, DOCUMENT_MEDIA_QUERY));
String[] args = {threadId + ""};
Cursor cursor = database.rawQuery(query, args);
setNotifyConverationListeners(cursor, threadId);
return cursor;
}
public @NonNull Cursor getAudioMediaForThread(long threadId, @NonNull Sorting sorting) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String query = sorting.applyToQuery(applyEqualityOperator(threadId, AUDIO_MEDIA_QUERY));
String[] args = {threadId + ""};
Cursor cursor = database.rawQuery(query, args);
setNotifyConverationListeners(cursor, threadId);
return cursor;
}
public @NonNull Cursor getAllMediaForThread(long threadId, @NonNull Sorting sorting) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
String query = sorting.applyToQuery(applyEqualityOperator(threadId, ALL_MEDIA_QUERY));
String[] args = {threadId + ""};
Cursor cursor = database.rawQuery(query, args);
setNotifyConverationListeners(cursor, threadId);
return cursor;
}
private static String applyEqualityOperator(long threadId, String query) {
return query.replace("__EQUALITY__", threadId == ALL_THREADS ? "!=" : "=");
}
public void subscribeToMediaChanges(@NonNull ContentObserver observer) {
registerAttachmentListeners(observer);
}
public void unsubscribeToMediaChanges(@NonNull ContentObserver observer) {
context.getContentResolver().unregisterContentObserver(observer);
}
public StorageBreakdown getStorageBreakdown() {
StorageBreakdown storageBreakdown = new StorageBreakdown();
SQLiteDatabase database = databaseHelper.getReadableDatabase();
try (Cursor cursor = database.rawQuery(UNIQUE_MEDIA_QUERY, new String[0])) {
int sizeColumn = cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE);
int contentTypeColumn = cursor.getColumnIndexOrThrow(AttachmentDatabase.CONTENT_TYPE);
while (cursor.moveToNext()) {
int size = cursor.getInt(sizeColumn);
String type = cursor.getString(contentTypeColumn);
switch (MediaUtil.getSlideTypeFromContentType(type)) {
case GIF:
case IMAGE:
case MMS:
storageBreakdown.photoSize += size;
break;
case VIDEO:
storageBreakdown.videoSize += size;
break;
case AUDIO:
storageBreakdown.audioSize += size;
break;
case LONG_TEXT:
case DOCUMENT:
storageBreakdown.documentSize += size;
break;
default:
break;
}
}
}
return storageBreakdown;
}
public static class MediaRecord {
private final DatabaseAttachment attachment;
private final RecipientId recipientId;
private final RecipientId threadRecipientId;
private final long threadId;
private final long date;
private final boolean outgoing;
private MediaRecord(@Nullable DatabaseAttachment attachment,
@NonNull RecipientId recipientId,
@NonNull RecipientId threadRecipientId,
long threadId,
long date,
boolean outgoing)
{
this.attachment = attachment;
this.recipientId = recipientId;
this.threadRecipientId = threadRecipientId;
this.threadId = threadId;
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);
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID));
boolean outgoing = MessagingDatabase.Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)));
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));
}
RecipientId threadRecipient = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_RECIPIENT_ID)));
return new MediaRecord(attachments != null && attachments.size() > 0 ? attachments.get(0) : null,
recipientId,
threadRecipient,
threadId,
date,
outgoing);
}
public @Nullable DatabaseAttachment getAttachment() {
return attachment;
}
public String getContentType() {
return attachment.getContentType();
}
public @NonNull RecipientId getRecipientId() {
return recipientId;
}
public @NonNull RecipientId getThreadRecipientId() {
return threadRecipientId;
}
public long getThreadId() {
return threadId;
}
public long getDate() {
return date;
}
public boolean isOutgoing() {
return outgoing;
}
}
public enum Sorting {
Newest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC"),
Oldest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " ASC" ),
Largest(AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + " DESC");
private final String postFix;
Sorting(@NonNull String order) {
postFix = " ORDER BY " + order;
}
private String applyToQuery(@NonNull String query) {
return query + postFix;
}
public boolean isRelatedToFileSize() {
return this == Largest;
}
}
public final static class StorageBreakdown {
private long photoSize;
private long videoSize;
private long audioSize;
private long documentSize;
public long getPhotoSize() {
return photoSize;
}
public long getVideoSize() {
return videoSize;
}
public long getAudioSize() {
return audioSize;
}
public long getDocumentSize() {
return documentSize;
}
}
}

View File

@@ -0,0 +1,463 @@
package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import com.google.protobuf.InvalidProtocolBufferException;
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.database.model.DatabaseProtos.ReactionList;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.insights.InsightsConstants;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
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();
protected abstract String getTypeField();
protected abstract String getDateSentColumnName();
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);
final int getInsecureMessagesSentForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[]{"COUNT(*)"};
String query = THREAD_ID + " = ? AND " + getOutgoingInsecureMessageClause() + " AND " + getDateSentColumnName() + " > ?";
String[] args = new String[]{String.valueOf(threadId), String.valueOf(System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
final int getInsecureMessageCountForInsights() {
return getMessageCountForRecipientsAndType(getOutgoingInsecureMessageClause());
}
final int getSecureMessageCountForInsights() {
return getMessageCountForRecipientsAndType(getOutgoingSecureMessageClause());
}
private int getMessageCountForRecipientsAndType(String typeClause) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[] {"COUNT(*)"};
String query = typeClause + " AND " + getDateSentColumnName() + " > ?";
String[] args = new String[]{String.valueOf(System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
private String getOutgoingInsecureMessageClause() {
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + getTypeField() + " & " + Types.SECURE_MESSAGE_BIT + ")";
}
private String getOutgoingSecureMessageClause() {
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
}
public void setReactionsSeen(long threadId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
String whereClause = THREAD_ID + " = ? AND " + REACTIONS_UNREAD + " = ?";
String[] whereArgs = new String[]{String.valueOf(threadId), "1"};
values.put(REACTIONS_UNREAD, 0);
values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis());
db.update(getTableName(), values, whereClause, whereArgs);
}
public void setAllReactionsSeen() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(REACTIONS_UNREAD, 0);
values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis());
db.update(getTableName(), values, null, null);
}
public void addReaction(long messageId, @NonNull ReactionRecord reaction) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
ReactionList.Reaction newReaction = ReactionList.Reaction.newBuilder()
.setEmoji(reaction.getEmoji())
.setAuthor(reaction.getAuthor().toLong())
.setSentTime(reaction.getDateSent())
.setReceivedTime(reaction.getDateReceived())
.build();
ReactionList updatedList = pruneByAuthor(reactions, reaction.getAuthor()).toBuilder()
.addReactions(newReaction)
.build();
setReactions(db, messageId, updatedList);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListeners(getThreadId(db, messageId));
}
public void deleteReaction(long messageId, @NonNull RecipientId author) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
ReactionList updatedList = pruneByAuthor(reactions, author);
setReactions(db, messageId, updatedList);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListeners(getThreadId(db, messageId));
}
public boolean hasReaction(long messageId, @NonNull ReactionRecord reactionRecord) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
for (ReactionList.Reaction reaction : reactions.getReactionsList()) {
if (reactionRecord.getAuthor().toLong() == reaction.getAuthor() &&
reactionRecord.getEmoji().equals(reaction.getEmoji()))
{
return true;
}
}
return false;
}
public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,
new IdentityKeyMismatch(recipientId, identityKey),
IdentityKeyMismatchList.class);
} catch (IOException e) {
Log.w(TAG, e);
}
}
public void removeMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) {
try {
removeFromDocument(messageId, MISMATCHED_IDENTITIES,
new IdentityKeyMismatch(recipientId, identityKey),
IdentityKeyMismatchList.class);
} catch (IOException e) {
Log.w(TAG, e);
}
}
protected List<ReactionRecord> parseReactions(@NonNull Cursor cursor) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS));
if (raw != null) {
try {
return Stream.of(ReactionList.parseFrom(raw).getReactionsList())
.map(r -> {
return new ReactionRecord(r.getEmoji(),
RecipientId.from(r.getAuthor()),
r.getSentTime(),
r.getReceivedTime());
})
.toList();
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "[parseReactions] Failed to parse reaction list!", e);
return Collections.emptyList();
}
} else {
return Collections.emptyList();
}
}
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();
}
}
private static @NonNull ReactionList pruneByAuthor(@NonNull ReactionList reactionList, @NonNull RecipientId recipientId) {
List<ReactionList.Reaction> pruned = Stream.of(reactionList.getReactionsList())
.filterNot(r -> r.getAuthor() == recipientId.toLong())
.toList();
return reactionList.toBuilder()
.clearReactions()
.addAllReactions(pruned)
.build();
}
private @NonNull Optional<ReactionList> getReactions(SQLiteDatabase db, long messageId) {
String[] projection = new String[]{ REACTIONS };
String query = ID + " = ?";
String[] args = new String[]{String.valueOf(messageId)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS));
if (raw != null) {
return Optional.of(ReactionList.parseFrom(raw));
}
}
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "[getRecipients] Failed to parse reaction list!", e);
}
return Optional.absent();
}
private void setReactions(@NonNull SQLiteDatabase db, long messageId, @NonNull ReactionList reactionList) {
ContentValues values = new ContentValues(1);
values.put(REACTIONS, reactionList.getReactionsList().isEmpty() ? null : reactionList.toByteArray());
values.put(REACTIONS_UNREAD, reactionList.getReactionsCount() != 0 ? 1 : 0);
String query = ID + " = ?";
String[] args = new String[] { String.valueOf(messageId) };
db.update(getTableName(), values, query, args);
}
private long getThreadId(@NonNull SQLiteDatabase db, long messageId) {
String[] projection = new String[]{ THREAD_ID };
String query = ID + " = ?";
String[] args = new String[]{ String.valueOf(messageId) };
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID));
}
}
return -1;
}
public static class SyncMessageId {
private final RecipientId recipientId;
private final long timetamp;
public SyncMessageId(@NonNull RecipientId recipientId, long timetamp) {
this.recipientId = recipientId;
this.timetamp = timetamp;
}
public RecipientId getRecipientId() {
return recipientId;
}
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

View File

@@ -0,0 +1,336 @@
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 RECIPIENT_ID = "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 final String REACTIONS = "reactions";
public static final String REACTIONS_UNREAD = "reactions_unread";
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
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 UNSUPPORTED_MESSAGE_TYPE = 5;
protected static final long INVALID_MESSAGE_TYPE = 6;
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;
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 isUnsupportedMessageType(long type) {
return (type & BASE_TYPE_MASK) == UNSUPPORTED_MESSAGE_TYPE;
}
public static boolean isInvalidMessageType(long type) {
return (type & BASE_TYPE_MASK) == INVALID_MESSAGE_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 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;
// }
}
}

View File

@@ -0,0 +1,519 @@
/*
* 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.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
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.RECIPIENT_ID, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT,
MmsSmsColumns.NORMALIZED_DATE_SENT,
MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX,
SmsDatabase.STATUS,
MmsSmsColumns.UNIDENTIFIED,
MmsSmsColumns.REACTIONS,
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,
MmsDatabase.VIEW_ONCE,
MmsSmsColumns.READ,
MmsSmsColumns.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public @Nullable MessageRecord getMessageFor(long timestamp, RecipientId 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 ((Recipient.resolved(author).isLocalNumber() && messageRecord.isOutgoing()) ||
(!Recipient.resolved(author).isLocalNumber() && messageRecord.getIndividualRecipient().getId().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.NOTIFIED + " = 0 AND (" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1)";
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 boolean checkMessageExists(@NonNull MessageRecord messageRecord) {
if (messageRecord.isMms()) {
try (Cursor mms = DatabaseFactory.getMmsDatabase(context).getMessage(messageRecord.getId())) {
return mms != null && mms.getCount() > 0;
}
} else {
try (Cursor sms = DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageRecord.getId())) {
return sms != null && sms.getCount() > 0;
}
}
}
public int getConversationCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId);
return count;
}
public int getInsecureSentCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessagesSentForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessagesSentForThread(threadId);
return count;
}
public int getInsecureMessageCountForInsights() {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessageCountForInsights();
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessageCountForInsights();
return count;
}
public int getSecureMessageCountForInsights() {
int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCountForInsights();
count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCountForInsights();
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 RecipientId recipientId) {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.RECIPIENT_ID}, selection, order, null)) {
boolean isOwnNumber = Recipient.resolved(recipientId).isLocalNumber();
while (cursor != null && cursor.moveToNext()) {
boolean quoteIdMatches = cursor.getLong(0) == quoteId;
boolean recipientIdMatches = recipientId.equals(RecipientId.from(cursor.getLong(1)));
if (quoteIdMatches && (recipientIdMatches || isOwnNumber)) {
return cursor.getPosition();
}
}
}
return -1;
}
public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull RecipientId recipientId) {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.RECIPIENT_ID}, selection, order, null)) {
boolean isOwnNumber = Recipient.resolved(recipientId).isLocalNumber();
while (cursor != null && cursor.moveToNext()) {
boolean timestampMatches = cursor.getLong(0) == receivedTimestamp;
boolean recipientIdMatches = recipientId.equals(RecipientId.from(cursor.getLong(1)));
if (timestampMatches && (recipientIdMatches || isOwnNumber)) {
return cursor.getPosition();
}
}
}
return -1;
}
boolean hasReceivedAnyCallsSince(long threadId, long timestamp) {
return DatabaseFactory.getSmsDatabase(context).hasReceivedAnyCallsSince(threadId, timestamp);
}
/**
* 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 + ", " +
"'" + AttachmentDatabase.BLUR_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", " +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES +
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.RECIPIENT_ID, 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,
MmsDatabase.VIEW_ONCE,
MmsDatabase.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN};
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.RECIPIENT_ID, 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,
MmsDatabase.VIEW_ONCE,
MmsDatabase.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN};
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.RECIPIENT_ID);
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);
mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE);
mmsColumnsPresent.add(MmsDatabase.REACTIONS);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN);
Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID);
smsColumnsPresent.add(MmsSmsColumns.BODY);
smsColumnsPresent.add(MmsSmsColumns.RECIPIENT_ID);
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);
smsColumnsPresent.add(SmsDatabase.REACTIONS);
smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD);
smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN);
@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();
}
}
}

View File

@@ -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
}
}

View File

@@ -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);}
}

View File

@@ -0,0 +1,4 @@
package org.thoughtcrime.securesms.database;
public class NotInDirectoryException extends Throwable {
}

View File

@@ -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);
}

View File

@@ -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)});
}
}

View File

@@ -0,0 +1,184 @@
package org.thoughtcrime.securesms.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
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.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.io.IOException;
import java.util.UUID;
public class PushDatabase extends Database {
private static final String TAG = PushDatabase.class.getSimpleName();
private static final String TABLE_NAME = "push";
public static final String ID = "_id";
public static final String TYPE = "type";
public static final String SOURCE_E164 = "source";
public static final String SOURCE_UUID = "source_uuid";
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_E164 + " TEXT, " + SOURCE_UUID + " 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_UUID, envelope.getSourceUuid().orNull());
values.put(SOURCE_E164, envelope.getSourceE164().orNull());
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));
String uuid = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_UUID));
String e164 = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_E164));
return new SignalServiceEnvelope(cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)),
SignalServiceAddress.fromRaw(uuid, e164),
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();
String query = TYPE + " = ? AND " +
DEVICE_ID + " = ? AND " +
LEGACY_MSG + " = ? AND " +
CONTENT + " = ? AND " +
TIMESTAMP + " = ? AND " +
"(" +
"(" + SOURCE_E164 + " NOT NULL AND " + SOURCE_E164 + " = ?) OR " +
"(" + SOURCE_UUID + " NOT NULL AND " + SOURCE_UUID + " = ?)" +
")";
String[] args = new String[] { String.valueOf(envelope.getType()),
String.valueOf(envelope.getSourceDevice()),
envelope.hasLegacyMessage() ? Base64.encodeBytes(envelope.getLegacyMessage()) : "",
envelope.hasContent() ? Base64.encodeBytes(envelope.getContent()) : "",
String.valueOf(envelope.getTimestamp()),
String.valueOf(envelope.getSourceUuid().orNull()),
String.valueOf(envelope.getSourceE164().orNull()) };
try (Cursor cursor = database.query(TABLE_NAME, null, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return Optional.of(cursor.getLong(cursor.getColumnIndexOrThrow(ID)));
} else {
return Optional.absent();
}
}
}
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 sourceUuid = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_UUID));
String sourceE164 = cursor.getString(cursor.getColumnIndexOrThrow(SOURCE_E164));
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,
SignalServiceAddress.fromRaw(sourceUuid, sourceE164),
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();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
package org.thoughtcrime.securesms.database;
import android.content.Context;
import android.text.TextUtils;
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;
/**
* 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_RECIPIENT = "conversation_recipient";
public static final String MESSAGE_RECIPIENT = "message_recipient";
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.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " +
MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " +
"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.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " +
MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " +
"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.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " +
MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " +
"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.RECIPIENT_ID + " AS " + CONVERSATION_RECIPIENT + ", " +
MmsSmsColumns.RECIPIENT_ID + " AS " + MESSAGE_RECIPIENT + ", " +
"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 fullTextSearchQuery = createFullTextSearchQuery(query);
if (TextUtils.isEmpty(fullTextSearchQuery)) {
return null;
}
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { fullTextSearchQuery,
fullTextSearchQuery });
setNotifyConverationListListeners(cursor);
return cursor;
}
public Cursor queryMessages(@NonNull String query, long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String fullTextSearchQuery = createFullTextSearchQuery(query);
if (TextUtils.isEmpty(fullTextSearchQuery)) {
return null;
}
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { fullTextSearchQuery,
String.valueOf(threadId),
fullTextSearchQuery,
String.valueOf(threadId) });
setNotifyConverationListListeners(cursor);
return cursor;
}
private static String createFullTextSearchQuery(@NonNull String query) {
return Stream.of(query.split(" "))
.map(String::trim)
.filter(s -> s.length() > 0)
.map(SearchDatabase::fullTextSearchEscape)
.collect(StringBuilder::new, (sb, s) -> sb.append(s).append("* "))
.toString();
}
private static String fullTextSearchEscape(String s) {
return "\"" + s.replace("\"", "\"\"") + "\"";
}
}

View File

@@ -0,0 +1,171 @@
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.recipients.RecipientId;
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 RECIPIENT_ID = "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, " + RECIPIENT_ID + " INTEGER NOT NULL, " +
DEVICE + " INTEGER NOT NULL, " + RECORD + " BLOB NOT NULL, " +
"UNIQUE(" + RECIPIENT_ID + "," + DEVICE + ") ON CONFLICT REPLACE);";
SessionDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public void store(@NonNull RecipientId recipientId, int deviceId, @NonNull SessionRecord record) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(RECIPIENT_ID, recipientId.serialize());
values.put(DEVICE, deviceId);
values.put(RECORD, record.serialize());
database.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
public @Nullable SessionRecord load(@NonNull RecipientId recipientId, int deviceId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
try (Cursor cursor = database.query(TABLE_NAME, new String[]{RECORD},
RECIPIENT_ID + " = ? AND " + DEVICE + " = ?",
new String[] {recipientId.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 RecipientId recipientId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
List<SessionRow> results = new LinkedList<>();
try (Cursor cursor = database.query(TABLE_NAME, null,
RECIPIENT_ID + " = ?",
new String[] {recipientId.serialize()},
null, null, null))
{
while (cursor != null && cursor.moveToNext()) {
try {
results.add(new SessionRow(recipientId,
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(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))),
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 RecipientId recipientId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
List<Integer> results = new LinkedList<>();
try (Cursor cursor = database.query(TABLE_NAME, new String[] {DEVICE},
RECIPIENT_ID + " = ?",
new String[] {recipientId.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 RecipientId recipientId, int deviceId) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.delete(TABLE_NAME, RECIPIENT_ID + " = ? AND " + DEVICE + " = ?",
new String[] {recipientId.serialize(), String.valueOf(deviceId)});
}
public void deleteAllFor(@NonNull RecipientId recipientId) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.delete(TABLE_NAME, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()});
}
public static final class SessionRow {
private final RecipientId recipientId;
private final int deviceId;
private final SessionRecord record;
public SessionRow(@NonNull RecipientId recipientId, int deviceId, SessionRecord record) {
this.recipientId = recipientId;
this.deviceId = deviceId;
this.record = record;
}
public RecipientId getRecipientId() {
return recipientId;
}
public int getDeviceId() {
return deviceId;
}
public SessionRecord getRecord() {
return record;
}
}
}

View File

@@ -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)});
}
}

View File

@@ -0,0 +1,972 @@
/*
* 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 androidx.annotation.NonNull;
import android.text.TextUtils;
import android.util.Pair;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteStatement;
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.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
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.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 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, " +
RECIPIENT_ID + " INTEGER, " +
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, " +
REACTIONS + " BLOB DEFAULT NULL, " +
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);";
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 + ");",
"CREATE INDEX IF NOT EXISTS sms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");"
};
private static final String[] MESSAGE_PROJECTION = new String[] {
ID, THREAD_ID, RECIPIENT_ID, 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, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN
};
private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")";
private final String OUTGOING_SECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + TYPE + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("SmsDelivery");
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache("SmsRead");
public SmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
protected String getTableName() {
return TABLE_NAME;
}
@Override
protected String getDateSentColumnName() {
return DATE_SENT;
}
@Override
protected String getTypeField() {
return TYPE;
}
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 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 markAsUnsupportedProtocolVersion(long id) {
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.UNSUPPORTED_MESSAGE_TYPE);
}
public void markAsInvalidMessage(long id) {
updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.INVALID_MESSAGE_TYPE);
}
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 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, RECIPIENT_ID, TYPE},
DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())},
null, null, null, null);
while (cursor.moveToNext()) {
if (Types.isOutgoingMessageType(cursor.getLong(cursor.getColumnIndexOrThrow(TYPE)))) {
RecipientId theirRecipientId = messageId.getRecipientId();
RecipientId outRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)));
String columnName = deliveryReceipt ? DELIVERY_RECEIPT_COUNT : READ_RECEIPT_COUNT;
if (outRecipientId.equals(theirRecipientId)) {
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.getRecipientId());
if (readReceipt) earlyReadReceiptCache.increment(messageId.getTimetamp(), messageId.getRecipientId());
}
} 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, RECIPIENT_ID, TYPE, EXPIRES_IN, EXPIRE_STARTED},
DATE_SENT + " = ?", new String[] {String.valueOf(messageId.getTimetamp())},
null, null, null, null);
while (cursor.moveToNext()) {
RecipientId theirRecipientId = messageId.getRecipientId();
RecipientId outRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)));
if (outRecipientId.equals(theirRecipientId)) {
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> setEntireThreadRead(long threadId) {
return setMessagesRead(THREAD_ID + " = ?", new String[] {String.valueOf(threadId)});
}
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, RECIPIENT_ID, 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(RecipientId.from(cursor.getLong(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(RECIPIENT_ID, record.getIndividualRecipient().getId().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());
ApplicationDependencies.getJobManager().add(new TrimThreadJob(record.getThreadId()));
return new Pair<>(newMessageId, record.getThreadId());
} catch (NoSuchMessageException e) {
throw new AssertionError(e);
}
}
boolean hasReceivedAnyCallsSince(long threadId, long timestamp) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[]{SmsDatabase.TYPE};
String selection = THREAD_ID + " = ? AND " + DATE_RECEIVED + " > ? AND (" + TYPE + " = ? OR " + TYPE + " = ?)";
String[] selectionArgs = new String[]{String.valueOf(threadId),
String.valueOf(timestamp),
String.valueOf(Types.INCOMING_CALL_TYPE),
String.valueOf(Types.MISSED_CALL_TYPE)};
try (Cursor cursor = db.query(TABLE_NAME, projection, selection, selectionArgs, null, null, null)) {
return cursor != null && cursor.moveToFirst();
}
}
public @NonNull Pair<Long, Long> insertReceivedCall(@NonNull RecipientId address) {
return insertCallLog(address, Types.INCOMING_CALL_TYPE, false);
}
public @NonNull Pair<Long, Long> insertOutgoingCall(@NonNull RecipientId address) {
return insertCallLog(address, Types.OUTGOING_CALL_TYPE, false);
}
public @NonNull Pair<Long, Long> insertMissedCall(@NonNull RecipientId address) {
return insertCallLog(address, Types.MISSED_CALL_TYPE, true);
}
private @NonNull Pair<Long, Long> insertCallLog(@NonNull RecipientId recipientId, long type, boolean unread) {
Recipient recipient = Recipient.resolved(recipientId);
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
ContentValues values = new ContentValues(6);
values.put(RECIPIENT_ID, recipientId.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);
ApplicationDependencies.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) {
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.resolved(message.getSender());
Recipient groupRecipient;
if (message.getGroupId() == null) {
groupRecipient = null;
} else {
RecipientId id = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(message.getGroupId());
groupRecipient = Recipient.resolved(id);
}
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).getThreadIdFor(recipient);
else threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient);
ContentValues values = new ContentValues(6);
values.put(RECIPIENT_ID, message.getSender().serialize());
values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId());
values.put(DATE_RECEIVED, System.currentTimeMillis());
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.getId(), message.getSubscriptionId());
}
notifyConversationListeners(threadId);
if (!message.isIdentityUpdate() && !message.isIdentityVerified() && !message.isIdentityDefault()) {
ApplicationDependencies.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);
}
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;
RecipientId recipientId = message.getRecipient().getId();
Map<RecipientId, Long> earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(date);
Map<RecipientId, Long> earlyReadReceipts = earlyReadReceiptCache.remove(date);
ContentValues contentValues = new ContentValues(6);
contentValues.put(RECIPIENT_ID, recipientId.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, null, 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()) {
ApplicationDependencies.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 " + RECIPIENT_ID + " = ? 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 + " (" + RECIPIENT_ID + ", " +
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, Collections.emptyList());
}
}
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 ));
long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID ));
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;
List<ReactionRecord> reactions = parseReactions(cursor);
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
}
List<IdentityKeyMismatch> mismatches = getMismatches(mismatchDocument);
Recipient recipient = Recipient.live(RecipientId.from(recipientId)).get();
return new SmsMessageRecord(messageId, body, recipient,
recipient,
addressDeviceId,
dateSent, dateReceived, deliveryReceiptCount, type,
threadId, status, mismatches, subscriptionId,
expiresIn, expireStarted,
readReceiptCount, unidentified, reactions);
}
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();
}
}

View File

@@ -0,0 +1,286 @@
/*
* 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 com.annimon.stream.Stream;
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.recipients.RecipientId;
import java.util.HashSet;
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 class SystemColumns {
private static final String ADDRESS = "address";
private static final String PERSON = "person";
private static final String DATE_RECEIVED = "date";
private static final String PROTOCOL = "protocol";
private static final String READ = "read";
private static final String STATUS = "status";
private static final String TYPE = "type";
private static final String SUBJECT = "subject";
private static final String REPLY_PATH_PRESENT = "reply_path_present";
private static final String BODY = "body";
private static final String SERVICE_CENTER = "service_center";
}
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 address = cursor.getString(cursor.getColumnIndexOrThrow(SystemColumns.ADDRESS));
RecipientId id = Recipient.external(context, address).getId();
statement.bindString(1, id.serialize());
addIntToStatement(statement, cursor, 2, SystemColumns.PERSON);
addIntToStatement(statement, cursor, 3, SystemColumns.DATE_RECEIVED);
addIntToStatement(statement, cursor, 4, SystemColumns.DATE_RECEIVED);
addIntToStatement(statement, cursor, 5, SystemColumns.PROTOCOL);
addIntToStatement(statement, cursor, 6, SystemColumns.READ);
addIntToStatement(statement, cursor, 7, SystemColumns.STATUS);
addTranslatedTypeToStatement(statement, cursor, 8, SystemColumns.TYPE);
addIntToStatement(statement, cursor, 9, SystemColumns.REPLY_PATH_PRESENT);
addStringToStatement(statement, cursor, 10, SystemColumns.SUBJECT);
addStringToStatement(statement, cursor, 11, SystemColumns.BODY);
addStringToStatement(statement, cursor, 12, SystemColumns.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.external(context, address));
}
}
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 addressColumn = cursor.getColumnIndexOrThrow(SystemColumns.ADDRESS);
int typeColumn = cursor.getColumnIndex(SmsDatabase.TYPE);
if (!cursor.isNull(addressColumn) && (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.getThreadIdFor(ourRecipients.iterator().next());
migrateConversation(context, listener, progress, theirThreadId, ourThreadId);
} else if (ourRecipients.size() > 1) {
ourRecipients.add(Recipient.self());
List<RecipientId> recipientIds = Stream.of(ourRecipients).map(Recipient::getId).toList();
String ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(recipientIds, true);
RecipientId ourGroupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(ourGroupId);
Recipient ourGroupRecipient = Recipient.resolved(ourGroupRecipientId);
long ourThreadId = threadDatabase.getThreadIdFor(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;
}
}
}

View File

@@ -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, boolean notify) 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() && notify) {
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, boolean notify) {
updatePackInstalled(databaseHelper.getWritableDatabase(), packKey, true, notify);
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, false);
deleteStickersInPackExceptCover(db, packId);
db.setTransactionSuccessful();
notifyStickerPackListeners();
notifyStickerListeners();
} finally {
db.endTransaction();
}
}
private void updatePackInstalled(@NonNull SQLiteDatabase db, @NonNull String packId, boolean installed, boolean notify) {
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 && notify) {
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();
}
}
}
}

View File

@@ -0,0 +1,115 @@
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.util.Base64;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* A list of storage keys whose types we do not currently have syncing logic for. We need to
* remember that these keys exist so that we don't blast any data away.
*/
public class StorageKeyDatabase extends Database {
private static final String TABLE_NAME = "storage_key";
private static final String ID = "_id";
private static final String TYPE = "type";
private static final String KEY = "key";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
TYPE + " INTEGER, " +
KEY + " TEXT UNIQUE)";
public static final String[] CREATE_INDEXES = new String[] {
"CREATE INDEX IF NOT EXISTS storage_key_type_index ON " + TABLE_NAME + " (" + TYPE + ");"
};
public StorageKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public List<byte[]> getAllKeys() {
List<byte[]> keys = new ArrayList<>();
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(KEY));
try {
keys.add(Base64.decode(keyEncoded));
} catch (IOException e) {
throw new AssertionError(e);
}
}
}
return keys;
}
public @Nullable SignalStorageRecord getByKey(@NonNull byte[] key) {
String query = KEY + " = ?";
String[] args = new String[] { Base64.encodeBytes(key) };
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE));
return SignalStorageRecord.forUnknown(key, type);
} else {
return null;
}
}
}
public void applyStorageSyncUpdates(@NonNull Collection<SignalStorageRecord> inserts,
@NonNull Collection<SignalStorageRecord> deletes)
{
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (SignalStorageRecord insert : inserts) {
ContentValues values = new ContentValues();
values.put(TYPE, insert.getType());
values.put(KEY, Base64.encodeBytes(insert.getKey()));
db.insert(TABLE_NAME, null, values);
}
String deleteQuery = KEY + " = ?";
for (SignalStorageRecord delete : deletes) {
String[] args = new String[] { Base64.encodeBytes(delete.getKey()) };
db.delete(TABLE_NAME, deleteQuery, args);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public void deleteByType(int type) {
String query = TYPE + " = ?";
String[] args = new String[]{String.valueOf(type)};
databaseHelper.getWritableDatabase().delete(TABLE_NAME, query, args);
}
public void deleteAll() {
databaseHelper.getWritableDatabase().delete(TABLE_NAME, null, null);
}
}

View File

@@ -0,0 +1,867 @@
/*
* 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 com.fasterxml.jackson.annotation.JsonProperty;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
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.recipients.RecipientId;
import org.thoughtcrime.securesms.util.JsonUtils;
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.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 ThreadDatabase extends Database {
private static final String TAG = ThreadDatabase.class.getSimpleName();
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 RECIPIENT_ID = "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 SNIPPET_CONTENT_TYPE = "snippet_content_type";
public static final String SNIPPET_EXTRAS = "snippet_extras";
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";
public 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, " + RECIPIENT_ID + " INTEGER, " + 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, " +
SNIPPET_CONTENT_TYPE + " TEXT DEFAULT NULL, " + SNIPPET_EXTRAS + " 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 + " (" + RECIPIENT_ID + ");",
"CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");",
};
private static final String[] THREAD_PROJECTION = {
ID, DATE, MESSAGE_COUNT, RECIPIENT_ID, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE,
SNIPPET_URI, SNIPPET_CONTENT_TYPE, SNIPPET_EXTRAS, 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(@NonNull RecipientId recipientId, boolean group, int distributionType) {
if (recipientId.isUnknown()) {
throw new AssertionError("Cannot create a thread for an unknown recipient!");
}
ContentValues contentValues = new ContentValues(4);
long date = System.currentTimeMillis();
contentValues.put(DATE, date - date % 1000);
contentValues.put(RECIPIENT_ID, recipientId.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,
@Nullable String contentType, @Nullable Extra extra,
long date, int status, int deliveryReceiptCount, long type, boolean unarchive,
long expiresIn, int readReceiptCount)
{
String extraSerialized = null;
if (extra != null) {
try {
extraSerialized = JsonUtils.toJson(extra);
} catch (IOException e) {
throw new AssertionError(e);
}
}
ContentValues contentValues = new ContentValues(7);
contentValues.put(DATE, date - date % 1000);
contentValues.put(MESSAGE_COUNT, count);
contentValues.put(SNIPPET, body);
contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString());
contentValues.put(SNIPPET_TYPE, type);
contentValues.put(SNIPPET_CONTENT_TYPE, contentType);
contentValues.put(SNIPPET_EXTRAS, extraSerialized);
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);
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 + ""});
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);
notifyConversationListListeners();
}
private void deleteAllThreads() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, null, null);
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();
DatabaseFactory.getSmsDatabase(context).setAllReactionsSeen();
DatabaseFactory.getMmsDatabase(context).setAllReactionsSeen();
notifyConversationListListeners();
return Util.concatenatedList(smsRecords, mmsRecords);
}
public boolean hasCalledSince(@NonNull Recipient recipient, long timestamp) {
return DatabaseFactory.getMmsSmsDatabase(context).hasReceivedAnyCallsSince(getThreadIdFor(recipient), timestamp);
}
public List<MarkedMessageInfo> setEntireThreadRead(long threadId) {
setRead(threadId, false);
final List<MarkedMessageInfo> smsRecords = DatabaseFactory.getSmsDatabase(context).setEntireThreadRead(threadId);
final List<MarkedMessageInfo> mmsRecords = DatabaseFactory.getMmsDatabase(context).setEntireThreadRead(threadId);
return Util.concatenatedList(smsRecords, 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);
DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId);
DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId);
notifyConversationListListeners();
return Util.concatenatedList(smsRecords, 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<RecipientId> filter) {
if (filter == null || filter.size() == 0)
return null;
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<List<RecipientId>> splitRecipientIds = Util.partition(filter, 900);
List<Cursor> cursors = new LinkedList<>();
for (List<RecipientId> recipientIds : splitRecipientIds) {
String selection = TABLE_NAME + "." + RECIPIENT_ID + " = ?";
String[] selectionArgs = new String[recipientIds.size()];
for (int i=0;i<recipientIds.size()-1;i++)
selection += (" OR " + TABLE_NAME + "." + RECIPIENT_ID + " = ?");
int i= 0;
for (RecipientId recipientId : recipientIds) {
selectionArgs[i++] = recipientId.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, boolean includeInactiveGroups) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = !includeInactiveGroups ? MESSAGE_COUNT + " != 0 AND (" + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " IS NULL OR " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1)"
: MESSAGE_COUNT + " != 0";
return db.rawQuery(createQuery(query, limit), null);
}
public Cursor getRecentPushConversationList(int limit, boolean includeInactiveGroups) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String activeGroupQuery = !includeInactiveGroups ? " AND " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1" : "";
String where = MESSAGE_COUNT + " != 0 AND " +
"(" +
RecipientDatabase.REGISTERED + " = " + RecipientDatabase.RegisteredState.REGISTERED.getId() + " OR " +
"(" +
GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID + " NOT NULL AND " +
GroupDatabase.TABLE_NAME + "." + GroupDatabase.MMS + " = 0" +
activeGroupQuery +
")" +
")";
String query = createQuery(where, limit);
return db.rawQuery(query, null);
}
public Cursor getConversationList() {
return getConversationList("0");
}
public Cursor getArchivedConversationList() {
return getConversationList("1");
}
public @NonNull Set<RecipientId> getArchivedRecipients() {
Set<RecipientId> archived = new HashSet<>();
try (Cursor cursor = DatabaseFactory.getThreadDatabase(context).getArchivedConversationList()) {
while (cursor != null && cursor.moveToNext()) {
archived.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID))));
}
}
return archived;
}
public @NonNull Map<RecipientId, Integer> getInboxPositions() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(MESSAGE_COUNT + " != ?", 0);
Map<RecipientId, Integer> positions = new HashMap<>();
try (Cursor cursor = db.rawQuery(query, new String[] { "0" })) {
int i = 0;
while (cursor != null && cursor.moveToNext()) {
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)));
positions.put(recipientId, i);
i++;
}
}
return positions;
}
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 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 long getThreadIdIfExistsFor(Recipient recipient) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String where = RECIPIENT_ID + " = ?";
String[] recipientsArg = new String[] {recipient.getId().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 getThreadIdFor(@NonNull Recipient recipient) {
return getThreadIdFor(recipient, DistributionTypes.DEFAULT);
}
public long getThreadIdFor(@NonNull Recipient recipient, int distributionType) {
Long threadId = getThreadIdFor(recipient.getId());
if (threadId != null) {
return threadId;
} else {
return createThreadForRecipient(recipient.getId(), recipient.isGroup(), distributionType);
}
}
public Long getThreadIdFor(@NonNull RecipientId recipientId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String where = RECIPIENT_ID + " = ?";
String[] recipientsArg = new String[]{recipientId.serialize()};
try (Cursor 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 null;
}
}
}
public @Nullable RecipientId getRecipientIdForThreadId(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, null, ID + " = ?", new String[]{ threadId + "" }, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)));
}
}
return null;
}
public @Nullable Recipient getRecipientForThreadId(long threadId) {
RecipientId id = getRecipientIdForThreadId(threadId);
if (id == null) return null;
return Recipient.resolved(id);
}
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),
getContentTypeFor(record), getExtrasFor(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();
}
}
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
if (messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getSharedContacts().size() > 0) {
Contact contact = ((MmsMessageRecord) messageRecord).getSharedContacts().get(0);
return ContactUtil.getStringSummary(context, contact).toString();
}
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 = Optional.fromNullable(slideDeck.getThumbnailSlide()).or(Optional.fromNullable(slideDeck.getStickerSlide())).orNull();
if (thumbnail != null && !((MmsMessageRecord) record).isViewOnce()) {
return thumbnail.getThumbnailUri();
}
return null;
}
private @Nullable String getContentTypeFor(MessageRecord record) {
if (record.isMms()) {
SlideDeck slideDeck = ((MmsMessageRecord) record).getSlideDeck();
if (slideDeck.getSlides().size() > 0) {
return slideDeck.getSlides().get(0).getContentType();
}
}
return null;
}
private @Nullable Extra getExtrasFor(MessageRecord record) {
if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) {
return Extra.forRevealableMessage();
} else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) {
return Extra.forSticker();
} else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) {
return Extra.forAlbum();
}
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 + "." + RECIPIENT_ID + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ID +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.RECIPIENT_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));
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)));
Recipient recipient = Recipient.live(recipientId).get();
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);
String contentType = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT_TYPE));
String extraString = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_EXTRAS));
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
}
Extra extra = null;
if (extraString != null) {
try {
extra = JsonUtils.fromJson(extraString, Extra.class);
} catch (IOException e) {
Log.w(TAG, "Failed to decode extras!");
}
}
return new ThreadRecord(body, snippetUri, contentType, extra, 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();
}
}
}
public static final class Extra {
@JsonProperty private final boolean isRevealable;
@JsonProperty private final boolean isSticker;
@JsonProperty private final boolean isAlbum;
public Extra(@JsonProperty("isRevealable") boolean isRevealable,
@JsonProperty("isSticker") boolean isSticker,
@JsonProperty("isAlbum") boolean isAlbum)
{
this.isRevealable = isRevealable;
this.isSticker = isSticker;
this.isAlbum = isAlbum;
}
public static @NonNull Extra forRevealableMessage() {
return new Extra(true, false, false);
}
public static @NonNull Extra forSticker() {
return new Extra(false, true, false);
}
public static @NonNull Extra forAlbum() {
return new Extra(false, false, true);
}
public boolean isRevealable() {
return isRevealable;
}
public boolean isSticker() {
return isSticker;
}
public boolean isAlbum() {
return isAlbum;
}
}
}

View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;"));
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();
}
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.database.documents;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
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.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import java.io.IOException;
import java.util.Objects;
public class IdentityKeyMismatch {
private static final String TAG = IdentityKeyMismatch.class.getSimpleName();
/** DEPRECATED */
@JsonProperty(value = "a")
private String address;
@JsonProperty(value = "r")
private String recipientId;
@JsonProperty(value = "k")
@JsonSerialize(using = IdentityKeySerializer.class)
@JsonDeserialize(using = IdentityKeyDeserializer.class)
private IdentityKey identityKey;
public IdentityKeyMismatch() {}
public IdentityKeyMismatch(RecipientId recipientId, IdentityKey identityKey) {
this.recipientId = recipientId.serialize();
this.address = "";
this.identityKey = identityKey;
}
@JsonIgnore
public RecipientId getRecipientId(@NonNull Context context) {
if (!TextUtils.isEmpty(recipientId)) {
return RecipientId.from(recipientId);
} else {
return Recipient.external(context, address).getId();
}
}
public IdentityKey getIdentityKey() {
return identityKey;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
IdentityKeyMismatch that = (IdentityKeyMismatch) o;
return Objects.equals(address, that.address) &&
Objects.equals(recipientId, that.recipientId) &&
Objects.equals(identityKey, that.identityKey);
}
@Override
public int hashCode() {
return Objects.hash(address, recipientId, identityKey);
}
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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.database.documents;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
public class NetworkFailure {
/** DEPRECATED */
@JsonProperty(value = "a")
private String address;
@JsonProperty(value = "r")
private String recipientId;
public NetworkFailure(@NonNull RecipientId recipientId) {
this.recipientId = recipientId.serialize();
this.address = "";
}
public NetworkFailure() {}
@JsonIgnore
public RecipientId getRecipientId(@NonNull Context context) {
if (!TextUtils.isEmpty(recipientId)) {
return RecipientId.from(recipientId);
} else {
return Recipient.external(context, address).getId();
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
NetworkFailure that = (NetworkFailure) o;
return Objects.equals(address, that.address) &&
Objects.equals(recipientId, that.recipientId);
}
@Override
public int hashCode() {
return Objects.hash(address, recipientId);
}
}

View File

@@ -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;
}
}

View File

@@ -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() {}
}
}

View File

@@ -0,0 +1,92 @@
package org.thoughtcrime.securesms.database.helpers;
import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.DelimiterUtil;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
public class RecipientIdCleanupHelper {
private static final String TAG = Log.tag(RecipientIdCleanupHelper.class);
public static void execute(@NonNull SQLiteDatabase db) {
Log.i(TAG, "Beginning migration.");
long startTime = System.currentTimeMillis();
Pattern pattern = Pattern.compile("^[0-9\\-+]+$");
Set<String> deletionCandidates = new HashSet<>();
try (Cursor cursor = db.query("recipient", new String[] { "_id", "phone" }, "group_id IS NULL AND email IS NULL", null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String id = cursor.getString(cursor.getColumnIndexOrThrow("_id"));
String phone = cursor.getString(cursor.getColumnIndexOrThrow("phone"));
if (TextUtils.isEmpty(phone) || !pattern.matcher(phone).matches()) {
Log.i(TAG, "Recipient ID " + id + " has non-numeric characters and can potentially be deleted.");
if (!isIdUsed(db, "identities", "address", id) &&
!isIdUsed(db, "sessions", "address", id) &&
!isIdUsed(db, "thread", "recipient_ids", id) &&
!isIdUsed(db, "sms", "address", id) &&
!isIdUsed(db, "mms", "address", id) &&
!isIdUsed(db, "mms", "quote_author", id) &&
!isIdUsed(db, "group_receipts", "address", id) &&
!isIdUsed(db, "groups", "recipient_id", id))
{
Log.i(TAG, "Determined ID " + id + " is unused in non-group membership. Marking for potential deletion.");
deletionCandidates.add(id);
} else {
Log.i(TAG, "Found that ID " + id + " is actually used in another table.");
}
}
}
}
Set<String> deletions = findUnusedInGroupMembership(db, deletionCandidates);
for (String deletion : deletions) {
Log.i(TAG, "Deleting ID " + deletion);
db.delete("recipient", "_id = ?", new String[] { String.valueOf(deletion) });
}
Log.i(TAG, "Migration took " + (System.currentTimeMillis() - startTime) + " ms.");
}
private static boolean isIdUsed(@NonNull SQLiteDatabase db, @NonNull String tableName, @NonNull String columnName, String id) {
try (Cursor cursor = db.query(tableName, new String[] { columnName }, columnName + " = ?", new String[] { id }, null, null, null, "1")) {
boolean used = cursor != null && cursor.moveToFirst();
if (used) {
Log.i(TAG, "Recipient " + id + " was used in (" + tableName + ", " + columnName + ")");
}
return used;
}
}
private static Set<String> findUnusedInGroupMembership(@NonNull SQLiteDatabase db, Set<String> candidates) {
Set<String> unused = new HashSet<>(candidates);
try (Cursor cursor = db.rawQuery("SELECT members FROM groups", null)) {
while (cursor != null && cursor.moveToNext()) {
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members"));
String[] members = DelimiterUtil.split(serializedMembers, ',');
for (String member : members) {
if (unused.remove(member)) {
Log.i(TAG, "Recipient " + member + " was found in a group membership list.");
}
}
}
}
return unused;
}
}

View File

@@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.database.helpers;
import android.content.ContentValues;
import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.util.DelimiterUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashSet;
import java.util.Set;
public class RecipientIdMigrationHelper {
private static final String TAG = Log.tag(RecipientIdMigrationHelper.class);
public static void execute(SQLiteDatabase db) {
Log.i(TAG, "Starting the recipient ID migration.");
long insertStart = System.currentTimeMillis();
Log.i(TAG, "Starting inserts for missing recipients.");
db.execSQL(buildInsertMissingRecipientStatement("identities", "address"));
db.execSQL(buildInsertMissingRecipientStatement("sessions", "address"));
db.execSQL(buildInsertMissingRecipientStatement("thread", "recipient_ids"));
db.execSQL(buildInsertMissingRecipientStatement("sms", "address"));
db.execSQL(buildInsertMissingRecipientStatement("mms", "address"));
db.execSQL(buildInsertMissingRecipientStatement("mms", "quote_author"));
db.execSQL(buildInsertMissingRecipientStatement("group_receipts", "address"));
db.execSQL(buildInsertMissingRecipientStatement("groups", "group_id"));
Log.i(TAG, "Finished inserts for missing recipients in " + (System.currentTimeMillis() - insertStart) + " ms.");
long updateMissingStart = System.currentTimeMillis();
Log.i(TAG, "Starting updates for invalid or missing addresses.");
db.execSQL(buildMissingAddressUpdateStatement("sms", "address"));
db.execSQL(buildMissingAddressUpdateStatement("mms", "address"));
db.execSQL(buildMissingAddressUpdateStatement("mms", "quote_author"));
Log.i(TAG, "Finished updates for invalid or missing addresses in " + (System.currentTimeMillis() - updateMissingStart) + " ms.");
db.execSQL("ALTER TABLE groups ADD COLUMN recipient_id INTEGER DEFAULT 0");
long updateStart = System.currentTimeMillis();
Log.i(TAG, "Starting recipient ID updates.");
db.execSQL(buildUpdateAddressToRecipientIdStatement("identities", "address"));
db.execSQL(buildUpdateAddressToRecipientIdStatement("sessions", "address"));
db.execSQL(buildUpdateAddressToRecipientIdStatement("thread", "recipient_ids"));
db.execSQL(buildUpdateAddressToRecipientIdStatement("sms", "address"));
db.execSQL(buildUpdateAddressToRecipientIdStatement("mms", "address"));
db.execSQL(buildUpdateAddressToRecipientIdStatement("mms", "quote_author"));
db.execSQL(buildUpdateAddressToRecipientIdStatement("group_receipts", "address"));
db.execSQL("UPDATE groups SET recipient_id = (SELECT _id FROM recipient_preferences WHERE recipient_preferences.recipient_ids = groups.group_id)");
Log.i(TAG, "Finished recipient ID updates in " + (System.currentTimeMillis() - updateStart) + " ms.");
// NOTE: Because there's an open cursor on the same table, inserts and updates aren't visible
// until afterwards, which is why this group stuff is split into multiple loops
long findGroupStart = System.currentTimeMillis();
Log.i(TAG, "Starting to find missing group recipients.");
Set<String> missingGroupMembers = new HashSet<>();
try (Cursor cursor = db.rawQuery("SELECT members FROM groups", null)) {
while (cursor != null && cursor.moveToNext()) {
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members"));
String[] members = DelimiterUtil.split(serializedMembers, ',');
for (String rawMember : members) {
String member = DelimiterUtil.unescape(rawMember, ',');
if (!TextUtils.isEmpty(member) && !recipientExists(db, member)) {
missingGroupMembers.add(member);
}
}
}
}
Log.i(TAG, "Finished finding " + missingGroupMembers.size() + " missing group recipients in " + (System.currentTimeMillis() - findGroupStart) + " ms.");
long insertGroupStart = System.currentTimeMillis();
Log.i(TAG, "Starting the insert of missing group recipients.");
for (String member : missingGroupMembers) {
ContentValues values = new ContentValues();
values.put("recipient_ids", member);
db.insert("recipient_preferences", null, values);
}
Log.i(TAG, "Finished inserting missing group recipients in " + (System.currentTimeMillis() - insertGroupStart) + " ms.");
long updateGroupStart = System.currentTimeMillis();
Log.i(TAG, "Starting group recipient ID updates.");
try (Cursor cursor = db.rawQuery("SELECT _id, members FROM groups", null)) {
while (cursor != null && cursor.moveToNext()) {
long groupId = cursor.getLong(cursor.getColumnIndexOrThrow("_id"));
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members"));
String[] members = DelimiterUtil.split(serializedMembers, ',');
long[] memberIds = new long[members.length];
for (int i = 0; i < members.length; i++) {
String member = DelimiterUtil.unescape(members[i], ',');
memberIds[i] = requireRecipientId(db, member);
}
String serializedMemberIds = Util.join(memberIds, ",");
db.execSQL("UPDATE groups SET members = ? WHERE _id = ?", new String[]{ serializedMemberIds, String.valueOf(groupId) });
}
}
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON groups (recipient_id)");
Log.i(TAG, "Finished group recipient ID updates in " + (System.currentTimeMillis() - updateGroupStart) + " ms.");
long tableCopyStart = System.currentTimeMillis();
Log.i(TAG, "Starting to copy the recipient table.");
db.execSQL("CREATE TABLE recipient (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"uuid TEXT UNIQUE DEFAULT NULL, " +
"phone TEXT UNIQUE DEFAULT NULL, " +
"email TEXT UNIQUE DEFAULT NULL, " +
"group_id TEXT UNIQUE DEFAULT NULL, " +
"blocked INTEGER DEFAULT 0, " +
"message_ringtone TEXT DEFAULT NULL, " +
"message_vibrate INTEGER DEFAULT 0, " +
"call_ringtone TEXT DEFAULT NULL, " +
"call_vibrate INTEGER DEFAULT 0, " +
"notification_channel TEXT DEFAULT NULL, " +
"mute_until INTEGER DEFAULT 0, " +
"color TEXT DEFAULT NULL, " +
"seen_invite_reminder INTEGER DEFAULT 0, " +
"default_subscription_id INTEGER DEFAULT -1, " +
"message_expiration_time 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, " +
"unidentified_access_mode INTEGER DEFAULT 0, " +
"force_sms_selection INTEGER DEFAULT 0)");
try (Cursor cursor = db.query("recipient_preferences", null, null, null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String address = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids"));
boolean isGroup = GroupUtil.isEncodedGroup(address);
boolean isEmail = !isGroup && NumberUtil.isValidEmail(address);
boolean isPhone = !isGroup && !isEmail;
ContentValues values = new ContentValues();
values.put("_id", cursor.getLong(cursor.getColumnIndexOrThrow("_id")));
values.put("uuid", (String) null);
values.put("phone", isPhone ? address : null);
values.put("email", isEmail ? address : null);
values.put("group_id", isGroup ? address : null);
values.put("blocked", cursor.getInt(cursor.getColumnIndexOrThrow("block")));
values.put("message_ringtone", cursor.getString(cursor.getColumnIndexOrThrow("notification")));
values.put("message_vibrate", cursor.getString(cursor.getColumnIndexOrThrow("vibrate")));
values.put("call_ringtone", cursor.getString(cursor.getColumnIndexOrThrow("call_ringtone")));
values.put("call_vibrate", cursor.getString(cursor.getColumnIndexOrThrow("call_vibrate")));
values.put("notification_channel", cursor.getString(cursor.getColumnIndexOrThrow("notification_channel")));
values.put("mute_until", cursor.getLong(cursor.getColumnIndexOrThrow("mute_until")));
values.put("color", cursor.getString(cursor.getColumnIndexOrThrow("color")));
values.put("seen_invite_reminder", cursor.getInt(cursor.getColumnIndexOrThrow("seen_invite_reminder")));
values.put("default_subscription_id", cursor.getInt(cursor.getColumnIndexOrThrow("default_subscription_id")));
values.put("message_expiration_time", cursor.getInt(cursor.getColumnIndexOrThrow("expire_messages")));
values.put("registered", cursor.getInt(cursor.getColumnIndexOrThrow("registered")));
values.put("system_display_name", cursor.getString(cursor.getColumnIndexOrThrow("system_display_name")));
values.put("system_photo_uri", cursor.getString(cursor.getColumnIndexOrThrow("system_contact_photo")));
values.put("system_phone_label", cursor.getString(cursor.getColumnIndexOrThrow("system_phone_label")));
values.put("system_contact_uri", cursor.getString(cursor.getColumnIndexOrThrow("system_contact_uri")));
values.put("profile_key", cursor.getString(cursor.getColumnIndexOrThrow("profile_key")));
values.put("signal_profile_name", cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_name")));
values.put("signal_profile_avatar", cursor.getString(cursor.getColumnIndexOrThrow("signal_profile_avatar")));
values.put("profile_sharing", cursor.getInt(cursor.getColumnIndexOrThrow("profile_sharing_approval")));
values.put("unidentified_access_mode", cursor.getInt(cursor.getColumnIndexOrThrow("unidentified_access_mode")));
values.put("force_sms_selection", cursor.getInt(cursor.getColumnIndexOrThrow("force_sms_selection")));
db.insert("recipient", null, values);
}
}
db.execSQL("DROP TABLE recipient_preferences");
Log.i(TAG, "Finished copying the recipient table in " + (System.currentTimeMillis() - tableCopyStart) + " ms.");
long sanityCheckStart = System.currentTimeMillis();
Log.i(TAG, "Starting DB integrity sanity checks.");
assertEmptyQuery(db, "identities", buildSanityCheckQuery("identities", "address"));
assertEmptyQuery(db, "sessions", buildSanityCheckQuery("sessions", "address"));
assertEmptyQuery(db, "groups", buildSanityCheckQuery("groups", "recipient_id"));
assertEmptyQuery(db, "thread", buildSanityCheckQuery("thread", "recipient_ids"));
assertEmptyQuery(db, "sms", buildSanityCheckQuery("sms", "address"));
assertEmptyQuery(db, "mms -- address", buildSanityCheckQuery("mms", "address"));
assertEmptyQuery(db, "mms -- quote_author", buildSanityCheckQuery("mms", "quote_author"));
assertEmptyQuery(db, "group_receipts", buildSanityCheckQuery("group_receipts", "address"));
Log.i(TAG, "Finished DB integrity sanity checks in " + (System.currentTimeMillis() - sanityCheckStart) + " ms.");
Log.i(TAG, "Finished recipient ID migration in " + (System.currentTimeMillis() - insertStart) + " ms.");
}
private static String buildUpdateAddressToRecipientIdStatement(@NonNull String table, @NonNull String addressColumn) {
return "UPDATE " + table + " SET " + addressColumn + "=(SELECT _id " +
"FROM recipient_preferences " +
"WHERE recipient_preferences.recipient_ids = " + table + "." + addressColumn + ")";
}
private static String buildInsertMissingRecipientStatement(@NonNull String table, @NonNull String addressColumn) {
return "INSERT INTO recipient_preferences(recipient_ids) SELECT DISTINCT " + addressColumn + " " +
"FROM " + table + " " +
"WHERE " + addressColumn + " != '' AND " +
addressColumn + " != 'insert-address-column' AND " +
addressColumn + " NOT NULL AND " +
addressColumn + " NOT IN (SELECT recipient_ids FROM recipient_preferences)";
}
private static String buildMissingAddressUpdateStatement(@NonNull String table, @NonNull String addressColumn) {
return "UPDATE " + table + " SET " + addressColumn + " = -1 " +
"WHERE " + addressColumn + " = '' OR " +
addressColumn + " IS NULL OR " +
addressColumn + " = 'insert-address-token'";
}
private static boolean recipientExists(@NonNull SQLiteDatabase db, @NonNull String address) {
return getRecipientId(db, address) != null;
}
private static @Nullable Long getRecipientId(@NonNull SQLiteDatabase db, @NonNull String address) {
try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient_preferences WHERE recipient_ids = ?", new String[]{ address })) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndex("_id"));
} else {
return null;
}
}
}
private static long requireRecipientId(@NonNull SQLiteDatabase db, @NonNull String address) {
Long id = getRecipientId(db, address);
if (id != null) {
return id;
} else {
throw new MissingRecipientError(address);
}
}
private static String buildSanityCheckQuery(@NonNull String table, @NonNull String idColumn) {
return "SELECT " + idColumn + " FROM " + table + " WHERE " + idColumn + " != -1 AND " + idColumn + " NOT IN (SELECT _id FROM recipient)";
}
private static void assertEmptyQuery(@NonNull SQLiteDatabase db, @NonNull String tag, @NonNull String query) {
try (Cursor cursor = db.rawQuery(query, null)) {
if (cursor != null && cursor.moveToFirst()) {
throw new FailedSanityCheckError(tag);
}
}
}
private static final class MissingRecipientError extends AssertionError {
MissingRecipientError(@NonNull String address) {
super("Could not find recipient with address " + address);
}
}
private static final class FailedSanityCheckError extends AssertionError {
FailedSanityCheckError(@NonNull String tableName) {
super("Sanity check failed for tag '" + tableName + "'");
}
}
}

View File

@@ -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.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.migrations.LegacyMigrationJob;
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();
int foregroundId = GenericForegroundService.startForegroundTask(context, context.getString(R.string.SQLCipherMigrationHelper_migrating_signal_database)).getId();
try {
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, foregroundId);
}
}
public static void migrateCiphertext(@NonNull Context context,
@NonNull MasterSecret masterSecret,
@NonNull android.database.sqlite.SQLiteDatabase legacyDb,
@NonNull net.sqlcipher.database.SQLiteDatabase modernDb,
@Nullable LegacyMigrationJob.DatabaseUpgradeListener listener)
{
MasterCipher legacyCipher = new MasterCipher(masterSecret);
AsymmetricMasterCipher legacyAsymmetricCipher = new AsymmetricMasterCipher(MasterSecretUtil.getAsymmetricMasterSecret(context, masterSecret));
modernDb.beginTransaction();
int foregroundId = GenericForegroundService.startForegroundTask(context, context.getString(R.string.SQLCipherMigrationHelper_migrating_signal_database)).getId();
try {
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, foregroundId);
}
}
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);
}
}

View File

@@ -0,0 +1,719 @@
package org.thoughtcrime.securesms.database.helpers;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import com.bumptech.glide.Glide;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteDatabaseHook;
import net.sqlcipher.database.SQLiteOpenHelper;
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper;
import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.crypto.MasterSecret;
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.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.StorageKeyDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.io.File;
import java.util.List;
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 REVEALABLE_MESSAGES = 22;
private static final int VIEW_ONCE_ONLY = 23;
private static final int RECIPIENT_IDS = 24;
private static final int RECIPIENT_SEARCH = 25;
private static final int RECIPIENT_CLEANUP = 26;
private static final int MMS_RECIPIENT_CLEANUP = 27;
private static final int ATTACHMENT_HASHING = 28;
private static final int NOTIFICATION_RECIPIENT_IDS = 29;
private static final int BLUR_HASH = 30;
private static final int MMS_RECIPIENT_CLEANUP_2 = 31;
private static final int ATTACHMENT_TRANSFORM_PROPERTIES = 32;
private static final int ATTACHMENT_CLEAR_HASHES = 33;
private static final int ATTACHMENT_CLEAR_HASHES_2 = 34;
private static final int UUIDS = 35;
private static final int USERNAMES = 36;
private static final int REACTIONS = 37;
private static final int STORAGE_SERVICE = 38;
private static final int REACTIONS_UNREAD_INDEX = 39;
private static final int RESUMABLE_DOWNLOADS = 40;
private static final int DATABASE_VERSION = 40;
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);
db.execSQL(StickerDatabase.CREATE_TABLE);
db.execSQL(StorageKeyDatabase.CREATE_TABLE);
executeStatements(db, SearchDatabase.CREATE_TABLE);
executeStatements(db, JobDatabase.CREATE_TABLE);
executeStatements(db, RecipientDatabase.CREATE_INDEXS);
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);
executeStatements(db, StorageKeyDatabase.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)) {
ApplicationDependencies.getJobManager().add(new RefreshPreKeysJob());
}
SessionStoreMigrationHelper.migrateSessions(context, db);
PreKeyMigrationHelper.cleanUpPreKeys(context);
}
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.i(TAG, "Upgrading database: " + oldVersion + ", " + newVersion);
long startTime = System.currentTimeMillis();
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)) {
ApplicationDependencies.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 && !SqlUtil.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 && !SqlUtil.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 rawAddress = cursor.getString(cursor.getColumnIndexOrThrow("recipient_ids"));
String address = PhoneNumberFormatter.get(context).format(rawAddress);
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, null, address);
boolean vibrateEnabled = vibrateState == 0 ? TextSecurePreferences.isNotificationVibrateEnabled(context) : vibrateState == 1;
if (GroupUtil.isEncodedGroup(address)) {
try(Cursor groupCursor = db.rawQuery("SELECT title FROM groups WHERE group_id = ?", new String[] { address })) {
if (groupCursor != null && groupCursor.moveToFirst()) {
String title = groupCursor.getString(groupCursor.getColumnIndexOrThrow("title"));
if (!TextUtils.isEmpty(title)) {
displayName = title;
}
}
}
}
String channelId = NotificationChannels.createChannelFor(context, "contact_" + address + "_" + System.currentTimeMillis(), displayName, messageSoundUri, vibrateEnabled);
ContentValues values = new ContentValues(1);
values.put("notification_channel", channelId);
db.update("recipient_preferences", values, "recipient_ids = ?", new String[] { rawAddress });
}
}
}
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 (!SqlUtil.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 < REVEALABLE_MESSAGES) {
db.execSQL("ALTER TABLE mms ADD COLUMN reveal_duration INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE mms ADD COLUMN reveal_start_time INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE thread ADD COLUMN snippet_content_type TEXT DEFAULT NULL");
db.execSQL("ALTER TABLE thread ADD COLUMN snippet_extras TEXT DEFAULT NULL");
}
if (oldVersion < VIEW_ONCE_ONLY) {
db.execSQL("UPDATE mms SET reveal_duration = 1 WHERE reveal_duration > 0");
db.execSQL("UPDATE mms SET reveal_start_time = 0");
}
if (oldVersion < RECIPIENT_IDS) {
RecipientIdMigrationHelper.execute(db);
}
if (oldVersion < RECIPIENT_SEARCH) {
db.execSQL("ALTER TABLE recipient ADD COLUMN system_phone_type INTEGER DEFAULT -1");
String localNumber = TextSecurePreferences.getLocalNumber(context);
if (!TextUtils.isEmpty(localNumber)) {
try (Cursor cursor = db.query("recipient", null, "phone = ?", new String[] { localNumber }, null, null, null)) {
if (cursor == null || !cursor.moveToFirst()) {
ContentValues values = new ContentValues();
values.put("phone", localNumber);
values.put("registered", 1);
values.put("profile_sharing", 1);
values.put("signal_profile_name", TextSecurePreferences.getProfileName(context));
db.insert("recipient", null, values);
} else {
db.execSQL("UPDATE recipient SET registered = ?, profile_sharing = ?, signal_profile_name = ? WHERE phone = ?",
new String[] { "1", "1", TextSecurePreferences.getProfileName(context), localNumber });
}
}
}
}
if (oldVersion < RECIPIENT_CLEANUP) {
RecipientIdCleanupHelper.execute(db);
}
if (oldVersion < MMS_RECIPIENT_CLEANUP) {
ContentValues values = new ContentValues(1);
values.put("address", "-1");
int count = db.update("mms", values, "address = ?", new String[] { "0" });
Log.i(TAG, "MMS recipient cleanup updated " + count + " rows.");
}
if (oldVersion < ATTACHMENT_HASHING) {
db.execSQL("ALTER TABLE part ADD COLUMN data_hash TEXT DEFAULT NULL");
db.execSQL("CREATE INDEX IF NOT EXISTS part_data_hash_index ON part (data_hash)");
}
if (oldVersion < NOTIFICATION_RECIPIENT_IDS && Build.VERSION.SDK_INT >= 26) {
NotificationManager notificationManager = ServiceUtil.getNotificationManager(context);
List<NotificationChannel> channels = Stream.of(notificationManager.getNotificationChannels())
.filter(c -> c.getId().startsWith("contact_"))
.toList();
Log.i(TAG, "Migrating " + channels.size() + " channels to use RecipientId's.");
for (NotificationChannel oldChannel : channels) {
notificationManager.deleteNotificationChannel(oldChannel.getId());
int startIndex = "contact_".length();
int endIndex = oldChannel.getId().lastIndexOf("_");
String address = oldChannel.getId().substring(startIndex, endIndex);
String recipientId;
try (Cursor cursor = db.query("recipient", new String[] { "_id" }, "phone = ? OR email = ? OR group_id = ?", new String[] { address, address, address}, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
recipientId = cursor.getString(cursor.getColumnIndexOrThrow("_id"));
} else {
Log.w(TAG, "Couldn't find recipient for address: " + address);
continue;
}
}
String newId = "contact_" + recipientId + "_" + System.currentTimeMillis();
NotificationChannel newChannel = new NotificationChannel(newId, oldChannel.getName(), oldChannel.getImportance());
Log.i(TAG, "Updating channel ID from '" + oldChannel.getId() + "' to '" + newChannel.getId() + "'.");
newChannel.setGroup(oldChannel.getGroup());
newChannel.setSound(oldChannel.getSound(), oldChannel.getAudioAttributes());
newChannel.setBypassDnd(oldChannel.canBypassDnd());
newChannel.enableVibration(oldChannel.shouldVibrate());
newChannel.setVibrationPattern(oldChannel.getVibrationPattern());
newChannel.setLockscreenVisibility(oldChannel.getLockscreenVisibility());
newChannel.setShowBadge(oldChannel.canShowBadge());
newChannel.setLightColor(oldChannel.getLightColor());
newChannel.enableLights(oldChannel.shouldShowLights());
notificationManager.createNotificationChannel(newChannel);
ContentValues contentValues = new ContentValues(1);
contentValues.put("notification_channel", newChannel.getId());
db.update("recipient", contentValues, "_id = ?", new String[] { recipientId });
}
}
if (oldVersion < BLUR_HASH) {
db.execSQL("ALTER TABLE part ADD COLUMN blur_hash TEXT DEFAULT NULL");
}
if (oldVersion < MMS_RECIPIENT_CLEANUP_2) {
ContentValues values = new ContentValues(1);
values.put("address", "-1");
int count = db.update("mms", values, "address = ? OR address IS NULL", new String[] { "0" });
Log.i(TAG, "MMS recipient cleanup 2 updated " + count + " rows.");
}
if (oldVersion < ATTACHMENT_TRANSFORM_PROPERTIES) {
db.execSQL("ALTER TABLE part ADD COLUMN transform_properties TEXT DEFAULT NULL");
}
if (oldVersion < ATTACHMENT_CLEAR_HASHES) {
db.execSQL("UPDATE part SET data_hash = null");
}
if (oldVersion < ATTACHMENT_CLEAR_HASHES_2) {
db.execSQL("UPDATE part SET data_hash = null");
Glide.get(context).clearDiskCache();
}
if (oldVersion < UUIDS) {
db.execSQL("ALTER TABLE recipient ADD COLUMN uuid_supported INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE push ADD COLUMN source_uuid TEXT DEFAULT NULL");
}
if (oldVersion < USERNAMES) {
db.execSQL("ALTER TABLE recipient ADD COLUMN username TEXT DEFAULT NULL");
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS recipient_username_index ON recipient (username)");
}
if (oldVersion < REACTIONS) {
db.execSQL("ALTER TABLE sms ADD COLUMN reactions BLOB DEFAULT NULL");
db.execSQL("ALTER TABLE mms ADD COLUMN reactions BLOB DEFAULT NULL");
db.execSQL("ALTER TABLE sms ADD COLUMN reactions_unread INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE mms ADD COLUMN reactions_unread INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE sms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1");
db.execSQL("ALTER TABLE mms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1");
}
if (oldVersion < STORAGE_SERVICE) {
db.execSQL("CREATE TABLE storage_key (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"type INTEGER, " +
"key TEXT UNIQUE)");
db.execSQL("CREATE INDEX IF NOT EXISTS storage_key_type_index ON storage_key (type)");
db.execSQL("ALTER TABLE recipient ADD COLUMN system_info_pending INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE recipient ADD COLUMN storage_service_key TEXT DEFAULT NULL");
db.execSQL("ALTER TABLE recipient ADD COLUMN dirty INTEGER DEFAULT 0");
db.execSQL("CREATE UNIQUE INDEX recipient_storage_service_key ON recipient (storage_service_key)");
db.execSQL("CREATE INDEX recipient_dirty_index ON recipient (dirty)");
// TODO [greyson] Do this in a future DB migration
// db.execSQL("UPDATE recipient SET dirty = 2 WHERE registered = 1");
//
// try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient WHERE registered = 1", null)) {
// while (cursor != null && cursor.moveToNext()) {
// String id = cursor.getString(cursor.getColumnIndexOrThrow("_id"));
// ContentValues values = new ContentValues(1);
//
// values.put("storage_service_key", Base64.encodeBytes(StorageSyncHelper.generateKey()));
//
// db.update("recipient", values, "_id = ?", new String[] { id });
// }
// }
}
if (oldVersion < REACTIONS_UNREAD_INDEX) {
db.execSQL("CREATE INDEX IF NOT EXISTS sms_reactions_unread_index ON sms (reactions_unread);");
db.execSQL("CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON mms (reactions_unread);");
}
if (oldVersion < RESUMABLE_DOWNLOADS) {
db.execSQL("ALTER TABLE part ADD COLUMN transfer_file TEXT DEFAULT NULL");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
if (oldVersion < MIGRATE_PREKEYS_VERSION) {
PreKeyMigrationHelper.cleanUpPreKeys(context);
}
Log.i(TAG, "Upgrade complete. Took " + (System.currentTimeMillis() - startTime) + " ms.");
}
public SQLiteDatabase getReadableDatabase() {
return getReadableDatabase(databaseSecret.asString());
}
public SQLiteDatabase getWritableDatabase() {
return getWritableDatabase(databaseSecret.asString());
}
public void markCurrent(SQLiteDatabase db) {
db.setVersion(DATABASE_VERSION);
}
public static boolean databaseFileExists(@NonNull Context context) {
return context.getDatabasePath(DATABASE_NAME).exists();
}
private void executeStatements(SQLiteDatabase db, String[] statements) {
for (String statement : statements)
db.execSQL(statement);
}
}

View File

@@ -0,0 +1,108 @@
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.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("[.]");
String address = 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.RECIPIENT_ID, address);
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);
}
}

View File

@@ -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.resolved(identityRecord.getRecipientId()));
}
}
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.resolved(identityRecord.getRecipientId()));
}
}
return unverified;
}
private boolean isUntrusted(IdentityRecord identityRecord) {
return !identityRecord.isApprovedNonBlocking() &&
System.currentTimeMillis() - identityRecord.getTimestamp() < TimeUnit.SECONDS.toMillis(5);
}
}

View File

@@ -0,0 +1,20 @@
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();
}
}

View File

@@ -0,0 +1,83 @@
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.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
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.RECIPIENT_ID, ThreadDatabase.SNIPPET, ThreadDatabase.READ, ThreadDatabase.UNREAD_COUNT,
ThreadDatabase.TYPE, ThreadDatabase.SNIPPET_TYPE, ThreadDatabase.SNIPPET_URI,
ThreadDatabase.SNIPPET_CONTENT_TYPE, ThreadDatabase.SNIPPET_EXTRAS,
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, null, null, 0, -1, 0, 0, 0, -1});
}
switchToArchiveCursor.addRow(new Object[] {-1L, System.currentTimeMillis(), archivedCount,
"-1", null, 1, 0, ThreadDatabase.DistributionTypes.ARCHIVE,
0, null, null, 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<RecipientId> recipientIds = new LinkedList<>();
for (String number : numbers) {
recipientIds.add(Recipient.external(context, number).getId());
}
return DatabaseFactory.getThreadDatabase(context).getFilteredConversationList(recipientIds);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import androidx.loader.content.AsyncTaskLoader;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public final class CountryListLoader extends AsyncTaskLoader<ArrayList<Map<String, String>>> {
public CountryListLoader(Context context) {
super(context);
}
@Override
public ArrayList<Map<String, String>> loadInBackground() {
Set<String> regions = PhoneNumberUtil.getInstance().getSupportedRegions();
ArrayList<Map<String, String>> results = new ArrayList<>(regions.size());
for (String region : regions) {
Map<String, String> data = new HashMap<>(2);
data.put("country_name", PhoneNumberFormatter.getRegionDisplayName(region));
data.put("country_code", "+" +PhoneNumberUtil.getInstance().getCountryCodeForRegion(region));
results.add(data);
}
Collections.sort(results, new RegionComparator());
return results;
}
private static class RegionComparator implements Comparator<Map<String, String>> {
private final Collator collator;
RegionComparator() {
collator = Collator.getInstance();
collator.setStrength(Collator.PRIMARY);
}
@Override
public int compare(Map<String, String> lhs, Map<String, String> rhs) {
String a = lhs.get("country_name");
String b = rhs.get("country_name");
return collator.compare(a, b);
}
}
}

View File

@@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.devicelist.Device;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.AsyncLoader;
import org.thoughtcrime.securesms.util.Base64;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.ecc.Curve;
import org.whispersystems.libsignal.ecc.ECPrivateKey;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import org.whispersystems.libsignal.util.ByteUtil;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import static org.thoughtcrime.securesms.devicelist.DeviceNameProtos.*;
public class DeviceListLoader extends AsyncLoader<List<Device>> {
private static final String TAG = DeviceListLoader.class.getSimpleName();
private final SignalServiceAccountManager accountManager;
public DeviceListLoader(Context context, SignalServiceAccountManager accountManager) {
super(context);
this.accountManager = accountManager;
}
@Override
public List<Device> loadInBackground() {
try {
List<Device> devices = Stream.of(accountManager.getDevices())
.filter(d -> d.getId() != SignalServiceAddress.DEFAULT_DEVICE_ID)
.map(this::mapToDevice)
.toList();
Collections.sort(devices, new DeviceComparator());
return devices;
} catch (IOException e) {
Log.w(TAG, e);
return null;
}
}
private Device mapToDevice(@NonNull DeviceInfo deviceInfo) {
try {
if (TextUtils.isEmpty(deviceInfo.getName()) || deviceInfo.getName().length() < 4) {
throw new IOException("Invalid DeviceInfo name.");
}
DeviceName deviceName = DeviceName.parseFrom(Base64.decode(deviceInfo.getName()));
if (!deviceName.hasCiphertext() || !deviceName.hasEphemeralPublic() || !deviceName.hasSyntheticIv()) {
throw new IOException("Got a DeviceName that wasn't properly populated.");
}
byte[] syntheticIv = deviceName.getSyntheticIv().toByteArray();
byte[] cipherText = deviceName.getCiphertext().toByteArray();
ECPrivateKey identityKey = IdentityKeyUtil.getIdentityKeyPair(getContext()).getPrivateKey();
ECPublicKey ephemeralPublic = Curve.decodePoint(deviceName.getEphemeralPublic().toByteArray(), 0);
byte[] masterSecret = Curve.calculateAgreement(ephemeralPublic, identityKey);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(masterSecret, "HmacSHA256"));
byte[] cipherKeyPart1 = mac.doFinal("cipher".getBytes());
mac.init(new SecretKeySpec(cipherKeyPart1, "HmacSHA256"));
byte[] cipherKey = mac.doFinal(syntheticIv);
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(new byte[16]));
final byte[] plaintext = cipher.doFinal(cipherText);
mac.init(new SecretKeySpec(masterSecret, "HmacSHA256"));
byte[] verificationPart1 = mac.doFinal("auth".getBytes());
mac.init(new SecretKeySpec(verificationPart1, "HmacSHA256"));
byte[] verificationPart2 = mac.doFinal(plaintext);
byte[] ourSyntheticIv = ByteUtil.trim(verificationPart2, 16);
if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) {
throw new GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.");
}
return new Device(deviceInfo.getId(), new String(plaintext), deviceInfo.getCreated(), deviceInfo.getLastSeen());
} catch (IOException e) {
Log.w(TAG, "Failed while reading the protobuf.", e);
} catch (GeneralSecurityException | InvalidKeyException e) {
Log.w(TAG, "Failed during decryption.", e);
}
return new Device(deviceInfo.getId(), deviceInfo.getName(), deviceInfo.getCreated(), deviceInfo.getLastSeen());
}
private static class DeviceComparator implements Comparator<Device> {
@Override
public int compare(Device lhs, Device rhs) {
if (lhs.getCreated() < rhs.getCreated()) return -1;
else if (lhs.getCreated() != rhs.getCreated()) return 1;
else return 0;
}
}
}

View File

@@ -0,0 +1,313 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.util.SparseArray;
import androidx.annotation.NonNull;
import androidx.loader.content.AsyncTaskLoader;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.CalendarDateOnly;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
public final class GroupedThreadMediaLoader extends AsyncTaskLoader<GroupedThreadMediaLoader.GroupedThreadMedia> {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(GroupedThreadMediaLoader.class);
private final ContentObserver observer;
private final MediaLoader.MediaType mediaType;
private final MediaDatabase.Sorting sorting;
private final long threadId;
public GroupedThreadMediaLoader(@NonNull Context context,
long threadId,
@NonNull MediaLoader.MediaType mediaType,
@NonNull MediaDatabase.Sorting sorting)
{
super(context);
this.threadId = threadId;
this.mediaType = mediaType;
this.sorting = sorting;
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 GroupedThreadMedia loadInBackground() {
Context context = getContext();
GroupingMethod groupingMethod = sorting.isRelatedToFileSize()
? new RoughSizeGroupingMethod(context)
: new DateGroupingMethod(context, CalendarDateOnly.getInstance());
PopulatedGroupedThreadMedia mediaGrouping = new PopulatedGroupedThreadMedia(groupingMethod);
DatabaseFactory.getMediaDatabase(context).subscribeToMediaChanges(observer);
try (Cursor cursor = ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting)) {
while (cursor != null && cursor.moveToNext()) {
mediaGrouping.add(MediaDatabase.MediaRecord.from(context, cursor));
}
}
if (sorting == MediaDatabase.Sorting.Oldest || sorting == MediaDatabase.Sorting.Largest) {
return new ReversedGroupedThreadMedia(mediaGrouping);
} else {
return mediaGrouping;
}
}
public interface GroupingMethod {
int groupForRecord(@NonNull MediaDatabase.MediaRecord mediaRecord);
@NonNull String groupName(int groupNo);
}
public static class DateGroupingMethod implements GroupingMethod {
private final Context context;
private final long yesterdayStart;
private final long todayStart;
private final long thisWeekStart;
private final long thisMonthStart;
private static final int TODAY = Integer.MIN_VALUE;
private static final int YESTERDAY = Integer.MIN_VALUE + 1;
private static final int THIS_WEEK = Integer.MIN_VALUE + 2;
private static final int THIS_MONTH = Integer.MIN_VALUE + 3;
DateGroupingMethod(@NonNull Context context, @NonNull Calendar today) {
this.context = context;
todayStart = today.getTimeInMillis();
yesterdayStart = getTimeInMillis(today, Calendar.DAY_OF_YEAR, -1);
thisWeekStart = getTimeInMillis(today, Calendar.DAY_OF_YEAR, -6);
thisMonthStart = getTimeInMillis(today, Calendar.DAY_OF_YEAR, -30);
}
private static long getTimeInMillis(@NonNull Calendar now, int field, int offset) {
Calendar copy = (Calendar) now.clone();
copy.add(field, offset);
return copy.getTimeInMillis();
}
@Override
public int groupForRecord(@NonNull MediaDatabase.MediaRecord mediaRecord) {
long date = mediaRecord.getDate();
if (date > todayStart) return TODAY;
if (date > yesterdayStart) return YESTERDAY;
if (date > thisWeekStart) return THIS_WEEK;
if (date > thisMonthStart) return THIS_MONTH;
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(date);
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH);
return -(year * 12 + month);
}
@Override
public @NonNull String groupName(int groupNo) {
switch (groupNo) {
case TODAY:
return context.getString(R.string.BucketedThreadMedia_Today);
case YESTERDAY:
return context.getString(R.string.BucketedThreadMedia_Yesterday);
case THIS_WEEK:
return context.getString(R.string.BucketedThreadMedia_This_week);
case THIS_MONTH:
return context.getString(R.string.BucketedThreadMedia_This_month);
default:
int yearAndMonth = -groupNo;
int month = yearAndMonth % 12;
int year = yearAndMonth / 12;
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, year);
calendar.set(Calendar.MONTH, month);
return new SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(calendar.getTime());
}
}
}
public static class RoughSizeGroupingMethod implements GroupingMethod {
private final String largeDescription;
private final String mediumDescription;
private final String smallDescription;
private static final int MB = 1024 * 1024;
private static final int SMALL = 0;
private static final int MEDIUM = 1;
private static final int LARGE = 2;
RoughSizeGroupingMethod(@NonNull Context context) {
smallDescription = context.getString(R.string.BucketedThreadMedia_Small);
mediumDescription = context.getString(R.string.BucketedThreadMedia_Medium);
largeDescription = context.getString(R.string.BucketedThreadMedia_Large);
}
@Override
public int groupForRecord(@NonNull MediaDatabase.MediaRecord mediaRecord) {
long size = mediaRecord.getAttachment().getSize();
if (size < MB) return SMALL;
if (size < 20 * MB) return MEDIUM;
return LARGE;
}
@Override
public @NonNull String groupName(int groupNo) {
switch (groupNo) {
case SMALL : return smallDescription;
case MEDIUM: return mediumDescription;
case LARGE : return largeDescription;
default: throw new AssertionError();
}
}
}
public static abstract class GroupedThreadMedia {
public abstract int getSectionCount();
public abstract int getSectionItemCount(int section);
public abstract @NonNull MediaDatabase.MediaRecord get(int section, int item);
public abstract @NonNull String getName(int section);
}
public static class EmptyGroupedThreadMedia extends GroupedThreadMedia {
@Override
public int getSectionCount() {
return 0;
}
@Override
public int getSectionItemCount(int section) {
return 0;
}
@Override
public @NonNull MediaDatabase.MediaRecord get(int section, int item) {
throw new AssertionError();
}
@Override
public @NonNull String getName(int section) {
throw new AssertionError();
}
}
public static class ReversedGroupedThreadMedia extends GroupedThreadMedia {
private final GroupedThreadMedia decorated;
ReversedGroupedThreadMedia(@NonNull GroupedThreadMedia decorated) {
this.decorated = decorated;
}
@Override
public int getSectionCount() {
return decorated.getSectionCount();
}
@Override
public int getSectionItemCount(int section) {
return decorated.getSectionItemCount(getReversedSection(section));
}
@Override
public @NonNull MediaDatabase.MediaRecord get(int section, int item) {
return decorated.get(getReversedSection(section), item);
}
@Override
public @NonNull String getName(int section) {
return decorated.getName(getReversedSection(section));
}
private int getReversedSection(int section) {
return decorated.getSectionCount() - 1 - section;
}
}
private static class PopulatedGroupedThreadMedia extends GroupedThreadMedia {
@NonNull
private final GroupingMethod groupingMethod;
private final SparseArray<List<MediaDatabase.MediaRecord>> records = new SparseArray<>();
private PopulatedGroupedThreadMedia(@NonNull GroupingMethod groupingMethod) {
this.groupingMethod = groupingMethod;
}
private void add(@NonNull MediaDatabase.MediaRecord mediaRecord) {
int groupNo = groupingMethod.groupForRecord(mediaRecord);
List<MediaDatabase.MediaRecord> mediaRecords = records.get(groupNo);
if (mediaRecords == null) {
mediaRecords = new LinkedList<>();
records.put(groupNo, mediaRecords);
}
mediaRecords.add(mediaRecord);
}
@Override
public int getSectionCount() {
return records.size();
}
@Override
public int getSectionItemCount(int section) {
return records.get(records.keyAt(section)).size();
}
@Override
public @NonNull MediaDatabase.MediaRecord get(int section, int item) {
return records.get(records.keyAt(section)).get(item);
}
@Override
public @NonNull String getName(int section) {
return groupingMethod.groupName(records.keyAt(section));
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
public abstract class MediaLoader extends AbstractCursorLoader {
MediaLoader(Context context) {
super(context);
}
public enum MediaType {
GALLERY,
DOCUMENT,
AUDIO,
ALL
}
}

View File

@@ -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");
}
}
}

View File

@@ -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.database.MediaDatabase.Sorting;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.AsyncLoader;
public final class PagingMediaLoader extends AsyncLoader<Pair<Cursor, Integer>> {
@SuppressWarnings("unused")
private static final String TAG = PagingMediaLoader.class.getSimpleName();
private final Uri uri;
private final boolean leftIsRecent;
private final Sorting sorting;
private final long threadId;
public PagingMediaLoader(@NonNull Context context, long threadId, @NonNull Uri uri, boolean leftIsRecent, @NonNull Sorting sorting) {
super(context);
this.threadId = threadId;
this.uri = uri;
this.leftIsRecent = leftIsRecent;
this.sorting = sorting;
}
@Override
public @Nullable Pair<Cursor, Integer> loadInBackground() {
Cursor cursor = DatabaseFactory.getMediaDatabase(getContext()).getGalleryMediaForThread(threadId, sorting);
while (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;
}
}

View File

@@ -0,0 +1,50 @@
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.DATA,
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 static final String SELECTION = MediaStore.Images.Media.DATA + " NOT NULL";
private final Context context;
public RecentPhotosLoader(Context context) {
super(context);
this.context = context.getApplicationContext();
}
@Override
public Cursor loadInBackground() {
if (Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
PROJECTION, SELECTION, null,
MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC");
} else {
return null;
}
}
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
/**
* It is more efficient to use the {@link ThreadMediaLoader} if you know the thread id already.
*/
public final class RecipientMediaLoader extends MediaLoader {
@Nullable private final RecipientId recipientId;
@NonNull private final MediaType mediaType;
@NonNull private final MediaDatabase.Sorting sorting;
public RecipientMediaLoader(@NonNull Context context,
@Nullable RecipientId recipientId,
@NonNull MediaType mediaType,
@NonNull MediaDatabase.Sorting sorting)
{
super(context);
this.recipientId = recipientId;
this.mediaType = mediaType;
this.sorting = sorting;
}
@Override
public Cursor getCursor() {
if (recipientId == null || recipientId.isUnknown()) return null;
long threadId = DatabaseFactory.getThreadDatabase(getContext())
.getThreadIdFor(Recipient.resolved(recipientId));
return ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting);
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.database.loaders;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MediaDatabase;
public final class ThreadMediaLoader extends MediaLoader {
private final long threadId;
@NonNull private final MediaType mediaType;
@NonNull private final MediaDatabase.Sorting sorting;
public ThreadMediaLoader(@NonNull Context context,
long threadId,
@NonNull MediaType mediaType,
@NonNull MediaDatabase.Sorting sorting)
{
super(context);
this.threadId = threadId;
this.mediaType = mediaType;
this.sorting = sorting;
}
@Override
public Cursor getCursor() {
return createThreadMediaCursor(context, threadId, mediaType, sorting);
}
static Cursor createThreadMediaCursor(@NonNull Context context,
long threadId,
@NonNull MediaType mediaType,
@NonNull MediaDatabase.Sorting sorting) {
MediaDatabase mediaDatabase = DatabaseFactory.getMediaDatabase(context);
switch (mediaType) {
case GALLERY : return mediaDatabase.getGalleryMediaForThread(threadId, sorting);
case DOCUMENT: return mediaDatabase.getDocumentMediaForThread(threadId, sorting);
case AUDIO : return mediaDatabase.getAudioMediaForThread(threadId, sorting);
case ALL : return mediaDatabase.getAllMediaForThread(threadId, sorting);
default : throw new AssertionError();
}
}
}

View File

@@ -0,0 +1,174 @@
/*
* 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.live().get();
}
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 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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,93 @@
/**
* 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 org.thoughtcrime.securesms.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,
boolean viewOnce, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions);
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);
}
}

View File

@@ -0,0 +1,259 @@
/*
* 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 org.thoughtcrime.securesms.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;
private 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;
private final List<ReactionRecord> reactions;
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,
@NonNull List<ReactionRecord> reactions)
{
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;
this.reactions = reactions;
}
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(context)));
} else if (isIncomingCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_s_called_you, getIndividualRecipient().toShortString(context)));
} 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(context)));
} 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(context)));
}
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(context), time));
} else if (isIdentityUpdate()) {
return new SpannableString(context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, getIndividualRecipient().toShortString(context)));
} else if (isIdentityVerified()) {
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, getIndividualRecipient().toShortString(context)));
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, getIndividualRecipient().toShortString(context)));
} else if (isIdentityDefault()) {
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, getIndividualRecipient().toShortString(context)));
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, getIndividualRecipient().toShortString(context)));
}
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 (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();
}
public boolean isMediaPending() {
return false;
}
public Recipient getIndividualRecipient() {
return individualRecipient.live().get();
}
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;
}
public boolean isViewOnce() {
return false;
}
public @NonNull List<ReactionRecord> getReactions() {
return reactions;
}
}

View File

@@ -0,0 +1,89 @@
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<>();
private final boolean viewOnce;
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, boolean viewOnce,
@NonNull SlideDeck slideDeck, int readReceiptCount,
@Nullable Quote quote, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
this.slideDeck = slideDeck;
this.quote = quote;
this.viewOnce = viewOnce;
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;
}
@Override
public boolean isViewOnce() {
return viewOnce;
}
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;
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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.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, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false,
Collections.emptyList());
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));
}
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.RecipientId;
public class Quote {
private final long id;
private final RecipientId author;
private final String text;
private final boolean missing;
private final SlideDeck attachment;
public Quote(long id, @NonNull RecipientId 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 RecipientId getAuthor() {
return author;
}
public @Nullable String getText() {
return text;
}
public boolean isOriginalMissing() {
return missing;
}
public @NonNull SlideDeck getAttachment() {
return attachment;
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.RecipientId;
public class ReactionRecord {
private final String emoji;
private final RecipientId author;
private final long dateSent;
private final long dateReceived;
public ReactionRecord(@NonNull String emoji,
@NonNull RecipientId author,
long dateSent,
long dateReceived)
{
this.emoji = emoji;
this.author = author;
this.dateSent = dateSent;
this.dateReceived = dateReceived;
}
public @NonNull String getEmoji() {
return emoji;
}
public @NonNull RecipientId getAuthor() {
return author;
}
public long getDateSent() {
return dateSent;
}
public long getDateReceived() {
return dateReceived;
}
}

View File

@@ -0,0 +1,106 @@
/*
* 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.R;
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;
/**
* 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,
@NonNull List<ReactionRecord> reactions)
{
super(id, body, recipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, threadId, status, deliveryReceiptCount, type,
mismatches, new LinkedList<>(), subscriptionId,
expiresIn, expireStarted, readReceiptCount, unidentified, reactions);
}
public long getType() {
return type;
}
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
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 (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(context)));
} else if (SmsDatabase.Types.isUnsupportedMessageType(type)) {
return emphasisAdded(context.getString(R.string.SmsMessageRecord_this_message_could_not_be_processed_because_it_was_sent_from_a_newer_version));
} else if (SmsDatabase.Types.isInvalidMessageType(type)) {
return emphasisAdded(context.getString(R.string.SmsMessageRecord_error_handling_incoming_message));
} else {
return super.getDisplayBody(context);
}
}
@Override
public boolean isMms() {
return false;
}
@Override
public boolean isMmsNotification() {
return false;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,91 @@
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);
}
}

View File

@@ -0,0 +1,182 @@
/*
* 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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase.Extra;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
/**
* The message record model which represents thread heading messages.
*
* @author Moxie Marlinspike
*
*/
public class ThreadRecord extends DisplayRecord {
private @Nullable final Uri snippetUri;
private @Nullable final String contentType;
private @Nullable final Extra extra;
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,
@Nullable String contentType, @Nullable Extra extra,
@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.contentType = contentType;
this.extra = extra;
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) {
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 (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(org.thoughtcrime.securesms.R.string.ThreadRecord_called));
} else if (SmsDatabase.Types.isIncomingCall(type)) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you));
} else if (SmsDatabase.Types.isMissedCall(type)) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call));
} else if (SmsDatabase.Types.isJoinedType(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, getRecipient().toShortString(context)));
} 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().isGroup()) 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(context)));
} 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 (SmsDatabase.Types.isUnsupportedMessageType(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_could_not_be_processed));
} else {
if (TextUtils.isEmpty(getBody())) {
if (extra != null && extra.isSticker()) {
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_sticker)));
} else if (extra != null && extra.isRevealable()) {
return new SpannableString(emphasisAdded(getViewOnceDescription(context, contentType)));
} else {
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;
}
private String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
if (MediaUtil.isVideoType(contentType)) {
return context.getString(R.string.ThreadRecord_disappearing_video);
} else {
return context.getString(R.string.ThreadRecord_disappearing_photo);
}
}
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;
}
}