diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index fa60639234..12e12996e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -142,6 +142,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V284_SetPlaceholder import org.thoughtcrime.securesms.database.helpers.migration.V285_AddEpochToCallLinksTable import org.thoughtcrime.securesms.database.helpers.migration.V286_FixRemoteKeyEncoding import org.thoughtcrime.securesms.database.helpers.migration.V287_FixInvalidArchiveState +import org.thoughtcrime.securesms.database.helpers.migration.V288_CopyStickerDataHashStartToEnd import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -289,10 +290,11 @@ object SignalDatabaseMigrations { 284 to V284_SetPlaceholderGroupFlag, 285 to V285_AddEpochToCallLinksTable, 286 to V286_FixRemoteKeyEncoding, - 287 to V287_FixInvalidArchiveState + 287 to V287_FixInvalidArchiveState, + 288 to V288_CopyStickerDataHashStartToEnd ) - const val DATABASE_VERSION = 287 + const val DATABASE_VERSION = 288 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V288_CopyStickerDataHashStartToEnd.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V288_CopyStickerDataHashStartToEnd.kt new file mode 100644 index 0000000000..d42bc1d9da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V288_CopyStickerDataHashStartToEnd.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Copy data_hash_start to data_hash_end for sticker attachments that have completed transfer. + */ +@Suppress("ClassName") +object V288_CopyStickerDataHashStartToEnd : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL( + "UPDATE attachment SET data_hash_end = data_hash_start WHERE sticker_pack_id IS NOT NULL AND data_hash_start IS NOT NULL AND data_hash_end IS NULL AND transfer_state = 0" + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/helpers/migration/V288_CopyStickerDataHashStartToEndTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/helpers/migration/V288_CopyStickerDataHashStartToEndTest.kt new file mode 100644 index 0000000000..0ef4d460ab --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/helpers/migration/V288_CopyStickerDataHashStartToEndTest.kt @@ -0,0 +1,333 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import android.content.ContentValues +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.testutil.SignalDatabaseMigrationRule + +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class V288_CopyStickerDataHashStartToEndTest { + + @get:Rule val signalDatabaseRule = SignalDatabaseMigrationRule(287) + + // Constants copied from AttachmentTable to ensure test stability + companion object { + private const val TABLE_NAME = "attachment" + private const val ID = "_id" + private const val DATA_FILE = "data_file" + private const val DATA_RANDOM = "data_random" + private const val TRANSFER_STATE = "transfer_state" + private const val STICKER_PACK_ID = "sticker_pack_id" + private const val STICKER_PACK_KEY = "sticker_pack_key" + private const val STICKER_ID = "sticker_id" + private const val STICKER_EMOJI = "sticker_emoji" + private const val DATA_HASH_START = "data_hash_start" + private const val DATA_HASH_END = "data_hash_end" + private const val TRANSFER_PROGRESS_DONE = 0 + } + + @Test + fun migrate_whenStickerHasDataHashStartButNoDataHashEndAndTransferDone_copiesDataHashStartToEnd() { + val stickerAttachmentId = insertStickerAttachment( + stickerPackId = "test-pack-id", + dataHashStart = "abc123def456", + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + val cursor = db.query( + TABLE_NAME, + arrayOf(DATA_HASH_START, DATA_HASH_END), + "$ID = ?", + arrayOf(stickerAttachmentId.toString()), + null, + null, + null + ) + + cursor.use { + assertThat(it.moveToFirst()).isEqualTo(true) + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo("abc123def456") + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo("abc123def456") + } + } + + @Test + fun migrate_whenStickerAlreadyHasDataHashEnd_doesNotOverwrite() { + val stickerAttachmentId = insertStickerAttachment( + stickerPackId = "test-pack-id", + dataHashStart = "abc123def456", + dataHashEnd = "existing-hash-end", + transferState = TRANSFER_PROGRESS_DONE + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + val cursor = db.query( + TABLE_NAME, + arrayOf(DATA_HASH_START, DATA_HASH_END), + "$ID = ?", + arrayOf(stickerAttachmentId.toString()), + null, + null, + null + ) + + cursor.use { + assertThat(it.moveToFirst()).isEqualTo(true) + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo("abc123def456") + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo("existing-hash-end") + } + } + + @Test + fun migrate_whenStickerHasNoDataHashStart_doesNothing() { + val stickerAttachmentId = insertStickerAttachment( + stickerPackId = "test-pack-id", + dataHashStart = null, + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + val cursor = db.query( + TABLE_NAME, + arrayOf(DATA_HASH_START, DATA_HASH_END), + "$ID = ?", + arrayOf(stickerAttachmentId.toString()), + null, + null, + null + ) + + cursor.use { + assertThat(it.moveToFirst()).isEqualTo(true) + assertThat(it.isNull(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo(true) + assertThat(it.isNull(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo(true) + } + } + + @Test + fun migrate_whenNonStickerAttachmentHasDataHashStart_doesNotCopy() { + val regularAttachmentId = insertRegularAttachment( + dataHashStart = "regular-hash-start", + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + val cursor = db.query( + TABLE_NAME, + arrayOf(DATA_HASH_START, DATA_HASH_END), + "$ID = ?", + arrayOf(regularAttachmentId.toString()), + null, + null, + null + ) + + cursor.use { + assertThat(it.moveToFirst()).isEqualTo(true) + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo("regular-hash-start") + assertThat(it.isNull(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo(true) + } + } + + @Test + fun migrate_whenMultipleStickerAttachmentsWithMixedStates_onlyCopiesWhenNeeded() { + // Should copy (sticker with data_hash_start, no data_hash_end, transfer done) + val copyId1 = insertStickerAttachment( + stickerPackId = "pack-1", + dataHashStart = "hash-start-1", + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + val copyId2 = insertStickerAttachment( + stickerPackId = "pack-2", + dataHashStart = "hash-start-2", + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + + // Should NOT copy (already has data_hash_end) + val noOverwriteId = insertStickerAttachment( + stickerPackId = "pack-3", + dataHashStart = "hash-start-3", + dataHashEnd = "existing-end-3", + transferState = TRANSFER_PROGRESS_DONE + ) + + // Should NOT copy (no data_hash_start) + val noDataHashStartId = insertStickerAttachment( + stickerPackId = "pack-4", + dataHashStart = null, + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + + // Should NOT copy (not a sticker) + val nonStickerID = insertRegularAttachment( + dataHashStart = "regular-hash", + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + // Check that data_hash_start was copied to data_hash_end + assertDataHashState(copyId1, expectedStart = "hash-start-1", expectedEnd = "hash-start-1") + assertDataHashState(copyId2, expectedStart = "hash-start-2", expectedEnd = "hash-start-2") + + // Check that existing data_hash_end was not overwritten + assertDataHashState(noOverwriteId, expectedStart = "hash-start-3", expectedEnd = "existing-end-3") + + // Check that null values remain null + assertDataHashState(noDataHashStartId, expectedStart = null, expectedEnd = null) + + // Check that non-sticker attachment was not affected + assertDataHashState(nonStickerID, expectedStart = "regular-hash", expectedEnd = null) + } + + @Test + fun migrate_whenNoStickersMatchCriteria_noChanges() { + val noStickerPackId = insertRegularAttachment( + dataHashStart = "hash1", + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + val stickerButNoHashStart = insertStickerAttachment( + stickerPackId = "pack-1", + dataHashStart = null, + dataHashEnd = null, + transferState = TRANSFER_PROGRESS_DONE + ) + val stickerWithExistingEnd = insertStickerAttachment( + stickerPackId = "pack-2", + dataHashStart = "start-hash", + dataHashEnd = "end-hash", + transferState = TRANSFER_PROGRESS_DONE + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + // Check no changes were made + assertDataHashState(noStickerPackId, expectedStart = "hash1", expectedEnd = null) + assertDataHashState(stickerButNoHashStart, expectedStart = null, expectedEnd = null) + assertDataHashState(stickerWithExistingEnd, expectedStart = "start-hash", expectedEnd = "end-hash") + } + + @Test + fun migrate_whenStickerTransferNotDone_doesNotCopy() { + val stickerInProgressId = insertStickerAttachment( + stickerPackId = "test-pack-id", + dataHashStart = "abc123def456", + dataHashEnd = null, + transferState = 1 // TRANSFER_PROGRESS_STARTED + ) + + val db = signalDatabaseRule.database + V288_CopyStickerDataHashStartToEnd.migrate(ApplicationProvider.getApplicationContext(), db, 287, 288) + + val cursor = db.query( + TABLE_NAME, + arrayOf(DATA_HASH_START, DATA_HASH_END), + "$ID = ?", + arrayOf(stickerInProgressId.toString()), + null, + null, + null + ) + + cursor.use { + assertThat(it.moveToFirst()).isEqualTo(true) + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo("abc123def456") + assertThat(it.isNull(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo(true) + } + } + + private fun insertStickerAttachment(stickerPackId: String, dataHashStart: String?, dataHashEnd: String?, transferState: Int = TRANSFER_PROGRESS_DONE): Long { + val db = signalDatabaseRule.database + + val values = ContentValues().apply { + put(DATA_FILE, "/fake/path/sticker.webp") + put(DATA_RANDOM, "/fake/path/sticker.webp".toByteArray()) + put(TRANSFER_STATE, transferState) + put(STICKER_PACK_ID, stickerPackId) + put(STICKER_PACK_KEY, "test-pack-key") + put(STICKER_ID, 1) + put(STICKER_EMOJI, "😀") + put(DATA_HASH_START, dataHashStart) + put(DATA_HASH_END, dataHashEnd) + } + + return db.insert(TABLE_NAME, null, values) + } + + private fun insertRegularAttachment(dataHashStart: String?, dataHashEnd: String?, transferState: Int = TRANSFER_PROGRESS_DONE): Long { + val db = signalDatabaseRule.database + + val values = ContentValues().apply { + put(DATA_FILE, "/fake/path/regular.jpg") + put(DATA_RANDOM, "/fake/path/regular.jpg".toByteArray()) + put(TRANSFER_STATE, transferState) + put(DATA_HASH_START, dataHashStart) + put(DATA_HASH_END, dataHashEnd) + // No sticker fields - this makes it a regular attachment + } + + return db.insert(TABLE_NAME, null, values) + } + + private fun assertDataHashState(attachmentId: Long, expectedStart: String?, expectedEnd: String?) { + val db = signalDatabaseRule.database + val cursor = db.query( + TABLE_NAME, + arrayOf(DATA_HASH_START, DATA_HASH_END), + "$ID = ?", + arrayOf(attachmentId.toString()), + null, + null, + null + ) + + cursor.use { + assertThat(it.moveToFirst()).isEqualTo(true) + + if (expectedStart == null) { + assertThat(it.isNull(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo(true) + } else { + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_START))).isEqualTo(expectedStart) + } + + if (expectedEnd == null) { + assertThat(it.isNull(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo(true) + } else { + assertThat(it.getString(it.getColumnIndexOrThrow(DATA_HASH_END))).isEqualTo(expectedEnd) + } + } + } +}