diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationstate/ConversationStateMirrorImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationstate/ConversationStateMirrorImplTest.kt index f2eefd298..9592c1d05 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversationstate/ConversationStateMirrorImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversationstate/ConversationStateMirrorImplTest.kt @@ -99,6 +99,7 @@ class ConversationStateMirrorImplTest { fun anUpdate_savesTheDatabaseStateWithTheTimeAndVersion() { databaseStore.blockedDestinations = setOf(BLOCKED_DESTINATION) databaseStore.archivedThreadIds = setOf(ARCHIVED_THREAD_ID) + databaseStore.unarchivedBlockedThreadIds = setOf(UNARCHIVED_BLOCKED_THREAD_ID) val updateStartedAt = System.currentTimeMillis() mirror.update() @@ -107,6 +108,10 @@ class ConversationStateMirrorImplTest { val mirroredState = preferencesStore.mirroredState assertEquals(setOf(BLOCKED_DESTINATION), mirroredState.blockedDestinations) assertEquals(setOf(ARCHIVED_THREAD_ID), mirroredState.archivedThreadIds) + assertEquals( + setOf(UNARCHIVED_BLOCKED_THREAD_ID), + mirroredState.unarchivedBlockedThreadIds, + ) assertEquals(DATABASE_VERSION, mirroredState.databaseVersion) assertTrue(mirroredState.mirroredAt in updateStartedAt..updateEndedAt) } @@ -259,6 +264,7 @@ class ConversationStateMirrorImplTest { var mirroredState = MirroredConversationState( blockedDestinations = emptySet(), archivedThreadIds = emptySet(), + unarchivedBlockedThreadIds = emptySet(), mirroredAt = 0L, databaseVersion = 0, ) @@ -295,6 +301,9 @@ class ConversationStateMirrorImplTest { @Volatile var archivedThreadIds = emptySet() + @Volatile + var unarchivedBlockedThreadIds = emptySet() + @Volatile var onReadArchivedThreadIds: () -> Unit = {} @@ -311,6 +320,10 @@ class ConversationStateMirrorImplTest { return archivedThreadIds } + override fun readUnarchivedBlockedThreadIds(): Set { + return unarchivedBlockedThreadIds + } + override fun restore(mirroredState: MirroredConversationState) { restoreFailure?.let { failure -> throw failure } restoredStates += mirroredState @@ -322,6 +335,7 @@ class ConversationStateMirrorImplTest { private const val BLOCKED_DESTINATION = "+15550100" private const val NEWLY_BLOCKED_DESTINATION = "+15550101" private const val ARCHIVED_THREAD_ID = "12" + private const val UNARCHIVED_BLOCKED_THREAD_ID = "13" private const val DATABASE_VERSION = 5 private const val FULL_SYNC_TIME = 2_000L private const val TIMEOUT_MILLIS = 10_000L @@ -330,6 +344,7 @@ class ConversationStateMirrorImplTest { private val MIRRORED_STATE = MirroredConversationState( blockedDestinations = setOf(BLOCKED_DESTINATION), archivedThreadIds = setOf(ARCHIVED_THREAD_ID), + unarchivedBlockedThreadIds = setOf(UNARCHIVED_BLOCKED_THREAD_ID), mirroredAt = 1_000L, databaseVersion = DATABASE_VERSION, ) diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStoreImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStoreImplTest.kt index 6775a6335..6a8056f3b 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStoreImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStoreImplTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.data.conversationstate.store import android.content.Context +import android.database.sqlite.SQLiteFullException import androidx.core.content.contentValuesOf import com.android.messaging.FactoryTestAccess import com.android.messaging.data.conversationstate.model.MirroredConversationState @@ -12,6 +13,7 @@ import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.DatabaseWrapper import com.android.messaging.datamodel.createInMemoryActionSyncTestDatabase import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.datamodel.simulateFullDisk import com.android.messaging.testutil.installTestFactory import io.mockk.every import io.mockk.mockk @@ -19,6 +21,7 @@ import io.mockk.unmockkAll import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -95,6 +98,29 @@ class ConversationStateDatabaseStoreImplTest { assertEquals(setOf(ARCHIVED_THREAD_ID.toString()), store.readArchivedThreadIds()) } + @Test + fun readUnarchivedBlockedThreadIds_readsTheBlockedSendersSyncedThreadsInTheInbox() { + db.insertSyncedConversation( + destination = BLOCKED_DESTINATION, + threadId = BLOCKED_THREAD_ID, + isBlocked = true, + ) + db.insertSyncedConversation( + destination = ARCHIVED_DESTINATION, + threadId = ARCHIVED_THREAD_ID, + isBlocked = true, + isArchived = true, + ) + db.insertSyncedConversation(destination = OTHER_DESTINATION, threadId = OTHER_THREAD_ID) + db.insertSyncedConversation( + destination = UNARCHIVED_BLOCKED_DESTINATION, + threadId = NO_THREAD_ID, + isBlocked = true, + ) + + assertEquals(setOf(BLOCKED_THREAD_ID.toString()), store.readUnarchivedBlockedThreadIds()) + } + @Test fun restore_blocksAndArchivesTheResyncedConversations() { insertResyncedConversations(sortTimestamp = SYNCED_SORT_TIMESTAMP) @@ -125,6 +151,35 @@ class ConversationStateDatabaseStoreImplTest { assertFalse(db.isArchived(threadId = ARCHIVED_THREAD_ID)) } + @Test + fun restore_leavesTheBlockedSendersConversationsThatWereInTheInboxThere() { + insertResyncedConversations(sortTimestamp = SYNCED_SORT_TIMESTAMP) + + store.restore(mirroredState = MIRRORED_STATE) + + assertTrue(db.isBlocked(destination = UNARCHIVED_BLOCKED_DESTINATION)) + assertFalse(db.isArchived(threadId = UNARCHIVED_BLOCKED_THREAD_ID)) + } + + @Test + fun restore_onAFullDisk_failsAndRestoresNothing() { + insertResyncedConversations(sortTimestamp = SYNCED_SORT_TIMESTAMP) + db.database.simulateFullDisk() + + assertThrows(SQLiteFullException::class.java) { + store.restore( + mirroredState = MIRRORED_STATE.copy( + blockedDestinations = MIRRORED_STATE.blockedDestinations + + OVERSIZED_DESTINATION, + ), + ) + } + + assertFalse(db.isBlocked(destination = BLOCKED_DESTINATION)) + assertFalse(db.isArchived(threadId = BLOCKED_THREAD_ID)) + assertFalse(db.isArchived(threadId = ARCHIVED_THREAD_ID)) + } + private fun insertResyncedConversations(sortTimestamp: Long) { db.insertSyncedConversation( destination = BLOCKED_DESTINATION, @@ -141,6 +196,11 @@ class ConversationStateDatabaseStoreImplTest { threadId = OTHER_THREAD_ID, sortTimestamp = sortTimestamp, ) + db.insertSyncedConversation( + destination = UNARCHIVED_BLOCKED_DESTINATION, + threadId = UNARCHIVED_BLOCKED_THREAD_ID, + sortTimestamp = sortTimestamp, + ) } private fun DatabaseWrapper.insertSyncedConversation( @@ -211,19 +271,24 @@ class ConversationStateDatabaseStoreImplTest { private const val BLOCKED_DESTINATION = "+15550100" private const val ARCHIVED_DESTINATION = "+15550101" private const val OTHER_DESTINATION = "+15550102" + private const val UNARCHIVED_BLOCKED_DESTINATION = "+15550103" private const val SELF_DESTINATION = "+15550199" private const val SELF_SUB_ID = 1 private const val BLOCKED_THREAD_ID = 11L private const val ARCHIVED_THREAD_ID = 12L private const val OTHER_THREAD_ID = 13L + private const val UNARCHIVED_BLOCKED_THREAD_ID = 14L private const val NO_THREAD_ID = 0L private const val MIRRORED_AT_MILLIS = 2_000L private const val SYNCED_SORT_TIMESTAMP = 1_000L + private val OVERSIZED_DESTINATION = "+1${"5".repeat(50_000)}" + private val MIRRORED_STATE = MirroredConversationState( - blockedDestinations = setOf(BLOCKED_DESTINATION), + blockedDestinations = setOf(BLOCKED_DESTINATION, UNARCHIVED_BLOCKED_DESTINATION), archivedThreadIds = setOf(BLOCKED_THREAD_ID.toString(), ARCHIVED_THREAD_ID.toString()), + unarchivedBlockedThreadIds = setOf(UNARCHIVED_BLOCKED_THREAD_ID.toString()), mirroredAt = MIRRORED_AT_MILLIS, databaseVersion = 1, ) diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStoreImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStoreImplTest.kt index 1727dab5c..e7694be96 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStoreImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStoreImplTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.data.conversationstate.store import android.content.Context +import androidx.core.content.edit import com.android.messaging.data.conversationstate.model.MirroredConversationState import com.android.messaging.testutil.backupRulesExcluding import org.junit.Assert.assertEquals @@ -37,6 +38,20 @@ class ConversationStatePreferencesStoreImplTest { ) } + @Test + fun aMirrorSavedBeforeUnarchivedBlockedThreadsWereKept_readsNone() { + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit(commit = true) { + putStringSet("blocked_destinations", MIRRORED_STATE.blockedDestinations) + putStringSet("archived_thread_ids", MIRRORED_STATE.archivedThreadIds) + } + + val mirroredState = ConversationStatePreferencesStoreImpl(context = context) + .readMirroredState() + + assertEquals(MIRRORED_STATE.blockedDestinations, mirroredState.blockedDestinations) + assertEquals(emptySet(), mirroredState.unarchivedBlockedThreadIds) + } + @Test fun aPendingRestore_staysPendingUntilCleared() { ConversationStatePreferencesStoreImpl(context = context).markRestorePending() @@ -60,6 +75,7 @@ class ConversationStatePreferencesStoreImplTest { setOf( "blocked_destinations", "archived_thread_ids", + "unarchived_blocked_thread_ids", "mirrored_at", "database_version", "restore_pending", @@ -78,6 +94,7 @@ class ConversationStatePreferencesStoreImplTest { private val MIRRORED_STATE = MirroredConversationState( blockedDestinations = setOf("+15550100", "+15550101"), archivedThreadIds = setOf("12"), + unarchivedBlockedThreadIds = setOf("13"), mirroredAt = 1_700_000_000_000L, databaseVersion = 5, ) diff --git a/app/src/test/kotlin/com/android/messaging/data/participantdestination/ParticipantDestinationNormalizerImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/participantdestination/ParticipantDestinationNormalizerImplTest.kt index fd8a2972b..9331049ba 100644 --- a/app/src/test/kotlin/com/android/messaging/data/participantdestination/ParticipantDestinationNormalizerImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/participantdestination/ParticipantDestinationNormalizerImplTest.kt @@ -11,6 +11,7 @@ import com.android.messaging.data.participantdestination.store.ParticipantDestin import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -252,6 +253,18 @@ class ParticipantDestinationNormalizerImplTest { assertEquals(setOf(US_SUB_ID), preferencesStore.savedNormalization?.subIds) } + @Test + fun aFailedRenormalization_staysPending() { + databaseStore.lastParticipantId = 7L + bareNumberReader.readings = listOf(BARE_READING) + databaseStore.applyFailure = IllegalStateException(DATABASE_FULL) + + assertThrows(IllegalStateException::class.java) { normalizer.renormalizeIfPending() } + + assertNull(preferencesStore.savedNormalization) + assertEquals(emptySet(), preferencesStore.normalizedSubIds) + } + @Test fun aRun_readsAndSavesHoldingTheLock() { databaseStore.lastParticipantId = 7L @@ -413,6 +426,7 @@ class ParticipantDestinationNormalizerImplTest { var senderDestinations = emptySet() var senderDestinationReadCount = 0 var appliedRenormalizations: List? = null + var applyFailure: RuntimeException? = null override fun readBareNumberParticipants(): List { error("The normalizer reads the participants through the reader") @@ -435,6 +449,7 @@ class ParticipantDestinationNormalizerImplTest { override fun applyRenormalizations(renormalizations: List) { recordCall(name = "applyRenormalizations") + applyFailure?.let { failure -> throw failure } appliedRenormalizations = renormalizations } } @@ -477,6 +492,7 @@ class ParticipantDestinationNormalizerImplTest { private companion object { private const val DATABASE_VERSION = 5 + private const val DATABASE_FULL = "database or disk is full" private const val US_SUB_ID = 1 private const val FO_SUB_ID = 2 private const val BARE_DESTINATION = "211234" diff --git a/app/src/test/kotlin/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStoreImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStoreImplTest.kt index 76a735edc..4f6da5bf2 100644 --- a/app/src/test/kotlin/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStoreImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStoreImplTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.data.participantdestination.store import android.content.Context +import android.database.sqlite.SQLiteFullException import androidx.core.content.contentValuesOf import com.android.messaging.FactoryTestAccess import com.android.messaging.data.conversation.model.ParticipantId @@ -15,15 +16,18 @@ import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.DatabaseWrapper import com.android.messaging.datamodel.createInMemoryActionSyncTestDatabase import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.datamodel.simulateFullDisk import com.android.messaging.testutil.installTestFactory import io.mockk.every import io.mockk.unmockkAll import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -147,6 +151,44 @@ class ParticipantDestinationDatabaseStoreImplTest { ) } + @Test + fun aFullDisk_failsTheRenormalizationsAndAppliesNone() { + val bareId = db.insertParticipant(destination = BARE_NUMBER) + val twinId = db.insertParticipant(destination = CANONICAL_NUMBER) + val loneBareId = db.insertParticipant(destination = LONE_BARE_NUMBER) + val directId = db.insertConversation(participantIds = listOf(bareId)) + val participants = databaseStore.readBareNumberParticipants() + .associateBy { participant -> participant.destination } + db.database.simulateFullDisk() + + val failure = assertThrows(ExecutionException::class.java) { + applyRenormalizations( + renormalizations = listOf( + Renormalization( + participant = participants.getValue(LONE_BARE_NUMBER), + canonicalDestination = OVERSIZED_DESTINATION, + ), + Renormalization( + participant = participants.getValue(BARE_NUMBER), + canonicalDestination = CANONICAL_NUMBER, + ), + ), + ) + } + + assertTrue("not a full disk: ${failure.cause}", failure.cause is SQLiteFullException) + assertTrue("the bare row was merged", db.participantExists(id = bareId)) + assertEquals(listOf(bareId), db.participantsOf(conversationId = directId)) + assertTrue(db.participantExists(id = twinId)) + assertEquals( + LONE_BARE_NUMBER, + db.participantColumn( + id = loneBareId, + column = ParticipantColumns.NORMALIZED_DESTINATION, + ), + ) + } + @Test fun rowsThatAreNotBareNumbers_areNotReadAsBareNumbers() { val bareId = db.insertParticipant(destination = BARE_NUMBER, isBlocked = true) @@ -242,6 +284,10 @@ class ParticipantDestinationDatabaseStoreImplTest { canonicalDestination = CANONICAL_DESTINATIONS.getValue(participant.destination), ) } + applyRenormalizations(renormalizations = renormalizations) + } + + private fun applyRenormalizations(renormalizations: List) { val executor = Executors.newSingleThreadExecutor() try { executor.submit( @@ -425,6 +471,8 @@ class ParticipantDestinationDatabaseStoreImplTest { const val CONTACT_NAME = "Repro Estonia" const val TIMEOUT_SECONDS = 10L + val OVERSIZED_DESTINATION = "+372${"5".repeat(50_000)}" + val CANONICAL_DESTINATIONS = mapOf( BARE_NUMBER to CANONICAL_NUMBER, LONE_BARE_NUMBER to LONE_CANONICAL_NUMBER, diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt b/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt index 87bdd6142..9663fb8fd 100644 --- a/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt +++ b/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt @@ -11,3 +11,13 @@ internal fun createInMemoryActionSyncTestDatabase(context: Context): DatabaseWra return DatabaseWrapper(context, sqliteDatabase) } + +internal fun SQLiteDatabase.simulateFullDisk() { + val pageCount = rawQuery("PRAGMA page_count", null).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) + } + rawQuery("PRAGMA max_page_count = $pageCount", null).use { cursor -> + cursor.moveToFirst() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/util/db/ext/SQLiteDatabaseExtensionsTest.kt b/app/src/test/kotlin/com/android/messaging/util/db/ext/SQLiteDatabaseExtensionsTest.kt new file mode 100644 index 000000000..a4ab87598 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/util/db/ext/SQLiteDatabaseExtensionsTest.kt @@ -0,0 +1,90 @@ +package com.android.messaging.util.db.ext + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.database.sqlite.SQLiteFullException +import androidx.core.content.contentValuesOf +import com.android.messaging.datamodel.simulateFullDisk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SQLiteDatabaseExtensionsTest { + + private lateinit var database: SQLiteDatabase + + @Before + fun setUp() { + database = SQLiteDatabase.create(null) + database.execSQL("CREATE TABLE $TABLE ($COLUMN TEXT)") + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun aCompletedBlock_isCommitted() { + database.withTransaction { + insertNote(text = NOTE) + } + + assertEquals(1, database.noteCount()) + } + + @Test + fun aFailedBlock_isRolledBackAndRethrown() { + val failure = IllegalStateException(NOTE) + + val thrown = assertThrows(IllegalStateException::class.java) { + database.withTransaction { + insertNote(text = NOTE) + throw failure + } + } + + assertSame(failure, thrown) + assertEquals(0, database.noteCount()) + } + + @Test + fun aFullDisk_throwsTheFullDiskRatherThanTheFailedRollback() { + database.simulateFullDisk() + + val thrown = assertThrows(SQLiteFullException::class.java) { + database.withTransaction { + insertNote(text = NOTE) + insertNote(text = NOTE.repeat(OVERSIZED_NOTE_REPEATS)) + } + } + + assertTrue(thrown.suppressed.single() is SQLiteException) + assertEquals(0, database.noteCount()) + } + + private fun SQLiteDatabase.insertNote(text: String) { + insertOrThrow(TABLE, null, contentValuesOf(COLUMN to text)) + } + + private fun SQLiteDatabase.noteCount(): Int { + return rawQuery("SELECT COUNT(*) FROM $TABLE", null).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + } + + private companion object { + private const val TABLE = "notes" + private const val COLUMN = "text" + private const val NOTE = "note" + private const val OVERSIZED_NOTE_REPEATS = 20_000 + } +} diff --git a/src/com/android/messaging/data/conversationstate/ConversationStateMirror.kt b/src/com/android/messaging/data/conversationstate/ConversationStateMirror.kt index 657176140..034b4b729 100644 --- a/src/com/android/messaging/data/conversationstate/ConversationStateMirror.kt +++ b/src/com/android/messaging/data/conversationstate/ConversationStateMirror.kt @@ -110,6 +110,7 @@ internal class ConversationStateMirrorImpl @Inject constructor( mirroredState = MirroredConversationState( blockedDestinations = blockedDestinations, archivedThreadIds = databaseStore.readArchivedThreadIds(), + unarchivedBlockedThreadIds = databaseStore.readUnarchivedBlockedThreadIds(), mirroredAt = System.currentTimeMillis(), databaseVersion = databaseVersion, ), diff --git a/src/com/android/messaging/data/conversationstate/model/MirroredConversationState.kt b/src/com/android/messaging/data/conversationstate/model/MirroredConversationState.kt index c50e9f770..11dcd44f9 100644 --- a/src/com/android/messaging/data/conversationstate/model/MirroredConversationState.kt +++ b/src/com/android/messaging/data/conversationstate/model/MirroredConversationState.kt @@ -3,6 +3,7 @@ package com.android.messaging.data.conversationstate.model internal data class MirroredConversationState( val blockedDestinations: Set, val archivedThreadIds: Set, + val unarchivedBlockedThreadIds: Set, val mirroredAt: Long, val databaseVersion: Int, ) { diff --git a/src/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStore.kt b/src/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStore.kt index 9ed4947ce..1d2d4de5a 100644 --- a/src/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStore.kt +++ b/src/com/android/messaging/data/conversationstate/store/ConversationStateDatabaseStore.kt @@ -1,5 +1,6 @@ package com.android.messaging.data.conversationstate.store +import android.database.sqlite.SQLiteDatabase import androidx.core.content.contentValuesOf import com.android.messaging.data.conversationstate.model.MirroredConversationState import com.android.messaging.data.extension.archiveDirectConversations @@ -7,7 +8,6 @@ import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.DatabaseHelper import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns -import com.android.messaging.datamodel.DatabaseWrapper import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.util.PhoneUtils import com.android.messaging.util.db.ext.withTransaction @@ -19,6 +19,8 @@ internal interface ConversationStateDatabaseStore { fun readArchivedThreadIds(): Set + fun readUnarchivedBlockedThreadIds(): Set + fun restore(mirroredState: MirroredConversationState) } @@ -33,19 +35,31 @@ internal class ConversationStateDatabaseStoreImpl @Inject constructor() : return readStrings(sql = ARCHIVED_THREAD_IDS_SQL) } + override fun readUnarchivedBlockedThreadIds(): Set { + return readStrings(sql = UNARCHIVED_BLOCKED_THREAD_IDS_SQL) + } + override fun restore(mirroredState: MirroredConversationState) { - val db = DataModel.get().database - db.withTransaction { + // DatabaseWrapper swallows a full disk, which would mark a partial restore as done + DataModel.get().database.database.withTransaction { mirroredState.blockedDestinations.forEach { destination -> - blockDestination(db = db, destination = destination) + blockDestination( + db = this, + destination = destination, + unarchivedThreadIds = mirroredState.unarchivedBlockedThreadIds, + ) } mirroredState.archivedThreadIds.forEach { threadId -> - archiveThread(db = db, threadId = threadId, mirroredAt = mirroredState.mirroredAt) + archiveThread(db = this, threadId = threadId, mirroredAt = mirroredState.mirroredAt) } } } - private fun blockDestination(db: DatabaseWrapper, destination: String) { + private fun blockDestination( + db: SQLiteDatabase, + destination: String, + unarchivedThreadIds: Set, + ) { val blockedCount = db.update( DatabaseHelper.PARTICIPANTS_TABLE, contentValuesOf(ParticipantColumns.BLOCKED to 1), @@ -53,7 +67,7 @@ internal class ConversationStateDatabaseStoreImpl @Inject constructor() : arrayOf(destination, ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), ) if (blockedCount == 0) { - db.insert( + db.insertOrThrow( DatabaseHelper.PARTICIPANTS_TABLE, null, contentValuesOf( @@ -66,10 +80,13 @@ internal class ConversationStateDatabaseStoreImpl @Inject constructor() : ), ) } - db.archiveDirectConversations(destination = destination) + db.archiveDirectConversations( + destination = destination, + exceptThreadIds = unarchivedThreadIds, + ) } - private fun archiveThread(db: DatabaseWrapper, threadId: String, mirroredAt: Long) { + private fun archiveThread(db: SQLiteDatabase, threadId: String, mirroredAt: Long) { db.update( DatabaseHelper.CONVERSATIONS_TABLE, contentValuesOf(ConversationColumns.ARCHIVE_STATUS to 1), @@ -101,5 +118,17 @@ internal class ConversationStateDatabaseStoreImpl @Inject constructor() : WHERE ${ConversationColumns.ARCHIVE_STATUS} = 1 AND ${ConversationColumns.SMS_THREAD_ID} > 0 """.trimIndent() + + private val UNARCHIVED_BLOCKED_THREAD_IDS_SQL = """ + SELECT conversation.${ConversationColumns.SMS_THREAD_ID} + FROM ${DatabaseHelper.CONVERSATIONS_TABLE} AS conversation + JOIN ${DatabaseHelper.PARTICIPANTS_TABLE} AS sender + ON sender.${ParticipantColumns.NORMALIZED_DESTINATION} = + conversation.${ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION} + WHERE sender.${ParticipantColumns.SUB_ID} = ${ParticipantData.OTHER_THAN_SELF_SUB_ID} + AND sender.${ParticipantColumns.BLOCKED} = 1 + AND conversation.${ConversationColumns.ARCHIVE_STATUS} = 0 + AND conversation.${ConversationColumns.SMS_THREAD_ID} > 0 + """.trimIndent() } } diff --git a/src/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStore.kt b/src/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStore.kt index 71face4a6..65b6d2e67 100644 --- a/src/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStore.kt +++ b/src/com/android/messaging/data/conversationstate/store/ConversationStatePreferencesStore.kt @@ -37,6 +37,9 @@ internal class ConversationStatePreferencesStoreImpl @Inject constructor( archivedThreadIds = preferences .getStringSet(ARCHIVED_THREAD_IDS_KEY, null) .orEmpty(), + unarchivedBlockedThreadIds = preferences + .getStringSet(UNARCHIVED_BLOCKED_THREAD_IDS_KEY, null) + .orEmpty(), mirroredAt = preferences.getLong(MIRRORED_AT_KEY, 0L), databaseVersion = preferences.getInt(DATABASE_VERSION_KEY, 0), ) @@ -46,6 +49,10 @@ internal class ConversationStatePreferencesStoreImpl @Inject constructor( preferences.edit(commit = true) { putStringSet(BLOCKED_DESTINATIONS_KEY, mirroredState.blockedDestinations) putStringSet(ARCHIVED_THREAD_IDS_KEY, mirroredState.archivedThreadIds) + putStringSet( + UNARCHIVED_BLOCKED_THREAD_IDS_KEY, + mirroredState.unarchivedBlockedThreadIds, + ) putLong(MIRRORED_AT_KEY, mirroredState.mirroredAt) putInt(DATABASE_VERSION_KEY, mirroredState.databaseVersion) } @@ -68,6 +75,7 @@ internal class ConversationStatePreferencesStoreImpl @Inject constructor( private const val BLOCKED_DESTINATIONS_KEY = "blocked_destinations" private const val ARCHIVED_THREAD_IDS_KEY = "archived_thread_ids" + private const val UNARCHIVED_BLOCKED_THREAD_IDS_KEY = "unarchived_blocked_thread_ids" private const val MIRRORED_AT_KEY = "mirrored_at" private const val DATABASE_VERSION_KEY = "database_version" private const val RESTORE_PENDING_KEY = "restore_pending" diff --git a/src/com/android/messaging/data/extension/ConversationArchiveExtensions.kt b/src/com/android/messaging/data/extension/ConversationArchiveExtensions.kt index 6e053de69..fe430f58d 100644 --- a/src/com/android/messaging/data/extension/ConversationArchiveExtensions.kt +++ b/src/com/android/messaging/data/extension/ConversationArchiveExtensions.kt @@ -1,15 +1,26 @@ package com.android.messaging.data.extension +import android.database.sqlite.SQLiteDatabase import androidx.core.content.contentValuesOf import com.android.messaging.datamodel.DatabaseHelper import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns -import com.android.messaging.datamodel.DatabaseWrapper -internal fun DatabaseWrapper.archiveDirectConversations(destination: String) { +internal fun SQLiteDatabase.archiveDirectConversations( + destination: String, + exceptThreadIds: Set = emptySet(), +) { + val destinationCondition = "${ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION}=?" + val selection = when { + exceptThreadIds.isEmpty() -> destinationCondition + else -> { + val placeholders = exceptThreadIds.joinToString(separator = ",") { "?" } + "$destinationCondition AND ${ConversationColumns.SMS_THREAD_ID} NOT IN ($placeholders)" + } + } update( DatabaseHelper.CONVERSATIONS_TABLE, contentValuesOf(ConversationColumns.ARCHIVE_STATUS to 1), - "${ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION}=?", - arrayOf(destination), + selection, + arrayOf(destination) + exceptThreadIds, ) } diff --git a/src/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStore.kt b/src/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStore.kt index b05540ff1..716eafeb1 100644 --- a/src/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStore.kt +++ b/src/com/android/messaging/data/participantdestination/store/ParticipantDestinationDatabaseStore.kt @@ -1,5 +1,6 @@ package com.android.messaging.data.participantdestination.store +import android.database.sqlite.SQLiteDatabase import androidx.core.content.contentValuesOf import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.extension.archiveDirectConversations @@ -102,10 +103,11 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : override fun applyRenormalizations(renormalizations: List) { if (renormalizations.isNotEmpty()) { val db = DataModel.get().database - db.withTransaction { + // DatabaseWrapper swallows a full disk, which would mark a partial merge as done + db.database.withTransaction { renormalizations .flatMapTo(mutableSetOf()) { renormalization -> - applyRenormalization(db = db, renormalization = renormalization) + applyRenormalization(db = this, renormalization = renormalization) } .forEach { conversationId -> refreshConversation(db = db, conversationId = conversationId) @@ -116,7 +118,7 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : } private fun applyRenormalization( - db: DatabaseWrapper, + db: SQLiteDatabase, renormalization: Renormalization, ): List { val conversationIds = conversationIdsOf( @@ -131,7 +133,7 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : return conversationIds } - private fun normalizeInPlace(db: DatabaseWrapper, renormalization: Renormalization) { + private fun normalizeInPlace(db: SQLiteDatabase, renormalization: Renormalization) { val canonicalDestination = renormalization.canonicalDestination db.update( DatabaseHelper.PARTICIPANTS_TABLE, @@ -148,7 +150,7 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : } private fun mergeInto( - db: DatabaseWrapper, + db: SQLiteDatabase, renormalization: Renormalization, twin: ParticipantTwin, ) { @@ -178,7 +180,7 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : } private fun carryBlock( - db: DatabaseWrapper, + db: SQLiteDatabase, renormalization: Renormalization, twin: ParticipantTwin, ) { @@ -200,11 +202,11 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : } private fun refreshConversation(db: DatabaseWrapper, conversationId: String) { - db.execSQL(UPDATE_PARTICIPANT_COUNT_SQL, arrayOf(conversationId, conversationId)) + db.database.execSQL(UPDATE_PARTICIPANT_COUNT_SQL, arrayOf(conversationId, conversationId)) BugleDatabaseOperations.updateConversationNameAndAvatarInTransaction(db, conversationId) } - private fun conversationIdsOf(db: DatabaseWrapper, participantId: ParticipantId): List { + private fun conversationIdsOf(db: SQLiteDatabase, participantId: ParticipantId): List { return db.query( DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, arrayOf(ConversationParticipantsColumns.CONVERSATION_ID), @@ -222,7 +224,7 @@ internal class ParticipantDestinationDatabaseStoreImpl @Inject constructor() : } } - private fun twinOf(db: DatabaseWrapper, normalizedDestination: String): ParticipantTwin? { + private fun twinOf(db: SQLiteDatabase, normalizedDestination: String): ParticipantTwin? { return db.query( DatabaseHelper.PARTICIPANTS_TABLE, arrayOf(ParticipantColumns._ID, ParticipantColumns.BLOCKED), diff --git a/src/com/android/messaging/util/db/ext/SQLiteDatabaseExtensions.kt b/src/com/android/messaging/util/db/ext/SQLiteDatabaseExtensions.kt new file mode 100644 index 000000000..1931aa543 --- /dev/null +++ b/src/com/android/messaging/util/db/ext/SQLiteDatabaseExtensions.kt @@ -0,0 +1,17 @@ +package com.android.messaging.util.db.ext + +import android.database.sqlite.SQLiteDatabase + +internal inline fun SQLiteDatabase.withTransaction(block: SQLiteDatabase.() -> Unit) { + beginTransaction() + runCatching { + block() + setTransactionSuccessful() + }.onSuccess { + endTransaction() + }.onFailure { failure -> + // A full disk can make SQLite roll back by itself, so the rollback here fails too + runCatching { endTransaction() }.onFailure(failure::addSuppressed) + throw failure + } +}