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; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.List; 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 PACK_ORDER = "pack_order"; 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, " + PACK_ORDER + " 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, PACK_ORDER + " ASC"); 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, PACK_ORDER + " ASC", 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(); } } public void updatePackOrder(@NonNull List packsInOrder) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.beginTransaction(); try { String selection = PACK_ID + " = ? AND " + COVER + " = ?"; for (int i = 0; i < packsInOrder.size(); i++) { String[] args = new String[]{ packsInOrder.get(i).getPackId(), "1" }; ContentValues values = new ContentValues(); values.put(PACK_ORDER, i); db.update(TABLE_NAME, values, selection, args); } db.setTransactionSuccessful(); notifyStickerPackListeners(); } 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 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(); } } } }