From 6bab29070db9e8caf72a6c1303b977cede40290f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 20:38:43 +0000 Subject: [PATCH 1/3] ci: close the coverage gaps from #42 - migration test, lint, release build Three of the gaps the Android CI workflow (#36) left open: 1. Robolectric test for the Room 6 -> 7 migration. Runs on the JVM under the existing testDebugUnitTest, no emulator. Schema export only starts at v7, so the test derives the v6 schema from the Room-generated v7 schema minus crash_logs (the only thing MIGRATION_6_7 adds), then lets Room's own schema validation on open be the assertion. Also flips on room.schemaLocation so 7.json onward are exported for future MigrationTestHelper tests. 2. Android Lint in CI, gated by a baseline: existing findings are frozen in app/lint-baseline.xml, new findings fail the build (abortOnError is now true). The baseline itself is generated by the first CI run via a clearly-marked temporary bootstrap step, then committed. 3. Unsigned assembleRelease in CI to exercise R8/ProGuard, which debug builds never touch. Requires the new explicit -PallowUnsignedRelease opt-in; without it the signing guard still fails release builds that have no signing config, exactly as before. Closes nothing on its own - items 2 (androidTest source set) and 4 (manual device smoke test) of #42 remain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GwZujP1Xxz6d9SypGb8hwN --- .github/workflows/android-ci.yml | 74 ++++++++ app/build.gradle | 33 +++- .../opendroid/ai/data/db/OpenDroidDatabase.kt | 2 +- .../data/db/OpenDroidDatabaseMigrationTest.kt | 179 ++++++++++++++++++ 4 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index bfaff81..c581626 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -57,3 +57,77 @@ jobs: path: app/build/reports/tests/ if-no-files-found: ignore retention-days: 14 + + lint: + name: Android Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Pre-existing findings live in app/lint-baseline.xml; only new + # findings fail this step. + - name: Run Android Lint + run: ./gradlew :app:lintDebug --stacktrace + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: | + app/build/reports/lint-results-debug.html + app/build/reports/lint-results-debug.txt + app/build/reports/lint-results-debug.xml + if-no-files-found: ignore + retention-days: 14 + + # TEMPORARY BOOTSTRAP - remove once app/lint-baseline.xml and + # app/schemas/ are committed. Prints the files lint/kapt just generated + # so they can be recovered from the job log and checked in. + - name: Print generated baseline and Room schema (bootstrap) + if: always() + run: | + echo "===BEGIN lint-baseline.xml===" + cat app/lint-baseline.xml 2>/dev/null || echo "(missing)" + echo "===END lint-baseline.xml===" + echo "===BEGIN 7.json===" + cat "app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json" 2>/dev/null || echo "(missing)" + echo "===END 7.json===" + + release-build: + name: Unsigned release build (R8/ProGuard) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # No signing secrets in CI. -PallowUnsignedRelease builds an unsigned, + # uninstallable APK purely to exercise R8/ProGuard, which debug builds + # never touch; a plain assembleRelease still refuses to run unsigned. + - name: Build unsigned release APK + run: ./gradlew :app:assembleRelease -PallowUnsignedRelease --stacktrace diff --git a/app/build.gradle b/app/build.gradle index c6895d9..3f46ede 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,6 +33,12 @@ android { project.hasProperty('RELEASE_KEY_PASSWORD') && file("${rootProject.projectDir}/opendroid-release.keystore").exists() + // Explicit opt-in for building release WITHOUT signing, so CI can exercise + // R8/ProGuard (where reflection-dependent code breaks) without holding any + // signing secrets. The resulting APK is unsigned and not installable - the + // guard below still fails a plain `assembleRelease` when signing is missing. + def allowUnsignedRelease = project.hasProperty('allowUnsignedRelease') + if (!hasSigningConfig) { logger.warn("WARNING: Release signing not configured. Set credentials in ~/.gradle/gradle.properties (see gradle.properties.example).") } @@ -68,7 +74,7 @@ android { def releaseBuildRequested = graph.allTasks.any { t -> t.project == project && t.name ==~ /(assemble|bundle|package)Release.*/ } - if (releaseBuildRequested && !hasSigningConfig) { + if (releaseBuildRequested && !hasSigningConfig && !allowUnsignedRelease) { throw new GradleException( "Release signing is not configured. Set RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS " + "and RELEASE_KEY_PASSWORD in ~/.gradle/gradle.properties (or as ORG_GRADLE_PROJECT_* " + @@ -94,17 +100,36 @@ android { buildFeatures { compose true } + testOptions { + unitTests { + // Robolectric needs the app's resources and manifest on the JVM. + includeAndroidResources = true + } + } packagingOptions { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } lint { - abortOnError false + // Pre-existing findings are frozen in the baseline so they do not + // block merges; anything NEW fails the build. Shrink the baseline + // over time (delete entries, fix, repeat) - never regenerate it to + // absorb new findings. + baseline file('lint-baseline.xml') + abortOnError true checkReleaseBuilds true } } +kapt { + arguments { + // Export Room schema JSONs (app/schemas/, committed to Git) so future + // migrations can be tested against the real historical schemas. + arg("room.schemaLocation", "$projectDir/schemas") + } +} + dependencies { // AndroidX Core & Lifecycle implementation 'androidx.core:core-ktx:1.12.0' @@ -167,6 +192,10 @@ dependencies { // Testing testImplementation 'junit:junit:4.13.2' + // Robolectric runs the Room migration tests on the JVM - no emulator, so + // they live in src/test and run under the existing testDebugUnitTest. + testImplementation 'org.robolectric:robolectric:4.16.1' + testImplementation 'androidx.test:core:1.6.1' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation platform('androidx.compose:compose-bom:2026.06.01') diff --git a/app/src/main/java/com/opendroid/ai/data/db/OpenDroidDatabase.kt b/app/src/main/java/com/opendroid/ai/data/db/OpenDroidDatabase.kt index 277b358..251a833 100644 --- a/app/src/main/java/com/opendroid/ai/data/db/OpenDroidDatabase.kt +++ b/app/src/main/java/com/opendroid/ai/data/db/OpenDroidDatabase.kt @@ -41,7 +41,7 @@ import androidx.room.TypeConverters CrashLogEntity::class ], version = 7, - exportSchema = false + exportSchema = true ) @TypeConverters(Converters::class) abstract class OpenDroidDatabase : RoomDatabase() { diff --git a/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt new file mode 100644 index 0000000..5cc9802 --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt @@ -0,0 +1,179 @@ +package com.opendroid.ai.data.db + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.opendroid.ai.data.db.entities.CrashLogEntity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Migration tests for [OpenDroidDatabase], on the JVM via Robolectric. + * + * The dangerous failure mode these exist to catch: a migration that runs + * without error but produces a schema that differs from what Room's generated + * code expects. Room detects the mismatch when the database is opened and + * throws - which in production is a crash on first launch after an upgrade, + * for every existing user. + * + * Schema export (`room.schemaLocation`) only began at version 7, so there is + * no exported 6.json for `MigrationTestHelper` to build a version-6 database + * from. Instead the v6 schema is derived from the current Room-generated v7 + * schema: version 7 is exactly version 6 plus the `crash_logs` table and its + * index, because MIGRATION_6_7 touches nothing else. Future migrations (7 -> + * 8 onwards) should use `MigrationTestHelper` against the committed schema + * JSONs instead. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class OpenDroidDatabaseMigrationTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + context.deleteDatabase(TEST_DB) + context.deleteDatabase(REFERENCE_DB) + context.deleteDatabase(IDEMPOTENCY_DB) + } + + @Test + fun migration6To7_preservesDataAndPassesRoomSchemaValidation() { + createVersion6Database() + + // No fallbackToDestructiveMigration here, unlike DatabaseModule: a + // broken migration must fail this test, not silently wipe and rebuild. + val db = Room.databaseBuilder(context, OpenDroidDatabase::class.java, TEST_DB) + .addMigrations( + OpenDroidDatabase.MIGRATION_1_2, + OpenDroidDatabase.MIGRATION_2_3, + OpenDroidDatabase.MIGRATION_3_4, + OpenDroidDatabase.MIGRATION_4_5, + OpenDroidDatabase.MIGRATION_5_6, + OpenDroidDatabase.MIGRATION_6_7 + ) + .build() + try { + // Opening the database runs MIGRATION_6_7 and then Room's own + // schema validation across every table. A schema mismatch throws + // here. + val support = db.openHelper.writableDatabase + assertEquals(7, support.version) + + support.query( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'index_crash_logs_timestamp'" + ).use { cursor -> + assertTrue("index_crash_logs_timestamp missing after migration", cursor.moveToFirst()) + } + + // Rows that existed before the upgrade are still there. + support.query("SELECT text, sessionId FROM conversations WHERE id = 'msg-1'").use { cursor -> + assertTrue("pre-upgrade conversation row lost by migration", cursor.moveToFirst()) + assertEquals("hello", cursor.getString(0)) + assertEquals("default_session", cursor.getString(1)) + } + + // The migrated table round-trips through the real DAO. + runBlocking { + db.crashLogDao().insert(sampleCrash()) + val logs = db.crashLogDao().getAll() + assertEquals(1, logs.size) + assertEquals("boom", logs[0].message) + assertEquals("java.lang.RuntimeException", logs[0].exceptionClass) + } + } finally { + db.close() + } + } + + @Test + fun migration6To7_isSafeToRunAgainstAnExistingCrashLogTable() { + // A partially-applied upgrade (process death mid-migration) can replay + // a migration; every statement in MIGRATION_6_7 claims to be re-run + // safe via IF NOT EXISTS. Hold it to that: run it against a database + // where crash_logs already exists and holds data - it must neither + // throw nor clobber the table. + val db = Room.databaseBuilder(context, OpenDroidDatabase::class.java, IDEMPOTENCY_DB).build() + try { + val support = db.openHelper.writableDatabase + runBlocking { db.crashLogDao().insert(sampleCrash()) } + + OpenDroidDatabase.MIGRATION_6_7.migrate(support) + + runBlocking { + val logs = db.crashLogDao().getAll() + assertEquals(1, logs.size) + assertEquals("boom", logs[0].message) + } + } finally { + db.close() + } + } + + /** + * Builds a populated database exactly as a v6 install would have left it: + * the Room-generated v7 schema minus `crash_logs` and its index (the only + * things MIGRATION_6_7 adds), stamped `user_version = 6`. + */ + private fun createVersion6Database() { + val version6Ddl = mutableListOf() + val reference = Room.databaseBuilder(context, OpenDroidDatabase::class.java, REFERENCE_DB).build() + try { + reference.openHelper.writableDatabase.query( + "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL " + + "AND name NOT LIKE 'sqlite_%' AND name != 'android_metadata' " + + "AND name != 'room_master_table' AND tbl_name != 'crash_logs'" + ).use { cursor -> + while (cursor.moveToNext()) { + version6Ddl += cursor.getString(0) + } + } + } finally { + reference.close() + } + assertTrue("failed to derive any v6 DDL from the reference database", version6Ddl.isNotEmpty()) + + context.deleteDatabase(TEST_DB) + val file = context.getDatabasePath(TEST_DB) + file.parentFile?.mkdirs() + SQLiteDatabase.openOrCreateDatabase(file, null).use { v6 -> + version6Ddl.forEach(v6::execSQL) + v6.execSQL( + "INSERT INTO chat_sessions (id, title, createdAt, updatedAt, isCurrent) " + + "VALUES ('default_session', 'Chat', 100, 100, 1)" + ) + v6.execSQL( + "INSERT INTO conversations (id, text, sender, timestamp, modelBadge, contactPickerData, sessionId) " + + "VALUES ('msg-1', 'hello', 'USER', 123, NULL, NULL, 'default_session')" + ) + v6.version = 6 + } + } + + private fun sampleCrash() = CrashLogEntity( + timestamp = 1L, + exceptionClass = "java.lang.RuntimeException", + message = "boom", + threadName = "main", + stackTrace = "java.lang.RuntimeException: boom\n\tat com.opendroid.ai.Somewhere.kt:1", + appVersionName = "1.0.2", + appVersionCode = 3L, + androidRelease = "15", + androidSdkInt = 35, + deviceManufacturer = "Robolectric", + deviceModel = "JVM" + ) + + private companion object { + const val TEST_DB = "migration-test.db" + const val REFERENCE_DB = "migration-reference.db" + const val IDEMPOTENCY_DB = "migration-idempotency.db" + } +} From 29b869e52dcef131becd6dca6cf6c5eb92974cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 20:43:38 +0000 Subject: [PATCH 2/3] ci: commit CI-generated lint baseline and Room schema, fix migration test app - app/lint-baseline.xml: generated by the bootstrap CI run (8 errors, 39 warnings frozen); new lint findings now fail the build. The bootstrap log-print step is removed from the workflow. - app/schemas/.../7.json: Room schema export recovered from the same run. - Migration tests now run under a plain Application instead of OpenDroidApp: the real app's startup reaches the Android Keystore via SecurePrefs, which does not exist on the Robolectric JVM and is not needed to open a database. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GwZujP1Xxz6d9SypGb8hwN --- .github/workflows/android-ci.yml | 13 - app/lint-baseline.xml | 458 ++++++++++++ .../7.json | 656 ++++++++++++++++++ .../data/db/OpenDroidDatabaseMigrationTest.kt | 6 +- 4 files changed, 1119 insertions(+), 14 deletions(-) create mode 100644 app/lint-baseline.xml create mode 100644 app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index c581626..f9f805b 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -94,19 +94,6 @@ jobs: if-no-files-found: ignore retention-days: 14 - # TEMPORARY BOOTSTRAP - remove once app/lint-baseline.xml and - # app/schemas/ are committed. Prints the files lint/kapt just generated - # so they can be recovered from the job log and checked in. - - name: Print generated baseline and Room schema (bootstrap) - if: always() - run: | - echo "===BEGIN lint-baseline.xml===" - cat app/lint-baseline.xml 2>/dev/null || echo "(missing)" - echo "===END lint-baseline.xml===" - echo "===BEGIN 7.json===" - cat "app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json" 2>/dev/null || echo "(missing)" - echo "===END 7.json===" - release-build: name: Unsigned release build (R8/ProGuard) runs-on: ubuntu-latest diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..d7f81c3 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json b/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json new file mode 100644 index 0000000..996b79e --- /dev/null +++ b/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json @@ -0,0 +1,656 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "f0ecc2c35055438b40d0f7d7d22e828b", + "entities": [ + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `text` TEXT NOT NULL, `sender` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `modelBadge` TEXT, `contactPickerData` TEXT, `sessionId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modelBadge", + "columnName": "modelBadge", + "affinity": "TEXT" + }, + { + "fieldPath": "contactPickerData", + "columnName": "contactPickerData", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_conversations_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_conversations_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ] + }, + { + "tableName": "chat_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `isCurrent` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCurrent", + "columnName": "isCurrent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "plans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`planId` TEXT NOT NULL, `goal` TEXT NOT NULL, `estimatedDuration` TEXT NOT NULL, `estimatedSteps` INTEGER NOT NULL, `stepsJson` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`planId`))", + "fields": [ + { + "fieldPath": "planId", + "columnName": "planId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedDuration", + "columnName": "estimatedDuration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedSteps", + "columnName": "estimatedSteps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stepsJson", + "columnName": "stepsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planId" + ] + } + }, + { + "tableName": "memories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `type` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `ttlHours` INTEGER NOT NULL, `category` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ttlHours", + "columnName": "ttlHours", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "task_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stepId` TEXT NOT NULL, `planId` TEXT NOT NULL, `description` TEXT NOT NULL, `actionType` TEXT NOT NULL, `paramsJson` TEXT NOT NULL, `success` INTEGER NOT NULL, `resultData` TEXT, `errorMessage` TEXT, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stepId", + "columnName": "stepId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planId", + "columnName": "planId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionType", + "columnName": "actionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paramsJson", + "columnName": "paramsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "success", + "columnName": "success", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resultData", + "columnName": "resultData", + "affinity": "TEXT" + }, + { + "fieldPath": "errorMessage", + "columnName": "errorMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "macros", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `trigger` TEXT NOT NULL, `stepsJson` TEXT NOT NULL, `isSystem` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stepsJson", + "columnName": "stepsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSystem", + "columnName": "isSystem", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "unknown_actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `attemptedAction` TEXT NOT NULL, `goal` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `fixStatus` TEXT NOT NULL, `wasAutoFixed` INTEGER NOT NULL, `fixedWith` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptedAction", + "columnName": "attemptedAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fixStatus", + "columnName": "fixStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wasAutoFixed", + "columnName": "wasAutoFixed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fixedWith", + "columnName": "fixedWith", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `packageName` TEXT NOT NULL, `appName` TEXT NOT NULL, `title` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `category` TEXT NOT NULL, `isAutoReplied` INTEGER NOT NULL, `autoReplyText` TEXT, `contactName` TEXT, `senderEmail` TEXT, `isRead` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appName", + "columnName": "appName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAutoReplied", + "columnName": "isAutoReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoReplyText", + "columnName": "autoReplyText", + "affinity": "TEXT" + }, + { + "fieldPath": "contactName", + "columnName": "contactName", + "affinity": "TEXT" + }, + { + "fieldPath": "senderEmail", + "columnName": "senderEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "models", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `version` TEXT NOT NULL, `size` INTEGER NOT NULL, `downloadUrl` TEXT NOT NULL, `localPath` TEXT NOT NULL, `status` TEXT NOT NULL, `downloadProgress` INTEGER NOT NULL, `lastUsed` INTEGER NOT NULL, `installedAt` INTEGER NOT NULL, `downloadedSize` INTEGER NOT NULL, `downloadSpeed` TEXT NOT NULL, `etaString` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadUrl", + "columnName": "downloadUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadProgress", + "columnName": "downloadProgress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "installedAt", + "columnName": "installedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedSize", + "columnName": "downloadedSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadSpeed", + "columnName": "downloadSpeed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etaString", + "columnName": "etaString", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "crash_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `exceptionClass` TEXT NOT NULL, `message` TEXT, `threadName` TEXT NOT NULL, `stackTrace` TEXT NOT NULL, `appVersionName` TEXT NOT NULL, `appVersionCode` INTEGER NOT NULL, `androidRelease` TEXT NOT NULL, `androidSdkInt` INTEGER NOT NULL, `deviceManufacturer` TEXT NOT NULL, `deviceModel` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exceptionClass", + "columnName": "exceptionClass", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "threadName", + "columnName": "threadName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stackTrace", + "columnName": "stackTrace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appVersionName", + "columnName": "appVersionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appVersionCode", + "columnName": "appVersionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "androidRelease", + "columnName": "androidRelease", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidSdkInt", + "columnName": "androidSdkInt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceManufacturer", + "columnName": "deviceManufacturer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceModel", + "columnName": "deviceModel", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_crash_logs_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_crash_logs_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f0ecc2c35055438b40d0f7d7d22e828b')" + ] + } +} diff --git a/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt index 5cc9802..d6a07ca 100644 --- a/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt +++ b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt @@ -1,5 +1,6 @@ package com.opendroid.ai.data.db +import android.app.Application import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.room.Room @@ -31,8 +32,11 @@ import org.robolectric.annotation.Config * 8 onwards) should use `MigrationTestHelper` against the committed schema * JSONs instead. */ +// A plain Application, not OpenDroidApp: the real app's startup touches the +// Android Keystore (SecurePrefs/EncryptedSharedPreferences), which does not +// exist on the Robolectric JVM - and none of it is needed to open a database. @RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) +@Config(sdk = [35], application = Application::class) class OpenDroidDatabaseMigrationTest { private val context: Context = ApplicationProvider.getApplicationContext() From 34c83b8b249586e86354543a35271340ae106681 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 20:59:24 +0000 Subject: [PATCH 3/3] test: address review - accurate idempotency rationale, staleness guards, APK artifact - Correct the idempotency test comment: Room wraps migrations in a transaction, so crash recovery replays whole migrations against the original schema - the IF NOT EXISTS guards only matter when the migration meets an already-existing crash_logs table. - Guard the v6-schema derivation against schema drift: assert the reference database is version 7 and the derived table set matches v6 exactly, with messages pointing at MigrationTestHelper + app/schemas/ for migrations 7 -> 8 onward. - Upload the unsigned release APK as a CI artifact so R8 breakage can be inspected with apkanalyzer/aapt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GwZujP1Xxz6d9SypGb8hwN --- .github/workflows/android-ci.yml | 10 ++++ .../data/db/OpenDroidDatabaseMigrationTest.kt | 46 ++++++++++++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index f9f805b..e82d5e1 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -118,3 +118,13 @@ jobs: # never touch; a plain assembleRelease still refuses to run unsigned. - name: Build unsigned release APK run: ./gradlew :app:assembleRelease -PallowUnsignedRelease --stacktrace + + # If R8 ever does strip something reflection needs, the minified APK is + # what gets inspected (apkanalyzer/aapt) to see which class went missing. + - name: Upload unsigned release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-unsigned + path: app/build/outputs/apk/release/*.apk + if-no-files-found: error + retention-days: 14 diff --git a/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt index d6a07ca..50e609d 100644 --- a/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt +++ b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt @@ -99,11 +99,12 @@ class OpenDroidDatabaseMigrationTest { @Test fun migration6To7_isSafeToRunAgainstAnExistingCrashLogTable() { - // A partially-applied upgrade (process death mid-migration) can replay - // a migration; every statement in MIGRATION_6_7 claims to be re-run - // safe via IF NOT EXISTS. Hold it to that: run it against a database - // where crash_logs already exists and holds data - it must neither - // throw nor clobber the table. + // Room wraps each migration in a transaction, so a crash mid-migration + // rolls back cleanly and the whole migration re-runs against the + // original schema - crash recovery does NOT need these guards. The + // IF NOT EXISTS statements instead promise something narrower: running + // the migration against a database where crash_logs already exists and + // holds data must neither throw nor clobber the table. Hold it to that. val db = Room.databaseBuilder(context, OpenDroidDatabase::class.java, IDEMPOTENCY_DB).build() try { val support = db.openHelper.writableDatabase @@ -128,21 +129,39 @@ class OpenDroidDatabaseMigrationTest { */ private fun createVersion6Database() { val version6Ddl = mutableListOf() + val derivedTables = mutableSetOf() val reference = Room.databaseBuilder(context, OpenDroidDatabase::class.java, REFERENCE_DB).build() try { + assertEquals( + "Derived v6 schema is stale - the schema has moved past v7. " + + "Test migrations 7 -> 8 onward with MigrationTestHelper against app/schemas/.", + 7, + reference.openHelper.writableDatabase.version + ) reference.openHelper.writableDatabase.query( - "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL " + + "SELECT sql, type, name FROM sqlite_master WHERE sql IS NOT NULL " + "AND name NOT LIKE 'sqlite_%' AND name != 'android_metadata' " + "AND name != 'room_master_table' AND tbl_name != 'crash_logs'" ).use { cursor -> while (cursor.moveToNext()) { version6Ddl += cursor.getString(0) + if (cursor.getString(1) == "table") { + derivedTables += cursor.getString(2) + } } } } finally { reference.close() } - assertTrue("failed to derive any v6 DDL from the reference database", version6Ddl.isNotEmpty()) + // Companion to the version guard above: the filtered sqlite_master + // dump must contain exactly the v6 tables. Anything extra or missing + // means the derivation is wrong, and letting it through would surface + // later as a cryptic Room identity-hash mismatch instead of this. + assertEquals( + "Derived v6 table set does not match a version-6 database.", + VERSION_6_TABLES, + derivedTables + ) context.deleteDatabase(TEST_DB) val file = context.getDatabasePath(TEST_DB) @@ -179,5 +198,18 @@ class OpenDroidDatabaseMigrationTest { const val TEST_DB = "migration-test.db" const val REFERENCE_DB = "migration-reference.db" const val IDEMPOTENCY_DB = "migration-idempotency.db" + + // Every table a version-6 database contained, i.e. v7 minus crash_logs. + val VERSION_6_TABLES = setOf( + "conversations", + "chat_sessions", + "plans", + "memories", + "task_history", + "macros", + "unknown_actions", + "notifications", + "models" + ) } }