diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index bfaff81..e82d5e1 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -57,3 +57,74 @@ 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 + + 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 + + # 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/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/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/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..50e609d --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/data/db/OpenDroidDatabaseMigrationTest.kt @@ -0,0 +1,215 @@ +package com.opendroid.ai.data.db + +import android.app.Application +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. + */ +// 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], application = Application::class) +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() { + // 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 + 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 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, 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() + } + // 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) + 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" + + // 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" + ) + } +}