That fuck shit the fascists are using
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}