|
| 1 | +package com.sysadmindoc.callshield.data |
| 2 | + |
| 3 | +import android.content.Context |
| 4 | +import androidx.test.core.app.ApplicationProvider |
| 5 | +import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 6 | +import com.sysadmindoc.callshield.data.local.AppDatabase |
| 7 | +import com.sysadmindoc.callshield.data.model.BlockedCall |
| 8 | +import com.sysadmindoc.callshield.data.model.SmsKeywordRule |
| 9 | +import com.sysadmindoc.callshield.data.model.WildcardRule |
| 10 | +import kotlinx.coroutines.flow.first |
| 11 | +import kotlinx.coroutines.runBlocking |
| 12 | +import org.junit.After |
| 13 | +import org.junit.Assert.assertEquals |
| 14 | +import org.junit.Assert.assertTrue |
| 15 | +import org.junit.Before |
| 16 | +import org.junit.Test |
| 17 | +import org.junit.runner.RunWith |
| 18 | +import java.io.File |
| 19 | +import java.io.RandomAccessFile |
| 20 | + |
| 21 | +/** |
| 22 | + * The corruption path on real SQLite: one table's pages are overwritten on |
| 23 | + * disk, Room reports the damage as corruption, and the user's own rows come |
| 24 | + * through the rebuild. |
| 25 | + */ |
| 26 | +@RunWith(AndroidJUnit4::class) |
| 27 | +class CorruptionRescueInstrumentedTest { |
| 28 | + private val context: Context = ApplicationProvider.getApplicationContext() |
| 29 | + private val stores = TestSettingsStores(context) |
| 30 | + private val rescueFile = File(context.noBackupFilesDir, "corruption-rescue.json") |
| 31 | + |
| 32 | + @Before |
| 33 | + fun setUp() { |
| 34 | + context.deleteDatabase(TEST_DB) |
| 35 | + rescueFile.delete() |
| 36 | + } |
| 37 | + |
| 38 | + @After |
| 39 | + fun tearDown() { |
| 40 | + context.deleteDatabase(TEST_DB) |
| 41 | + rescueFile.delete() |
| 42 | + stores.close() |
| 43 | + } |
| 44 | + |
| 45 | + @Test |
| 46 | + fun aMalformedCallLogKeepsTheUsersRowsThroughTheRebuild() = |
| 47 | + runBlocking { |
| 48 | + val original = AppDatabase.builder(context, TEST_DB, afterRebuild = {}).build() |
| 49 | + val seeded = stores.repository(context, original) |
| 50 | + assertTrue(seeded.blockNumber("+12125550101", "robocall", "kept calling")) |
| 51 | + assertTrue(seeded.addToWhitelist("+14155550100", "Dr. Patel's office", rangeDigits = 2)) |
| 52 | + original.spamDao().insertWildcardRule(WildcardRule(pattern = "+1832555*")) |
| 53 | + original.spamDao().insertKeywordRule(SmsKeywordRule(keyword = "gift card")) |
| 54 | + original.spamDao().insertBlockedCalls( |
| 55 | + List(LOG_ROWS) { BlockedCall(number = "+1212555${1000 + it}", isCall = false, smsBody = "x".repeat(LOG_BODY_CHARS)) }, |
| 56 | + ) |
| 57 | + val sqlite = original.openHelper.writableDatabase |
| 58 | + val pageSize = |
| 59 | + sqlite.query("PRAGMA page_size").use { |
| 60 | + it.moveToFirst() |
| 61 | + it.getLong(0) |
| 62 | + } |
| 63 | + val rootPage = |
| 64 | + sqlite.query("SELECT rootpage FROM sqlite_master WHERE name = 'call_log'").use { |
| 65 | + it.moveToFirst() |
| 66 | + it.getLong(0) |
| 67 | + } |
| 68 | + sqlite.query("PRAGMA wal_checkpoint(TRUNCATE)").use { it.moveToFirst() } |
| 69 | + original.close() |
| 70 | + RandomAccessFile(context.getDatabasePath(TEST_DB), "rw").use { file -> |
| 71 | + file.seek((rootPage - 1) * pageSize) |
| 72 | + file.write(ByteArray(pageSize.toInt()) { 0x5A }) |
| 73 | + } |
| 74 | + |
| 75 | + var rebuilds = 0 |
| 76 | + val corrupt = AppDatabase.builder(context, TEST_DB, afterRebuild = { rebuilds++ }).build() |
| 77 | + try { |
| 78 | + val failure = |
| 79 | + runCatching { |
| 80 | + corrupt.openHelper.readableDatabase.query("SELECT smsBody FROM call_log").use { cursor -> |
| 81 | + while (cursor.moveToNext()) cursor.getString(0) |
| 82 | + } |
| 83 | + }.exceptionOrNull() |
| 84 | + assertTrue("reading the damaged table should fail as corruption: $failure", AppDatabase.isCorruptionException(failure)) |
| 85 | + assertEquals("SQLite replaced the file once", 1, rebuilds) |
| 86 | + assertTrue(CorruptionRescue.isPending(context)) |
| 87 | + |
| 88 | + // The same Room instance carries on with the empty file SQLite made. |
| 89 | + assertTrue(CorruptionRescue.importPending(context, corrupt.spamDao(), stores.repository(context, corrupt))) |
| 90 | + val dao = corrupt.spamDao() |
| 91 | + assertEquals( |
| 92 | + 0, |
| 93 | + corrupt.openHelper.readableDatabase.query("SELECT COUNT(*) FROM call_log").use { |
| 94 | + it.moveToFirst() |
| 95 | + it.getInt(0) |
| 96 | + }, |
| 97 | + ) |
| 98 | + assertEquals(listOf("+12125550101"), dao.getUserBlockedNumbers().first().map { it.number }) |
| 99 | + assertEquals(listOf("+14155550100" to 2), dao.getAllWhitelist().first().map { it.number to it.rangeDigits }) |
| 100 | + assertEquals(listOf("+1832555*"), dao.getAllWildcardRules().first().map { it.pattern }) |
| 101 | + assertEquals(listOf("gift card"), dao.getAllKeywordRules().first().map { it.keyword }) |
| 102 | + } finally { |
| 103 | + corrupt.close() |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + private companion object { |
| 108 | + const val TEST_DB = "corruption-rescue-test.db" |
| 109 | + const val LOG_ROWS = 200 |
| 110 | + const val LOG_BODY_CHARS = 400 |
| 111 | + } |
| 112 | +} |
0 commit comments