/* * 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 . */ package org.tm.archive.database import android.content.ContentValues import android.content.Context import android.database.Cursor import android.media.MediaDataSource import android.os.Parcelable import android.text.TextUtils import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.content.contentValuesOf import com.bumptech.glide.Glide import com.fasterxml.jackson.annotation.JsonProperty import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONException import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil import org.signal.core.util.StreamUtil import org.signal.core.util.ThreadUtil import org.signal.core.util.delete import org.signal.core.util.deleteAll import org.signal.core.util.drain import org.signal.core.util.exists import org.signal.core.util.forEach import org.signal.core.util.groupBy import org.signal.core.util.isNull import org.signal.core.util.logging.Log import org.signal.core.util.readToList import org.signal.core.util.readToSingleObject import org.signal.core.util.requireBlob import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullBlob import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString import org.signal.core.util.select import org.signal.core.util.toInt import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.tm.archive.attachments.Attachment import org.tm.archive.attachments.AttachmentId import org.tm.archive.attachments.DatabaseAttachment import org.tm.archive.audio.AudioHash import org.tm.archive.blurhash.BlurHash import org.tm.archive.crypto.AttachmentSecret import org.tm.archive.crypto.ClassicDecryptingPartInputStream import org.tm.archive.crypto.ModernDecryptingPartInputStream import org.tm.archive.crypto.ModernEncryptingPartOutputStream import org.tm.archive.database.SignalDatabase.Companion.messages import org.tm.archive.database.SignalDatabase.Companion.stickers import org.tm.archive.database.SignalDatabase.Companion.threads import org.tm.archive.database.model.databaseprotos.AudioWaveFormData import org.tm.archive.dependencies.ApplicationDependencies import org.tm.archive.jobs.AttachmentDownloadJob import org.tm.archive.jobs.AttachmentUploadJob import org.tm.archive.jobs.GenerateAudioWaveFormJob import org.tm.archive.mms.MediaStream import org.tm.archive.mms.MmsException import org.tm.archive.mms.PartAuthority import org.tm.archive.mms.SentMediaQuality import org.tm.archive.stickers.StickerLocator import org.tm.archive.util.FileUtils import org.tm.archive.util.JsonUtils.SaneJSONObject import org.tm.archive.util.MediaUtil import org.tm.archive.util.StorageUtil import org.tm.archive.video.EncryptedMediaDataSource import org.whispersystems.signalservice.internal.util.JsonUtil import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.security.DigestInputStream import java.security.MessageDigest import java.security.NoSuchAlgorithmException import java.util.LinkedList import java.util.Optional import java.util.UUID import kotlin.time.Duration.Companion.days open class AttachmentTable( //TM_SA make class open context: Context, databaseHelper: SignalDatabase, private val attachmentSecret: AttachmentSecret ) : DatabaseTable(context, databaseHelper) { companion object { val TAG = Log.tag(AttachmentTable::class.java) const val TABLE_NAME = "attachment" const val ID = "_id" const val MESSAGE_ID = "message_id" const val CONTENT_TYPE = "content_type" const val REMOTE_KEY = "remote_key" const val REMOTE_LOCATION = "remote_location" const val REMOTE_DIGEST = "remote_digest" const val REMOTE_INCREMENTAL_DIGEST = "remote_incremental_digest" const val REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE = "remote_incremental_digest_chunk_size" const val CDN_NUMBER = "cdn_number" const val TRANSFER_STATE = "transfer_state" const val TRANSFER_FILE = "transfer_file" const val DATA_FILE = "data_file" const val DATA_SIZE = "data_size" const val DATA_RANDOM = "data_random" const val DATA_HASH_START = "data_hash_start" const val DATA_HASH_END = "data_hash_end" const val FILE_NAME = "file_name" const val FAST_PREFLIGHT_ID = "fast_preflight_id" const val VOICE_NOTE = "voice_note" const val BORDERLESS = "borderless" const val VIDEO_GIF = "video_gif" const val QUOTE = "quote" const val WIDTH = "width" const val HEIGHT = "height" const val CAPTION = "caption" const val STICKER_PACK_ID = "sticker_pack_id" const val STICKER_PACK_KEY = "sticker_pack_key" const val STICKER_ID = "sticker_id" const val STICKER_EMOJI = "sticker_emoji" const val BLUR_HASH = "blur_hash" const val TRANSFORM_PROPERTIES = "transform_properties" const val DISPLAY_ORDER = "display_order" const val UPLOAD_TIMESTAMP = "upload_timestamp" const val ATTACHMENT_JSON_ALIAS = "attachment_json" private const val DIRECTORY = "parts" const val TRANSFER_PROGRESS_DONE = 0 const val TRANSFER_PROGRESS_STARTED = 1 const val TRANSFER_PROGRESS_PENDING = 2 const val TRANSFER_PROGRESS_FAILED = 3 const val TRANSFER_PROGRESS_PERMANENT_FAILURE = 4 const val PREUPLOAD_MESSAGE_ID: Long = -8675309 private val PROJECTION = arrayOf( ID, MESSAGE_ID, CONTENT_TYPE, REMOTE_KEY, REMOTE_LOCATION, REMOTE_DIGEST, REMOTE_INCREMENTAL_DIGEST, REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, CDN_NUMBER, TRANSFER_STATE, TRANSFER_FILE, DATA_FILE, DATA_SIZE, DATA_RANDOM, FILE_NAME, FAST_PREFLIGHT_ID, VOICE_NOTE, BORDERLESS, VIDEO_GIF, QUOTE, WIDTH, HEIGHT, CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, STICKER_EMOJI, BLUR_HASH, TRANSFORM_PROPERTIES, DISPLAY_ORDER, UPLOAD_TIMESTAMP, DATA_HASH_START, DATA_HASH_END ) const val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( $ID INTEGER PRIMARY KEY AUTOINCREMENT, $MESSAGE_ID INTEGER, $CONTENT_TYPE TEXT, $REMOTE_KEY TEXT, $REMOTE_LOCATION TEXT, $REMOTE_DIGEST BLOB, $REMOTE_INCREMENTAL_DIGEST BLOB, $REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE INTEGER DEFAULT 0, $CDN_NUMBER INTEGER DEFAULT 0, $TRANSFER_STATE INTEGER, $TRANSFER_FILE TEXT DEFAULT NULL, $DATA_FILE TEXT, $DATA_SIZE INTEGER, $DATA_RANDOM BLOB, $FILE_NAME TEXT, $FAST_PREFLIGHT_ID TEXT, $VOICE_NOTE INTEGER DEFAULT 0, $BORDERLESS INTEGER DEFAULT 0, $VIDEO_GIF INTEGER DEFAULT 0, $QUOTE INTEGER DEFAULT 0, $WIDTH INTEGER DEFAULT 0, $HEIGHT INTEGER DEFAULT 0, $CAPTION TEXT DEFAULT NULL, $STICKER_PACK_ID TEXT DEFAULT NULL, $STICKER_PACK_KEY DEFAULT NULL, $STICKER_ID INTEGER DEFAULT -1, $STICKER_EMOJI STRING DEFAULT NULL, $BLUR_HASH TEXT DEFAULT NULL, $TRANSFORM_PROPERTIES TEXT DEFAULT NULL, $DISPLAY_ORDER INTEGER DEFAULT 0, $UPLOAD_TIMESTAMP INTEGER DEFAULT 0, $DATA_HASH_START TEXT DEFAULT NULL, $DATA_HASH_END TEXT DEFAULT NULL ) """ @JvmField val CREATE_INDEXS = arrayOf( "CREATE INDEX IF NOT EXISTS attachment_message_id_index ON $TABLE_NAME ($MESSAGE_ID);", "CREATE INDEX IF NOT EXISTS attachment_transfer_state_index ON $TABLE_NAME ($TRANSFER_STATE);", "CREATE INDEX IF NOT EXISTS attachment_sticker_pack_id_index ON $TABLE_NAME ($STICKER_PACK_ID);", "CREATE INDEX IF NOT EXISTS attachment_data_hash_start_index ON $TABLE_NAME ($DATA_HASH_START);", "CREATE INDEX IF NOT EXISTS attachment_data_hash_end_index ON $TABLE_NAME ($DATA_HASH_END);", "CREATE INDEX IF NOT EXISTS attachment_data_index ON $TABLE_NAME ($DATA_FILE);" ) val ATTACHMENT_POINTER_REUSE_THRESHOLD = 7.days.inWholeMilliseconds @JvmStatic @JvmOverloads @Throws(IOException::class) fun newDataFile(context: Context): File { val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) return PartFileProtector.protect { File.createTempFile("part", ".mms", partsDirectory) } } } @Throws(IOException::class) fun getAttachmentStream(attachmentId: AttachmentId, offset: Long): InputStream { return try { getDataStream(attachmentId, offset) } catch (e: FileNotFoundException) { throw IOException("No stream for: $attachmentId", e) } ?: throw IOException("No stream for: $attachmentId") } /** * Returns a [File] for an attachment that has no [DATA_HASH_END] and is in the [TRANSFER_PROGRESS_DONE] state, if present. */ fun getUnhashedDataFile(): Pair? { return readableDatabase .select(ID, DATA_FILE) .from(TABLE_NAME) .where("$DATA_FILE NOT NULL AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE") .orderBy("$ID DESC") .limit(1) .run() .readToSingleObject { File(it.requireNonNullString(DATA_FILE)) to AttachmentId(it.requireLong(ID)) } } /** * Sets the [DATA_HASH_END] for a given file. This is used to backfill the hash for attachments that were created before we started hashing them. * As a result, this will _not_ update the hashes on files that are not fully uploaded. */ fun setHashForDataFile(file: File, hash: ByteArray) { writableDatabase.withinTransaction { db -> val hashEnd = Base64.encodeWithPadding(hash) val (existingFile: String?, existingSize: Long?, existingRandom: ByteArray?) = db.select(DATA_FILE, DATA_SIZE, DATA_RANDOM) .from(TABLE_NAME) .where("$DATA_HASH_END = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL AND $DATA_FILE != ?", hashEnd, file.absolutePath) .limit(1) .run() .readToSingleObject { Triple( it.requireString(DATA_FILE), it.requireLong(DATA_SIZE), it.requireBlob(DATA_RANDOM) ) } ?: Triple(null, null, null) if (existingFile != null) { Log.i(TAG, "[setHashForDataFile] Found that a different file has the same HASH_END. Using that one instead. Pre-existing file: $existingFile", true) val updateCount = writableDatabase .update(TABLE_NAME) .values( DATA_FILE to existingFile, DATA_HASH_END to hashEnd, DATA_SIZE to existingSize, DATA_RANDOM to existingRandom ) .where("$DATA_FILE = ? AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) .run() Log.i(TAG, "[setHashForDataFile] Deduped $updateCount attachments.", true) val oldFileInUse = db.exists(TABLE_NAME).where("$DATA_FILE = ?", file.absolutePath).run() if (oldFileInUse) { Log.i(TAG, "[setHashForDataFile] Old file is still in use by some in-progress attachment.", true) } else { Log.i(TAG, "[setHashForDataFile] Deleting unused file: $file") if (!file.delete()) { Log.w(TAG, "Failed to delete duped file!") } } } else { val updateCount = writableDatabase .update(TABLE_NAME) .values(DATA_HASH_END to Base64.encodeWithPadding(hash)) .where("$DATA_FILE = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) .run() Log.i(TAG, "[setHashForDataFile] Updated the HASH_END for $updateCount rows using file ${file.absolutePath}") } } } fun getAttachment(attachmentId: AttachmentId): DatabaseAttachment? { return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .run() .readToList { it.readAttachments() } .flatten() .firstOrNull() } fun getAttachmentsForMessage(mmsId: Long): List { return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) .where("$MESSAGE_ID = ?", mmsId) .orderBy("$ID ASC") .run() .readToList { it.readAttachments() } .flatten() } fun getAttachmentsForMessages(mmsIds: Collection): Map> { if (mmsIds.isEmpty()) { return emptyMap() } val query = SqlUtil.buildSingleCollectionQuery(MESSAGE_ID, mmsIds) return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) .where(query.where, query.whereArgs) .orderBy("$ID ASC") .run() .groupBy { cursor -> val attachment = cursor.readAttachment() attachment.mmsId to attachment } } fun hasAttachment(id: AttachmentId): Boolean { return readableDatabase .exists(TABLE_NAME) .where("$ID = ?", id.id) .run() } fun getPendingAttachments(): List { return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) .where("$TRANSFER_STATE = ?", TRANSFER_PROGRESS_STARTED.toString()) .run() .readToList { it.readAttachments() } .flatten() } fun deleteAttachmentsForMessage(mmsId: Long): Boolean { Log.d(TAG, "[deleteAttachmentsForMessage] mmsId: $mmsId") return writableDatabase.withinTransaction { db -> db.select(DATA_FILE, CONTENT_TYPE, ID) .from(TABLE_NAME) .where("$MESSAGE_ID = ?", mmsId) .run() .forEach { cursor -> val attachmentId = AttachmentId(cursor.requireLong(ID)) ApplicationDependencies.getJobManager().cancelAllInQueue(AttachmentDownloadJob.constructQueueString(attachmentId)) deleteDataFileIfPossible( filePath = cursor.requireString(DATA_FILE), contentType = cursor.requireString(CONTENT_TYPE), attachmentId = attachmentId ) } val deleteCount = db.delete(TABLE_NAME) .where("$MESSAGE_ID = ?", mmsId) .run() notifyAttachmentListeners() deleteCount > 0 } } /** * Deletes all attachments with an ID of [PREUPLOAD_MESSAGE_ID]. These represent * attachments that were pre-uploaded and haven't been assigned to a message. This should only be * done when you *know* that all attachments *should* be assigned a real mmsId. For instance, when * the app starts. Otherwise you could delete attachments that are legitimately being * pre-uploaded. */ fun deleteAbandonedPreuploadedAttachments(): Int { var count = 0 writableDatabase .select(ID) .from(TABLE_NAME) .where("$MESSAGE_ID = ?", PREUPLOAD_MESSAGE_ID) .run() .forEach { cursor -> val id = AttachmentId(cursor.requireLong(ID)) deleteAttachment(id) count++ } return count } fun deleteAttachmentFilesForViewOnceMessage(messageId: Long) { Log.d(TAG, "[deleteAttachmentFilesForViewOnceMessage] messageId: $messageId") writableDatabase.withinTransaction { db -> db.select(DATA_FILE, CONTENT_TYPE, ID) .from(TABLE_NAME) .where("$MESSAGE_ID = ?", messageId) .run() .forEach { cursor -> deleteDataFileIfPossible( filePath = cursor.requireString(DATA_FILE), contentType = cursor.requireString(CONTENT_TYPE), attachmentId = AttachmentId(cursor.requireLong(ID)) ) } db.update(TABLE_NAME) .values( DATA_FILE to null, DATA_RANDOM to null, DATA_HASH_START to null, DATA_HASH_END to null, FILE_NAME to null, CAPTION to null, DATA_SIZE to 0, WIDTH to 0, HEIGHT to 0, TRANSFER_STATE to TRANSFER_PROGRESS_DONE, BLUR_HASH to null, CONTENT_TYPE to MediaUtil.VIEW_ONCE ) .where("$MESSAGE_ID = ?", messageId) .run() notifyAttachmentListeners() val threadId = messages.getThreadIdForMessage(messageId) if (threadId > 0) { notifyConversationListeners(threadId) } } } fun deleteAttachment(id: AttachmentId) { Log.d(TAG, "[deleteAttachment] attachmentId: $id") writableDatabase.withinTransaction { db -> db.select(DATA_FILE, CONTENT_TYPE) .from(TABLE_NAME) .where("$ID = ?", id.id) .run() .use { cursor -> if (!cursor.moveToFirst()) { Log.w(TAG, "Tried to delete an attachment, but it didn't exist.") return@withinTransaction } val data = cursor.requireString(DATA_FILE) val contentType = cursor.requireString(CONTENT_TYPE) deleteDataFileIfPossible( filePath = data, contentType = contentType, attachmentId = id ) db.delete(TABLE_NAME) .where("$ID = ?", id.id) .run() deleteDataFileIfPossible(data, contentType, id) notifyAttachmentListeners() } } } fun trimAllAbandonedAttachments() { val deleteCount = writableDatabase .delete(TABLE_NAME) .where("$MESSAGE_ID != $PREUPLOAD_MESSAGE_ID AND $MESSAGE_ID NOT IN (SELECT ${MessageTable.ID} FROM ${MessageTable.TABLE_NAME})") .run() if (deleteCount > 0) { Log.i(TAG, "Trimmed $deleteCount abandoned attachments.") } } fun deleteAbandonedAttachmentFiles(): Int { val diskFiles = context.getDir(DIRECTORY, Context.MODE_PRIVATE).listFiles() ?: return 0 val filesOnDisk: Set = diskFiles .filter { file: File -> !PartFileProtector.isProtected(file) } .map { file: File -> file.absolutePath } .toSet() val filesInDb: Set = readableDatabase .select(DATA_FILE) .from(TABLE_NAME) .run() .readToList { it.requireString(DATA_FILE) } .filterNotNull() .toSet() + stickers.allStickerFiles val onDiskButNotInDatabase: Set = filesOnDisk - filesInDb for (filePath in onDiskButNotInDatabase) { val success = File(filePath).delete() if (!success) { Log.w(TAG, "[deleteAbandonedAttachmentFiles] Failed to delete attachment file. $filePath") } } return onDiskButNotInDatabase.size } /** * Removes all references to the provided [DATA_FILE] from all attachments. * Only do this if the file is known to not exist or has some other critical problem! */ fun clearUsagesOfDataFile(file: File) { val updateCount = writableDatabase .update(TABLE_NAME) .values(DATA_FILE to null) .where("$DATA_FILE = ?", file.absolutePath) .run() Log.i(TAG, "[clearUsagesOfFile] Cleared $updateCount usages of $file", true) } /** * Indicates that, for whatever reason, a hash could not be calculated for the file in question. * We put in a "bad hash" that will never match anything else so that we don't attempt to backfill it in the future. */ fun markDataFileAsUnhashable(file: File) { val updateCount = writableDatabase .update(TABLE_NAME) .values(DATA_HASH_END to "UNHASHABLE-${UUID.randomUUID()}") .where("$DATA_FILE = ? AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) .run() Log.i(TAG, "[markDataFileAsUnhashable] Marked $updateCount attachments as unhashable with file: ${file.absolutePath}", true) } fun deleteAllAttachments() { Log.d(TAG, "[deleteAllAttachments]") writableDatabase.deleteAll(TABLE_NAME) FileUtils.deleteDirectoryContents(context.getDir(DIRECTORY, Context.MODE_PRIVATE)) notifyAttachmentListeners() } fun setTransferState(messageId: Long, attachmentId: AttachmentId, transferState: Int) { writableDatabase .update(TABLE_NAME) .values(TRANSFER_STATE to transferState) .where("$ID = ?", attachmentId.id) .run() val threadId = messages.getThreadIdForMessage(messageId) notifyConversationListeners(threadId) } @Throws(MmsException::class) fun setTransferProgressFailed(attachmentId: AttachmentId, mmsId: Long) { writableDatabase .update(TABLE_NAME) .values(TRANSFER_STATE to TRANSFER_PROGRESS_FAILED) .where("$ID = ? AND $TRANSFER_STATE < $TRANSFER_PROGRESS_PERMANENT_FAILURE", attachmentId.id) .run() notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) } @Throws(MmsException::class) fun setTransferProgressPermanentFailure(attachmentId: AttachmentId, mmsId: Long) { writableDatabase .update(TABLE_NAME) .values(TRANSFER_STATE to TRANSFER_PROGRESS_PERMANENT_FAILURE) .where("$ID = ?", attachmentId.id) .run() notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) } /** * When we find out about a new inbound attachment pointer, we insert a row for it that contains all the info we need to download it via [insertAttachmentWithData]. * Later, we download the data for that pointer. Call this method once you have the data to associate it with the attachment. At this point, it is assumed * that the content of the attachment will never change. */ @Throws(MmsException::class) open fun finalizeAttachmentAfterDownload(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream) { //TM_SA make fun open Log.i(TAG, "[finalizeAttachmentAfterDownload] Finalizing downloaded data for $attachmentId. (MessageId: $mmsId, $attachmentId)") val existingPlaceholder: DatabaseAttachment = getAttachment(attachmentId) ?: throw MmsException("No attachment found for id: $attachmentId") val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), inputStream, TransformProperties.empty()) val transferFile: File? = getTransferFile(databaseHelper.signalReadableDatabase, attachmentId) val foundDuplicate = writableDatabase.withinTransaction { db -> // We can look and see if we have any exact matches on hash_ends and dedupe the file if we see one. // We don't look at hash_start here because that could result in us matching on a file that got compressed down to something smaller, effectively lowering // the quality of the attachment we received. val hashMatch: DataFileInfo? = readableDatabase .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) .from(TABLE_NAME) .where("$DATA_HASH_END = ? AND $DATA_HASH_END NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL", fileWriteResult.hash) .run() .readToList { it.readDataFileInfo() } .firstOrNull() val values = ContentValues() if (hashMatch != null) { Log.i(TAG, "[finalizeAttachmentAfterDownload] Found that ${hashMatch.id} has the same DATA_HASH_END. Deduping. (MessageId: $mmsId, $attachmentId)") values.put(DATA_FILE, hashMatch.file.absolutePath) values.put(DATA_SIZE, hashMatch.length) values.put(DATA_RANDOM, hashMatch.random) values.put(DATA_HASH_START, hashMatch.hashEnd) values.put(DATA_HASH_END, hashMatch.hashEnd) } else { values.put(DATA_FILE, fileWriteResult.file.absolutePath) values.put(DATA_SIZE, fileWriteResult.length) values.put(DATA_RANDOM, fileWriteResult.random) values.put(DATA_HASH_START, fileWriteResult.hash) values.put(DATA_HASH_END, fileWriteResult.hash) } val visualHashString = existingPlaceholder.getVisualHashStringOrNull() if (visualHashString != null) { values.put(BLUR_HASH, visualHashString) } values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE) values.put(TRANSFER_FILE, null as String?) values.put(TRANSFORM_PROPERTIES, TransformProperties.forSkipTransform().serialize()) db.update(TABLE_NAME) .values(values) .where("$ID = ?", attachmentId.id) .run() hashMatch != null } val threadId = messages.getThreadIdForMessage(mmsId) if (!messages.isStory(mmsId)) { threads.updateSnippetUriSilently(threadId, PartAuthority.getAttachmentDataUri(attachmentId)) } notifyConversationListeners(threadId) notifyConversationListListeners() notifyAttachmentListeners() if (foundDuplicate) { if (!fileWriteResult.file.delete()) { Log.w(TAG, "Failed to delete unused attachment") } } if (transferFile != null) { if (!transferFile.delete()) { Log.w(TAG, "Unable to delete transfer file.") } } if (MediaUtil.isAudio(existingPlaceholder)) { GenerateAudioWaveFormJob.enqueue(existingPlaceholder.attachmentId) } } /** * Needs to be called after an attachment is successfully uploaded. Writes metadata around it's final remote location, as well as calculates * it's ending hash, which is critical for backups. */ @Throws(IOException::class) fun finalizeAttachmentAfterUpload(id: AttachmentId, attachment: Attachment, uploadTimestamp: Long) { Log.i(TAG, "[finalizeAttachmentAfterUpload] Finalizing upload for $id.") val dataStream = getAttachmentStream(id, 0) val messageDigest = MessageDigest.getInstance("SHA-256") DigestInputStream(dataStream, messageDigest).use { it.drain() } val dataHashEnd = Base64.encodeWithPadding(messageDigest.digest()) val values = contentValuesOf( TRANSFER_STATE to TRANSFER_PROGRESS_DONE, CDN_NUMBER to attachment.cdnNumber, REMOTE_LOCATION to attachment.remoteLocation, REMOTE_DIGEST to attachment.remoteDigest, REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest, REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize, REMOTE_KEY to attachment.remoteKey, DATA_SIZE to attachment.size, DATA_HASH_END to dataHashEnd, FAST_PREFLIGHT_ID to attachment.fastPreflightId, BLUR_HASH to attachment.getVisualHashStringOrNull(), UPLOAD_TIMESTAMP to uploadTimestamp ) val dataFilePath = getDataFilePath(id) ?: throw IOException("No data file found for attachment!") val updateCount = writableDatabase .update(TABLE_NAME) .values(values) .where("$ID = ? OR $DATA_FILE = ?", id.id, dataFilePath) .run() if (updateCount <= 0) { Log.w(TAG, "[finalizeAttachmentAfterUpload] Failed to update attachment after upload! $id") } } @Throws(MmsException::class) fun copyAttachmentData(sourceId: AttachmentId, destinationId: AttachmentId) { val sourceAttachment = getAttachment(sourceId) ?: throw MmsException("Cannot find attachment for source!") val sourceDataInfo = getDataFileInfo(sourceId) ?: throw MmsException("No attachment data found for source!") writableDatabase .update(TABLE_NAME) .values( DATA_FILE to sourceDataInfo.file.absolutePath, DATA_HASH_START to sourceDataInfo.hashStart, DATA_HASH_END to sourceDataInfo.hashEnd, DATA_SIZE to sourceDataInfo.length, DATA_RANDOM to sourceDataInfo.random, TRANSFER_STATE to sourceAttachment.transferState, CDN_NUMBER to sourceAttachment.cdnNumber, REMOTE_LOCATION to sourceAttachment.remoteLocation, REMOTE_DIGEST to sourceAttachment.remoteDigest, REMOTE_INCREMENTAL_DIGEST to sourceAttachment.incrementalDigest, REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to sourceAttachment.incrementalMacChunkSize, REMOTE_KEY to sourceAttachment.remoteKey, DATA_SIZE to sourceAttachment.size, FAST_PREFLIGHT_ID to sourceAttachment.fastPreflightId, WIDTH to sourceAttachment.width, HEIGHT to sourceAttachment.height, CONTENT_TYPE to sourceAttachment.contentType, BLUR_HASH to sourceAttachment.getVisualHashStringOrNull(), TRANSFORM_PROPERTIES to sourceAttachment.transformProperties?.serialize() ) .where("$ID = ?", destinationId.id) .run() } fun updateAttachmentCaption(id: AttachmentId, caption: String?) { writableDatabase .update(TABLE_NAME) .values(CAPTION to caption) .where("$ID = ?", id.id) .run() } fun updateDisplayOrder(orderMap: Map) { writableDatabase.withinTransaction { db -> for ((key, value) in orderMap) { db.update(TABLE_NAME) .values(DISPLAY_ORDER to value) .where("$ID = ?", key.id) .run() } } } @Throws(MmsException::class) open fun insertAttachmentForPreUpload(attachment: Attachment): DatabaseAttachment { //TM_SA make fun open val result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID, listOf(attachment), emptyList()) if (result.values.isEmpty()) { throw MmsException("Bad attachment result!") } return getAttachment(result.values.iterator().next()) ?: throw MmsException("Failed to retrieve attachment we just inserted!") } fun updateMessageId(attachmentIds: Collection, mmsId: Long, isStory: Boolean) { writableDatabase.withinTransaction { db -> val values = ContentValues(2).apply { put(MESSAGE_ID, mmsId) if (!isStory) { putNull(CAPTION) } } var updatedCount = 0 var attachmentIdSize = 0 for (attachmentId in attachmentIds) { attachmentIdSize++ updatedCount += db .update(TABLE_NAME) .values(values) .where("$ID = ?", attachmentId.id) .run() } Log.d(TAG, "[updateMessageId] Updated $updatedCount out of $attachmentIdSize ids.") } } /** * Inserts new attachments in the table. The [Attachment]s may or may not have data, depending on whether it's an attachment we created locally or some * inbound attachment that we haven't fetched yet. * * If the attachment has no data, it is assumed that you will later call [finalizeAttachmentAfterDownload]. */ @Throws(MmsException::class) open fun insertAttachmentsForMessage(mmsId: Long, attachments: List, quoteAttachment: List): Map { //TM_SA make fun open if (attachments.isEmpty() && quoteAttachment.isEmpty()) { return emptyMap() } Log.d(TAG, "[insertAttachmentsForMessage] insertParts(${attachments.size})") val insertedAttachments: MutableMap = mutableMapOf() for (attachment in attachments) { val attachmentId = if (attachment.uri != null) { insertAttachmentWithData(mmsId, attachment, attachment.quote) } else { insertUndownloadedAttachment(mmsId, attachment, attachment.quote) } insertedAttachments[attachment] = attachmentId Log.i(TAG, "[insertAttachmentsForMessage] Inserted attachment at $attachmentId") } try { for (attachment in quoteAttachment) { val attachmentId = if (attachment.uri != null) { insertAttachmentWithData(mmsId, attachment, true) } else { insertUndownloadedAttachment(mmsId, attachment, true) } insertedAttachments[attachment] = attachmentId Log.i(TAG, "[insertAttachmentsForMessage] Inserted quoted attachment at $attachmentId") } } catch (e: MmsException) { Log.w(TAG, "Failed to insert quote attachment! messageId: $mmsId") } return insertedAttachments } /** * Updates the data stored for an existing attachment. This happens after transformations, like transcoding. */ @Throws(MmsException::class, IOException::class) fun updateAttachmentData( databaseAttachment: DatabaseAttachment, mediaStream: MediaStream ) { val attachmentId = databaseAttachment.attachmentId val existingDataFileInfo: DataFileInfo = getDataFileInfo(attachmentId) ?: throw MmsException("No attachment data found!") val newDataFileInfo: DataFileWriteResult = writeToDataFile(existingDataFileInfo.file, mediaStream.stream, databaseAttachment.transformProperties ?: TransformProperties.empty()) // TODO We don't dedupe here because we're assuming that we should have caught any dupe scenarios on first insert. We could consider doing dupe checks here though. writableDatabase.withinTransaction { db -> val contentValues = contentValuesOf( DATA_SIZE to newDataFileInfo.length, CONTENT_TYPE to mediaStream.mimeType, WIDTH to mediaStream.width, HEIGHT to mediaStream.height, DATA_FILE to newDataFileInfo.file.absolutePath, DATA_RANDOM to newDataFileInfo.random ) val updateCount = db.update(TABLE_NAME) .values(contentValues) .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, existingDataFileInfo.file.absolutePath) .run() Log.i(TAG, "[updateAttachmentData] Updated $updateCount rows.") } } fun duplicateAttachmentsForMessage(destinationMessageId: Long, sourceMessageId: Long, excludedIds: Collection) { writableDatabase.withinTransaction { db -> db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM $TABLE_NAME WHERE $MESSAGE_ID = ?", SqlUtil.buildArgs(sourceMessageId)) val queries = SqlUtil.buildCollectionQuery(ID, excludedIds) for (query in queries) { db.delete("tmp_part", query.where, query.whereArgs) } db.execSQL("UPDATE tmp_part SET $ID = NULL, $MESSAGE_ID = ?", SqlUtil.buildArgs(destinationMessageId)) db.execSQL("INSERT INTO $TABLE_NAME SELECT * FROM tmp_part") db.execSQL("DROP TABLE tmp_part") } } @Throws(IOException::class) fun getOrCreateTransferFile(attachmentId: AttachmentId): File { val existing = getTransferFile(writableDatabase, attachmentId) if (existing != null) { return existing } val transferFile = newTransferFile() writableDatabase .update(TABLE_NAME) .values(TRANSFER_FILE to transferFile.absolutePath) .where("$ID = ?", attachmentId.id) .run() return transferFile } fun getDataFileInfo(attachmentId: AttachmentId): DataFileInfo? { return readableDatabase .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .run() .readToSingleObject { cursor -> if (cursor.isNull(DATA_FILE)) { null } else { cursor.readDataFileInfo() } } } fun getDataFilePath(attachmentId: AttachmentId): String? { return readableDatabase .select(DATA_FILE) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .run() .readToSingleObject { it.requireString(DATA_FILE) } } fun markAttachmentAsTransformed(attachmentId: AttachmentId, withFastStart: Boolean) { Log.i(TAG, "[markAttachmentAsTransformed] Marking $attachmentId as transformed. withFastStart: $withFastStart") writableDatabase.withinTransaction { db -> try { val dataInfo = getDataFileInfo(attachmentId) if (dataInfo == null) { Log.w(TAG, "[markAttachmentAsTransformed] Failed to get transformation properties, attachment no longer exists.") return@withinTransaction } var transformProperties = dataInfo.transformProperties.withSkipTransform() if (withFastStart) { transformProperties = transformProperties.withMp4FastStart() } val count = writableDatabase .update(TABLE_NAME) .values(TRANSFORM_PROPERTIES to transformProperties.serialize()) .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, dataInfo.file.absolutePath) .run() Log.i(TAG, "[markAttachmentAsTransformed] Updated $count rows.") } catch (e: Exception) { Log.w(TAG, "[markAttachmentAsTransformed] Could not mark attachment as transformed.", e) } } } @WorkerThread fun writeAudioHash(attachmentId: AttachmentId, audioWaveForm: AudioWaveFormData?) { Log.i(TAG, "updating part audio wave form for $attachmentId") writableDatabase .update(TABLE_NAME) .values(BLUR_HASH to audioWaveForm?.let { AudioHash(it).hash }) .where("$ID = ?", attachmentId.id) .run() } @RequiresApi(23) fun mediaDataSourceFor(attachmentId: AttachmentId, allowReadingFromTempFile: Boolean): MediaDataSource? { val dataInfo = getDataFileInfo(attachmentId) if (dataInfo != null) { return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length) } if (allowReadingFromTempFile) { Log.d(TAG, "Completed data file not found for video attachment, checking for in-progress files.") val transferFile = getTransferFile(readableDatabase, attachmentId) if (transferFile != null) { return EncryptedMediaDataSource.createForDiskBlob(attachmentSecret, transferFile) } } Log.w(TAG, "No data file found for video attachment!") return null } /** * @return null if we fail to find the given attachmentId */ fun getTransformProperties(attachmentId: AttachmentId): TransformProperties? { return readableDatabase .select(TRANSFORM_PROPERTIES) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .run() .readToSingleObject { TransformProperties.parse(it.requireString(TRANSFORM_PROPERTIES)) } } open fun markAttachmentUploaded(messageId: Long, attachment: Attachment) { //TM_SA make fun open writableDatabase .update(TABLE_NAME) .values(TRANSFER_STATE to TRANSFER_PROGRESS_DONE) .where("$ID = ?", (attachment as DatabaseAttachment).attachmentId.id) .run() val threadId = messages.getThreadIdForMessage(messageId) notifyConversationListeners(threadId) } fun getAttachments(cursor: Cursor): List { return try { if (cursor.getColumnIndex(ATTACHMENT_JSON_ALIAS) != -1) { if (cursor.isNull(ATTACHMENT_JSON_ALIAS)) { return LinkedList() } val result: MutableList = mutableListOf() val array = JSONArray(cursor.requireString(ATTACHMENT_JSON_ALIAS)) for (i in 0 until array.length()) { val jsonObject = SaneJSONObject(array.getJSONObject(i)) if (!jsonObject.isNull(ID)) { val contentType = jsonObject.getString(CONTENT_TYPE) result += DatabaseAttachment( attachmentId = AttachmentId(jsonObject.getLong(ID)), mmsId = jsonObject.getLong(MESSAGE_ID), hasData = !TextUtils.isEmpty(jsonObject.getString(DATA_FILE)), hasThumbnail = MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType), contentType = contentType, transferProgress = jsonObject.getInt(TRANSFER_STATE), size = jsonObject.getLong(DATA_SIZE), fileName = jsonObject.getString(FILE_NAME), cdnNumber = jsonObject.getInt(CDN_NUMBER), location = jsonObject.getString(REMOTE_LOCATION), key = jsonObject.getString(REMOTE_KEY), digest = null, incrementalDigest = null, incrementalMacChunkSize = 0, fastPreflightId = jsonObject.getString(FAST_PREFLIGHT_ID), voiceNote = jsonObject.getInt(VOICE_NOTE) == 1, borderless = jsonObject.getInt(BORDERLESS) == 1, videoGif = jsonObject.getInt(VIDEO_GIF) == 1, width = jsonObject.getInt(WIDTH), height = jsonObject.getInt(HEIGHT), quote = jsonObject.getInt(QUOTE) == 1, caption = jsonObject.getString(CAPTION), stickerLocator = if (jsonObject.getInt(STICKER_ID) >= 0) { StickerLocator( jsonObject.getString(STICKER_PACK_ID)!!, jsonObject.getString(STICKER_PACK_KEY)!!, jsonObject.getInt(STICKER_ID), jsonObject.getString(STICKER_EMOJI) ) } else { null }, blurHash = if (MediaUtil.isAudioType(contentType)) null else BlurHash.parseOrNull(jsonObject.getString(BLUR_HASH)), audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(jsonObject.getString(BLUR_HASH)) else null, transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)), displayOrder = jsonObject.getInt(DISPLAY_ORDER), uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP), dataHash = jsonObject.getString(DATA_HASH_END) ) } } result } else { listOf(getAttachment(cursor)) } } catch (e: JSONException) { throw AssertionError(e) } } fun hasStickerAttachments(): Boolean { return readableDatabase .exists(TABLE_NAME) .where("$STICKER_PACK_ID NOT NULL") .run() } fun containsStickerPackId(stickerPackId: String): Boolean { return readableDatabase.exists(TABLE_NAME) .where("$STICKER_PACK_ID = ?", stickerPackId) .run() } fun getUnavailableStickerPacks(): Cursor { val query = """ SELECT DISTINCT $STICKER_PACK_ID, $STICKER_PACK_KEY FROM $TABLE_NAME WHERE $STICKER_PACK_ID NOT NULL AND $STICKER_PACK_KEY NOT NULL AND $STICKER_PACK_ID NOT IN (SELECT DISTINCT ${StickerTable.PACK_ID} FROM ${StickerTable.TABLE_NAME}) """ return readableDatabase.rawQuery(query, null) } /** * Deletes the data file if there's no strong references to other attachments. * If deleted, it will also clear all weak references (i.e. quotes) of the attachment. */ private fun deleteDataFileIfPossible( filePath: String?, contentType: String?, attachmentId: AttachmentId ) { check(writableDatabase.inTransaction()) { "Must be in a transaction!" } if (filePath == null) { Log.w(TAG, "[deleteDataFileIfPossible] Null data file path for $attachmentId! Can't delete anything.") return } val strongReferenceExists = readableDatabase .exists(TABLE_NAME) .where("$DATA_FILE = ? AND QUOTE = 0 AND $ID != ${attachmentId.id}", filePath) .run() if (strongReferenceExists) { Log.i(TAG, "[deleteDataFileIfPossible] Attachment in use. Skipping deletion of $attachmentId. Path: $filePath") return } val weakReferenceCount = writableDatabase .update(TABLE_NAME) .values( DATA_FILE to null, DATA_RANDOM to null, DATA_HASH_START to null, DATA_HASH_END to null ) .where("$DATA_FILE = ?", filePath) .run() Log.i(TAG, "[deleteDataFileIfPossible] Cleared $weakReferenceCount weak references for $attachmentId. Path: $filePath") if (!File(filePath).delete()) { Log.w(TAG, "[deleteDataFileIfPossible] Failed to delete $attachmentId. Path: $filePath") } if (MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType)) { Glide.get(context).clearDiskCache() ThreadUtil.runOnMain { Glide.get(context).clearMemory() } } } @Throws(FileNotFoundException::class) private fun getDataStream(attachmentId: AttachmentId, offset: Long): InputStream? { val dataInfo = getDataFileInfo(attachmentId) ?: return null return try { if (dataInfo.random != null && dataInfo.random.size == 32) { ModernDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.random, dataInfo.file, offset) } else { val stream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.file) val skipped = stream.skip(offset) if (skipped != offset) { Log.w(TAG, "Skip failed: $skipped vs $offset") return null } stream } } catch (e: FileNotFoundException) { Log.w(TAG, e) throw e } catch (e: IOException) { Log.w(TAG, e) null } } @Throws(IOException::class) private fun newTransferFile(): File { val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) return PartFileProtector.protect { File.createTempFile("transfer", ".mms", partsDirectory) } } /** * Reads the entire stream and saves to disk and returns a bunch of metadat about the write. */ @Throws(MmsException::class, IllegalStateException::class) private fun writeToDataFile(destination: File, inputStream: InputStream, transformProperties: TransformProperties): DataFileWriteResult { return try { // Sometimes the destination is a file that's already in use, sometimes it's not. // To avoid writing to a file while it's in-use, we write to a temp file and then rename it to the destination file at the end. val tempFile = newDataFile(context) val messageDigest = MessageDigest.getInstance("SHA-256") val digestInputStream = DigestInputStream(inputStream, messageDigest) val encryptingStreamData = ModernEncryptingPartOutputStream.createFor(attachmentSecret, tempFile, false) val random = encryptingStreamData.first val encryptingOutputStream = encryptingStreamData.second val length = StreamUtil.copy(digestInputStream, encryptingOutputStream) val hash = Base64.encodeWithPadding(digestInputStream.messageDigest.digest()) if (!tempFile.renameTo(destination)) { Log.w(TAG, "[writeToDataFile] Couldn't rename ${tempFile.path} to ${destination.path}") tempFile.delete() throw IllegalStateException("Couldn't rename ${tempFile.path} to ${destination.path}") } DataFileWriteResult( file = destination, length = length, random = random, hash = hash, transformProperties = transformProperties ) } catch (e: IOException) { throw MmsException(e) } catch (e: NoSuchAlgorithmException) { throw MmsException(e) } } private fun areTransformationsCompatible( newProperties: TransformProperties, potentialMatchProperties: TransformProperties, newHashStart: String, potentialMatchHashEnd: String?, newIsQuote: Boolean ): Boolean { // If we're starting now where another attachment finished, then it means we're forwarding an attachment. if (newHashStart == potentialMatchHashEnd) { // Quotes don't get transcoded or anything and are just a reference to the original attachment, so as long as the hashes match we're fine if (newIsQuote) { return true } // If the new attachment is an edited video, we can't re-use the file if (newProperties.videoEdited) { return false } return true } if (newProperties.sentMediaQuality != potentialMatchProperties.sentMediaQuality) { return false } if (newProperties.videoEdited != potentialMatchProperties.videoEdited) { return false } if (newProperties.videoTrimStartTimeUs != potentialMatchProperties.videoTrimStartTimeUs) { return false } if (newProperties.videoTrimEndTimeUs != potentialMatchProperties.videoTrimEndTimeUs) { return false } if (newProperties.mp4FastStart != potentialMatchProperties.mp4FastStart) { return false } return true } /** * Attachments need records in the database even if they haven't been downloaded yet. That allows us to store the info we need to download it, what message * it's associated with, etc. We treat this case separately from attachments with data (see [insertAttachmentWithData]) because it's much simpler, * and splitting the two use cases makes the code easier to understand. * * Callers are expected to later call [finalizeAttachmentAfterDownload] once they have downloaded the data for this attachment. */ @Throws(MmsException::class) private fun insertUndownloadedAttachment(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { Log.d(TAG, "[insertAttachment] Inserting attachment for messageId $messageId.") val attachmentId: AttachmentId = writableDatabase.withinTransaction { db -> val contentValues = ContentValues().apply { put(MESSAGE_ID, messageId) put(CONTENT_TYPE, attachment.contentType) put(TRANSFER_STATE, attachment.transferState) put(CDN_NUMBER, attachment.cdnNumber) put(REMOTE_LOCATION, attachment.remoteLocation) put(REMOTE_DIGEST, attachment.remoteDigest) put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) put(REMOTE_KEY, attachment.remoteKey) put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) put(DATA_SIZE, attachment.size) put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) put(VOICE_NOTE, attachment.voiceNote.toInt()) put(BORDERLESS, attachment.borderless.toInt()) put(VIDEO_GIF, attachment.videoGif.toInt()) put(WIDTH, attachment.width) put(HEIGHT, attachment.height) put(QUOTE, quote) put(CAPTION, attachment.caption) put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) put(BLUR_HASH, attachment.blurHash?.hash) attachment.stickerLocator?.let { sticker -> put(STICKER_PACK_ID, sticker.packId) put(STICKER_PACK_KEY, sticker.packKey) put(STICKER_ID, sticker.stickerId) put(STICKER_EMOJI, sticker.emoji) } } val rowId = db.insert(TABLE_NAME, null, contentValues) AttachmentId(rowId) } notifyAttachmentListeners() return attachmentId } /** * Inserts an attachment with existing data. This is likely an outgoing attachment that we're in the process of sending. */ @Throws(MmsException::class) private fun insertAttachmentWithData(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { requireNotNull(attachment.uri) { "Attachment must have a uri!" } Log.d(TAG, "[insertAttachmentWithData] Inserting attachment for messageId $messageId. (MessageId: $messageId, ${attachment.uri})") val dataStream = try { PartAuthority.getAttachmentStream(context, attachment.uri!!) } catch (e: IOException) { throw MmsException(e) } // To avoid performing long-running operations in a transaction, we write the data to an independent file first in a way that doesn't rely on db state. val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), dataStream, attachment.transformProperties ?: TransformProperties.empty()) Log.d(TAG, "[insertAttachmentWithData] Wrote data to file: ${fileWriteResult.file.absolutePath} (MessageId: $messageId, ${attachment.uri})") val (attachmentId: AttachmentId, foundDuplicate: Boolean) = writableDatabase.withinTransaction { db -> val contentValues = ContentValues() var transformProperties = attachment.transformProperties ?: TransformProperties.empty() // First we'll check if our file hash matches the starting or ending hash of any other attachments and has compatible transform properties. // We'll prefer the match with the most recent upload timestamp. val hashMatch: DataFileInfo? = readableDatabase .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) .from(TABLE_NAME) .where("$DATA_FILE NOT NULL AND ($DATA_HASH_START = ? OR $DATA_HASH_END = ?)", fileWriteResult.hash, fileWriteResult.hash) .run() .readToList { it.readDataFileInfo() } .sortedByDescending { it.uploadTimestamp } .firstOrNull { existingMatch -> areTransformationsCompatible( newProperties = transformProperties, potentialMatchProperties = existingMatch.transformProperties, newHashStart = fileWriteResult.hash, potentialMatchHashEnd = existingMatch.hashEnd, newIsQuote = quote ) } if (hashMatch != null) { if (fileWriteResult.hash == hashMatch.hashStart) { Log.i(TAG, "[insertAttachmentWithData] Found that the new attachment hash matches the DATA_HASH_START of ${hashMatch.id}. Using all of it's fields. (MessageId: $messageId, ${attachment.uri})") } else if (fileWriteResult.hash == hashMatch.hashEnd) { Log.i(TAG, "[insertAttachmentWithData] Found that the new attachment hash matches the DATA_HASH_END of ${hashMatch.id}. Using all of it's fields. (MessageId: $messageId, ${attachment.uri})") } else { throw IllegalStateException("Should not be possible based on query.") } contentValues.put(DATA_FILE, hashMatch.file.absolutePath) contentValues.put(DATA_SIZE, hashMatch.length) contentValues.put(DATA_RANDOM, hashMatch.random) contentValues.put(DATA_HASH_START, fileWriteResult.hash) contentValues.put(DATA_HASH_END, hashMatch.hashEnd) if (hashMatch.transformProperties.skipTransform) { Log.i(TAG, "[insertAttachmentWithData] The hash match has a DATA_HASH_END and skipTransform=true, so skipping transform of the new file as well. (MessageId: $messageId, ${attachment.uri})") transformProperties = transformProperties.copy(skipTransform = true) } } else { Log.i(TAG, "[insertAttachmentWithData] No matching hash found. (MessageId: $messageId, ${attachment.uri})") contentValues.put(DATA_FILE, fileWriteResult.file.absolutePath) contentValues.put(DATA_SIZE, fileWriteResult.length) contentValues.put(DATA_RANDOM, fileWriteResult.random) contentValues.put(DATA_HASH_START, fileWriteResult.hash) } // Our hashMatch already represents a transform-compatible attachment with the most recent upload timestamp. We just need to make sure it has all of the // other necessary fields, and if so, we can use that to skip the upload. var uploadTemplate: Attachment? = null if (hashMatch?.hashEnd != null && System.currentTimeMillis() - hashMatch.uploadTimestamp < AttachmentUploadJob.UPLOAD_REUSE_THRESHOLD) { uploadTemplate = readableDatabase .select(*PROJECTION) .from(TABLE_NAME) .where("$ID = ${hashMatch.id.id} AND $REMOTE_DIGEST NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_HASH_END NOT NULL") .run() .readToSingleObject { it.readAttachment() } } if (uploadTemplate != null) { Log.i(TAG, "[insertAttachmentWithData] Found a valid template we could use to skip upload. (MessageId: $messageId, ${attachment.uri})") transformProperties = (uploadTemplate.transformProperties ?: transformProperties).copy(skipTransform = true) } contentValues.put(MESSAGE_ID, messageId) contentValues.put(CONTENT_TYPE, uploadTemplate?.contentType ?: attachment.contentType) contentValues.put(TRANSFER_STATE, attachment.transferState) // Even if we have a template, we let AttachmentUploadJob have the final say so it can re-check and make sure the template is still valid contentValues.put(CDN_NUMBER, uploadTemplate?.cdnNumber ?: 0) contentValues.put(REMOTE_LOCATION, uploadTemplate?.remoteLocation) contentValues.put(REMOTE_DIGEST, uploadTemplate?.remoteDigest) contentValues.put(REMOTE_INCREMENTAL_DIGEST, uploadTemplate?.incrementalDigest) contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, uploadTemplate?.incrementalMacChunkSize ?: 0) contentValues.put(REMOTE_KEY, uploadTemplate?.remoteKey) contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) contentValues.put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) contentValues.put(VOICE_NOTE, if (attachment.voiceNote) 1 else 0) contentValues.put(BORDERLESS, if (attachment.borderless) 1 else 0) contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width) contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height) contentValues.put(QUOTE, quote) contentValues.put(CAPTION, attachment.caption) contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0) contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()) if (attachment.transformProperties?.videoEdited == true) { contentValues.putNull(BLUR_HASH) } else { contentValues.put(BLUR_HASH, uploadTemplate.getVisualHashStringOrNull()) } attachment.stickerLocator?.let { sticker -> contentValues.put(STICKER_PACK_ID, sticker.packId) contentValues.put(STICKER_PACK_KEY, sticker.packKey) contentValues.put(STICKER_ID, sticker.stickerId) contentValues.put(STICKER_EMOJI, sticker.emoji) } val rowId = db.insert(TABLE_NAME, null, contentValues) AttachmentId(rowId) to (hashMatch != null) } if (foundDuplicate) { if (!fileWriteResult.file.delete()) { Log.w(TAG, "[insertAttachmentWithData] Failed to delete duplicate file: ${fileWriteResult.file.absolutePath}") } } notifyAttachmentListeners() return attachmentId } private fun getTransferFile(db: SQLiteDatabase, attachmentId: AttachmentId): File? { return db .select(TRANSFER_FILE) .from(TABLE_NAME) .where("$ID = ?", attachmentId.id) .limit(1) .run() .readToSingleObject { cursor -> cursor.requireString(TRANSFER_FILE)?.let { File(it) } } } private fun getAttachment(cursor: Cursor): DatabaseAttachment { val contentType = cursor.requireString(CONTENT_TYPE) return DatabaseAttachment( attachmentId = AttachmentId(cursor.requireLong(ID)), mmsId = cursor.requireLong(MESSAGE_ID), hasData = !cursor.isNull(DATA_FILE), hasThumbnail = MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType), contentType = contentType, transferProgress = cursor.requireInt(TRANSFER_STATE), size = cursor.requireLong(DATA_SIZE), fileName = cursor.requireString(FILE_NAME), cdnNumber = cursor.requireInt(CDN_NUMBER), location = cursor.requireString(REMOTE_LOCATION), key = cursor.requireString(REMOTE_KEY), digest = cursor.requireBlob(REMOTE_DIGEST), incrementalDigest = cursor.requireBlob(REMOTE_INCREMENTAL_DIGEST), incrementalMacChunkSize = cursor.requireInt(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE), fastPreflightId = cursor.requireString(FAST_PREFLIGHT_ID), voiceNote = cursor.requireBoolean(VOICE_NOTE), borderless = cursor.requireBoolean(BORDERLESS), videoGif = cursor.requireBoolean(VIDEO_GIF), width = cursor.requireInt(WIDTH), height = cursor.requireInt(HEIGHT), quote = cursor.requireBoolean(QUOTE), caption = cursor.requireString(CAPTION), stickerLocator = cursor.readStickerLocator(), blurHash = if (MediaUtil.isAudioType(contentType)) null else BlurHash.parseOrNull(cursor.requireString(BLUR_HASH)), audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(cursor.requireString(BLUR_HASH)) else null, transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)), displayOrder = cursor.requireInt(DISPLAY_ORDER), uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP), dataHash = cursor.requireString(DATA_HASH_END) ) } private fun Cursor.readAttachments(): List { return getAttachments(this) } private fun Cursor.readAttachment(): DatabaseAttachment { return getAttachment(this) } private fun Cursor.readDataFileInfo(): DataFileInfo { return DataFileInfo( id = AttachmentId(this.requireLong(ID)), file = File(this.requireNonNullString(DATA_FILE)), length = this.requireLong(DATA_SIZE), random = this.requireNonNullBlob(DATA_RANDOM), hashStart = this.requireString(DATA_HASH_START), hashEnd = this.requireString(DATA_HASH_END), transformProperties = TransformProperties.parse(this.requireString(TRANSFORM_PROPERTIES)), uploadTimestamp = this.requireLong(UPLOAD_TIMESTAMP) ) } private fun Cursor.readStickerLocator(): StickerLocator? { return if (this.requireInt(STICKER_ID) >= 0) { StickerLocator( packId = this.requireNonNullString(STICKER_PACK_ID), packKey = this.requireNonNullString(STICKER_PACK_KEY), stickerId = this.requireInt(STICKER_ID), emoji = this.requireString(STICKER_EMOJI) ) } else { null } } private fun Attachment?.getVisualHashStringOrNull(): String? { return when { this == null -> null this.blurHash != null -> this.blurHash.hash this.audioHash != null -> this.audioHash.hash else -> null } } fun debugGetLatestAttachments(): List { return readableDatabase .select(*PROJECTION) .from(TABLE_NAME) .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH_END IS NOT NULL") .orderBy("$ID DESC") .limit(30) .run() .readToList { it.readAttachments() } .flatten() } class DataFileWriteResult( val file: File, val length: Long, val random: ByteArray, val hash: String, val transformProperties: TransformProperties ) @VisibleForTesting class DataFileInfo( val id: AttachmentId, val file: File, val length: Long, val random: ByteArray, val hashStart: String?, val hashEnd: String?, val transformProperties: TransformProperties, val uploadTimestamp: Long ) @Parcelize data class TransformProperties( @JsonProperty("skipTransform") @JvmField val skipTransform: Boolean = false, @JsonProperty("videoTrim") @JvmField val videoTrim: Boolean = false, @JsonProperty("videoTrimStartTimeUs") @JvmField val videoTrimStartTimeUs: Long = 0, @JsonProperty("videoTrimEndTimeUs") @JvmField val videoTrimEndTimeUs: Long = 0, @JsonProperty("sentMediaQuality") @JvmField val sentMediaQuality: Int = SentMediaQuality.STANDARD.code, @JsonProperty("mp4Faststart") @JvmField val mp4FastStart: Boolean = false ) : Parcelable { fun shouldSkipTransform(): Boolean { return skipTransform } @IgnoredOnParcel @JsonProperty("videoEdited") val videoEdited: Boolean = videoTrim fun withSkipTransform(): TransformProperties { return this.copy( skipTransform = true ) } fun withMp4FastStart(): TransformProperties { return this.copy(mp4FastStart = true) } fun serialize(): String { return JsonUtil.toJson(this) } companion object { private val DEFAULT_MEDIA_QUALITY = SentMediaQuality.STANDARD.code @JvmStatic fun empty(): TransformProperties { return TransformProperties( skipTransform = false, videoTrim = false, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 0, sentMediaQuality = DEFAULT_MEDIA_QUALITY, mp4FastStart = false ) } fun forSkipTransform(): TransformProperties { return TransformProperties( skipTransform = true, videoTrim = false, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 0, sentMediaQuality = DEFAULT_MEDIA_QUALITY, mp4FastStart = false ) } fun forVideoTrim(videoTrimStartTimeUs: Long, videoTrimEndTimeUs: Long): TransformProperties { return TransformProperties( skipTransform = false, videoTrim = true, videoTrimStartTimeUs = videoTrimStartTimeUs, videoTrimEndTimeUs = videoTrimEndTimeUs, sentMediaQuality = DEFAULT_MEDIA_QUALITY, mp4FastStart = false ) } @JvmStatic fun forSentMediaQuality(currentProperties: Optional, sentMediaQuality: SentMediaQuality): TransformProperties { val existing = currentProperties.orElse(empty()) return existing.copy(sentMediaQuality = sentMediaQuality.code) } @JvmStatic fun forSentMediaQuality(sentMediaQuality: Int): TransformProperties { return TransformProperties(sentMediaQuality = sentMediaQuality) } @JvmStatic fun parse(serialized: String?): TransformProperties { return if (serialized == null) { empty() } else { try { JsonUtil.fromJson(serialized, TransformProperties::class.java) } catch (e: IOException) { Log.w(TAG, "Failed to parse TransformProperties!", e) empty() } } } } } }