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"
+ )
+ }
+}