diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/AccountDataMigratorTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/AccountDataMigratorTest.kt index 66e37d1..eda28f0 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/AccountDataMigratorTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/AccountDataMigratorTest.kt @@ -187,6 +187,40 @@ class AccountDataMigratorTest { } } + @Test + fun copiesFromACacheOlderThanTheCurrentSchema() = runBlocking { + // A cache last written at v12 — before account_settings gained retentionCount/retentionMonths + // (v13). The copy must not choke on the columns the destination has but the source lacks + // (a device upgrade from an old install crashed the migrator here). + helper.createDatabase(cacheName, 12).apply { + execSQL( + "INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " + + "smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " + + "'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')", + ) + execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')") + execSQL( + "INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " + + "VALUES ('acct', 'Sig', 0, 1)", + ) + close() + } + + AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile) + + openAccountsDb().apply { + assertEquals("ada@example.org", accountDao().getById("acct")?.email) + assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret) + val settings = accountSettingsDao().get("acct") + assertEquals(false, settings?.signatureEnabled) + assertEquals(true, settings?.notificationsEnabled) + // Columns the v12 source lacked come across as the destination's defaults (null). + assertNull("retentionCount absent from a v12 cache must default to null", settings?.retentionCount) + assertNull(settings?.retentionMonths) + close() + } + } + @Test fun migratorDdlMatchesExportedAccountDatabaseSchema() { val schema = JSONObject( diff --git a/app/src/main/kotlin/org/libremail/data/local/AccountDataMigrator.kt b/app/src/main/kotlin/org/libremail/data/local/AccountDataMigrator.kt index 8e5a50e..521bc81 100644 --- a/app/src/main/kotlin/org/libremail/data/local/AccountDataMigrator.kt +++ b/app/src/main/kotlin/org/libremail/data/local/AccountDataMigrator.kt @@ -158,10 +158,15 @@ class AccountDataMigrator @Inject constructor( if (present.isEmpty()) return // fresh cache or already dropped: nothing to move TABLES.forEach { db.rawExecSQL(CREATE_TABLE_SQL.getValue(it)) } db.rawExecSQL(SIGNATURES_INDEX_SQL) - // Parent first so an enforced foreign key (Room enables them; this raw connection - // does not) would still be satisfied. INSERT OR IGNORE makes each copy idempotent. + // Copy by explicit shared column names, never SELECT *: the on-disk cache may predate + // columns the current schema added (e.g. account_settings gained retentionCount / + // retentionMonths at v13), and a bare SELECT * would then supply fewer values than the + // destination has columns and fail the whole migration. Listing the columns the source + // actually has lets the destination's newer columns take their defaults (NULL). Parent + // first so an enforced foreign key would still be satisfied; INSERT OR IGNORE is idempotent. TABLES.filter { it in present }.forEach { table -> - db.rawExecSQL("INSERT OR IGNORE INTO `$table` SELECT * FROM cache.`$table`") + val cols = sharedColumns(db, table) + db.rawExecSQL("INSERT OR IGNORE INTO `$table` ($cols) SELECT $cols FROM cache.`$table`") } Log.d(TAG, "moved account tables into the account database: $present") } finally { @@ -189,5 +194,28 @@ class AccountDataMigrator @Inject constructor( } return present } + + /** + * Column names present in BOTH the freshly-created destination `$table` (always the current + * schema) and the source `cache.$table` (possibly an older on-disk schema), backtick-quoted and + * comma-joined for an INSERT/SELECT column list. Destination-only columns are omitted so they + * take their defaults instead of overflowing the value list. + */ + private fun sharedColumns(db: SQLiteDatabase, table: String): String { + val source = tableColumns(db, "cache", table) + return tableColumns(db, "main", table) + .filter { it in source } + .joinToString(", ") { "`$it`" } + } + + /** The column names of `$schema.$table`, in declared order, via `PRAGMA table_info`. */ + private fun tableColumns(db: SQLiteDatabase, schema: String, table: String): List { + val columns = mutableListOf() + db.rawQuery("PRAGMA $schema.table_info(`$table`)", null).use { cursor -> + val nameIndex = cursor.getColumnIndexOrThrow("name") + while (cursor.moveToNext()) columns += cursor.getString(nameIndex) + } + return columns + } } }