That fuck shit the fascists are using
at master 1745 lines 66 kB view raw
1/* 2 * Copyright (C) 2011 Whisper Systems 3 * 4 * This program is free software: you can redistribute it and/or modify 5 * it under the terms of the GNU General Public License as published by 6 * the Free Software Foundation, either version 3 of the License, or 7 * (at your option) any later version. 8 * 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <http://www.gnu.org/licenses/>. 16 */ 17package org.tm.archive.database 18 19import android.content.ContentValues 20import android.content.Context 21import android.database.Cursor 22import android.media.MediaDataSource 23import android.os.Parcelable 24import android.text.TextUtils 25import androidx.annotation.RequiresApi 26import androidx.annotation.VisibleForTesting 27import androidx.annotation.WorkerThread 28import androidx.core.content.contentValuesOf 29import com.bumptech.glide.Glide 30import com.fasterxml.jackson.annotation.JsonProperty 31import kotlinx.parcelize.IgnoredOnParcel 32import kotlinx.parcelize.Parcelize 33import org.json.JSONArray 34import org.json.JSONException 35import org.signal.core.util.Base64 36import org.signal.core.util.SqlUtil 37import org.signal.core.util.StreamUtil 38import org.signal.core.util.ThreadUtil 39import org.signal.core.util.delete 40import org.signal.core.util.deleteAll 41import org.signal.core.util.drain 42import org.signal.core.util.exists 43import org.signal.core.util.forEach 44import org.signal.core.util.groupBy 45import org.signal.core.util.isNull 46import org.signal.core.util.logging.Log 47import org.signal.core.util.readToList 48import org.signal.core.util.readToSingleObject 49import org.signal.core.util.requireBlob 50import org.signal.core.util.requireBoolean 51import org.signal.core.util.requireInt 52import org.signal.core.util.requireLong 53import org.signal.core.util.requireNonNullBlob 54import org.signal.core.util.requireNonNullString 55import org.signal.core.util.requireString 56import org.signal.core.util.select 57import org.signal.core.util.toInt 58import org.signal.core.util.update 59import org.signal.core.util.withinTransaction 60import org.tm.archive.attachments.Attachment 61import org.tm.archive.attachments.AttachmentId 62import org.tm.archive.attachments.DatabaseAttachment 63import org.tm.archive.audio.AudioHash 64import org.tm.archive.blurhash.BlurHash 65import org.tm.archive.crypto.AttachmentSecret 66import org.tm.archive.crypto.ClassicDecryptingPartInputStream 67import org.tm.archive.crypto.ModernDecryptingPartInputStream 68import org.tm.archive.crypto.ModernEncryptingPartOutputStream 69import org.tm.archive.database.SignalDatabase.Companion.messages 70import org.tm.archive.database.SignalDatabase.Companion.stickers 71import org.tm.archive.database.SignalDatabase.Companion.threads 72import org.tm.archive.database.model.databaseprotos.AudioWaveFormData 73import org.tm.archive.dependencies.ApplicationDependencies 74import org.tm.archive.jobs.AttachmentDownloadJob 75import org.tm.archive.jobs.AttachmentUploadJob 76import org.tm.archive.jobs.GenerateAudioWaveFormJob 77import org.tm.archive.mms.MediaStream 78import org.tm.archive.mms.MmsException 79import org.tm.archive.mms.PartAuthority 80import org.tm.archive.mms.SentMediaQuality 81import org.tm.archive.stickers.StickerLocator 82import org.tm.archive.util.FileUtils 83import org.tm.archive.util.JsonUtils.SaneJSONObject 84import org.tm.archive.util.MediaUtil 85import org.tm.archive.util.StorageUtil 86import org.tm.archive.video.EncryptedMediaDataSource 87import org.whispersystems.signalservice.internal.util.JsonUtil 88import java.io.File 89import java.io.FileNotFoundException 90import java.io.IOException 91import java.io.InputStream 92import java.security.DigestInputStream 93import java.security.MessageDigest 94import java.security.NoSuchAlgorithmException 95import java.util.LinkedList 96import java.util.Optional 97import java.util.UUID 98import kotlin.time.Duration.Companion.days 99 100open class AttachmentTable( //TM_SA make class open 101 context: Context, 102 databaseHelper: SignalDatabase, 103 private val attachmentSecret: AttachmentSecret 104) : DatabaseTable(context, databaseHelper) { 105 106 companion object { 107 val TAG = Log.tag(AttachmentTable::class.java) 108 109 const val TABLE_NAME = "attachment" 110 const val ID = "_id" 111 const val MESSAGE_ID = "message_id" 112 const val CONTENT_TYPE = "content_type" 113 const val REMOTE_KEY = "remote_key" 114 const val REMOTE_LOCATION = "remote_location" 115 const val REMOTE_DIGEST = "remote_digest" 116 const val REMOTE_INCREMENTAL_DIGEST = "remote_incremental_digest" 117 const val REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE = "remote_incremental_digest_chunk_size" 118 const val CDN_NUMBER = "cdn_number" 119 const val TRANSFER_STATE = "transfer_state" 120 const val TRANSFER_FILE = "transfer_file" 121 const val DATA_FILE = "data_file" 122 const val DATA_SIZE = "data_size" 123 const val DATA_RANDOM = "data_random" 124 const val DATA_HASH_START = "data_hash_start" 125 const val DATA_HASH_END = "data_hash_end" 126 const val FILE_NAME = "file_name" 127 const val FAST_PREFLIGHT_ID = "fast_preflight_id" 128 const val VOICE_NOTE = "voice_note" 129 const val BORDERLESS = "borderless" 130 const val VIDEO_GIF = "video_gif" 131 const val QUOTE = "quote" 132 const val WIDTH = "width" 133 const val HEIGHT = "height" 134 const val CAPTION = "caption" 135 const val STICKER_PACK_ID = "sticker_pack_id" 136 const val STICKER_PACK_KEY = "sticker_pack_key" 137 const val STICKER_ID = "sticker_id" 138 const val STICKER_EMOJI = "sticker_emoji" 139 const val BLUR_HASH = "blur_hash" 140 const val TRANSFORM_PROPERTIES = "transform_properties" 141 const val DISPLAY_ORDER = "display_order" 142 const val UPLOAD_TIMESTAMP = "upload_timestamp" 143 144 const val ATTACHMENT_JSON_ALIAS = "attachment_json" 145 146 private const val DIRECTORY = "parts" 147 148 const val TRANSFER_PROGRESS_DONE = 0 149 const val TRANSFER_PROGRESS_STARTED = 1 150 const val TRANSFER_PROGRESS_PENDING = 2 151 const val TRANSFER_PROGRESS_FAILED = 3 152 const val TRANSFER_PROGRESS_PERMANENT_FAILURE = 4 153 const val PREUPLOAD_MESSAGE_ID: Long = -8675309 154 155 private val PROJECTION = arrayOf( 156 ID, 157 MESSAGE_ID, 158 CONTENT_TYPE, 159 REMOTE_KEY, 160 REMOTE_LOCATION, 161 REMOTE_DIGEST, 162 REMOTE_INCREMENTAL_DIGEST, 163 REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, 164 CDN_NUMBER, 165 TRANSFER_STATE, 166 TRANSFER_FILE, 167 DATA_FILE, 168 DATA_SIZE, 169 DATA_RANDOM, 170 FILE_NAME, 171 FAST_PREFLIGHT_ID, 172 VOICE_NOTE, 173 BORDERLESS, 174 VIDEO_GIF, 175 QUOTE, 176 WIDTH, 177 HEIGHT, 178 CAPTION, 179 STICKER_PACK_ID, 180 STICKER_PACK_KEY, 181 STICKER_ID, 182 STICKER_EMOJI, 183 BLUR_HASH, 184 TRANSFORM_PROPERTIES, 185 DISPLAY_ORDER, 186 UPLOAD_TIMESTAMP, 187 DATA_HASH_START, 188 DATA_HASH_END 189 ) 190 191 const val CREATE_TABLE = """ 192 CREATE TABLE $TABLE_NAME ( 193 $ID INTEGER PRIMARY KEY AUTOINCREMENT, 194 $MESSAGE_ID INTEGER, 195 $CONTENT_TYPE TEXT, 196 $REMOTE_KEY TEXT, 197 $REMOTE_LOCATION TEXT, 198 $REMOTE_DIGEST BLOB, 199 $REMOTE_INCREMENTAL_DIGEST BLOB, 200 $REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE INTEGER DEFAULT 0, 201 $CDN_NUMBER INTEGER DEFAULT 0, 202 $TRANSFER_STATE INTEGER, 203 $TRANSFER_FILE TEXT DEFAULT NULL, 204 $DATA_FILE TEXT, 205 $DATA_SIZE INTEGER, 206 $DATA_RANDOM BLOB, 207 $FILE_NAME TEXT, 208 $FAST_PREFLIGHT_ID TEXT, 209 $VOICE_NOTE INTEGER DEFAULT 0, 210 $BORDERLESS INTEGER DEFAULT 0, 211 $VIDEO_GIF INTEGER DEFAULT 0, 212 $QUOTE INTEGER DEFAULT 0, 213 $WIDTH INTEGER DEFAULT 0, 214 $HEIGHT INTEGER DEFAULT 0, 215 $CAPTION TEXT DEFAULT NULL, 216 $STICKER_PACK_ID TEXT DEFAULT NULL, 217 $STICKER_PACK_KEY DEFAULT NULL, 218 $STICKER_ID INTEGER DEFAULT -1, 219 $STICKER_EMOJI STRING DEFAULT NULL, 220 $BLUR_HASH TEXT DEFAULT NULL, 221 $TRANSFORM_PROPERTIES TEXT DEFAULT NULL, 222 $DISPLAY_ORDER INTEGER DEFAULT 0, 223 $UPLOAD_TIMESTAMP INTEGER DEFAULT 0, 224 $DATA_HASH_START TEXT DEFAULT NULL, 225 $DATA_HASH_END TEXT DEFAULT NULL 226 ) 227 """ 228 229 @JvmField 230 val CREATE_INDEXS = arrayOf( 231 "CREATE INDEX IF NOT EXISTS attachment_message_id_index ON $TABLE_NAME ($MESSAGE_ID);", 232 "CREATE INDEX IF NOT EXISTS attachment_transfer_state_index ON $TABLE_NAME ($TRANSFER_STATE);", 233 "CREATE INDEX IF NOT EXISTS attachment_sticker_pack_id_index ON $TABLE_NAME ($STICKER_PACK_ID);", 234 "CREATE INDEX IF NOT EXISTS attachment_data_hash_start_index ON $TABLE_NAME ($DATA_HASH_START);", 235 "CREATE INDEX IF NOT EXISTS attachment_data_hash_end_index ON $TABLE_NAME ($DATA_HASH_END);", 236 "CREATE INDEX IF NOT EXISTS attachment_data_index ON $TABLE_NAME ($DATA_FILE);" 237 ) 238 239 val ATTACHMENT_POINTER_REUSE_THRESHOLD = 7.days.inWholeMilliseconds 240 241 @JvmStatic 242 @JvmOverloads 243 @Throws(IOException::class) 244 fun newDataFile(context: Context): File { 245 val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) 246 return PartFileProtector.protect { File.createTempFile("part", ".mms", partsDirectory) } 247 } 248 } 249 250 @Throws(IOException::class) 251 fun getAttachmentStream(attachmentId: AttachmentId, offset: Long): InputStream { 252 return try { 253 getDataStream(attachmentId, offset) 254 } catch (e: FileNotFoundException) { 255 throw IOException("No stream for: $attachmentId", e) 256 } ?: throw IOException("No stream for: $attachmentId") 257 } 258 259 /** 260 * Returns a [File] for an attachment that has no [DATA_HASH_END] and is in the [TRANSFER_PROGRESS_DONE] state, if present. 261 */ 262 fun getUnhashedDataFile(): Pair<File, AttachmentId>? { 263 return readableDatabase 264 .select(ID, DATA_FILE) 265 .from(TABLE_NAME) 266 .where("$DATA_FILE NOT NULL AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE") 267 .orderBy("$ID DESC") 268 .limit(1) 269 .run() 270 .readToSingleObject { 271 File(it.requireNonNullString(DATA_FILE)) to AttachmentId(it.requireLong(ID)) 272 } 273 } 274 275 /** 276 * 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. 277 * As a result, this will _not_ update the hashes on files that are not fully uploaded. 278 */ 279 fun setHashForDataFile(file: File, hash: ByteArray) { 280 writableDatabase.withinTransaction { db -> 281 val hashEnd = Base64.encodeWithPadding(hash) 282 283 val (existingFile: String?, existingSize: Long?, existingRandom: ByteArray?) = db.select(DATA_FILE, DATA_SIZE, DATA_RANDOM) 284 .from(TABLE_NAME) 285 .where("$DATA_HASH_END = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL AND $DATA_FILE != ?", hashEnd, file.absolutePath) 286 .limit(1) 287 .run() 288 .readToSingleObject { 289 Triple( 290 it.requireString(DATA_FILE), 291 it.requireLong(DATA_SIZE), 292 it.requireBlob(DATA_RANDOM) 293 ) 294 } ?: Triple(null, null, null) 295 296 if (existingFile != null) { 297 Log.i(TAG, "[setHashForDataFile] Found that a different file has the same HASH_END. Using that one instead. Pre-existing file: $existingFile", true) 298 299 val updateCount = writableDatabase 300 .update(TABLE_NAME) 301 .values( 302 DATA_FILE to existingFile, 303 DATA_HASH_END to hashEnd, 304 DATA_SIZE to existingSize, 305 DATA_RANDOM to existingRandom 306 ) 307 .where("$DATA_FILE = ? AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) 308 .run() 309 310 Log.i(TAG, "[setHashForDataFile] Deduped $updateCount attachments.", true) 311 312 val oldFileInUse = db.exists(TABLE_NAME).where("$DATA_FILE = ?", file.absolutePath).run() 313 if (oldFileInUse) { 314 Log.i(TAG, "[setHashForDataFile] Old file is still in use by some in-progress attachment.", true) 315 } else { 316 Log.i(TAG, "[setHashForDataFile] Deleting unused file: $file") 317 if (!file.delete()) { 318 Log.w(TAG, "Failed to delete duped file!") 319 } 320 } 321 } else { 322 val updateCount = writableDatabase 323 .update(TABLE_NAME) 324 .values(DATA_HASH_END to Base64.encodeWithPadding(hash)) 325 .where("$DATA_FILE = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) 326 .run() 327 328 Log.i(TAG, "[setHashForDataFile] Updated the HASH_END for $updateCount rows using file ${file.absolutePath}") 329 } 330 } 331 } 332 333 fun getAttachment(attachmentId: AttachmentId): DatabaseAttachment? { 334 return readableDatabase 335 .select(*PROJECTION) 336 .from(TABLE_NAME) 337 .where("$ID = ?", attachmentId.id) 338 .run() 339 .readToList { it.readAttachments() } 340 .flatten() 341 .firstOrNull() 342 } 343 344 fun getAttachmentsForMessage(mmsId: Long): List<DatabaseAttachment> { 345 return readableDatabase 346 .select(*PROJECTION) 347 .from(TABLE_NAME) 348 .where("$MESSAGE_ID = ?", mmsId) 349 .orderBy("$ID ASC") 350 .run() 351 .readToList { it.readAttachments() } 352 .flatten() 353 } 354 355 fun getAttachmentsForMessages(mmsIds: Collection<Long?>): Map<Long, List<DatabaseAttachment>> { 356 if (mmsIds.isEmpty()) { 357 return emptyMap() 358 } 359 360 val query = SqlUtil.buildSingleCollectionQuery(MESSAGE_ID, mmsIds) 361 362 return readableDatabase 363 .select(*PROJECTION) 364 .from(TABLE_NAME) 365 .where(query.where, query.whereArgs) 366 .orderBy("$ID ASC") 367 .run() 368 .groupBy { cursor -> 369 val attachment = cursor.readAttachment() 370 attachment.mmsId to attachment 371 } 372 } 373 374 fun hasAttachment(id: AttachmentId): Boolean { 375 return readableDatabase 376 .exists(TABLE_NAME) 377 .where("$ID = ?", id.id) 378 .run() 379 } 380 381 fun getPendingAttachments(): List<DatabaseAttachment> { 382 return readableDatabase 383 .select(*PROJECTION) 384 .from(TABLE_NAME) 385 .where("$TRANSFER_STATE = ?", TRANSFER_PROGRESS_STARTED.toString()) 386 .run() 387 .readToList { it.readAttachments() } 388 .flatten() 389 } 390 391 fun deleteAttachmentsForMessage(mmsId: Long): Boolean { 392 Log.d(TAG, "[deleteAttachmentsForMessage] mmsId: $mmsId") 393 394 return writableDatabase.withinTransaction { db -> 395 db.select(DATA_FILE, CONTENT_TYPE, ID) 396 .from(TABLE_NAME) 397 .where("$MESSAGE_ID = ?", mmsId) 398 .run() 399 .forEach { cursor -> 400 val attachmentId = AttachmentId(cursor.requireLong(ID)) 401 402 ApplicationDependencies.getJobManager().cancelAllInQueue(AttachmentDownloadJob.constructQueueString(attachmentId)) 403 404 deleteDataFileIfPossible( 405 filePath = cursor.requireString(DATA_FILE), 406 contentType = cursor.requireString(CONTENT_TYPE), 407 attachmentId = attachmentId 408 ) 409 } 410 411 val deleteCount = db.delete(TABLE_NAME) 412 .where("$MESSAGE_ID = ?", mmsId) 413 .run() 414 415 notifyAttachmentListeners() 416 417 deleteCount > 0 418 } 419 } 420 421 /** 422 * Deletes all attachments with an ID of [PREUPLOAD_MESSAGE_ID]. These represent 423 * attachments that were pre-uploaded and haven't been assigned to a message. This should only be 424 * done when you *know* that all attachments *should* be assigned a real mmsId. For instance, when 425 * the app starts. Otherwise you could delete attachments that are legitimately being 426 * pre-uploaded. 427 */ 428 fun deleteAbandonedPreuploadedAttachments(): Int { 429 var count = 0 430 431 writableDatabase 432 .select(ID) 433 .from(TABLE_NAME) 434 .where("$MESSAGE_ID = ?", PREUPLOAD_MESSAGE_ID) 435 .run() 436 .forEach { cursor -> 437 val id = AttachmentId(cursor.requireLong(ID)) 438 deleteAttachment(id) 439 count++ 440 } 441 442 return count 443 } 444 445 fun deleteAttachmentFilesForViewOnceMessage(messageId: Long) { 446 Log.d(TAG, "[deleteAttachmentFilesForViewOnceMessage] messageId: $messageId") 447 448 writableDatabase.withinTransaction { db -> 449 db.select(DATA_FILE, CONTENT_TYPE, ID) 450 .from(TABLE_NAME) 451 .where("$MESSAGE_ID = ?", messageId) 452 .run() 453 .forEach { cursor -> 454 deleteDataFileIfPossible( 455 filePath = cursor.requireString(DATA_FILE), 456 contentType = cursor.requireString(CONTENT_TYPE), 457 attachmentId = AttachmentId(cursor.requireLong(ID)) 458 ) 459 } 460 461 db.update(TABLE_NAME) 462 .values( 463 DATA_FILE to null, 464 DATA_RANDOM to null, 465 DATA_HASH_START to null, 466 DATA_HASH_END to null, 467 FILE_NAME to null, 468 CAPTION to null, 469 DATA_SIZE to 0, 470 WIDTH to 0, 471 HEIGHT to 0, 472 TRANSFER_STATE to TRANSFER_PROGRESS_DONE, 473 BLUR_HASH to null, 474 CONTENT_TYPE to MediaUtil.VIEW_ONCE 475 ) 476 .where("$MESSAGE_ID = ?", messageId) 477 .run() 478 479 notifyAttachmentListeners() 480 481 val threadId = messages.getThreadIdForMessage(messageId) 482 if (threadId > 0) { 483 notifyConversationListeners(threadId) 484 } 485 } 486 } 487 488 fun deleteAttachment(id: AttachmentId) { 489 Log.d(TAG, "[deleteAttachment] attachmentId: $id") 490 491 writableDatabase.withinTransaction { db -> 492 db.select(DATA_FILE, CONTENT_TYPE) 493 .from(TABLE_NAME) 494 .where("$ID = ?", id.id) 495 .run() 496 .use { cursor -> 497 if (!cursor.moveToFirst()) { 498 Log.w(TAG, "Tried to delete an attachment, but it didn't exist.") 499 return@withinTransaction 500 } 501 502 val data = cursor.requireString(DATA_FILE) 503 val contentType = cursor.requireString(CONTENT_TYPE) 504 505 deleteDataFileIfPossible( 506 filePath = data, 507 contentType = contentType, 508 attachmentId = id 509 ) 510 511 db.delete(TABLE_NAME) 512 .where("$ID = ?", id.id) 513 .run() 514 515 deleteDataFileIfPossible(data, contentType, id) 516 notifyAttachmentListeners() 517 } 518 } 519 } 520 521 fun trimAllAbandonedAttachments() { 522 val deleteCount = writableDatabase 523 .delete(TABLE_NAME) 524 .where("$MESSAGE_ID != $PREUPLOAD_MESSAGE_ID AND $MESSAGE_ID NOT IN (SELECT ${MessageTable.ID} FROM ${MessageTable.TABLE_NAME})") 525 .run() 526 527 if (deleteCount > 0) { 528 Log.i(TAG, "Trimmed $deleteCount abandoned attachments.") 529 } 530 } 531 532 fun deleteAbandonedAttachmentFiles(): Int { 533 val diskFiles = context.getDir(DIRECTORY, Context.MODE_PRIVATE).listFiles() ?: return 0 534 535 val filesOnDisk: Set<String> = diskFiles 536 .filter { file: File -> !PartFileProtector.isProtected(file) } 537 .map { file: File -> file.absolutePath } 538 .toSet() 539 540 val filesInDb: Set<String> = readableDatabase 541 .select(DATA_FILE) 542 .from(TABLE_NAME) 543 .run() 544 .readToList { it.requireString(DATA_FILE) } 545 .filterNotNull() 546 .toSet() + stickers.allStickerFiles 547 548 val onDiskButNotInDatabase: Set<String> = filesOnDisk - filesInDb 549 550 for (filePath in onDiskButNotInDatabase) { 551 val success = File(filePath).delete() 552 if (!success) { 553 Log.w(TAG, "[deleteAbandonedAttachmentFiles] Failed to delete attachment file. $filePath") 554 } 555 } 556 557 return onDiskButNotInDatabase.size 558 } 559 560 /** 561 * Removes all references to the provided [DATA_FILE] from all attachments. 562 * Only do this if the file is known to not exist or has some other critical problem! 563 */ 564 fun clearUsagesOfDataFile(file: File) { 565 val updateCount = writableDatabase 566 .update(TABLE_NAME) 567 .values(DATA_FILE to null) 568 .where("$DATA_FILE = ?", file.absolutePath) 569 .run() 570 571 Log.i(TAG, "[clearUsagesOfFile] Cleared $updateCount usages of $file", true) 572 } 573 574 /** 575 * Indicates that, for whatever reason, a hash could not be calculated for the file in question. 576 * We put in a "bad hash" that will never match anything else so that we don't attempt to backfill it in the future. 577 */ 578 fun markDataFileAsUnhashable(file: File) { 579 val updateCount = writableDatabase 580 .update(TABLE_NAME) 581 .values(DATA_HASH_END to "UNHASHABLE-${UUID.randomUUID()}") 582 .where("$DATA_FILE = ? AND $DATA_HASH_END IS NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE", file.absolutePath) 583 .run() 584 585 Log.i(TAG, "[markDataFileAsUnhashable] Marked $updateCount attachments as unhashable with file: ${file.absolutePath}", true) 586 } 587 588 fun deleteAllAttachments() { 589 Log.d(TAG, "[deleteAllAttachments]") 590 591 writableDatabase.deleteAll(TABLE_NAME) 592 593 FileUtils.deleteDirectoryContents(context.getDir(DIRECTORY, Context.MODE_PRIVATE)) 594 595 notifyAttachmentListeners() 596 } 597 598 fun setTransferState(messageId: Long, attachmentId: AttachmentId, transferState: Int) { 599 writableDatabase 600 .update(TABLE_NAME) 601 .values(TRANSFER_STATE to transferState) 602 .where("$ID = ?", attachmentId.id) 603 .run() 604 605 val threadId = messages.getThreadIdForMessage(messageId) 606 notifyConversationListeners(threadId) 607 } 608 609 @Throws(MmsException::class) 610 fun setTransferProgressFailed(attachmentId: AttachmentId, mmsId: Long) { 611 writableDatabase 612 .update(TABLE_NAME) 613 .values(TRANSFER_STATE to TRANSFER_PROGRESS_FAILED) 614 .where("$ID = ? AND $TRANSFER_STATE < $TRANSFER_PROGRESS_PERMANENT_FAILURE", attachmentId.id) 615 .run() 616 617 notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) 618 } 619 620 @Throws(MmsException::class) 621 fun setTransferProgressPermanentFailure(attachmentId: AttachmentId, mmsId: Long) { 622 writableDatabase 623 .update(TABLE_NAME) 624 .values(TRANSFER_STATE to TRANSFER_PROGRESS_PERMANENT_FAILURE) 625 .where("$ID = ?", attachmentId.id) 626 .run() 627 628 notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) 629 } 630 631 /** 632 * 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]. 633 * 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 634 * that the content of the attachment will never change. 635 */ 636 @Throws(MmsException::class) 637 open fun finalizeAttachmentAfterDownload(mmsId: Long, attachmentId: AttachmentId, inputStream: InputStream) { //TM_SA make fun open 638 Log.i(TAG, "[finalizeAttachmentAfterDownload] Finalizing downloaded data for $attachmentId. (MessageId: $mmsId, $attachmentId)") 639 640 val existingPlaceholder: DatabaseAttachment = getAttachment(attachmentId) ?: throw MmsException("No attachment found for id: $attachmentId") 641 642 val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), inputStream, TransformProperties.empty()) 643 val transferFile: File? = getTransferFile(databaseHelper.signalReadableDatabase, attachmentId) 644 645 val foundDuplicate = writableDatabase.withinTransaction { db -> 646 // We can look and see if we have any exact matches on hash_ends and dedupe the file if we see one. 647 // 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 648 // the quality of the attachment we received. 649 val hashMatch: DataFileInfo? = readableDatabase 650 .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) 651 .from(TABLE_NAME) 652 .where("$DATA_HASH_END = ? AND $DATA_HASH_END NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL", fileWriteResult.hash) 653 .run() 654 .readToList { it.readDataFileInfo() } 655 .firstOrNull() 656 657 val values = ContentValues() 658 659 if (hashMatch != null) { 660 Log.i(TAG, "[finalizeAttachmentAfterDownload] Found that ${hashMatch.id} has the same DATA_HASH_END. Deduping. (MessageId: $mmsId, $attachmentId)") 661 values.put(DATA_FILE, hashMatch.file.absolutePath) 662 values.put(DATA_SIZE, hashMatch.length) 663 values.put(DATA_RANDOM, hashMatch.random) 664 values.put(DATA_HASH_START, hashMatch.hashEnd) 665 values.put(DATA_HASH_END, hashMatch.hashEnd) 666 } else { 667 values.put(DATA_FILE, fileWriteResult.file.absolutePath) 668 values.put(DATA_SIZE, fileWriteResult.length) 669 values.put(DATA_RANDOM, fileWriteResult.random) 670 values.put(DATA_HASH_START, fileWriteResult.hash) 671 values.put(DATA_HASH_END, fileWriteResult.hash) 672 } 673 674 val visualHashString = existingPlaceholder.getVisualHashStringOrNull() 675 if (visualHashString != null) { 676 values.put(BLUR_HASH, visualHashString) 677 } 678 679 values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE) 680 values.put(TRANSFER_FILE, null as String?) 681 values.put(TRANSFORM_PROPERTIES, TransformProperties.forSkipTransform().serialize()) 682 683 db.update(TABLE_NAME) 684 .values(values) 685 .where("$ID = ?", attachmentId.id) 686 .run() 687 688 hashMatch != null 689 } 690 691 val threadId = messages.getThreadIdForMessage(mmsId) 692 693 if (!messages.isStory(mmsId)) { 694 threads.updateSnippetUriSilently(threadId, PartAuthority.getAttachmentDataUri(attachmentId)) 695 } 696 697 notifyConversationListeners(threadId) 698 notifyConversationListListeners() 699 notifyAttachmentListeners() 700 701 if (foundDuplicate) { 702 if (!fileWriteResult.file.delete()) { 703 Log.w(TAG, "Failed to delete unused attachment") 704 } 705 } 706 707 if (transferFile != null) { 708 if (!transferFile.delete()) { 709 Log.w(TAG, "Unable to delete transfer file.") 710 } 711 } 712 713 if (MediaUtil.isAudio(existingPlaceholder)) { 714 GenerateAudioWaveFormJob.enqueue(existingPlaceholder.attachmentId) 715 } 716 } 717 718 /** 719 * Needs to be called after an attachment is successfully uploaded. Writes metadata around it's final remote location, as well as calculates 720 * it's ending hash, which is critical for backups. 721 */ 722 @Throws(IOException::class) 723 fun finalizeAttachmentAfterUpload(id: AttachmentId, attachment: Attachment, uploadTimestamp: Long) { 724 Log.i(TAG, "[finalizeAttachmentAfterUpload] Finalizing upload for $id.") 725 726 val dataStream = getAttachmentStream(id, 0) 727 val messageDigest = MessageDigest.getInstance("SHA-256") 728 729 DigestInputStream(dataStream, messageDigest).use { 730 it.drain() 731 } 732 733 val dataHashEnd = Base64.encodeWithPadding(messageDigest.digest()) 734 735 val values = contentValuesOf( 736 TRANSFER_STATE to TRANSFER_PROGRESS_DONE, 737 CDN_NUMBER to attachment.cdnNumber, 738 REMOTE_LOCATION to attachment.remoteLocation, 739 REMOTE_DIGEST to attachment.remoteDigest, 740 REMOTE_INCREMENTAL_DIGEST to attachment.incrementalDigest, 741 REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to attachment.incrementalMacChunkSize, 742 REMOTE_KEY to attachment.remoteKey, 743 DATA_SIZE to attachment.size, 744 DATA_HASH_END to dataHashEnd, 745 FAST_PREFLIGHT_ID to attachment.fastPreflightId, 746 BLUR_HASH to attachment.getVisualHashStringOrNull(), 747 UPLOAD_TIMESTAMP to uploadTimestamp 748 ) 749 750 val dataFilePath = getDataFilePath(id) ?: throw IOException("No data file found for attachment!") 751 752 val updateCount = writableDatabase 753 .update(TABLE_NAME) 754 .values(values) 755 .where("$ID = ? OR $DATA_FILE = ?", id.id, dataFilePath) 756 .run() 757 758 if (updateCount <= 0) { 759 Log.w(TAG, "[finalizeAttachmentAfterUpload] Failed to update attachment after upload! $id") 760 } 761 } 762 763 @Throws(MmsException::class) 764 fun copyAttachmentData(sourceId: AttachmentId, destinationId: AttachmentId) { 765 val sourceAttachment = getAttachment(sourceId) ?: throw MmsException("Cannot find attachment for source!") 766 val sourceDataInfo = getDataFileInfo(sourceId) ?: throw MmsException("No attachment data found for source!") 767 768 writableDatabase 769 .update(TABLE_NAME) 770 .values( 771 DATA_FILE to sourceDataInfo.file.absolutePath, 772 DATA_HASH_START to sourceDataInfo.hashStart, 773 DATA_HASH_END to sourceDataInfo.hashEnd, 774 DATA_SIZE to sourceDataInfo.length, 775 DATA_RANDOM to sourceDataInfo.random, 776 TRANSFER_STATE to sourceAttachment.transferState, 777 CDN_NUMBER to sourceAttachment.cdnNumber, 778 REMOTE_LOCATION to sourceAttachment.remoteLocation, 779 REMOTE_DIGEST to sourceAttachment.remoteDigest, 780 REMOTE_INCREMENTAL_DIGEST to sourceAttachment.incrementalDigest, 781 REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE to sourceAttachment.incrementalMacChunkSize, 782 REMOTE_KEY to sourceAttachment.remoteKey, 783 DATA_SIZE to sourceAttachment.size, 784 FAST_PREFLIGHT_ID to sourceAttachment.fastPreflightId, 785 WIDTH to sourceAttachment.width, 786 HEIGHT to sourceAttachment.height, 787 CONTENT_TYPE to sourceAttachment.contentType, 788 BLUR_HASH to sourceAttachment.getVisualHashStringOrNull(), 789 TRANSFORM_PROPERTIES to sourceAttachment.transformProperties?.serialize() 790 ) 791 .where("$ID = ?", destinationId.id) 792 .run() 793 } 794 795 fun updateAttachmentCaption(id: AttachmentId, caption: String?) { 796 writableDatabase 797 .update(TABLE_NAME) 798 .values(CAPTION to caption) 799 .where("$ID = ?", id.id) 800 .run() 801 } 802 803 fun updateDisplayOrder(orderMap: Map<AttachmentId, Int?>) { 804 writableDatabase.withinTransaction { db -> 805 for ((key, value) in orderMap) { 806 db.update(TABLE_NAME) 807 .values(DISPLAY_ORDER to value) 808 .where("$ID = ?", key.id) 809 .run() 810 } 811 } 812 } 813 814 @Throws(MmsException::class) 815 open fun insertAttachmentForPreUpload(attachment: Attachment): DatabaseAttachment { //TM_SA make fun open 816 val result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID, listOf(attachment), emptyList()) 817 818 if (result.values.isEmpty()) { 819 throw MmsException("Bad attachment result!") 820 } 821 822 return getAttachment(result.values.iterator().next()) ?: throw MmsException("Failed to retrieve attachment we just inserted!") 823 } 824 825 fun updateMessageId(attachmentIds: Collection<AttachmentId>, mmsId: Long, isStory: Boolean) { 826 writableDatabase.withinTransaction { db -> 827 val values = ContentValues(2).apply { 828 put(MESSAGE_ID, mmsId) 829 if (!isStory) { 830 putNull(CAPTION) 831 } 832 } 833 834 var updatedCount = 0 835 var attachmentIdSize = 0 836 for (attachmentId in attachmentIds) { 837 attachmentIdSize++ 838 updatedCount += db 839 .update(TABLE_NAME) 840 .values(values) 841 .where("$ID = ?", attachmentId.id) 842 .run() 843 } 844 845 Log.d(TAG, "[updateMessageId] Updated $updatedCount out of $attachmentIdSize ids.") 846 } 847 } 848 849 /** 850 * 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 851 * inbound attachment that we haven't fetched yet. 852 * 853 * If the attachment has no data, it is assumed that you will later call [finalizeAttachmentAfterDownload]. 854 */ 855 @Throws(MmsException::class) 856 open fun insertAttachmentsForMessage(mmsId: Long, attachments: List<Attachment>, quoteAttachment: List<Attachment>): Map<Attachment, AttachmentId> { //TM_SA make fun open 857 if (attachments.isEmpty() && quoteAttachment.isEmpty()) { 858 return emptyMap() 859 } 860 861 Log.d(TAG, "[insertAttachmentsForMessage] insertParts(${attachments.size})") 862 863 val insertedAttachments: MutableMap<Attachment, AttachmentId> = mutableMapOf() 864 for (attachment in attachments) { 865 val attachmentId = if (attachment.uri != null) { 866 insertAttachmentWithData(mmsId, attachment, attachment.quote) 867 } else { 868 insertUndownloadedAttachment(mmsId, attachment, attachment.quote) 869 } 870 871 insertedAttachments[attachment] = attachmentId 872 Log.i(TAG, "[insertAttachmentsForMessage] Inserted attachment at $attachmentId") 873 } 874 875 try { 876 for (attachment in quoteAttachment) { 877 val attachmentId = if (attachment.uri != null) { 878 insertAttachmentWithData(mmsId, attachment, true) 879 } else { 880 insertUndownloadedAttachment(mmsId, attachment, true) 881 } 882 883 insertedAttachments[attachment] = attachmentId 884 Log.i(TAG, "[insertAttachmentsForMessage] Inserted quoted attachment at $attachmentId") 885 } 886 } catch (e: MmsException) { 887 Log.w(TAG, "Failed to insert quote attachment! messageId: $mmsId") 888 } 889 890 return insertedAttachments 891 } 892 893 /** 894 * Updates the data stored for an existing attachment. This happens after transformations, like transcoding. 895 */ 896 @Throws(MmsException::class, IOException::class) 897 fun updateAttachmentData( 898 databaseAttachment: DatabaseAttachment, 899 mediaStream: MediaStream 900 ) { 901 val attachmentId = databaseAttachment.attachmentId 902 val existingDataFileInfo: DataFileInfo = getDataFileInfo(attachmentId) ?: throw MmsException("No attachment data found!") 903 val newDataFileInfo: DataFileWriteResult = writeToDataFile(existingDataFileInfo.file, mediaStream.stream, databaseAttachment.transformProperties ?: TransformProperties.empty()) 904 905 // 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. 906 907 writableDatabase.withinTransaction { db -> 908 val contentValues = contentValuesOf( 909 DATA_SIZE to newDataFileInfo.length, 910 CONTENT_TYPE to mediaStream.mimeType, 911 WIDTH to mediaStream.width, 912 HEIGHT to mediaStream.height, 913 DATA_FILE to newDataFileInfo.file.absolutePath, 914 DATA_RANDOM to newDataFileInfo.random 915 ) 916 917 val updateCount = db.update(TABLE_NAME) 918 .values(contentValues) 919 .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, existingDataFileInfo.file.absolutePath) 920 .run() 921 922 Log.i(TAG, "[updateAttachmentData] Updated $updateCount rows.") 923 } 924 } 925 926 fun duplicateAttachmentsForMessage(destinationMessageId: Long, sourceMessageId: Long, excludedIds: Collection<Long>) { 927 writableDatabase.withinTransaction { db -> 928 db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM $TABLE_NAME WHERE $MESSAGE_ID = ?", SqlUtil.buildArgs(sourceMessageId)) 929 930 val queries = SqlUtil.buildCollectionQuery(ID, excludedIds) 931 for (query in queries) { 932 db.delete("tmp_part", query.where, query.whereArgs) 933 } 934 935 db.execSQL("UPDATE tmp_part SET $ID = NULL, $MESSAGE_ID = ?", SqlUtil.buildArgs(destinationMessageId)) 936 db.execSQL("INSERT INTO $TABLE_NAME SELECT * FROM tmp_part") 937 db.execSQL("DROP TABLE tmp_part") 938 } 939 } 940 941 @Throws(IOException::class) 942 fun getOrCreateTransferFile(attachmentId: AttachmentId): File { 943 val existing = getTransferFile(writableDatabase, attachmentId) 944 if (existing != null) { 945 return existing 946 } 947 948 val transferFile = newTransferFile() 949 950 writableDatabase 951 .update(TABLE_NAME) 952 .values(TRANSFER_FILE to transferFile.absolutePath) 953 .where("$ID = ?", attachmentId.id) 954 .run() 955 956 return transferFile 957 } 958 959 fun getDataFileInfo(attachmentId: AttachmentId): DataFileInfo? { 960 return readableDatabase 961 .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) 962 .from(TABLE_NAME) 963 .where("$ID = ?", attachmentId.id) 964 .run() 965 .readToSingleObject { cursor -> 966 if (cursor.isNull(DATA_FILE)) { 967 null 968 } else { 969 cursor.readDataFileInfo() 970 } 971 } 972 } 973 974 fun getDataFilePath(attachmentId: AttachmentId): String? { 975 return readableDatabase 976 .select(DATA_FILE) 977 .from(TABLE_NAME) 978 .where("$ID = ?", attachmentId.id) 979 .run() 980 .readToSingleObject { it.requireString(DATA_FILE) } 981 } 982 983 fun markAttachmentAsTransformed(attachmentId: AttachmentId, withFastStart: Boolean) { 984 Log.i(TAG, "[markAttachmentAsTransformed] Marking $attachmentId as transformed. withFastStart: $withFastStart") 985 writableDatabase.withinTransaction { db -> 986 try { 987 val dataInfo = getDataFileInfo(attachmentId) 988 if (dataInfo == null) { 989 Log.w(TAG, "[markAttachmentAsTransformed] Failed to get transformation properties, attachment no longer exists.") 990 return@withinTransaction 991 } 992 993 var transformProperties = dataInfo.transformProperties.withSkipTransform() 994 if (withFastStart) { 995 transformProperties = transformProperties.withMp4FastStart() 996 } 997 998 val count = writableDatabase 999 .update(TABLE_NAME) 1000 .values(TRANSFORM_PROPERTIES to transformProperties.serialize()) 1001 .where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, dataInfo.file.absolutePath) 1002 .run() 1003 1004 Log.i(TAG, "[markAttachmentAsTransformed] Updated $count rows.") 1005 } catch (e: Exception) { 1006 Log.w(TAG, "[markAttachmentAsTransformed] Could not mark attachment as transformed.", e) 1007 } 1008 } 1009 } 1010 1011 @WorkerThread 1012 fun writeAudioHash(attachmentId: AttachmentId, audioWaveForm: AudioWaveFormData?) { 1013 Log.i(TAG, "updating part audio wave form for $attachmentId") 1014 writableDatabase 1015 .update(TABLE_NAME) 1016 .values(BLUR_HASH to audioWaveForm?.let { AudioHash(it).hash }) 1017 .where("$ID = ?", attachmentId.id) 1018 .run() 1019 } 1020 1021 @RequiresApi(23) 1022 fun mediaDataSourceFor(attachmentId: AttachmentId, allowReadingFromTempFile: Boolean): MediaDataSource? { 1023 val dataInfo = getDataFileInfo(attachmentId) 1024 if (dataInfo != null) { 1025 return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length) 1026 } 1027 1028 if (allowReadingFromTempFile) { 1029 Log.d(TAG, "Completed data file not found for video attachment, checking for in-progress files.") 1030 val transferFile = getTransferFile(readableDatabase, attachmentId) 1031 if (transferFile != null) { 1032 return EncryptedMediaDataSource.createForDiskBlob(attachmentSecret, transferFile) 1033 } 1034 } 1035 1036 Log.w(TAG, "No data file found for video attachment!") 1037 return null 1038 } 1039 1040 /** 1041 * @return null if we fail to find the given attachmentId 1042 */ 1043 fun getTransformProperties(attachmentId: AttachmentId): TransformProperties? { 1044 return readableDatabase 1045 .select(TRANSFORM_PROPERTIES) 1046 .from(TABLE_NAME) 1047 .where("$ID = ?", attachmentId.id) 1048 .run() 1049 .readToSingleObject { 1050 TransformProperties.parse(it.requireString(TRANSFORM_PROPERTIES)) 1051 } 1052 } 1053 1054 open fun markAttachmentUploaded(messageId: Long, attachment: Attachment) { //TM_SA make fun open 1055 writableDatabase 1056 .update(TABLE_NAME) 1057 .values(TRANSFER_STATE to TRANSFER_PROGRESS_DONE) 1058 .where("$ID = ?", (attachment as DatabaseAttachment).attachmentId.id) 1059 .run() 1060 1061 val threadId = messages.getThreadIdForMessage(messageId) 1062 notifyConversationListeners(threadId) 1063 } 1064 1065 fun getAttachments(cursor: Cursor): List<DatabaseAttachment> { 1066 return try { 1067 if (cursor.getColumnIndex(ATTACHMENT_JSON_ALIAS) != -1) { 1068 if (cursor.isNull(ATTACHMENT_JSON_ALIAS)) { 1069 return LinkedList() 1070 } 1071 1072 val result: MutableList<DatabaseAttachment> = mutableListOf() 1073 val array = JSONArray(cursor.requireString(ATTACHMENT_JSON_ALIAS)) 1074 1075 for (i in 0 until array.length()) { 1076 val jsonObject = SaneJSONObject(array.getJSONObject(i)) 1077 1078 if (!jsonObject.isNull(ID)) { 1079 val contentType = jsonObject.getString(CONTENT_TYPE) 1080 1081 result += DatabaseAttachment( 1082 attachmentId = AttachmentId(jsonObject.getLong(ID)), 1083 mmsId = jsonObject.getLong(MESSAGE_ID), 1084 hasData = !TextUtils.isEmpty(jsonObject.getString(DATA_FILE)), 1085 hasThumbnail = MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType), 1086 contentType = contentType, 1087 transferProgress = jsonObject.getInt(TRANSFER_STATE), 1088 size = jsonObject.getLong(DATA_SIZE), 1089 fileName = jsonObject.getString(FILE_NAME), 1090 cdnNumber = jsonObject.getInt(CDN_NUMBER), 1091 location = jsonObject.getString(REMOTE_LOCATION), 1092 key = jsonObject.getString(REMOTE_KEY), 1093 digest = null, 1094 incrementalDigest = null, 1095 incrementalMacChunkSize = 0, 1096 fastPreflightId = jsonObject.getString(FAST_PREFLIGHT_ID), 1097 voiceNote = jsonObject.getInt(VOICE_NOTE) == 1, 1098 borderless = jsonObject.getInt(BORDERLESS) == 1, 1099 videoGif = jsonObject.getInt(VIDEO_GIF) == 1, 1100 width = jsonObject.getInt(WIDTH), 1101 height = jsonObject.getInt(HEIGHT), 1102 quote = jsonObject.getInt(QUOTE) == 1, 1103 caption = jsonObject.getString(CAPTION), 1104 stickerLocator = if (jsonObject.getInt(STICKER_ID) >= 0) { 1105 StickerLocator( 1106 jsonObject.getString(STICKER_PACK_ID)!!, 1107 jsonObject.getString(STICKER_PACK_KEY)!!, 1108 jsonObject.getInt(STICKER_ID), 1109 jsonObject.getString(STICKER_EMOJI) 1110 ) 1111 } else { 1112 null 1113 }, 1114 blurHash = if (MediaUtil.isAudioType(contentType)) null else BlurHash.parseOrNull(jsonObject.getString(BLUR_HASH)), 1115 audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(jsonObject.getString(BLUR_HASH)) else null, 1116 transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)), 1117 displayOrder = jsonObject.getInt(DISPLAY_ORDER), 1118 uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP), 1119 dataHash = jsonObject.getString(DATA_HASH_END) 1120 ) 1121 } 1122 } 1123 1124 result 1125 } else { 1126 listOf(getAttachment(cursor)) 1127 } 1128 } catch (e: JSONException) { 1129 throw AssertionError(e) 1130 } 1131 } 1132 1133 fun hasStickerAttachments(): Boolean { 1134 return readableDatabase 1135 .exists(TABLE_NAME) 1136 .where("$STICKER_PACK_ID NOT NULL") 1137 .run() 1138 } 1139 1140 fun containsStickerPackId(stickerPackId: String): Boolean { 1141 return readableDatabase.exists(TABLE_NAME) 1142 .where("$STICKER_PACK_ID = ?", stickerPackId) 1143 .run() 1144 } 1145 1146 fun getUnavailableStickerPacks(): Cursor { 1147 val query = """ 1148 SELECT DISTINCT $STICKER_PACK_ID, $STICKER_PACK_KEY 1149 FROM $TABLE_NAME 1150 WHERE 1151 $STICKER_PACK_ID NOT NULL AND 1152 $STICKER_PACK_KEY NOT NULL AND 1153 $STICKER_PACK_ID NOT IN (SELECT DISTINCT ${StickerTable.PACK_ID} FROM ${StickerTable.TABLE_NAME}) 1154 """ 1155 1156 return readableDatabase.rawQuery(query, null) 1157 } 1158 1159 /** 1160 * Deletes the data file if there's no strong references to other attachments. 1161 * If deleted, it will also clear all weak references (i.e. quotes) of the attachment. 1162 */ 1163 private fun deleteDataFileIfPossible( 1164 filePath: String?, 1165 contentType: String?, 1166 attachmentId: AttachmentId 1167 ) { 1168 check(writableDatabase.inTransaction()) { "Must be in a transaction!" } 1169 1170 if (filePath == null) { 1171 Log.w(TAG, "[deleteDataFileIfPossible] Null data file path for $attachmentId! Can't delete anything.") 1172 return 1173 } 1174 1175 val strongReferenceExists = readableDatabase 1176 .exists(TABLE_NAME) 1177 .where("$DATA_FILE = ? AND QUOTE = 0 AND $ID != ${attachmentId.id}", filePath) 1178 .run() 1179 1180 if (strongReferenceExists) { 1181 Log.i(TAG, "[deleteDataFileIfPossible] Attachment in use. Skipping deletion of $attachmentId. Path: $filePath") 1182 return 1183 } 1184 1185 val weakReferenceCount = writableDatabase 1186 .update(TABLE_NAME) 1187 .values( 1188 DATA_FILE to null, 1189 DATA_RANDOM to null, 1190 DATA_HASH_START to null, 1191 DATA_HASH_END to null 1192 ) 1193 .where("$DATA_FILE = ?", filePath) 1194 .run() 1195 1196 Log.i(TAG, "[deleteDataFileIfPossible] Cleared $weakReferenceCount weak references for $attachmentId. Path: $filePath") 1197 1198 if (!File(filePath).delete()) { 1199 Log.w(TAG, "[deleteDataFileIfPossible] Failed to delete $attachmentId. Path: $filePath") 1200 } 1201 1202 if (MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType)) { 1203 Glide.get(context).clearDiskCache() 1204 ThreadUtil.runOnMain { Glide.get(context).clearMemory() } 1205 } 1206 } 1207 1208 @Throws(FileNotFoundException::class) 1209 private fun getDataStream(attachmentId: AttachmentId, offset: Long): InputStream? { 1210 val dataInfo = getDataFileInfo(attachmentId) ?: return null 1211 1212 return try { 1213 if (dataInfo.random != null && dataInfo.random.size == 32) { 1214 ModernDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.random, dataInfo.file, offset) 1215 } else { 1216 val stream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, dataInfo.file) 1217 val skipped = stream.skip(offset) 1218 if (skipped != offset) { 1219 Log.w(TAG, "Skip failed: $skipped vs $offset") 1220 return null 1221 } 1222 stream 1223 } 1224 } catch (e: FileNotFoundException) { 1225 Log.w(TAG, e) 1226 throw e 1227 } catch (e: IOException) { 1228 Log.w(TAG, e) 1229 null 1230 } 1231 } 1232 1233 @Throws(IOException::class) 1234 private fun newTransferFile(): File { 1235 val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE) 1236 return PartFileProtector.protect { 1237 File.createTempFile("transfer", ".mms", partsDirectory) 1238 } 1239 } 1240 1241 /** 1242 * Reads the entire stream and saves to disk and returns a bunch of metadat about the write. 1243 */ 1244 @Throws(MmsException::class, IllegalStateException::class) 1245 private fun writeToDataFile(destination: File, inputStream: InputStream, transformProperties: TransformProperties): DataFileWriteResult { 1246 return try { 1247 // Sometimes the destination is a file that's already in use, sometimes it's not. 1248 // 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. 1249 val tempFile = newDataFile(context) 1250 val messageDigest = MessageDigest.getInstance("SHA-256") 1251 val digestInputStream = DigestInputStream(inputStream, messageDigest) 1252 1253 val encryptingStreamData = ModernEncryptingPartOutputStream.createFor(attachmentSecret, tempFile, false) 1254 val random = encryptingStreamData.first 1255 val encryptingOutputStream = encryptingStreamData.second 1256 1257 val length = StreamUtil.copy(digestInputStream, encryptingOutputStream) 1258 val hash = Base64.encodeWithPadding(digestInputStream.messageDigest.digest()) 1259 1260 if (!tempFile.renameTo(destination)) { 1261 Log.w(TAG, "[writeToDataFile] Couldn't rename ${tempFile.path} to ${destination.path}") 1262 tempFile.delete() 1263 throw IllegalStateException("Couldn't rename ${tempFile.path} to ${destination.path}") 1264 } 1265 1266 DataFileWriteResult( 1267 file = destination, 1268 length = length, 1269 random = random, 1270 hash = hash, 1271 transformProperties = transformProperties 1272 ) 1273 } catch (e: IOException) { 1274 throw MmsException(e) 1275 } catch (e: NoSuchAlgorithmException) { 1276 throw MmsException(e) 1277 } 1278 } 1279 1280 private fun areTransformationsCompatible( 1281 newProperties: TransformProperties, 1282 potentialMatchProperties: TransformProperties, 1283 newHashStart: String, 1284 potentialMatchHashEnd: String?, 1285 newIsQuote: Boolean 1286 ): Boolean { 1287 // If we're starting now where another attachment finished, then it means we're forwarding an attachment. 1288 if (newHashStart == potentialMatchHashEnd) { 1289 // 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 1290 if (newIsQuote) { 1291 return true 1292 } 1293 1294 // If the new attachment is an edited video, we can't re-use the file 1295 if (newProperties.videoEdited) { 1296 return false 1297 } 1298 1299 return true 1300 } 1301 1302 if (newProperties.sentMediaQuality != potentialMatchProperties.sentMediaQuality) { 1303 return false 1304 } 1305 1306 if (newProperties.videoEdited != potentialMatchProperties.videoEdited) { 1307 return false 1308 } 1309 1310 if (newProperties.videoTrimStartTimeUs != potentialMatchProperties.videoTrimStartTimeUs) { 1311 return false 1312 } 1313 1314 if (newProperties.videoTrimEndTimeUs != potentialMatchProperties.videoTrimEndTimeUs) { 1315 return false 1316 } 1317 1318 if (newProperties.mp4FastStart != potentialMatchProperties.mp4FastStart) { 1319 return false 1320 } 1321 1322 return true 1323 } 1324 1325 /** 1326 * 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 1327 * it's associated with, etc. We treat this case separately from attachments with data (see [insertAttachmentWithData]) because it's much simpler, 1328 * and splitting the two use cases makes the code easier to understand. 1329 * 1330 * Callers are expected to later call [finalizeAttachmentAfterDownload] once they have downloaded the data for this attachment. 1331 */ 1332 @Throws(MmsException::class) 1333 private fun insertUndownloadedAttachment(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { 1334 Log.d(TAG, "[insertAttachment] Inserting attachment for messageId $messageId.") 1335 1336 val attachmentId: AttachmentId = writableDatabase.withinTransaction { db -> 1337 val contentValues = ContentValues().apply { 1338 put(MESSAGE_ID, messageId) 1339 put(CONTENT_TYPE, attachment.contentType) 1340 put(TRANSFER_STATE, attachment.transferState) 1341 put(CDN_NUMBER, attachment.cdnNumber) 1342 put(REMOTE_LOCATION, attachment.remoteLocation) 1343 put(REMOTE_DIGEST, attachment.remoteDigest) 1344 put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) 1345 put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) 1346 put(REMOTE_KEY, attachment.remoteKey) 1347 put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) 1348 put(DATA_SIZE, attachment.size) 1349 put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) 1350 put(VOICE_NOTE, attachment.voiceNote.toInt()) 1351 put(BORDERLESS, attachment.borderless.toInt()) 1352 put(VIDEO_GIF, attachment.videoGif.toInt()) 1353 put(WIDTH, attachment.width) 1354 put(HEIGHT, attachment.height) 1355 put(QUOTE, quote) 1356 put(CAPTION, attachment.caption) 1357 put(UPLOAD_TIMESTAMP, attachment.uploadTimestamp) 1358 put(BLUR_HASH, attachment.blurHash?.hash) 1359 1360 attachment.stickerLocator?.let { sticker -> 1361 put(STICKER_PACK_ID, sticker.packId) 1362 put(STICKER_PACK_KEY, sticker.packKey) 1363 put(STICKER_ID, sticker.stickerId) 1364 put(STICKER_EMOJI, sticker.emoji) 1365 } 1366 } 1367 1368 val rowId = db.insert(TABLE_NAME, null, contentValues) 1369 AttachmentId(rowId) 1370 } 1371 1372 notifyAttachmentListeners() 1373 return attachmentId 1374 } 1375 1376 /** 1377 * Inserts an attachment with existing data. This is likely an outgoing attachment that we're in the process of sending. 1378 */ 1379 @Throws(MmsException::class) 1380 private fun insertAttachmentWithData(messageId: Long, attachment: Attachment, quote: Boolean): AttachmentId { 1381 requireNotNull(attachment.uri) { "Attachment must have a uri!" } 1382 1383 Log.d(TAG, "[insertAttachmentWithData] Inserting attachment for messageId $messageId. (MessageId: $messageId, ${attachment.uri})") 1384 1385 val dataStream = try { 1386 PartAuthority.getAttachmentStream(context, attachment.uri!!) 1387 } catch (e: IOException) { 1388 throw MmsException(e) 1389 } 1390 1391 // 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. 1392 val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), dataStream, attachment.transformProperties ?: TransformProperties.empty()) 1393 Log.d(TAG, "[insertAttachmentWithData] Wrote data to file: ${fileWriteResult.file.absolutePath} (MessageId: $messageId, ${attachment.uri})") 1394 1395 val (attachmentId: AttachmentId, foundDuplicate: Boolean) = writableDatabase.withinTransaction { db -> 1396 val contentValues = ContentValues() 1397 var transformProperties = attachment.transformProperties ?: TransformProperties.empty() 1398 1399 // First we'll check if our file hash matches the starting or ending hash of any other attachments and has compatible transform properties. 1400 // We'll prefer the match with the most recent upload timestamp. 1401 val hashMatch: DataFileInfo? = readableDatabase 1402 .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) 1403 .from(TABLE_NAME) 1404 .where("$DATA_FILE NOT NULL AND ($DATA_HASH_START = ? OR $DATA_HASH_END = ?)", fileWriteResult.hash, fileWriteResult.hash) 1405 .run() 1406 .readToList { it.readDataFileInfo() } 1407 .sortedByDescending { it.uploadTimestamp } 1408 .firstOrNull { existingMatch -> 1409 areTransformationsCompatible( 1410 newProperties = transformProperties, 1411 potentialMatchProperties = existingMatch.transformProperties, 1412 newHashStart = fileWriteResult.hash, 1413 potentialMatchHashEnd = existingMatch.hashEnd, 1414 newIsQuote = quote 1415 ) 1416 } 1417 1418 if (hashMatch != null) { 1419 if (fileWriteResult.hash == hashMatch.hashStart) { 1420 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})") 1421 } else if (fileWriteResult.hash == hashMatch.hashEnd) { 1422 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})") 1423 } else { 1424 throw IllegalStateException("Should not be possible based on query.") 1425 } 1426 1427 contentValues.put(DATA_FILE, hashMatch.file.absolutePath) 1428 contentValues.put(DATA_SIZE, hashMatch.length) 1429 contentValues.put(DATA_RANDOM, hashMatch.random) 1430 contentValues.put(DATA_HASH_START, fileWriteResult.hash) 1431 contentValues.put(DATA_HASH_END, hashMatch.hashEnd) 1432 1433 if (hashMatch.transformProperties.skipTransform) { 1434 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})") 1435 transformProperties = transformProperties.copy(skipTransform = true) 1436 } 1437 } else { 1438 Log.i(TAG, "[insertAttachmentWithData] No matching hash found. (MessageId: $messageId, ${attachment.uri})") 1439 contentValues.put(DATA_FILE, fileWriteResult.file.absolutePath) 1440 contentValues.put(DATA_SIZE, fileWriteResult.length) 1441 contentValues.put(DATA_RANDOM, fileWriteResult.random) 1442 contentValues.put(DATA_HASH_START, fileWriteResult.hash) 1443 } 1444 1445 // 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 1446 // other necessary fields, and if so, we can use that to skip the upload. 1447 var uploadTemplate: Attachment? = null 1448 if (hashMatch?.hashEnd != null && System.currentTimeMillis() - hashMatch.uploadTimestamp < AttachmentUploadJob.UPLOAD_REUSE_THRESHOLD) { 1449 uploadTemplate = readableDatabase 1450 .select(*PROJECTION) 1451 .from(TABLE_NAME) 1452 .where("$ID = ${hashMatch.id.id} AND $REMOTE_DIGEST NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_HASH_END NOT NULL") 1453 .run() 1454 .readToSingleObject { it.readAttachment() } 1455 } 1456 1457 if (uploadTemplate != null) { 1458 Log.i(TAG, "[insertAttachmentWithData] Found a valid template we could use to skip upload. (MessageId: $messageId, ${attachment.uri})") 1459 transformProperties = (uploadTemplate.transformProperties ?: transformProperties).copy(skipTransform = true) 1460 } 1461 1462 contentValues.put(MESSAGE_ID, messageId) 1463 contentValues.put(CONTENT_TYPE, uploadTemplate?.contentType ?: attachment.contentType) 1464 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 1465 contentValues.put(CDN_NUMBER, uploadTemplate?.cdnNumber ?: 0) 1466 contentValues.put(REMOTE_LOCATION, uploadTemplate?.remoteLocation) 1467 contentValues.put(REMOTE_DIGEST, uploadTemplate?.remoteDigest) 1468 contentValues.put(REMOTE_INCREMENTAL_DIGEST, uploadTemplate?.incrementalDigest) 1469 contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, uploadTemplate?.incrementalMacChunkSize ?: 0) 1470 contentValues.put(REMOTE_KEY, uploadTemplate?.remoteKey) 1471 contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) 1472 contentValues.put(FAST_PREFLIGHT_ID, attachment.fastPreflightId) 1473 contentValues.put(VOICE_NOTE, if (attachment.voiceNote) 1 else 0) 1474 contentValues.put(BORDERLESS, if (attachment.borderless) 1 else 0) 1475 contentValues.put(VIDEO_GIF, if (attachment.videoGif) 1 else 0) 1476 contentValues.put(WIDTH, uploadTemplate?.width ?: attachment.width) 1477 contentValues.put(HEIGHT, uploadTemplate?.height ?: attachment.height) 1478 contentValues.put(QUOTE, quote) 1479 contentValues.put(CAPTION, attachment.caption) 1480 contentValues.put(UPLOAD_TIMESTAMP, uploadTemplate?.uploadTimestamp ?: 0) 1481 contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()) 1482 1483 if (attachment.transformProperties?.videoEdited == true) { 1484 contentValues.putNull(BLUR_HASH) 1485 } else { 1486 contentValues.put(BLUR_HASH, uploadTemplate.getVisualHashStringOrNull()) 1487 } 1488 1489 attachment.stickerLocator?.let { sticker -> 1490 contentValues.put(STICKER_PACK_ID, sticker.packId) 1491 contentValues.put(STICKER_PACK_KEY, sticker.packKey) 1492 contentValues.put(STICKER_ID, sticker.stickerId) 1493 contentValues.put(STICKER_EMOJI, sticker.emoji) 1494 } 1495 1496 val rowId = db.insert(TABLE_NAME, null, contentValues) 1497 1498 AttachmentId(rowId) to (hashMatch != null) 1499 } 1500 1501 if (foundDuplicate) { 1502 if (!fileWriteResult.file.delete()) { 1503 Log.w(TAG, "[insertAttachmentWithData] Failed to delete duplicate file: ${fileWriteResult.file.absolutePath}") 1504 } 1505 } 1506 1507 notifyAttachmentListeners() 1508 return attachmentId 1509 } 1510 1511 private fun getTransferFile(db: SQLiteDatabase, attachmentId: AttachmentId): File? { 1512 return db 1513 .select(TRANSFER_FILE) 1514 .from(TABLE_NAME) 1515 .where("$ID = ?", attachmentId.id) 1516 .limit(1) 1517 .run() 1518 .readToSingleObject { cursor -> 1519 cursor.requireString(TRANSFER_FILE)?.let { File(it) } 1520 } 1521 } 1522 1523 private fun getAttachment(cursor: Cursor): DatabaseAttachment { 1524 val contentType = cursor.requireString(CONTENT_TYPE) 1525 1526 return DatabaseAttachment( 1527 attachmentId = AttachmentId(cursor.requireLong(ID)), 1528 mmsId = cursor.requireLong(MESSAGE_ID), 1529 hasData = !cursor.isNull(DATA_FILE), 1530 hasThumbnail = MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType), 1531 contentType = contentType, 1532 transferProgress = cursor.requireInt(TRANSFER_STATE), 1533 size = cursor.requireLong(DATA_SIZE), 1534 fileName = cursor.requireString(FILE_NAME), 1535 cdnNumber = cursor.requireInt(CDN_NUMBER), 1536 location = cursor.requireString(REMOTE_LOCATION), 1537 key = cursor.requireString(REMOTE_KEY), 1538 digest = cursor.requireBlob(REMOTE_DIGEST), 1539 incrementalDigest = cursor.requireBlob(REMOTE_INCREMENTAL_DIGEST), 1540 incrementalMacChunkSize = cursor.requireInt(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE), 1541 fastPreflightId = cursor.requireString(FAST_PREFLIGHT_ID), 1542 voiceNote = cursor.requireBoolean(VOICE_NOTE), 1543 borderless = cursor.requireBoolean(BORDERLESS), 1544 videoGif = cursor.requireBoolean(VIDEO_GIF), 1545 width = cursor.requireInt(WIDTH), 1546 height = cursor.requireInt(HEIGHT), 1547 quote = cursor.requireBoolean(QUOTE), 1548 caption = cursor.requireString(CAPTION), 1549 stickerLocator = cursor.readStickerLocator(), 1550 blurHash = if (MediaUtil.isAudioType(contentType)) null else BlurHash.parseOrNull(cursor.requireString(BLUR_HASH)), 1551 audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(cursor.requireString(BLUR_HASH)) else null, 1552 transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)), 1553 displayOrder = cursor.requireInt(DISPLAY_ORDER), 1554 uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP), 1555 dataHash = cursor.requireString(DATA_HASH_END) 1556 ) 1557 } 1558 1559 private fun Cursor.readAttachments(): List<DatabaseAttachment> { 1560 return getAttachments(this) 1561 } 1562 1563 private fun Cursor.readAttachment(): DatabaseAttachment { 1564 return getAttachment(this) 1565 } 1566 1567 private fun Cursor.readDataFileInfo(): DataFileInfo { 1568 return DataFileInfo( 1569 id = AttachmentId(this.requireLong(ID)), 1570 file = File(this.requireNonNullString(DATA_FILE)), 1571 length = this.requireLong(DATA_SIZE), 1572 random = this.requireNonNullBlob(DATA_RANDOM), 1573 hashStart = this.requireString(DATA_HASH_START), 1574 hashEnd = this.requireString(DATA_HASH_END), 1575 transformProperties = TransformProperties.parse(this.requireString(TRANSFORM_PROPERTIES)), 1576 uploadTimestamp = this.requireLong(UPLOAD_TIMESTAMP) 1577 ) 1578 } 1579 1580 private fun Cursor.readStickerLocator(): StickerLocator? { 1581 return if (this.requireInt(STICKER_ID) >= 0) { 1582 StickerLocator( 1583 packId = this.requireNonNullString(STICKER_PACK_ID), 1584 packKey = this.requireNonNullString(STICKER_PACK_KEY), 1585 stickerId = this.requireInt(STICKER_ID), 1586 emoji = this.requireString(STICKER_EMOJI) 1587 ) 1588 } else { 1589 null 1590 } 1591 } 1592 1593 private fun Attachment?.getVisualHashStringOrNull(): String? { 1594 return when { 1595 this == null -> null 1596 this.blurHash != null -> this.blurHash.hash 1597 this.audioHash != null -> this.audioHash.hash 1598 else -> null 1599 } 1600 } 1601 1602 fun debugGetLatestAttachments(): List<DatabaseAttachment> { 1603 return readableDatabase 1604 .select(*PROJECTION) 1605 .from(TABLE_NAME) 1606 .where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH_END IS NOT NULL") 1607 .orderBy("$ID DESC") 1608 .limit(30) 1609 .run() 1610 .readToList { it.readAttachments() } 1611 .flatten() 1612 } 1613 1614 class DataFileWriteResult( 1615 val file: File, 1616 val length: Long, 1617 val random: ByteArray, 1618 val hash: String, 1619 val transformProperties: TransformProperties 1620 ) 1621 1622 @VisibleForTesting 1623 class DataFileInfo( 1624 val id: AttachmentId, 1625 val file: File, 1626 val length: Long, 1627 val random: ByteArray, 1628 val hashStart: String?, 1629 val hashEnd: String?, 1630 val transformProperties: TransformProperties, 1631 val uploadTimestamp: Long 1632 ) 1633 1634 @Parcelize 1635 data class TransformProperties( 1636 @JsonProperty("skipTransform") 1637 @JvmField 1638 val skipTransform: Boolean = false, 1639 1640 @JsonProperty("videoTrim") 1641 @JvmField 1642 val videoTrim: Boolean = false, 1643 1644 @JsonProperty("videoTrimStartTimeUs") 1645 @JvmField 1646 val videoTrimStartTimeUs: Long = 0, 1647 1648 @JsonProperty("videoTrimEndTimeUs") 1649 @JvmField 1650 val videoTrimEndTimeUs: Long = 0, 1651 1652 @JsonProperty("sentMediaQuality") 1653 @JvmField 1654 val sentMediaQuality: Int = SentMediaQuality.STANDARD.code, 1655 1656 @JsonProperty("mp4Faststart") 1657 @JvmField 1658 val mp4FastStart: Boolean = false 1659 ) : Parcelable { 1660 fun shouldSkipTransform(): Boolean { 1661 return skipTransform 1662 } 1663 1664 @IgnoredOnParcel 1665 @JsonProperty("videoEdited") 1666 val videoEdited: Boolean = videoTrim 1667 1668 fun withSkipTransform(): TransformProperties { 1669 return this.copy( 1670 skipTransform = true 1671 ) 1672 } 1673 1674 fun withMp4FastStart(): TransformProperties { 1675 return this.copy(mp4FastStart = true) 1676 } 1677 1678 fun serialize(): String { 1679 return JsonUtil.toJson(this) 1680 } 1681 1682 companion object { 1683 private val DEFAULT_MEDIA_QUALITY = SentMediaQuality.STANDARD.code 1684 1685 @JvmStatic 1686 fun empty(): TransformProperties { 1687 return TransformProperties( 1688 skipTransform = false, 1689 videoTrim = false, 1690 videoTrimStartTimeUs = 0, 1691 videoTrimEndTimeUs = 0, 1692 sentMediaQuality = DEFAULT_MEDIA_QUALITY, 1693 mp4FastStart = false 1694 ) 1695 } 1696 1697 fun forSkipTransform(): TransformProperties { 1698 return TransformProperties( 1699 skipTransform = true, 1700 videoTrim = false, 1701 videoTrimStartTimeUs = 0, 1702 videoTrimEndTimeUs = 0, 1703 sentMediaQuality = DEFAULT_MEDIA_QUALITY, 1704 mp4FastStart = false 1705 ) 1706 } 1707 1708 fun forVideoTrim(videoTrimStartTimeUs: Long, videoTrimEndTimeUs: Long): TransformProperties { 1709 return TransformProperties( 1710 skipTransform = false, 1711 videoTrim = true, 1712 videoTrimStartTimeUs = videoTrimStartTimeUs, 1713 videoTrimEndTimeUs = videoTrimEndTimeUs, 1714 sentMediaQuality = DEFAULT_MEDIA_QUALITY, 1715 mp4FastStart = false 1716 ) 1717 } 1718 1719 @JvmStatic 1720 fun forSentMediaQuality(currentProperties: Optional<TransformProperties>, sentMediaQuality: SentMediaQuality): TransformProperties { 1721 val existing = currentProperties.orElse(empty()) 1722 return existing.copy(sentMediaQuality = sentMediaQuality.code) 1723 } 1724 1725 @JvmStatic 1726 fun forSentMediaQuality(sentMediaQuality: Int): TransformProperties { 1727 return TransformProperties(sentMediaQuality = sentMediaQuality) 1728 } 1729 1730 @JvmStatic 1731 fun parse(serialized: String?): TransformProperties { 1732 return if (serialized == null) { 1733 empty() 1734 } else { 1735 try { 1736 JsonUtil.fromJson(serialized, TransformProperties::class.java) 1737 } catch (e: IOException) { 1738 Log.w(TAG, "Failed to parse TransformProperties!", e) 1739 empty() 1740 } 1741 } 1742 } 1743 } 1744 } 1745}