diff --git a/app/build.gradle b/app/build.gradle index 3f46ede..b6ded1a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,7 @@ plugins { id 'dagger.hilt.android.plugin' id 'kotlinx-serialization' id 'org.jetbrains.kotlin.plugin.compose' + id 'androidx.room' } android { @@ -70,17 +71,27 @@ android { // Fail release builds loudly when release signing is not configured, // instead of silently producing a debug-signed or unsigned artifact. + // CI (and other non-distribution builds) can opt out explicitly with + // -PallowUnsignedRelease to compile-check release without signing. gradle.taskGraph.whenReady { graph -> def releaseBuildRequested = graph.allTasks.any { t -> t.project == project && t.name ==~ /(assemble|bundle|package)Release.*/ } - 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_* " + - "environment variables) and place opendroid-release.keystore at the project root " + - "(see gradle.properties.example). Refusing to sign a release build with the debug key." - ) + if (releaseBuildRequested && !hasSigningConfig) { + if (allowUnsignedRelease) { + logger.warn( + "WARNING: Building an UNSIGNED release because -PallowUnsignedRelease was set. " + + "This artifact must never be distributed." + ) + } else { + 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_* " + + "environment variables) and place opendroid-release.keystore at the project root " + + "(see gradle.properties.example). Refusing to sign a release build with the debug key. " + + "To build an unsigned release on purpose (e.g. CI compile checks), pass -PallowUnsignedRelease=true." + ) + } } } compileOptions { @@ -106,6 +117,14 @@ android { includeAndroidResources = true } } + sourceSets { + // This AGP version does not merge unit-test sourceSet assets into the + // Robolectric resource APK, so the exported Room schemas are exposed via + // the debug variant (unit tests run against debug); release stays clean. + debug { + assets.srcDirs += "$projectDir/schemas".toString() + } + } packagingOptions { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' @@ -116,18 +135,16 @@ android { // 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 + baseline = file("lint-baseline.xml") } } -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") - } +// Export Room schema JSONs (app/schemas/, committed to Git) so future +// migrations can be tested against the real historical schemas. +room { + schemaDirectory "$projectDir/schemas" } dependencies { @@ -181,9 +198,6 @@ dependencies { // Lottie animations implementation 'com.airbnb.android:lottie-compose:6.3.0' - // Accompanist Permissions - implementation 'com.google.accompanist:accompanist-permissions:0.34.0' - // Google ML Kit GenAI Prompt API (Android AI Core) implementation 'com.google.mlkit:genai-prompt:1.0.0-beta2' @@ -192,10 +206,11 @@ dependencies { // Testing testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.room:room-testing:2.8.4' + testImplementation 'androidx.test:core:1.6.1' // 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 index d7f81c3..13ab05e 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,17 +1,6 @@ - - - - + message="Field requires API level 30 (current min is 26): `android.provider.Settings#ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION`" + errorLine1=" Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + line="566" + column="28"/> @@ -96,7 +96,7 @@ errorLine2=" ~~~~~~~~"> @@ -107,7 +107,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -118,7 +118,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -129,7 +129,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -140,7 +140,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -151,7 +151,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> diff --git a/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/6.json b/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/6.json new file mode 100644 index 0000000..25dfb5a --- /dev/null +++ b/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/6.json @@ -0,0 +1,562 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "4e53326f14505d186783b4c7db2236e6", + "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" + ] + } + } + ], + "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/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json b/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json index 996b79e..a532c4d 100644 --- a/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json +++ b/app/schemas/com.opendroid.ai.data.db.OpenDroidDatabase/7.json @@ -593,37 +593,37 @@ "notNull": true }, { - "fieldPath": "appVersionName", + "fieldPath": "device.appVersionName", "columnName": "appVersionName", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "appVersionCode", + "fieldPath": "device.appVersionCode", "columnName": "appVersionCode", "affinity": "INTEGER", "notNull": true }, { - "fieldPath": "androidRelease", + "fieldPath": "device.androidRelease", "columnName": "androidRelease", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "androidSdkInt", + "fieldPath": "device.androidSdkInt", "columnName": "androidSdkInt", "affinity": "INTEGER", "notNull": true }, { - "fieldPath": "deviceManufacturer", + "fieldPath": "device.deviceManufacturer", "columnName": "deviceManufacturer", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "deviceModel", + "fieldPath": "device.deviceModel", "columnName": "deviceModel", "affinity": "TEXT", "notNull": true @@ -653,4 +653,4 @@ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f0ecc2c35055438b40d0f7d7d22e828b')" ] } -} +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c979902..88d75a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,9 +8,7 @@ - - diff --git a/app/src/main/java/com/opendroid/ai/actions/AdvancedControlActions.kt b/app/src/main/java/com/opendroid/ai/actions/AdvancedControlActions.kt index b7746f4..228cc9f 100644 --- a/app/src/main/java/com/opendroid/ai/actions/AdvancedControlActions.kt +++ b/app/src/main/java/com/opendroid/ai/actions/AdvancedControlActions.kt @@ -343,6 +343,12 @@ class AdvancedControlActions @Inject constructor() { cameraId: String, outputFile: File ): Boolean = suspendCoroutine { continuation -> + // Guard against SecurityException from openCamera - the permission can + // be revoked between the caller's check and this capture starting. + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + continuation.resume(false) + return@suspendCoroutine + } val handlerThread = HandlerThread("CameraBackgroundThread") handlerThread.start() val backgroundHandler = Handler(handlerThread.looper) diff --git a/app/src/main/java/com/opendroid/ai/core/agent/AgentLoop.kt b/app/src/main/java/com/opendroid/ai/core/agent/AgentLoop.kt index 2929041..1d29e8c 100644 --- a/app/src/main/java/com/opendroid/ai/core/agent/AgentLoop.kt +++ b/app/src/main/java/com/opendroid/ai/core/agent/AgentLoop.kt @@ -22,8 +22,10 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,11 +41,13 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.contentOrNull import com.opendroid.ai.core.util.NetworkErrorFormatter +import com.opendroid.ai.core.llm.error.LLMException import java.util.UUID import javax.inject.Inject import javax.inject.Singleton private const val MAX_NEEDS_INPUT_PROMPTS = 5 +private const val MAX_INCOMPLETE_MESSAGE_IDS = 100 private val CONTACT_NUMBER_PROMPT_ACTIONS = setOf("MAKE_CALL", "SEND_SMS", "SEND_WHATSAPP") internal fun paramKeyForNeedsInput(needsInput: ActionResult.NeedsInput, actionName: String): String { @@ -125,6 +129,23 @@ class AgentLoop @Inject constructor( private val _agentState = MutableStateFlow(AgentState.Idle) val agentState: StateFlow = _agentState.asStateFlow() + private val _chatError = MutableStateFlow(null) + val chatError: StateFlow = _chatError.asStateFlow() + + // Ids of partially streamed agent replies, so re-sent context can label them as + // incomplete. Bounded: an insertion-ordered set capped at + // MAX_INCOMPLETE_MESSAGE_IDS, dropping the oldest entry once full - only the ids + // still inside the last-10-messages context window matter, so evicted entries can + // never affect a prompt again. + private val incompleteMessageIds: MutableSet = java.util.Collections.synchronizedSet( + java.util.Collections.newSetFromMap( + object : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = + size > MAX_INCOMPLETE_MESSAGE_IDS + } + ) + ) + // A single pending awaitUserResponse() prompt, identified by [requestId] - not just a // session id, so that even a second prompt opened for the SAME session can never be // resolved by a reply aimed at an earlier, already-abandoned one. [deferred] is @@ -294,7 +315,48 @@ class AgentLoop @Inject constructor( } } + /** + * Re-executes a previously failed request after the user taps Retry on its error + * card. Mirrors processQuery's new-task path, but reuses the user message already + * persisted for [requestId] in [sessionId] instead of inserting a duplicate bubble - + * and always runs in the error's own session, never wherever the user is looking. + * Falls back to the session's last user message if [requestId] doesn't resolve to + * one (e.g. a plan re-evaluation failure, whose requestId is a plan id). + */ + fun retryRequest(requestId: String, sessionId: String, context: Context) { + val waitingSession = waitingSessionId + if (waitingSession != null && waitingSession != sessionId) { + abandonWaitingTask(waitingSession) + } else { + currentJob?.cancel() + } + + val job = scope.launch { + try { + activeTaskSessionId = sessionId + resolveStaleProposedPlan(sessionId) + + val messages = conversationRepository.getMessages(sessionId).first() + val userMsg = messages.lastOrNull { + it.id == requestId && it.sender == ChatMessage.Sender.USER + } ?: messages.lastOrNull { it.sender == ChatMessage.Sender.USER } ?: return@launch + + queryMutex.withLock { + processQueryLocked(userMsg, userMsg.text, context, sessionId) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _agentState.value = AgentState.Error(e.localizedMessage ?: "Unknown processing error") + } + } + currentJob = job + } + private suspend fun processQueryLocked(userMsg: ChatMessage, query: String, context: Context, sessionId: String) { + // Each new task starts with a clean slate: a stale error card from an + // earlier request must not outlive the request it described. + _chatError.value = null _agentState.value = AgentState.Thinking // 0. Check if this is a complex, multi-step query @@ -389,7 +451,18 @@ class AgentLoop @Inject constructor( } } + fun dismissChatError() { + _chatError.value = null + } + + private fun publishChatError(error: ChatErrorUiState) { + _chatError.value = error + _agentState.value = AgentState.Error(error.title()) + } + private suspend fun executeSimpleQuery(userMsg: ChatMessage, sessionId: String) { + val runId = UUID.randomUUID().toString() + val requestId = userMsg.id try { val provider = llmProviderFactory.getActiveProvider() val relevantContext = memoryManager.getRelevantContext(userMsg.text) @@ -411,22 +484,29 @@ class AgentLoop @Inject constructor( """.trimIndent() val lastMsgs = conversationRepository.getLastMessages(sessionId, 10).map { msg -> - if (msg.id == userMsg.id) { + val withImage = if (msg.id == userMsg.id) { msg.copy(imageBase64 = userMsg.imageBase64) } else { msg } + if (incompleteMessageIds.contains(withImage.id) && + withImage.sender == ChatMessage.Sender.AGENT + ) { + withImage.copy(text = "[incomplete assistant reply]\n${withImage.text}") + } else { + withImage + } } val replyId = UUID.randomUUID().toString() var currentReplyText = "" + var inserted = false val replyMsg = ChatMessage( id = replyId, text = currentReplyText, sender = ChatMessage.Sender.AGENT, modelBadge = provider.name ) - conversationRepository.insertMessage(sessionId, replyMsg) try { provider.streamComplete( @@ -438,34 +518,76 @@ class AgentLoop @Inject constructor( responseFormat = ResponseFormat.TEXT ) ).collect { chunk -> + if (chunk.isEmpty()) return@collect currentReplyText += chunk conversationRepository.insertMessage(sessionId, replyMsg.copy(text = currentReplyText)) + inserted = true } } catch (streamError: CancellationException) { - throw streamError - } catch (streamError: Exception) { - if (currentReplyText.isEmpty()) { - val response = provider.complete( - LLMRequest( - systemPrompt = systemPrompt, - messages = lastMsgs, - temperature = 0.5f, - maxTokens = 500, - responseFormat = ResponseFormat.TEXT + if (inserted && currentReplyText.isNotBlank()) { + // This coroutine is already cancelled; without NonCancellable the + // suspend insert would abort immediately and the "Stopped" partial + // would never persist. + withContext(NonCancellable) { + conversationRepository.insertMessage( + sessionId, + replyMsg.copy(text = currentReplyText, modelBadge = "Stopped") ) - ) - currentReplyText = response.content.trim() + } + } + throw streamError + } catch (streamError: LLMException) { + val partialId = if (inserted && currentReplyText.isNotBlank()) { + incompleteMessageIds.add(replyId) conversationRepository.insertMessage(sessionId, replyMsg.copy(text = currentReplyText)) + replyId + } else { + null } + publishChatError( + ChatErrorUiState.fromException( + sessionId = sessionId, + requestId = requestId, + runId = runId, + failure = streamError, + partialMessageId = partialId + ) + ) + return + } + + if (!inserted || currentReplyText.isBlank()) { + publishChatError( + ChatErrorUiState.fromException( + sessionId = sessionId, + requestId = requestId, + runId = runId, + failure = com.opendroid.ai.core.llm.error.LLMErrorMapper.malformed( + provider.name, + "" + ) + ) + ) + return } - val finalReplyMsg = replyMsg.copy(text = formatStreamedReply(currentReplyText)) + val finalReplyMsg = replyMsg.copy(text = currentReplyText) conversationRepository.insertMessage(sessionId, finalReplyMsg) memoryManager.storeMessage(finalReplyMsg, sessionId) + _chatError.value = null _agentState.value = AgentState.Speaking(finalReplyMsg.text) onSpeakCallback?.invoke(finalReplyMsg.text) } catch (e: CancellationException) { throw e + } catch (e: LLMException) { + publishChatError( + ChatErrorUiState.fromException( + sessionId = sessionId, + requestId = requestId, + runId = runId, + failure = e + ) + ) } catch (e: Exception) { _agentState.value = AgentState.Error(NetworkErrorFormatter.toUserMessage(e)) } @@ -561,14 +683,23 @@ class AgentLoop @Inject constructor( } } catch (e: CancellationException) { throw e + } catch (e: LLMException) { + publishChatError( + ChatErrorUiState.fromException( + sessionId = sessionId, + requestId = userMsg.id, + runId = UUID.randomUUID().toString(), + failure = e + ) + ) } catch (e: Exception) { fallbackOrError(userMsg, context, e, sessionId) } } /** - * Treat malformed plan JSON as an actionable planning failure. Other provider - * failures can still degrade to a normal chat response. + * Non-LLM planning failures may still degrade to alias/simple chat. Typed + * [LLMException]s are handled above and must never fall through here. */ private suspend fun fallbackOrError(userMsg: ChatMessage, context: Context, cause: Throwable, sessionId: String) { android.util.Log.e("AgentLoop", "Plan generation failed: ${cause.localizedMessage}", cause) @@ -590,7 +721,7 @@ class AgentLoop @Inject constructor( params = emptyMap(), success = false, resultData = null, - errorMessage = cause.localizedMessage + errorMessage = cause.localizedMessage?.take(200) ) } catch (e: CancellationException) { throw e @@ -783,6 +914,8 @@ class AgentLoop @Inject constructor( speakAndSaveSummary(currentPlanState, false, sessionId) } else { planManager.updatePlanStatus(PlanStatus.COMPLETED) + // Successful completion supersedes any error card still showing. + _chatError.value = null speakAndSaveSummary(currentPlanState, true, sessionId) } break @@ -870,13 +1003,29 @@ class AgentLoop @Inject constructor( val completed = currentPlanState.steps.filter { it.status == StepStatus.COMPLETED } val remaining = currentPlanState.steps.filter { it.status == StepStatus.PENDING } - val replan = reEvalEngine.get().replanAfterUnknownAction( - originalGoal = currentPlanState.goal, - failedStep = stepToExecute, - completedSteps = completed, - remainingSteps = remaining, - planId = currentPlanState.planId - ) + val replan = try { + reEvalEngine.get().replanAfterUnknownAction( + originalGoal = currentPlanState.goal, + failedStep = stepToExecute, + completedSteps = completed, + remainingSteps = remaining, + planId = currentPlanState.planId + ) + } catch (e: LLMException) { + // Nothing ever resumes a PAUSED plan - mark it FAILED so the Plan + // tab shows a truthful terminal state; the error card still offers + // the retry path. + planManager.updatePlanStatus(PlanStatus.FAILED) + publishChatError( + ChatErrorUiState.fromException( + sessionId = sessionId, + requestId = currentPlanState.planId, + runId = UUID.randomUUID().toString(), + failure = e + ) + ) + return + } if (replan.speech.isNotEmpty()) { onSpeakCallback?.invoke(replan.speech) @@ -972,13 +1121,28 @@ class AgentLoop @Inject constructor( continue } - val reEval = reEvalEngine.get().evaluateStepResult( - originalGoal = currentPlanState.goal, - completedSteps = completed, - failedSteps = failed, - remainingSteps = remaining, - planId = currentPlanState.planId - ) + val reEval = try { + reEvalEngine.get().evaluateStepResult( + originalGoal = currentPlanState.goal, + completedSteps = completed, + failedSteps = failed, + remainingSteps = remaining, + planId = currentPlanState.planId + ) + } catch (e: LLMException) { + // Same rationale as the replan failure above: PAUSED is a dead end, so + // fail the plan and let the error card drive recovery. + planManager.updatePlanStatus(PlanStatus.FAILED) + publishChatError( + ChatErrorUiState.fromException( + sessionId = sessionId, + requestId = currentPlanState.planId, + runId = UUID.randomUUID().toString(), + failure = e + ) + ) + return + } // Speak post-step evaluation speech if any if (reEval.speech.isNotEmpty()) { diff --git a/app/src/main/java/com/opendroid/ai/core/agent/ChatErrorUiState.kt b/app/src/main/java/com/opendroid/ai/core/agent/ChatErrorUiState.kt new file mode 100644 index 0000000..9551541 --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/agent/ChatErrorUiState.kt @@ -0,0 +1,105 @@ +package com.opendroid.ai.core.agent + +import com.opendroid.ai.core.llm.error.LLMError +import com.opendroid.ai.core.llm.error.LLMException +import com.opendroid.ai.core.llm.error.RedactedDetail + +/** + * Inline recovery card state. Never a ChatMessage / conversation entity / + * memory item / TTS or prompt input. + */ +data class ChatErrorUiState( + val sessionId: String, + val requestId: String, + val runId: String, + val category: LLMError, + val provider: String, + val model: String, + val httpStatus: Int? = null, + val retryable: Boolean = false, + val retryAfterMillis: Long? = null, + val redactedDetail: RedactedDetail? = null, + val phase: Phase = Phase.Final, + val partialMessageId: String? = null +) { + sealed class Phase { + data object Final : Phase() + data object Retrying : Phase() + data class WaitingUntil(val epochMillis: Long) : Phase() + } + + companion object { + fun fromException( + sessionId: String, + requestId: String, + runId: String, + failure: LLMException, + partialMessageId: String? = null, + nowMillis: Long = System.currentTimeMillis() + ): ChatErrorUiState { + val waiting = failure.retryAfterMillis?.takeIf { it > 0 }?.let { nowMillis + it } + return ChatErrorUiState( + sessionId = sessionId, + requestId = requestId, + runId = runId, + category = failure.error, + provider = failure.provider, + model = failure.model, + httpStatus = failure.status, + retryable = failure.retryable, + retryAfterMillis = failure.retryAfterMillis, + redactedDetail = failure.detail, + phase = if (waiting != null) Phase.WaitingUntil(waiting) else Phase.Final, + partialMessageId = partialMessageId + ) + } + } +} + +enum class ChatErrorPrimaryAction { + OPEN_SETTINGS, + CHOOSE_PROVIDER, + CHOOSE_MODEL, + EDIT_MESSAGE, + RETRY, + NONE +} + +fun ChatErrorUiState.primaryAction(): ChatErrorPrimaryAction = when (category) { + LLMError.AuthMissing, LLMError.AuthInvalid -> ChatErrorPrimaryAction.OPEN_SETTINGS + LLMError.QuotaExhausted -> ChatErrorPrimaryAction.CHOOSE_PROVIDER + LLMError.ModelUnavailable -> ChatErrorPrimaryAction.CHOOSE_MODEL + LLMError.RequestInvalid -> ChatErrorPrimaryAction.EDIT_MESSAGE + LLMError.RateLimited, + LLMError.Network, + LLMError.ServerError, + LLMError.MalformedResponse, + LLMError.Unknown -> + if (retryable) ChatErrorPrimaryAction.RETRY else ChatErrorPrimaryAction.NONE +} + +fun ChatErrorUiState.title(): String = when (category) { + LLMError.AuthMissing -> "Set up $provider to continue" + LLMError.AuthInvalid -> "$provider rejected the API key" + LLMError.QuotaExhausted -> "$provider has no credits available" + LLMError.RateLimited -> "$provider rate limited the request" + LLMError.ModelUnavailable -> "Model unavailable on $provider" + LLMError.RequestInvalid -> "Request rejected by $provider" + LLMError.Network -> "Can't reach $provider" + LLMError.ServerError -> "$provider had a server error" + LLMError.MalformedResponse -> "$provider returned an unreadable response" + LLMError.Unknown -> "$provider request failed" +} + +fun ChatErrorUiState.guidance(): String = when (category) { + LLMError.AuthMissing -> "Add an API key in Settings." + LLMError.AuthInvalid -> "Check or replace the key in Settings." + LLMError.QuotaExhausted -> "Add credits with $provider, or choose another provider." + LLMError.RateLimited -> "Wait a moment, then retry." + LLMError.ModelUnavailable -> "Choose an available model, then retry." + LLMError.RequestInvalid -> "Edit the message and try again." + LLMError.Network -> "Check your connection and try again." + LLMError.ServerError -> "Try again in a moment." + LLMError.MalformedResponse -> "Try again, or check technical details." + LLMError.Unknown -> "Try again, or check technical details." +} diff --git a/app/src/main/java/com/opendroid/ai/core/agent/ReEvaluationEngine.kt b/app/src/main/java/com/opendroid/ai/core/agent/ReEvaluationEngine.kt index 85ed126..630246f 100644 --- a/app/src/main/java/com/opendroid/ai/core/agent/ReEvaluationEngine.kt +++ b/app/src/main/java/com/opendroid/ai/core/agent/ReEvaluationEngine.kt @@ -4,12 +4,15 @@ import com.opendroid.ai.actions.ActionDispatcher import com.opendroid.ai.core.llm.LLMProviderFactory import com.opendroid.ai.core.llm.LLMRequest import com.opendroid.ai.core.llm.ResponseFormat +import com.opendroid.ai.core.llm.error.LLMErrorMapper +import com.opendroid.ai.core.llm.error.LLMException import com.opendroid.ai.core.llm.prompts.ReEvalPrompts import com.opendroid.ai.data.db.dao.UnknownActionDao import com.opendroid.ai.data.db.entities.UnknownActionEntity import com.opendroid.ai.data.models.Plan import com.opendroid.ai.data.models.PlanStep import com.opendroid.ai.data.models.StepStatus +import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -69,14 +72,17 @@ class ReEvaluationEngine @Inject constructor( ) val cleaned = cleanJsonString(response.content) - json.decodeFromString(cleaned) + try { + json.decodeFromString(cleaned) + } catch (decode: Exception) { + throw LLMErrorMapper.malformed(provider.name, response.model) + } + } catch (e: CancellationException) { + throw e + } catch (e: LLMException) { + throw e } catch (e: Exception) { - // Safe fallback if network / parsing fails - ReEvalResult( - speech = "Re-evaluation offline. Continuing with existing plan.", - decision = "CONTINUE", - updatedPlan = null - ) + throw LLMErrorMapper.fromThrowable("Unknown provider", "", e) } } @@ -140,7 +146,11 @@ class ReEvaluationEngine @Inject constructor( ) val cleaned = cleanJsonString(response.content) - val result = json.decodeFromString(cleaned) + val result = try { + json.decodeFromString(cleaned) + } catch (decode: Exception) { + throw LLMErrorMapper.malformed(provider.name, response.model) + } // Log to unknown actions DB only — never to semantic memory try { @@ -154,8 +164,20 @@ class ReEvaluationEngine @Inject constructor( } catch (e: Exception) {} result + } catch (e: CancellationException) { + throw e + } catch (e: LLMException) { + try { + unknownActionDao.get().insertUnknownAction( + UnknownActionEntity( + attemptedAction = failedStep.action, + goal = originalGoal, + fixStatus = "FAILED" + ) + ) + } catch (_: Exception) {} + throw e } catch (e: Exception) { - // Log failed status to DB only — never to semantic memory try { unknownActionDao.get().insertUnknownAction( UnknownActionEntity( @@ -164,13 +186,8 @@ class ReEvaluationEngine @Inject constructor( fixStatus = "FAILED" ) ) - } catch (ex: Exception) {} - - ReEvalResult( - speech = "Failed to replan failed step: ${e.localizedMessage}", - decision = "ABANDON", - updatedPlan = null - ) + } catch (_: Exception) {} + throw LLMErrorMapper.fromThrowable("Unknown provider", "", e) } } diff --git a/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecord.kt b/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecord.kt index 93230ea..fe45e76 100644 --- a/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecord.kt +++ b/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecord.kt @@ -12,12 +12,7 @@ data class CrashLogRecord( val message: String?, val threadName: String, val stackTrace: String, - val appVersionName: String, - val appVersionCode: Long, - val androidRelease: String, - val androidSdkInt: Int, - val deviceManufacturer: String, - val deviceModel: String + val device: DeviceMetadata ) { /** Single-line headline for the crash list. */ val summary: String diff --git a/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecorder.kt b/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecorder.kt index eba0f72..8e2d2ec 100644 --- a/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecorder.kt +++ b/app/src/main/java/com/opendroid/ai/core/crash/CrashLogRecorder.kt @@ -47,12 +47,7 @@ class CrashLogRecorder( message = CrashReportFormatter.messageOf(throwable), threadName = thread.name, stackTrace = CrashReportFormatter.stackTraceOf(throwable), - appVersionName = metadata.appVersionName, - appVersionCode = metadata.appVersionCode, - androidRelease = metadata.androidRelease, - androidSdkInt = metadata.androidSdkInt, - deviceManufacturer = metadata.deviceManufacturer, - deviceModel = metadata.deviceModel + device = metadata ) companion object { diff --git a/app/src/main/java/com/opendroid/ai/core/crash/CrashReportExporter.kt b/app/src/main/java/com/opendroid/ai/core/crash/CrashReportExporter.kt index b525b5f..0c9d388 100644 --- a/app/src/main/java/com/opendroid/ai/core/crash/CrashReportExporter.kt +++ b/app/src/main/java/com/opendroid/ai/core/crash/CrashReportExporter.kt @@ -82,9 +82,9 @@ object CrashReportExporter { ): String = buildString { appendLine("Crash: ${record.summary}") appendLine("Time: ${formatTimestamp(record.timestamp)}") - appendLine("App: ${record.appVersionName} (${record.appVersionCode})") - appendLine("Android: ${record.androidRelease} (SDK ${record.androidSdkInt})") - appendLine("Device: ${record.deviceManufacturer} ${record.deviceModel}") + appendLine("App: ${record.device.appVersionName} (${record.device.appVersionCode})") + appendLine("Android: ${record.device.androidRelease} (SDK ${record.device.androidSdkInt})") + appendLine("Device: ${record.device.deviceManufacturer} ${record.device.deviceModel}") appendLine("Thread: ${record.threadName}") appendLine() append(record.stackTrace) diff --git a/app/src/main/java/com/opendroid/ai/core/llm/ClaudeModelCatalog.kt b/app/src/main/java/com/opendroid/ai/core/llm/ClaudeModelCatalog.kt index 0a165c6..0778ba1 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/ClaudeModelCatalog.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/ClaudeModelCatalog.kt @@ -38,29 +38,51 @@ object ClaudeModelCatalog { /** The Claude models Anthropic currently serves and OpenDroid supports. */ val models: List = listOf( ClaudeModelSpec( - id = "claude-opus-5", - displayName = "Claude Opus 5", + id = "claude-fable-5", + displayName = "Claude Fable 5", isPremium = true ), + ClaudeModelSpec( + id = "claude-opus-5", + displayName = "Claude Opus 5" + ), ClaudeModelSpec( id = "claude-sonnet-5", displayName = "Claude Sonnet 5", isRecommended = true ), ClaudeModelSpec( - id = "claude-haiku-4-5", - displayName = "Claude Haiku 4.5", - isFree = true, + id = "claude-opus-4-8", + displayName = "Claude Opus 4.8" + ), + ClaudeModelSpec( + id = "claude-opus-4-7", + displayName = "Claude Opus 4.7" + ), + ClaudeModelSpec( + id = "claude-opus-4-6", + displayName = "Claude Opus 4.6", acceptsSamplingParameters = true ), ClaudeModelSpec( - id = "claude-opus-4-8", - displayName = "Claude Opus 4.8" + id = "claude-opus-4-5-20251101", + displayName = "Claude Opus 4.5", + acceptsSamplingParameters = true ), ClaudeModelSpec( id = "claude-sonnet-4-6", displayName = "Claude Sonnet 4.6", acceptsSamplingParameters = true + ), + ClaudeModelSpec( + id = "claude-sonnet-4-5-20250929", + displayName = "Claude Sonnet 4.5", + acceptsSamplingParameters = true + ), + ClaudeModelSpec( + id = "claude-haiku-4-5-20251001", + displayName = "Claude Haiku 4.5", + acceptsSamplingParameters = true ) ) @@ -75,48 +97,37 @@ object ClaudeModelCatalog { // Unversioned family IDs previously accepted by the provider. "claude-opus-4" to "claude-opus-4-8", "claude-sonnet-4" to "claude-sonnet-4-6", - "claude-haiku-4" to "claude-haiku-4-5", - // The date-suffixed Haiku ID the provider used to rewrite requests to. - "claude-haiku-4-5-20251001" to "claude-haiku-4-5", - // Retired Anthropic 4.0/4.1 models. These match the well-formed-ID shape - // below, so without an explicit entry they would be forwarded verbatim and - // fail with an HTTP 404 at request time instead of migrating. + "claude-haiku-4" to "claude-haiku-4-5-20251001", + "claude-haiku-4-5" to "claude-haiku-4-5-20251001", + // Retired Anthropic 4.0/4.1 models. Keep explicit entries so persisted + // selections migrate instead of being rejected as unknown. "claude-opus-4-0" to "claude-opus-4-8", "claude-opus-4-20250514" to "claude-opus-4-8", - "claude-opus-4-1" to "claude-opus-5", - "claude-opus-4-1-20250805" to "claude-opus-5", - "claude-sonnet-4-0" to "claude-sonnet-5", - "claude-sonnet-4-20250514" to "claude-sonnet-5", + "claude-opus-4-1" to "claude-opus-4-8", + "claude-opus-4-1-20250805" to "claude-opus-4-8", + "claude-sonnet-4-0" to "claude-sonnet-4-6", + "claude-sonnet-4-20250514" to "claude-sonnet-4-6", // Retired Anthropic 3.x models. "claude-3-opus-20240229" to "claude-opus-4-8", - "claude-3-7-sonnet-20250219" to "claude-sonnet-5", - "claude-3-5-sonnet-20241022" to "claude-sonnet-5", - "claude-3-5-sonnet-20240620" to "claude-sonnet-5", - "claude-3-sonnet-20240229" to "claude-sonnet-5", - "claude-3-5-haiku-20241022" to "claude-haiku-4-5", - "claude-3-haiku-20240307" to "claude-haiku-4-5", - // Retired Claude 2 models. Anthropic documents Sonnet as their replacement. - "claude-2.1" to "claude-sonnet-5", - "claude-2.0" to "claude-sonnet-5" + "claude-3-7-sonnet-20250219" to "claude-sonnet-4-6", + "claude-3-5-sonnet-20241022" to "claude-sonnet-4-6", + "claude-3-5-sonnet-20240620" to "claude-sonnet-4-6", + "claude-3-sonnet-20240229" to "claude-sonnet-4-6", + "claude-3-5-haiku-20241022" to "claude-haiku-4-5-20251001", + "claude-3-haiku-20240307" to "claude-haiku-4-5-20251001", + // Retired Claude 2 models. + "claude-2.1" to "claude-opus-4-8", + "claude-2.0" to "claude-opus-4-8", + // Retired Claude 1 and Instant models. + "claude-1.0" to "claude-haiku-4-5-20251001", + "claude-1.1" to "claude-haiku-4-5-20251001", + "claude-1.2" to "claude-haiku-4-5-20251001", + "claude-1.3" to "claude-haiku-4-5-20251001", + "claude-instant-1.0" to "claude-haiku-4-5-20251001", + "claude-instant-1.1" to "claude-haiku-4-5-20251001", + "claude-instant-1.2" to "claude-haiku-4-5-20251001" ) - /** - * Retired Anthropic models with no documented replacement. Selecting one is - * an error the user has to resolve, not something to migrate silently. - */ - private val retiredWithoutReplacement: Set = setOf( - "claude-instant-1.2", - "claude-instant-1", - "claude-instant-v1" - ) - - /** - * Shape of an Anthropic model ID. Anything matching this is forwarded to the - * API as-is so a model released before OpenDroid ships a catalog update stays - * usable; anything else is rejected before it reaches the request body. - */ - private val anthropicModelIdPattern = Regex("^claude-[a-z0-9]+(?:[.-][a-z0-9]+)*$") - private val byId: Map = models.associateBy { it.id } /** @@ -124,17 +135,15 @@ object ClaudeModelCatalog { * model ID that may be sent to Anthropic. * * @return the catalog ID (unchanged for a current model, the replacement for - * a legacy alias), the ID itself for an unknown-but-well-formed Anthropic - * model, or `null` when the ID is retired without a replacement, belongs to - * another provider, or is malformed. + * a legacy alias), or `null` when the ID is unknown, retired without a + * replacement, belongs to another provider, or is malformed. */ fun resolve(id: String): String? { val trimmed = id.trim() if (trimmed.isEmpty()) return null if (byId.containsKey(trimmed)) return trimmed legacyAliases[trimmed]?.let { return it } - if (trimmed in retiredWithoutReplacement) return null - return trimmed.takeIf { anthropicModelIdPattern.matches(it) } + return null } /** The catalog entry for [id], or `null` if OpenDroid does not know the model. */ diff --git a/app/src/main/java/com/opendroid/ai/core/llm/ConnectionTest.kt b/app/src/main/java/com/opendroid/ai/core/llm/ConnectionTest.kt new file mode 100644 index 0000000..5aed47c --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/llm/ConnectionTest.kt @@ -0,0 +1,116 @@ +package com.opendroid.ai.core.llm + +import com.opendroid.ai.core.llm.error.LLMError +import com.opendroid.ai.core.llm.error.LLMErrorMapper +import com.opendroid.ai.core.llm.error.LLMException +import com.opendroid.ai.data.models.LLMConfig +import com.opendroid.ai.data.models.selectedModelFor + +/** + * Typed connection-test outcomes shared by Settings and Benchmark. + * Latency is recorded only for successful responses — never as a failure sentinel. + */ +sealed class ConnectionTestState { + data object Idle : ConnectionTestState() + data class Testing(val provider: String, val index: Int = 1, val total: Int = 1) : ConnectionTestState() + data class Connected( + val provider: String, + val model: String, + val latencyMs: Long, + val testedAtMillis: Long + ) : ConnectionTestState() + data class Failed( + val provider: String, + val model: String, + val error: LLMError, + val status: Int? = null, + val retryAfterMillis: Long? = null, + val testedAtMillis: Long + ) : ConnectionTestState() + data class ConfigMissing( + val provider: String, + val reason: LLMError, + val testedAtMillis: Long + ) : ConnectionTestState() +} + +object ConnectionTestPlanner { + private val onDeviceCanonicalNames = setOf( + ProviderCatalog.ON_DEVICE, + "LiteRT-LM (On-device)" + ) + + fun cloudProviders(): List = ProviderCatalog.providers + .filter { spec -> spec.canonicalName !in onDeviceCanonicalNames } + .map { spec -> spec.displayName } + + fun configuredProviders(config: LLMConfig): List = + cloudProviders().filter { provider -> configurationGap(config, provider) == null } + + /** + * Returns a local configuration failure without contacting the network, or + * null when the provider snapshot is complete enough to probe. + */ + fun configurationGap(config: LLMConfig, providerName: String): ConnectionTestState.ConfigMissing? { + val provider = ProviderCatalog.canonicalName(providerName) + val model = config.selectedModelFor(provider) + when (provider) { + "Ollama" -> if (config.ollamaUrl.isBlank()) { + return ConnectionTestState.ConfigMissing(provider, LLMError.RequestInvalid, 0L) + } + "Copilot API" -> if (config.copilotUrl.isBlank()) { + return ConnectionTestState.ConfigMissing(provider, LLMError.RequestInvalid, 0L) + } + "Custom OpenAI Compatible" -> { + if (config.customEndpoints[provider].isNullOrBlank()) { + return ConnectionTestState.ConfigMissing(provider, LLMError.RequestInvalid, 0L) + } + if (config.apiKeys[provider].isNullOrBlank()) { + return ConnectionTestState.ConfigMissing(provider, LLMError.AuthMissing, 0L) + } + } + else -> if (ProviderCatalog.requiresApiKey(provider) && + !(provider == "Google Gemini" && model == "gemini-nano") && + config.apiKeys[provider].isNullOrBlank() + ) { + return ConnectionTestState.ConfigMissing(provider, LLMError.AuthMissing, 0L) + } + } + return null + } + + fun fromException( + provider: String, + model: String, + throwable: Throwable, + testedAtMillis: Long + ): ConnectionTestState { + val failure = throwable as? LLMException + ?: LLMErrorMapper.fromThrowable(provider, model, throwable) + return ConnectionTestState.Failed( + provider = failure.provider, + model = failure.model, + error = failure.error, + status = failure.status, + retryAfterMillis = failure.retryAfterMillis, + testedAtMillis = testedAtMillis + ) + } + + fun success( + provider: String, + model: String, + latencyMs: Long, + testedAtMillis: Long + ): ConnectionTestState.Connected = ConnectionTestState.Connected( + provider = ProviderCatalog.canonicalName(provider), + model = model, + latencyMs = latencyMs, + testedAtMillis = testedAtMillis + ) + + fun stamp( + state: ConnectionTestState.ConfigMissing, + testedAtMillis: Long + ): ConnectionTestState.ConfigMissing = state.copy(testedAtMillis = testedAtMillis) +} diff --git a/app/src/main/java/com/opendroid/ai/core/llm/LLMProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/LLMProvider.kt index 3b84ddc..01f2556 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/LLMProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/LLMProvider.kt @@ -3,6 +3,7 @@ package com.opendroid.ai.core.llm import com.opendroid.ai.data.models.ChatMessage import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable data class ToolDefinition( @@ -51,20 +52,44 @@ interface LLMProvider : AIProvider { } } +/** + * Ephemeral provider configuration captured with a resolved request. It is + * deliberately excluded from serialization and renders only as redacted text. + */ +class ProviderRequestConfig( + val apiKey: String, + val endpoint: String +) { + override fun toString(): String = "" +} + @Serializable data class LLMRequest( val systemPrompt: String, val messages: List, + /** + * Resolved by [WrappedLLMProvider] from the provider/model pairing. Direct + * provider callers may leave it null and receive the catalog default. + */ + val model: String? = null, val temperature: Float = 0.7f, val maxTokens: Int = 2000, val responseFormat: ResponseFormat = ResponseFormat.JSON, - val tools: List? = null + val tools: List? = null, + val retryPolicy: RetryPolicy = RetryPolicy.DEFAULT, + @Transient + val providerConfig: ProviderRequestConfig? = null ) enum class ResponseFormat { JSON, TEXT } +enum class RetryPolicy { + DEFAULT, + NONE +} + @Serializable data class Tool( val name: String, diff --git a/app/src/main/java/com/opendroid/ai/core/llm/LLMProviderFactory.kt b/app/src/main/java/com/opendroid/ai/core/llm/LLMProviderFactory.kt index 7a70b42..c8ada0e 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/LLMProviderFactory.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/LLMProviderFactory.kt @@ -1,17 +1,27 @@ package com.opendroid.ai.core.llm +import android.util.Log import com.opendroid.ai.actions.ActionDispatcher import com.opendroid.ai.core.agent.ActionSchema import com.opendroid.ai.core.agent.DeviceStateProvider import com.opendroid.ai.core.agent.IntentClassifier import com.opendroid.ai.core.agent.QueryComplexity import com.opendroid.ai.core.llm.prompts.SystemPrompts +import com.opendroid.ai.core.llm.error.LLMErrorMapper +import com.opendroid.ai.core.llm.error.SecretRegistry import com.opendroid.ai.core.llm.providers.* import com.opendroid.ai.core.llm.providers.HybridOnDeviceProvider import com.opendroid.ai.core.llm.providers.LiteRTLMProvider +import com.opendroid.ai.data.models.LLMConfig +import com.opendroid.ai.data.models.selectedModelFor import com.opendroid.ai.data.repository.SettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.first +import kotlin.math.min +import kotlin.random.Random import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -58,130 +68,210 @@ class LLMProviderFactory @Inject constructor( "Gemma 4 (On-device)" -> hybridOnDeviceProvider.get() // Direct backend access (for advanced users / testing) "LiteRT-LM (On-device)" -> liteRTLMProvider.get() - else -> geminiProvider.get() + else -> { + Log.w(TAG, "Unknown LLM provider persisted; falling back to Google Gemini.") + geminiProvider.get() + } } - return WrappedLLMProvider(rawProvider, actionDispatcher, intentClassifier, deviceStateProvider) - } - - private fun getFallbackChain(primaryName: String): List { - val providersList = listOf( - "Google Gemini", - "OpenAI", - "Anthropic Claude", - "Groq", - "Mistral AI", - "OpenRouter", - "Together AI", - "Cohere", - "DeepSeek", - "Copilot API", - "Custom OpenAI Compatible", - "Ollama", - "On-Device AI" + return WrappedLLMProvider( + delegate = rawProvider, + configProvider = { settingsRepository.llmConfig.first() }, + requestRewriter = ::rewriteRequestIfNeeded ) - // Normalize legacy name - val normalizedPrimary = if (primaryName == "Gemma 4 (On-device)") "On-Device AI" else primaryName - val orderedNames = mutableListOf() - orderedNames.add(normalizedPrimary) - providersList.forEach { name -> - if (name != normalizedPrimary) orderedNames.add(name) - } - return orderedNames.map { getProviderByName(it) } } suspend fun getActiveProvider(): LLMProvider { val config = settingsRepository.llmConfig.first() - val chain = getFallbackChain(config.activeProvider) - for (provider in chain) { - if (provider.isAvailable()) { - return provider - } - } - // If nothing is configured, default to Gemini (it has Nano offline mock fallback) - return getProviderByName("Google Gemini") + return getProviderByName(ProviderCatalog.canonicalName(config.activeProvider)) } - suspend fun executeWithFallback(request: LLMRequest): LLMResponse { - val config = settingsRepository.llmConfig.first() - val chain = getFallbackChain(config.activeProvider) - val errors = mutableListOf() + private fun rewriteRequestIfNeeded(request: LLMRequest): LLMRequest { + if (request.systemPrompt.contains("Planning Engine") || request.systemPrompt.contains("AVAILABLE ACTIONS")) { + val userMessageText = request.messages.lastOrNull()?.text ?: "" + val complexity = intentClassifier.get().classifyComplexity(userMessageText) + val maxSteps = when (complexity) { + QueryComplexity.SIMPLE -> 1 + QueryComplexity.MEDIUM -> 3 + QueryComplexity.COMPLEX -> 10 + } - for (provider in chain) { - if (provider.isAvailable()) { - try { - val response = provider.complete(request) - // Benchmark successfully executed provider in settings background - updateLatencyBenchmark(provider.name, response.latencyMs) - return response - } catch (e: Exception) { - errors.add("${provider.name}: ${e.localizedMessage}") - } + val memoryContext = if (request.systemPrompt.contains("Context about user and device:")) { + request.systemPrompt.substringAfter("Context about user and device:").trim() + } else { + "" } + + val currentDateTime = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) + val deviceState = deviceStateProvider.getFullStateString() + val registeredActions = actionDispatcher.get().getAllRegisteredActions() + + return request.copy( + systemPrompt = SystemPrompts.buildMainPrompt( + registeredActions = registeredActions, + memoryContext = memoryContext, + currentDateTime = currentDateTime, + deviceState = deviceState, + maxSteps = maxSteps + ) + ) } - throw IllegalStateException("All available LLM providers failed execution:\n" + errors.joinToString("\n")) + return request } - private suspend fun updateLatencyBenchmark(providerName: String, latency: Long) { - settingsRepository.updateConfig { current -> - val updatedBenchmarks = current.latencyBenchmarks.toMutableMap() - updatedBenchmarks[providerName] = latency - current.copy(latencyBenchmarks = updatedBenchmarks) - } + private companion object { + const val TAG = "LLMProviderFactory" } } +data class RetryRuntime( + val nowMillis: () -> Long = { System.nanoTime() / 1_000_000L }, + val delayMillis: suspend (Long) -> Unit = { delay(it) }, + val jitterMillis: (Long) -> Long = { upperBound -> + if (upperBound <= 0L) 0L else Random.nextLong(upperBound + 1L) + } +) + class WrappedLLMProvider( private val delegate: LLMProvider, - private val actionDispatcher: dagger.Lazy, - private val intentClassifier: dagger.Lazy, - private val deviceStateProvider: DeviceStateProvider + private val configProvider: suspend () -> LLMConfig, + private val requestRewriter: (LLMRequest) -> LLMRequest = { it }, + private val retryRuntime: RetryRuntime = RetryRuntime() ) : LLMProvider { override val name: String get() = delegate.name override val availableModels: List get() = delegate.availableModels override suspend fun complete(request: LLMRequest): LLMResponse { - val rewrittenRequest = rewriteRequestIfNeeded(request) - return delegate.complete(rewrittenRequest) + val resolved = resolveRequest(request) + val registrations = registerSecrets(resolved) + return try { + executeWithRetry(resolved) { delegate.complete(it) } + } finally { + registrations.asReversed().forEach(AutoCloseable::close) + } } - override fun streamComplete(request: LLMRequest): Flow { - val rewrittenRequest = rewriteRequestIfNeeded(request) - return delegate.streamComplete(rewrittenRequest) + override fun streamComplete(request: LLMRequest): Flow = flow { + val resolved = resolveRequest(request) + val registrations = registerSecrets(resolved) + var attempt = 1 + var emitted = false + val startedAt = retryRuntime.nowMillis() + try { + while (true) { + try { + delegate.streamComplete(resolved).collect { chunk -> + if (chunk.isNotEmpty()) { + emitted = true + emit(chunk) + } + } + if (!emitted) { + throw LLMErrorMapper.malformed(name, resolved.model.orEmpty(), transient = true) + } + break + } catch (cancellation: CancellationException) { + throw cancellation + } catch (throwable: Throwable) { + val failure = LLMErrorMapper.fromThrowable(name, resolved.model.orEmpty(), throwable) + val delayMillis = proposedDelayMillis(failure, attempt) + if (emitted || !shouldRetry(resolved, failure, attempt, startedAt, delayMillis)) throw failure + retryRuntime.delayMillis(delayMillis) + attempt++ + } + } + } finally { + registrations.asReversed().forEach(AutoCloseable::close) + } } override suspend fun isAvailable(): Boolean = delegate.isAvailable() - private fun rewriteRequestIfNeeded(request: LLMRequest): LLMRequest { - if (request.systemPrompt.contains("Planning Engine") || request.systemPrompt.contains("AVAILABLE ACTIONS")) { - val userMessageText = request.messages.lastOrNull()?.text ?: "" - val complexity = intentClassifier.get().classifyComplexity(userMessageText) - val maxSteps = when (complexity) { - QueryComplexity.SIMPLE -> 1 - QueryComplexity.MEDIUM -> 3 - QueryComplexity.COMPLEX -> 10 - } + private suspend fun resolveRequest(request: LLMRequest): LLMRequest { + val config = configProvider() + val provider = ProviderCatalog.canonicalName(name) + val model = config.selectedModelFor(provider) + val endpoint = when (provider) { + "Custom OpenAI Compatible" -> config.customEndpoints[provider].orEmpty().trim() + "Copilot API" -> config.copilotUrl.trim() + "Ollama" -> config.ollamaUrl.trim() + else -> config.customEndpoints[provider].orEmpty().trim() + } + val apiKey = config.apiKeys[provider].orEmpty() - val memoryContext = if (request.systemPrompt.contains("Context about user and device:")) { - request.systemPrompt.substringAfter("Context about user and device:").trim() - } else { - "" - } + if (ProviderCatalog.requiresApiKey(provider) && + !(provider == "Google Gemini" && model == "gemini-nano") && + apiKey.isBlank() + ) { + throw LLMErrorMapper.authMissing(provider, model) + } + if (provider in setOf("Custom OpenAI Compatible", "Copilot API", "Ollama") && endpoint.isBlank()) { + throw LLMErrorMapper.requestInvalid(provider, model) + } - val currentDateTime = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) - val deviceState = deviceStateProvider.getFullStateString() + return requestRewriter(request).copy( + model = model, + providerConfig = ProviderRequestConfig(apiKey = apiKey, endpoint = endpoint) + ) + } - val registeredActions = actionDispatcher.get().getAllRegisteredActions() + private suspend fun executeWithRetry( + request: LLMRequest, + operation: suspend (LLMRequest) -> T + ): T { + var attempt = 1 + val startedAt = retryRuntime.nowMillis() + while (true) { + try { + return operation(request) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (throwable: Throwable) { + val failure = LLMErrorMapper.fromThrowable(name, request.model.orEmpty(), throwable) + val delayMillis = proposedDelayMillis(failure, attempt) + if (!shouldRetry(request, failure, attempt, startedAt, delayMillis)) throw failure + retryRuntime.delayMillis(delayMillis) + attempt++ + } + } + } - val newSystemPrompt = SystemPrompts.buildMainPrompt( - registeredActions = registeredActions, - memoryContext = memoryContext, - currentDateTime = currentDateTime, - deviceState = deviceState, - maxSteps = maxSteps - ) + private fun shouldRetry( + request: LLMRequest, + failure: com.opendroid.ai.core.llm.error.LLMException, + attempt: Int, + startedAt: Long, + proposedDelay: Long + ): Boolean { + if (request.retryPolicy == RetryPolicy.NONE || !failure.retryable || attempt >= MAX_ATTEMPTS) { + return false + } + val elapsed = (retryRuntime.nowMillis() - startedAt).coerceAtLeast(0L) + if (elapsed >= RETRY_WINDOW_MILLIS) return false + return proposedDelay <= RETRY_WINDOW_MILLIS - elapsed + } - return request.copy(systemPrompt = newSystemPrompt) + private fun proposedDelayMillis( + failure: com.opendroid.ai.core.llm.error.LLMException, + attempt: Int + ): Long { + failure.retryAfterMillis?.let { retryAfter -> + return retryAfter + retryRuntime.jitterMillis(250L).coerceIn(0L, 250L) } - return request + val exponentialCap = min(500L shl (attempt - 1), 4_000L) + return 250L + retryRuntime.jitterMillis(exponentialCap).coerceIn(0L, exponentialCap) + } + + private fun registerSecrets(request: LLMRequest): List = buildList { + request.providerConfig?.apiKey?.takeIf(String::isNotBlank)?.let { + add(SecretRegistry.register(it)) + } + request.providerConfig?.endpoint?.takeIf(String::isNotBlank)?.let { + add(SecretRegistry.register(it)) + } + } + + private companion object { + const val MAX_ATTEMPTS = 3 + const val RETRY_WINDOW_MILLIS = 30_000L } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/ProviderCatalog.kt b/app/src/main/java/com/opendroid/ai/core/llm/ProviderCatalog.kt new file mode 100644 index 0000000..7ddad76 --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/llm/ProviderCatalog.kt @@ -0,0 +1,69 @@ +package com.opendroid.ai.core.llm + +/** + * Stable provider identity and defaults used by Settings, request resolution, + * connection tests, and providers. Provider display strings are persisted, so + * aliases are normalized here instead of being interpreted ad hoc. + */ +object ProviderCatalog { + data class ProviderSpec( + val displayName: String, + val defaultModel: String, + val canonicalName: String = displayName + ) + + const val ON_DEVICE = "On-Device AI" + const val LEGACY_ON_DEVICE = "Gemma 4 (On-device)" + + val providers: List = listOf( + ProviderSpec("Google Gemini", "gemini-2.0-flash"), + ProviderSpec("OpenAI", "gpt-4o"), + ProviderSpec("Anthropic Claude", ClaudeModelCatalog.defaultModelId), + ProviderSpec("Mistral AI", "mistral-large-latest"), + ProviderSpec("Groq", "llama-3.3-70b-specdec"), + ProviderSpec("OpenRouter", "google/gemini-2.0-flash-exp:free"), + ProviderSpec("Together AI", "meta-llama/Llama-3-70b-chat-hf"), + ProviderSpec("Cohere", "command-r-plus"), + ProviderSpec("DeepSeek", "deepseek-chat"), + ProviderSpec("Copilot API", "gpt-4o"), + ProviderSpec("Custom OpenAI Compatible", "custom-model"), + ProviderSpec("Ollama", "llama3"), + ProviderSpec(ON_DEVICE, "gemma-4-on-device"), + ProviderSpec("LiteRT-LM (On-device)", "gemma3-1b-it"), + // Compatibility entry for the directly addressable AI Core backend. + // Its persisted key is normalized to the unified on-device provider. + ProviderSpec(LEGACY_ON_DEVICE, "gemma-4-on-device", ON_DEVICE) + ) + + private val byExternalName = providers.associateBy { it.displayName } + private val byCanonicalName = providers.associateBy { it.canonicalName } + + fun canonicalName(providerName: String): String = + byExternalName[providerName.trim()]?.canonicalName ?: providerName.trim() + + fun isKnown(providerName: String): Boolean { + val normalized = canonicalName(providerName) + return byCanonicalName.containsKey(normalized) + } + + fun defaultModel(providerName: String): String { + val normalized = canonicalName(providerName) + return requireNotNull(byCanonicalName[normalized]) { + "Unknown LLM provider." + }.defaultModel + } + + fun requiresApiKey(providerName: String): Boolean = when (canonicalName(providerName)) { + "Google Gemini", + "OpenAI", + "Anthropic Claude", + "Mistral AI", + "Groq", + "OpenRouter", + "Together AI", + "Cohere", + "DeepSeek", + "Custom OpenAI Compatible" -> true + else -> false + } +} diff --git a/app/src/main/java/com/opendroid/ai/core/llm/error/LLMError.kt b/app/src/main/java/com/opendroid/ai/core/llm/error/LLMError.kt new file mode 100644 index 0000000..50bdc35 --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/llm/error/LLMError.kt @@ -0,0 +1,320 @@ +package com.opendroid.ai.core.llm.error + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.opendroid.ai.core.llm.ProviderCatalog +import kotlinx.coroutines.CancellationException +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.roundToLong + +sealed class LLMError(val code: String) { + data object AuthMissing : LLMError("AUTH_MISSING") + data object AuthInvalid : LLMError("AUTH_INVALID") + data object QuotaExhausted : LLMError("QUOTA_EXHAUSTED") + data object RateLimited : LLMError("RATE_LIMITED") + data object ModelUnavailable : LLMError("MODEL_UNAVAILABLE") + data object RequestInvalid : LLMError("REQUEST_INVALID") + data object Network : LLMError("NETWORK") + data object ServerError : LLMError("SERVER_ERROR") + data object MalformedResponse : LLMError("MALFORMED_RESPONSE") + data object Unknown : LLMError("UNKNOWN") +} + +/** + * Allowlisted diagnostic detail. No raw body, endpoint, prompt, key, or cause is + * representable by this type. + */ +class RedactedDetail private constructor( + val vendorType: String?, + val vendorCode: String? +) { + override fun toString(): String = buildList { + vendorType?.let { add("type=$it") } + vendorCode?.let { add("code=$it") } + }.joinToString() + + companion object { + internal fun fromProviderDetail(detail: ProviderErrorDetail): RedactedDetail = + RedactedDetail( + vendorType = detail.vendorType, + vendorCode = detail.vendorCode + ) + } +} + +class LLMException internal constructor( + val error: LLMError, + provider: String, + model: String, + val status: Int? = null, + val retryable: Boolean = false, + val retryAfterMillis: Long? = null, + val detail: RedactedDetail? = null, + internal val transientMalformedResponse: Boolean = false +) : IOException(safeMessage(error, provider, status)) { + val provider: String = provider + .takeIf(ProviderCatalog::isKnown) + ?.let(ProviderCatalog::canonicalName) + ?: "Unknown provider" + val model: String = model + .trim() + .filter { character -> character.code in 0x20..0x7e } + .take(128) + + companion object { + private fun safeMessage(error: LLMError, provider: String, status: Int?): String = + buildString { + append(error.code) + append(" from ") + append( + provider + .takeIf(ProviderCatalog::isKnown) + ?.let(ProviderCatalog::canonicalName) + ?: "Unknown provider" + ) + status?.let { + append(" (HTTP ") + append(it) + append(')') + } + } + } +} + +/** + * Process-local scrub scope for credentials that are not yet persisted, such + * as a candidate key being tested from Settings. + */ +object SecretRegistry { + private val registrations = mutableMapOf() + + fun register(secret: String): AutoCloseable { + val normalized = secret.trim() + if (normalized.length < 4) return AutoCloseable {} + synchronized(registrations) { + registrations[normalized] = (registrations[normalized] ?: 0) + 1 + } + return AutoCloseable { + synchronized(registrations) { + val remaining = (registrations[normalized] ?: 1) - 1 + if (remaining <= 0) registrations.remove(normalized) + else registrations[normalized] = remaining + } + } + } + + fun snapshot(): Set = synchronized(registrations) { registrations.keys.toSet() } +} + +object LLMErrorMapper { + private const val MAX_ERROR_BODY_CHARS = 32_768 + private val GO_DURATION_PART = Regex("""(\d+(?:\.\d+)?)(ms|h|m|s)""") + + fun fromHttpFailure( + provider: ProviderErrorDetail.Provider, + model: String, + httpStatus: Int, + headers: Map = emptyMap(), + rawBody: String?, + knownSecrets: Iterable = emptyList(), + forbiddenText: Iterable = emptyList() + ): LLMException { + val registeredSecrets = SecretRegistry.snapshot() + val allSecrets = knownSecrets + registeredSecrets + val boundedBody = rawBody?.takeIf { it.length <= MAX_ERROR_BODY_CHARS } + val root = parseObject(boundedBody) + val errorElement = root?.get("error") + val errorObject = errorElement?.takeIf(JsonElement::isJsonObject)?.asJsonObject + val evidence = buildList { + addJsonEvidence(errorElement) + addJsonEvidence(errorObject?.get("type")) + addJsonEvidence(errorObject?.get("code")) + addJsonEvidence(errorObject?.get("status")) + addJsonEvidence(errorObject?.get("message")) + addJsonEvidence(root?.get("type")) + addJsonEvidence(root?.get("code")) + addJsonEvidence(root?.get("status")) + addJsonEvidence(root?.get("message")) + }.joinToString(" ").lowercase(Locale.ROOT) + + val category = classify(provider, httpStatus, evidence) + val providerDetail = ProviderErrorDetail.fromHttpFailure( + provider = provider, + httpStatus = httpStatus, + rawBody = boundedBody, + knownSecrets = allSecrets, + forbiddenText = forbiddenText + ) + return LLMException( + error = category, + provider = provider.displayName, + model = model, + status = httpStatus, + retryable = category == LLMError.RateLimited || + category == LLMError.ServerError || + httpStatus == 408, + retryAfterMillis = parseRetryAfter( + headers.entries.firstOrNull { it.key.equals("Retry-After", ignoreCase = true) }?.value + ), + detail = RedactedDetail.fromProviderDetail(providerDetail) + ) + } + + fun fromThrowable(provider: String, model: String, throwable: Throwable): LLMException { + if (throwable is CancellationException) throw throwable + if (throwable is LLMException) return throwable + + val safeMessage = throwable.message.orEmpty() + val isMalformed = safeMessage.startsWith("Empty response body") || + throwable is com.google.gson.JsonParseException || + throwable is IndexOutOfBoundsException || + throwable is NoSuchElementException + val isAuthMissing = throwable is IllegalStateException && + safeMessage.contains("API Key", ignoreCase = true) && + (safeMessage.contains("not set", ignoreCase = true) || + safeMessage.contains("not configured", ignoreCase = true)) + val isNetwork = throwable is SocketTimeoutException || + throwable is ConnectException || + throwable is UnknownHostException || + throwable is IOException + // Only connect-phase failures are safe to retry: once the request may + // have reached the server, re-POSTing risks duplicate processing. + val isConnectFailure = throwable is ConnectException || + throwable is UnknownHostException + + val error = when { + isMalformed -> LLMError.MalformedResponse + isAuthMissing -> LLMError.AuthMissing + isNetwork -> LLMError.Network + else -> LLMError.Unknown + } + return LLMException( + error = error, + provider = provider, + model = model, + retryable = error == LLMError.Network && isConnectFailure + ) + } + + fun malformed( + provider: String, + model: String, + transient: Boolean = false + ): LLMException = LLMException( + error = LLMError.MalformedResponse, + provider = provider, + model = model, + retryable = transient, + transientMalformedResponse = transient + ) + + fun authMissing(provider: String, model: String): LLMException = LLMException( + error = LLMError.AuthMissing, + provider = provider, + model = model + ) + + fun requestInvalid(provider: String, model: String): LLMException = LLMException( + error = LLMError.RequestInvalid, + provider = provider, + model = model + ) + + private fun classify( + provider: ProviderErrorDetail.Provider, + status: Int, + evidence: String + ): LLMError { + val quota = evidence.containsAny("quota", "billing", "credit", "insufficient_quota") + val invalidKey = evidence.containsAny( + "invalid_api_key", + "api_key_invalid", + "api key not valid", + "authentication_error", + "invalid x-api-key" + ) + val contextTooLong = evidence.containsAny( + "context length", + "context_length", + "too many tokens", + "token limit", + "input too long" + ) + val modelUnavailable = evidence.containsAny( + "model_not_found", + "unknown_model", + "model unavailable", + "model_deprecated", + "not_found_error" + ) + + return when { + invalidKey && (status == 400 || status == 401 || status == 403) -> LLMError.AuthInvalid + status == 401 || status == 403 -> LLMError.AuthInvalid + // A 429 is retryable rate limiting unless the body specifically + // reports exhausted credit; generic "quota" wording stays retryable. + status == 429 -> + if (evidence.containsAny("insufficient_quota", "billing")) LLMError.QuotaExhausted + else LLMError.RateLimited + status == 402 || quota -> LLMError.QuotaExhausted + modelUnavailable || status == 404 -> LLMError.ModelUnavailable + provider == ProviderErrorDetail.Provider.GEMINI && + status in setOf(500, 504) && contextTooLong -> LLMError.RequestInvalid + status in setOf(400, 413, 422) -> LLMError.RequestInvalid + status == 408 -> LLMError.Network + status >= 500 -> LLMError.ServerError + else -> LLMError.Unknown + } + } + + private fun parseObject(body: String?): JsonObject? { + if (body.isNullOrBlank()) return null + return try { + JsonParser.parseString(body).takeIf(JsonElement::isJsonObject)?.asJsonObject + } catch (_: Exception) { + null + } + } + + private fun MutableList.addJsonEvidence(element: JsonElement?) { + if (element == null || !element.isJsonPrimitive) return + runCatching { element.asString.take(512) }.getOrNull()?.let(::add) + } + + private fun String.containsAny(vararg needles: String): Boolean = + needles.any { contains(it) } + + private fun parseRetryAfter(value: String?): Long? { + val text = value?.trim()?.takeIf(String::isNotEmpty) ?: return null + text.toDoubleOrNull()?.takeIf { it >= 0.0 }?.let { + return (it * 1_000.0).roundToLong() + } + + val parts = GO_DURATION_PART.findAll(text).toList() + if (parts.isNotEmpty() && parts.joinToString("") { it.value } == text) { + val millis = parts.sumOf { match -> + val amount = match.groupValues[1].toDouble() + val multiplier = when (match.groupValues[2]) { + "h" -> 3_600_000.0 + "m" -> 60_000.0 + "s" -> 1_000.0 + else -> 1.0 + } + amount * multiplier + } + return millis.roundToLong().takeIf { it >= 0L } + } + + return runCatching { + val retryAt = ZonedDateTime.parse(text, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() + (retryAt.toEpochMilli() - System.currentTimeMillis()).coerceAtLeast(0L) + }.getOrNull() + } +} diff --git a/app/src/main/java/com/opendroid/ai/core/llm/error/ProviderErrorDetail.kt b/app/src/main/java/com/opendroid/ai/core/llm/error/ProviderErrorDetail.kt new file mode 100644 index 0000000..a496cf9 --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/llm/error/ProviderErrorDetail.kt @@ -0,0 +1,206 @@ +package com.opendroid.ai.core.llm.error + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.opendroid.ai.core.llm.LLMRequest +import okhttp3.Response +import java.io.IOException + +private const val MAX_PROVIDER_ERROR_BODY_CHARS = 32_768 + +/** + * A structurally safe summary of a cloud provider HTTP failure. + * + * The raw response body is accepted only at the factory boundary, parsed for a + * small allowlist of vendor identifiers, and never retained. Consequently, + * [message] and [toString] cannot expose provider messages, request content, or + * endpoint URLs. + */ +class ProviderErrorDetail private constructor( + val provider: Provider, + val httpStatus: Int, + val vendorType: String?, + val vendorCode: String? +) { + + val message: String = buildString { + append(provider.displayName) + append(" request failed with HTTP ") + append(httpStatus) + + val attributes = buildList { + vendorType?.let { add("type=$it") } + vendorCode?.takeUnless { it == vendorType }?.let { add("code=$it") } + } + if (attributes.isNotEmpty()) { + append(" (") + append(attributes.joinToString()) + append(')') + } + append('.') + } + + override fun toString(): String = message + + enum class Provider(val displayName: String) { + OPENAI("OpenAI"), + CLAUDE("Anthropic Claude"), + GEMINI("Google Gemini"), + GROQ("Groq"), + MISTRAL("Mistral AI"), + COHERE("Cohere"), + DEEPSEEK("DeepSeek"), + OPENROUTER("OpenRouter"), + TOGETHER_AI("Together AI"), + CUSTOM_OPENAI("Custom OpenAI Compatible"), + COPILOT("Copilot API"), + OLLAMA("Ollama") + } + + companion object { + private val SAFE_VENDOR_TOKEN = Regex("""[A-Za-z][A-Za-z0-9._-]{0,63}""") + private val CREDENTIAL_PREFIXES = listOf( + "sk-", + "gsk_", + "AIza", + "hf_", + "xai-", + "csk-", + "r8_" + ) + + fun fromHttpFailure( + provider: Provider, + httpStatus: Int, + rawBody: String?, + knownSecrets: Iterable, + forbiddenText: Iterable + ): ProviderErrorDetail { + require(httpStatus in 100..599) { "HTTP status must be between 100 and 599." } + + val forbiddenFragments = knownSecretFragments(knownSecrets) + val forbiddenValues = forbiddenText.filterTo(mutableSetOf()) { it.isNotBlank() } + val root = parseBody(rawBody) + val error = root?.get("error")?.takeIf { it.isJsonObject }?.asJsonObject + + val vendorType = firstSafeToken( + candidates = listOf(error?.get("type"), root?.get("type")), + forbiddenFragments = forbiddenFragments, + forbiddenValues = forbiddenValues + ) + val vendorCode = firstSafeToken( + candidates = listOf( + error?.get("code"), + error?.get("status"), + root?.get("code"), + root?.get("status") + ), + forbiddenFragments = forbiddenFragments, + forbiddenValues = forbiddenValues + ) + + return ProviderErrorDetail( + provider = provider, + httpStatus = httpStatus, + vendorType = vendorType, + vendorCode = vendorCode + ) + } + + private fun parseBody(rawBody: String?): JsonObject? { + if (rawBody.isNullOrBlank() || rawBody.length > MAX_PROVIDER_ERROR_BODY_CHARS) return null + + return try { + JsonParser.parseString(rawBody).takeIf { it.isJsonObject }?.asJsonObject + } catch (_: Exception) { + null + } + } + + private fun firstSafeToken( + candidates: List, + forbiddenFragments: Set, + forbiddenValues: Set + ): String? = candidates.firstNotNullOfOrNull { candidate -> + if (candidate == null || !candidate.isJsonPrimitive || !candidate.asJsonPrimitive.isString) { + return@firstNotNullOfOrNull null + } + + candidate.asString.takeIf { token -> + SAFE_VENDOR_TOKEN.matches(token) && + CREDENTIAL_PREFIXES.none { prefix -> token.startsWith(prefix, ignoreCase = true) } && + forbiddenFragments.none(token::contains) && + forbiddenValues.none { value -> token.contains(value) || value.contains(token) } + } + } + + private fun knownSecretFragments(knownSecrets: Iterable): Set = buildSet { + knownSecrets.forEach { rawSecret -> + sequenceOf(rawSecret, rawSecret.trim()) + .filter { it.length >= 4 } + .forEach { secret -> + add(secret) + add(secret.take(4)) + add(secret.take(8)) + add(secret.takeLast(4)) + } + } + } + } +} + +/** + * Consumes an unsuccessful response body at the single safe classification + * boundary and returns an exception that contains only [ProviderErrorDetail]. + */ +internal fun Response.toSafeProviderException( + provider: ProviderErrorDetail.Provider, + request: LLMRequest, + knownSecrets: Iterable +): LLMException { + val forbiddenText = buildList { + add(this@toSafeProviderException.request.url.toString()) + add(request.systemPrompt) + request.messages.forEach { message -> + add(message.text) + message.imageBase64?.let(::add) + } + } + val rawBody = try { + consumeBoundedErrorBody() + } catch (_: IOException) { + null + } + return LLMErrorMapper.fromHttpFailure( + provider = provider, + model = request.model.orEmpty(), + httpStatus = code, + headers = headers.toMultimap().mapValues { (_, values) -> values.firstOrNull().orEmpty() }, + rawBody = rawBody, + knownSecrets = knownSecrets, + forbiddenText = forbiddenText + ) +} + +/** + * Reads at most the structural classification budget. The provider controls + * this response, so calling ResponseBody.string() here would allocate an + * attacker-sized body before any post-read length check could run. + */ +internal fun Response.consumeBoundedErrorBody(): String? { + val responseBody = body ?: return null + val declaredLength = responseBody.contentLength() + if (declaredLength > MAX_PROVIDER_ERROR_BODY_CHARS) return null + + return responseBody.charStream().use { reader -> + val buffer = CharArray(4_096) + val collected = StringBuilder() + while (collected.length <= MAX_PROVIDER_ERROR_BODY_CHARS) { + val remaining = MAX_PROVIDER_ERROR_BODY_CHARS + 1 - collected.length + val count = reader.read(buffer, 0, minOf(buffer.size, remaining)) + if (count == -1) return@use collected.toString() + collected.append(buffer, 0, count) + } + null + } +} diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/ClaudeProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/ClaudeProvider.kt index b034fbe..2130273 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/ClaudeProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/ClaudeProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,18 +33,21 @@ class ClaudeProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() // The persisted model ID is untrusted input: resolve it against the catalog // (migrating legacy IDs) rather than sending it to Anthropic verbatim. - val selectedModel = if (config.activeModel.isBlank()) { + val requestedModel = request.model?.takeIf { it.isNotBlank() } + val selectedModel = if (requestedModel == null) { ClaudeModelCatalog.defaultModelId } else { - ClaudeModelCatalog.resolve(config.activeModel) + ClaudeModelCatalog.resolve(requestedModel) ?: throw IllegalStateException( - "The selected Claude model \"${config.activeModel}\" is no longer supported. " + + "The selected Claude model \"$requestedModel\" is no longer supported. " + "Please pick another model in Settings." ) } @@ -95,9 +100,11 @@ class ClaudeProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - // Never surface or log the raw Anthropic body: it can echo request - // content and credentials into logcat and bug reports. - throw IOException("Claude request failed with HTTP ${response.code}.") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.CLAUDE, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from Claude") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -120,19 +127,11 @@ class ClaudeProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: IllegalStateException) { - // Configuration problems the user can fix (unsupported model, missing key) - // are surfaced as-is: a clear instruction, not an exception dump. - emit(e.message ?: "Claude is not configured correctly. Check Settings.") - } catch (e: Exception) { - emit("Error streaming Claude: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/CohereProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/CohereProvider.kt index 23fb76a..810570d 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/CohereProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/CohereProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,13 +33,15 @@ class CohereProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "command-r-plus" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "command-r-plus" val requestBodyMap = mutableMapOf( "model" to selectedModel, @@ -55,7 +59,11 @@ class CohereProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - throw IOException("Cohere request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.COHERE, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from Cohere") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -78,15 +86,11 @@ class CohereProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Cohere: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/CopilotProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/CopilotProvider.kt index 0aba4f7..d1f0615 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/CopilotProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/CopilotProvider.kt @@ -3,7 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* -import com.opendroid.ai.core.util.NetworkErrorFormatter +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.core.util.UrlUtils import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers @@ -33,7 +34,10 @@ class CopilotProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val baseUrl = UrlUtils.formatBaseUrl(config.copilotUrl, "") + val baseUrl = UrlUtils.formatBaseUrl( + request.providerConfig?.endpoint?.takeIf { it.isNotBlank() } ?: config.copilotUrl, + "" + ) if (baseUrl.isEmpty()) { throw IllegalStateException("Copilot server URL is not configured. Set it in Settings.") } @@ -45,7 +49,7 @@ class CopilotProvider @Inject constructor( val startTime = System.currentTimeMillis() - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "gpt-4o" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "gpt-4o" // Build messages payload val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) @@ -66,17 +70,21 @@ class CopilotProvider @Inject constructor( .url(endpoint) .post(bodyJson.toRequestBody(mediaType)) - val apiKey = config.apiKeys[name] + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } ?: config.apiKeys[name] if (!apiKey.isNullOrBlank()) { requestBuilder.header("Authorization", "Bearer $apiKey") } return withContext(Dispatchers.IO) { client.newCall(requestBuilder.build()).execute().use { response -> - val responseBody = response.body?.string() if (!response.isSuccessful) { - throw IOException("Copilot API request failed: Code ${response.code} - $responseBody") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.COPILOT, + request = request, + knownSecrets = listOfNotNull(apiKey) + ) } + val responseBody = response.body?.string() if (responseBody == null) { throw IOException("Empty response body from Copilot API") } @@ -100,15 +108,11 @@ class CopilotProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Copilot API: ${NetworkErrorFormatter.toUserMessage(e)}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/CustomOpenAIProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/CustomOpenAIProvider.kt index 50a4ee2..a08822e 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/CustomOpenAIProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/CustomOpenAIProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.core.util.UrlUtils import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers @@ -32,11 +34,13 @@ class CustomOpenAIProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: "" - val baseUrl = UrlUtils.formatBaseUrl(config.customEndpoints[name] ?: "", "https://api.openai.com/v1") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } ?: config.apiKeys[name] ?: "" + val baseUrl = request.providerConfig?.endpoint?.takeIf { it.isNotBlank() } + ?.let { UrlUtils.formatBaseUrl(it, "https://api.openai.com/v1") } + ?: UrlUtils.formatBaseUrl(config.customEndpoints[name] ?: "", "https://api.openai.com/v1") val startTime = System.currentTimeMillis() - val selectedModel = config.activeModel.ifBlank { "gpt-4o" } + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "gpt-4o" // Build messages payload val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) @@ -61,10 +65,14 @@ class CustomOpenAIProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> - val responseBody = response.body?.string() if (!response.isSuccessful) { - throw IOException("Custom OpenAI request failed: Code ${response.code} - $responseBody") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.CUSTOM_OPENAI, + request = request, + knownSecrets = listOf(apiKey) + ) } + val responseBody = response.body?.string() if (responseBody == null) { throw IOException("Empty response body from Custom OpenAI provider") } @@ -88,15 +96,11 @@ class CustomOpenAIProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Custom OpenAI Compatible: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/DeepSeekProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/DeepSeekProvider.kt index f90d4ea..d120272 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/DeepSeekProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/DeepSeekProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,13 +33,15 @@ class DeepSeekProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "deepseek-chat" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "deepseek-chat" val requestBodyMap = mutableMapOf( "model" to selectedModel, @@ -59,7 +63,11 @@ class DeepSeekProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - throw IOException("DeepSeek request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.DEEPSEEK, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from DeepSeek") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -82,15 +90,11 @@ class DeepSeekProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming DeepSeek: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/GeminiProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/GeminiProvider.kt index 5b45388..f3d48f4 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/GeminiProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/GeminiProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,14 +33,16 @@ class GeminiProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val activeModel = config.activeModel + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(name) // On-device Nano Mock fallback (to prevent crashes and support offline testing) - if (activeModel == "gemini-nano") { + if (selectedModel == "gemini-nano") { return executeNanoMock(request) } - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() // Map roles to user and model @@ -82,18 +86,23 @@ class GeminiProvider @Inject constructor( requestBodyMap["generationConfig"] = generationConfig val bodyJson = gson.toJson(requestBodyMap) - val url = "https://generativelanguage.googleapis.com/v1beta/models/$activeModel:generateContent?key=$apiKey" + val url = "https://generativelanguage.googleapis.com/v1beta/models/$selectedModel:generateContent" val httpRequest = Request.Builder() .url(url) + .header("x-goog-api-key", apiKey) .post(bodyJson.toRequestBody(mediaType)) .build() return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> - val responseBody = response.body?.string() if (!response.isSuccessful) { - throw IOException("Gemini request failed: Code ${response.code} - $responseBody") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.GEMINI, + request = request, + knownSecrets = listOf(apiKey) + ) } + val responseBody = response.body?.string() if (responseBody == null) { throw IOException("Empty response body from Gemini") } @@ -110,7 +119,7 @@ class GeminiProvider @Inject constructor( LLMResponse( content = text, tokensUsed = totalTokens, - model = activeModel, + model = selectedModel, provider = name, latencyMs = System.currentTimeMillis() - startTime ) @@ -149,15 +158,11 @@ class GeminiProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Gemini: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/GemmaProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/GemmaProvider.kt index 4eb8af9..2654527 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/GemmaProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/GemmaProvider.kt @@ -6,6 +6,7 @@ import com.google.mlkit.genai.common.FeatureStatus import com.google.mlkit.genai.common.DownloadStatus import com.opendroid.ai.core.llm.* import com.opendroid.ai.data.models.ChatMessage +import com.opendroid.ai.data.models.selectedModelFor import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* @@ -50,8 +51,7 @@ class GemmaProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val startTime = System.currentTimeMillis() - val config = settingsRepository.llmConfig.first() - val selectedModel = config.activeModel + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(name) val systemPrompt = request.systemPrompt val messages = request.messages val prompt = buildPrompt(systemPrompt, messages, request.tools?.map { ToolDefinition(it.name, it.description, it.parameters) } ?: emptyList()) @@ -89,8 +89,7 @@ class GemmaProvider @Inject constructor( override fun streamComplete(request: LLMRequest): Flow = flow { try { - val config = settingsRepository.llmConfig.first() - val selectedModel = config.activeModel + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(name) val generativeModel = getClientForModel(selectedModel) val status = generativeModel.checkStatus() @@ -127,8 +126,7 @@ class GemmaProvider @Inject constructor( tools: List ): Flow = flow { try { - val config = settingsRepository.llmConfig.first() - val selectedModel = config.activeModel + val selectedModel = settingsRepository.llmConfig.first().selectedModelFor(name) val generativeModel = getClientForModel(selectedModel) val status = generativeModel.checkStatus() diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/GroqProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/GroqProvider.kt index 1a277a0..96231cd 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/GroqProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/GroqProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,13 +33,15 @@ class GroqProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "llama-3.3-70b-specdec" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "llama-3.3-70b-specdec" val requestBodyMap = mutableMapOf( "model" to selectedModel, @@ -59,7 +63,11 @@ class GroqProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - throw IOException("Groq request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.GROQ, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from Groq") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -82,15 +90,11 @@ class GroqProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Groq: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/HybridOnDeviceProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/HybridOnDeviceProvider.kt index bee1970..09aeb9b 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/HybridOnDeviceProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/HybridOnDeviceProvider.kt @@ -3,6 +3,7 @@ package com.opendroid.ai.core.llm.providers import android.util.Log import com.opendroid.ai.core.llm.* import com.opendroid.ai.data.models.ChatMessage +import com.opendroid.ai.data.models.selectedModelFor import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -65,8 +66,8 @@ class HybridOnDeviceProvider @Inject constructor( } override suspend fun complete(request: LLMRequest): LLMResponse { - val config = settingsRepository.llmConfig.first() - val backend = resolveBackend(config.activeModel) + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(PROVIDER_NAME) + val backend = resolveBackend(selectedModel) val primary = delegateFor(backend) val fallback = fallbackFor(backend) @@ -102,8 +103,8 @@ class HybridOnDeviceProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - val config = settingsRepository.llmConfig.first() - val backend = resolveBackend(config.activeModel) + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(PROVIDER_NAME) + val backend = resolveBackend(selectedModel) val primary = delegateFor(backend) val fallback = fallbackFor(backend) @@ -144,8 +145,8 @@ class HybridOnDeviceProvider @Inject constructor( messages: List, tools: List ): Flow = flow { - val config = settingsRepository.llmConfig.first() - val backend = resolveBackend(config.activeModel) + val selectedModel = settingsRepository.llmConfig.first().selectedModelFor(PROVIDER_NAME) + val backend = resolveBackend(selectedModel) val primary = delegateFor(backend) val fallback = fallbackFor(backend) diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/LiteRTLMProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/LiteRTLMProvider.kt index dfaf4a2..3db6a04 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/LiteRTLMProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/LiteRTLMProvider.kt @@ -5,6 +5,7 @@ import android.os.Build import android.util.Log import com.opendroid.ai.core.llm.* import com.opendroid.ai.data.models.ChatMessage +import com.opendroid.ai.data.models.selectedModelFor import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* @@ -146,8 +147,8 @@ class LiteRTLMProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val startTime = System.currentTimeMillis() - val config = settingsRepository.llmConfig.first() - val spec = resolveModelSpec(config.activeModel) + val modelId = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(name) + val spec = resolveModelSpec(modelId) return withContext(Dispatchers.IO) { try { @@ -178,9 +179,9 @@ class LiteRTLMProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { + val modelId = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(name) try { - val config = settingsRepository.llmConfig.first() - val spec = resolveModelSpec(config.activeModel) + val spec = resolveModelSpec(modelId) checkSdkCompatibility(spec) val modelPath = getModelFilePath(spec) checkModelReady(modelPath, spec) @@ -213,8 +214,7 @@ class LiteRTLMProvider @Inject constructor( conversation.close() } } catch (e: Throwable) { - val config = settingsRepository.llmConfig.first() - val spec = resolveModelSpec(config.activeModel) + val spec = resolveModelSpec(modelId) emit("Error (LiteRT-LM): ${handleThrowable(e, spec).localizedMessage}") } } @@ -223,9 +223,9 @@ class LiteRTLMProvider @Inject constructor( messages: List, tools: List ): Flow = flow { + val modelId = settingsRepository.llmConfig.first().selectedModelFor(name) try { - val config = settingsRepository.llmConfig.first() - val spec = resolveModelSpec(config.activeModel) + val spec = resolveModelSpec(modelId) checkSdkCompatibility(spec) val modelPath = getModelFilePath(spec) checkModelReady(modelPath, spec) @@ -252,8 +252,7 @@ class LiteRTLMProvider @Inject constructor( // Not JSON — treat as plain text } } catch (e: Throwable) { - val config = settingsRepository.llmConfig.first() - val spec = resolveModelSpec(config.activeModel) + val spec = resolveModelSpec(modelId) emit(StreamChunk.Content("Error (LiteRT-LM): ${handleThrowable(e, spec).localizedMessage}")) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/MistralProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/MistralProvider.kt index 01b5538..bf613b2 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/MistralProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/MistralProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,13 +33,15 @@ class MistralProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "mistral-large-latest" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "mistral-large-latest" val requestBodyMap = mutableMapOf( "model" to selectedModel, @@ -59,7 +63,11 @@ class MistralProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - throw IOException("Mistral request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.MISTRAL, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from Mistral") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -82,15 +90,11 @@ class MistralProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Mistral: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/OllamaProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/OllamaProvider.kt index 1bc3b40..26b7b6f 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/OllamaProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/OllamaProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.core.util.NetworkErrorFormatter import com.opendroid.ai.core.util.UrlUtils import com.opendroid.ai.data.repository.SettingsRepository @@ -33,7 +35,11 @@ class OllamaProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val baseUrl = UrlUtils.formatBaseUrl(config.ollamaUrl, "") + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: ProviderCatalog.defaultModel(name) + val baseUrl = UrlUtils.formatBaseUrl( + request.providerConfig?.endpoint?.takeIf { it.isNotBlank() } ?: config.ollamaUrl, + "" + ) if (baseUrl.isEmpty()) { throw IllegalStateException("Ollama server URL is not configured. Set it in Settings.") } @@ -44,7 +50,7 @@ class OllamaProvider @Inject constructor( val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) val requestBodyMap = mutableMapOf( - "model" to config.activeModel, + "model" to selectedModel, "messages" to messagesList, "stream" to false, "options" to mapOf( @@ -59,7 +65,7 @@ class OllamaProvider @Inject constructor( .post(bodyJson.toRequestBody(mediaType)) // Add optional authorization if a bearer token key is configured - val apiKey = config.apiKeys[name] + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } ?: config.apiKeys[name] if (!apiKey.isNullOrBlank()) { requestBuilder.header("Authorization", "Bearer $apiKey") } @@ -67,7 +73,11 @@ class OllamaProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(requestBuilder.build()).execute().use { response -> if (!response.isSuccessful) { - throw IOException("Ollama request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.OLLAMA, + request = request, + knownSecrets = listOfNotNull(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from Ollama") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -80,7 +90,7 @@ class OllamaProvider @Inject constructor( LLMResponse( content = content, tokensUsed = promptEvalCount + evalCount, - model = config.activeModel, + model = selectedModel, provider = name, latencyMs = System.currentTimeMillis() - startTime ) diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenAIProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenAIProvider.kt index ca16161..b7dce0c 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenAIProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenAIProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,11 +33,13 @@ class OpenAIProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "gpt-4o" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "gpt-4o" // Build messages payload val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) @@ -60,10 +64,14 @@ class OpenAIProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> - val responseBody = response.body?.string() if (!response.isSuccessful) { - throw IOException("OpenAI request failed: Code ${response.code} - $responseBody") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.OPENAI, + request = request, + knownSecrets = listOf(apiKey) + ) } + val responseBody = response.body?.string() if (responseBody == null) { throw IOException("Empty response body from OpenAI") } @@ -90,16 +98,12 @@ class OpenAIProvider @Inject constructor( // Fallback simple streaming mock or raw API line-by-line stream. // For simplicity and completeness, we will fetch complete first and emit, // or parse SSE streams if needed. Let's execute complete and stream it. - try { - val response = complete(request) - // Stream chunks - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming OpenAI: ${e.localizedMessage}") + val response = complete(request) + // Stream chunks + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenRouterProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenRouterProvider.kt index b6bac47..7658730 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenRouterProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/OpenRouterProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,13 +33,15 @@ class OpenRouterProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "google/gemini-2.0-flash-exp:free" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "google/gemini-2.0-flash-exp:free" val requestBodyMap = mutableMapOf( "model" to selectedModel, @@ -61,7 +65,11 @@ class OpenRouterProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - throw IOException("OpenRouter request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.OPENROUTER, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from OpenRouter") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -84,15 +92,11 @@ class OpenRouterProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming OpenRouter: ${com.opendroid.ai.core.util.NetworkErrorFormatter.toUserMessage(e)}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/llm/providers/TogetherAIProvider.kt b/app/src/main/java/com/opendroid/ai/core/llm/providers/TogetherAIProvider.kt index 7b20f5f..e935d5e 100644 --- a/app/src/main/java/com/opendroid/ai/core/llm/providers/TogetherAIProvider.kt +++ b/app/src/main/java/com/opendroid/ai/core/llm/providers/TogetherAIProvider.kt @@ -3,6 +3,8 @@ package com.opendroid.ai.core.llm.providers import com.google.gson.Gson import com.google.gson.JsonObject import com.opendroid.ai.core.llm.* +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import com.opendroid.ai.core.llm.error.toSafeProviderException import com.opendroid.ai.data.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -31,13 +33,15 @@ class TogetherAIProvider @Inject constructor( override suspend fun complete(request: LLMRequest): LLMResponse { val config = settingsRepository.llmConfig.first() - val apiKey = config.apiKeys[name] ?: throw IllegalStateException("API Key for $name is not set.") + val apiKey = request.providerConfig?.apiKey?.takeIf { it.isNotBlank() } + ?: config.apiKeys[name] + ?: throw IllegalStateException("API Key for $name is not set.") val startTime = System.currentTimeMillis() val messagesList = request.messages.toOpenAIMessages(request.systemPrompt) - val selectedModel = if (config.activeModel.isNotBlank()) config.activeModel else "meta-llama/Llama-3-70b-chat-hf" + val selectedModel = request.model?.takeIf { it.isNotBlank() } ?: "meta-llama/Llama-3-70b-chat-hf" val requestBodyMap = mutableMapOf( "model" to selectedModel, @@ -59,7 +63,11 @@ class TogetherAIProvider @Inject constructor( return withContext(Dispatchers.IO) { client.newCall(httpRequest).execute().use { response -> if (!response.isSuccessful) { - throw IOException("Together AI request failed: Code ${response.code} - ${response.body?.string()}") + throw response.toSafeProviderException( + provider = ProviderErrorDetail.Provider.TOGETHER_AI, + request = request, + knownSecrets = listOf(apiKey) + ) } val responseBody = response.body?.string() ?: throw IOException("Empty response body from Together AI") val jsonResponse = gson.fromJson(responseBody, JsonObject::class.java) @@ -82,15 +90,11 @@ class TogetherAIProvider @Inject constructor( } override fun streamComplete(request: LLMRequest): Flow = flow { - try { - val response = complete(request) - val words = response.content.split(" ") - for (word in words) { - emit("$word ") - kotlinx.coroutines.delay(50) - } - } catch (e: Exception) { - emit("Error streaming Together AI: ${e.localizedMessage}") + val response = complete(request) + val words = response.content.split(" ") + for (word in words) { + emit("$word ") + kotlinx.coroutines.delay(50) } } diff --git a/app/src/main/java/com/opendroid/ai/core/permissions/PermissionAskedStore.kt b/app/src/main/java/com/opendroid/ai/core/permissions/PermissionAskedStore.kt new file mode 100644 index 0000000..02fd327 --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/permissions/PermissionAskedStore.kt @@ -0,0 +1,29 @@ +package com.opendroid.ai.core.permissions + +import android.content.Context + +object PermissionAskedStore { + private const val PREFS_NAME = "permission_asked" + private const val KEY_ASKED = "asked_permissions" + + fun asked(context: Context): Set = + context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getStringSet(KEY_ASKED, emptySet()) + ?.toSet() + .orEmpty() + + fun markAsked( + context: Context, + permissions: Collection, + ) { + if (permissions.isEmpty()) return + + val updated = asked(context) + permissions + context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putStringSet(KEY_ASKED, updated) + .apply() + } +} diff --git a/app/src/main/java/com/opendroid/ai/core/permissions/PermissionModel.kt b/app/src/main/java/com/opendroid/ai/core/permissions/PermissionModel.kt new file mode 100644 index 0000000..cf0114f --- /dev/null +++ b/app/src/main/java/com/opendroid/ai/core/permissions/PermissionModel.kt @@ -0,0 +1,362 @@ +package com.opendroid.ai.core.permissions + +enum class PermissionCardId { + MICROPHONE, + LOCATION, + SMS_TELEPHONY, + CONTACTS_CALENDAR, + CAMERA, + NOTIFICATIONS, + STORAGE, + WRITE_SETTINGS, + ACCESSIBILITY, +} + +enum class CardStatus { + GRANTED, + PARTIAL, + MISSING, + MANUAL_PENDING, + MANUAL_GRANTED, +} + +fun visibleCards(sdkInt: Int): List = + PermissionCardId.entries.filter { card -> + card != PermissionCardId.NOTIFICATIONS || sdkInt >= 33 + } + +fun runtimePermissions( + card: PermissionCardId, + sdkInt: Int, +): List = when (card) { + PermissionCardId.MICROPHONE -> listOf( + "android.permission.RECORD_AUDIO", + ) + + PermissionCardId.LOCATION -> listOf( + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ) + + PermissionCardId.SMS_TELEPHONY -> listOf( + "android.permission.SEND_SMS", + "android.permission.CALL_PHONE", + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + ) + + PermissionCardId.CONTACTS_CALENDAR -> listOf( + "android.permission.READ_CONTACTS", + "android.permission.WRITE_CONTACTS", + "android.permission.READ_CALENDAR", + ) + + PermissionCardId.CAMERA -> listOf( + "android.permission.CAMERA", + ) + + PermissionCardId.NOTIFICATIONS -> if (sdkInt >= 33) { + listOf("android.permission.POST_NOTIFICATIONS") + } else { + emptyList() + } + + PermissionCardId.STORAGE -> if (sdkInt < 30) { + listOf( + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + ) + } else { + emptyList() + } + + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + -> emptyList() +} + +fun allRuntimePermissions(sdkInt: Int): List = + visibleCards(sdkInt) + .flatMap { card -> runtimePermissions(card, sdkInt) } + .distinct() + +fun requestPlan( + granted: Set, + sdkInt: Int, +): List = + allRuntimePermissions(sdkInt).filterNot { permission -> permission in granted } + +fun requestPlan( + granted: Set, + sdkInt: Int, + card: PermissionCardId, +): List = + runtimePermissions(card, sdkInt).filterNot { permission -> permission in granted } + +fun cardStatus( + card: PermissionCardId, + granted: Set, + sdkInt: Int, + manualHeld: Boolean, +): CardStatus { + if (card == PermissionCardId.NOTIFICATIONS && sdkInt < 33) { + return CardStatus.GRANTED + } + + val permissions = runtimePermissions(card, sdkInt) + if (permissions.isEmpty()) { + return if (manualHeld) CardStatus.MANUAL_GRANTED else CardStatus.MANUAL_PENDING + } + + val grantedCount = permissions.count { permission -> permission in granted } + return when (grantedCount) { + 0 -> CardStatus.MISSING + permissions.size -> CardStatus.GRANTED + else -> CardStatus.PARTIAL + } +} + +@Suppress("UNUSED_PARAMETER") +fun isBlocked( + permission: String, + granted: Boolean, + asked: Boolean, + showRationale: Boolean?, +): Boolean = !granted && asked && showRationale != true + +sealed interface GrantAllState { + data object Idle : GrantAllState + + data object InFlight : GrantAllState + + data class Returned( + val lastBatch: Set, + ) : GrantAllState +} + +data class PermissionsSnapshot( + val sdkInt: Int, + val granted: Set, + val asked: Set, + val rationale: Map, + val manualHeld: Set, + val grantAll: GrantAllState, + val appInfoOffered: Set, +) + +enum class GrantAllButtonState { + RequestAll, + InFlight, + RequestRemaining, + Complete, + AllBlocked, +} + +data class GrantAllButtonPresentation( + val state: GrantAllButtonState, + val label: String, + val enabled: Boolean, +) + +fun grantAllButton(snapshot: PermissionsSnapshot): GrantAllButtonPresentation { + val missing = requestPlan(snapshot.granted, snapshot.sdkInt) + return when { + snapshot.grantAll is GrantAllState.InFlight -> GrantAllButtonPresentation( + state = GrantAllButtonState.InFlight, + label = "Requesting…", + enabled = false, + ) + + missing.isEmpty() -> GrantAllButtonPresentation( + state = GrantAllButtonState.Complete, + label = "All runtime permissions granted", + enabled = false, + ) + + snapshot.grantAll is GrantAllState.Returned && + missing.all { permission -> permissionIsBlocked(snapshot, permission) } -> + GrantAllButtonPresentation( + state = GrantAllButtonState.AllBlocked, + label = "Blocked → use App info below", + enabled = false, + ) + + snapshot.grantAll is GrantAllState.Returned -> GrantAllButtonPresentation( + state = GrantAllButtonState.RequestRemaining, + label = "Grant remaining permissions", + enabled = true, + ) + + else -> GrantAllButtonPresentation( + state = GrantAllButtonState.RequestAll, + label = "Grant all permissions", + enabled = true, + ) + } +} + +fun summaryLine(snapshot: PermissionsSnapshot): String = when (val state = snapshot.grantAll) { + GrantAllState.Idle -> { + val missingCount = requestPlan(snapshot.granted, snapshot.sdkInt).size + val noun = if (missingCount == 1) "permission" else "permissions" + "$missingCount runtime $noun still needed. Android asks for them in one dialog." + } + + GrantAllState.InFlight -> "Android is showing one permission batch." + + is GrantAllState.Returned -> returnedSummary(snapshot, state.lastBatch) +} + +fun summaryHasBlocked(snapshot: PermissionsSnapshot): Boolean { + val returned = snapshot.grantAll as? GrantAllState.Returned ?: return false + return returned.lastBatch.any { permission -> + permissionIsBlocked(snapshot, permission) + } +} + +fun cardButtonLabel( + card: PermissionCardId, + snapshot: PermissionsSnapshot, +): String { + val status = statusFor(card, snapshot) + if (card in snapshot.appInfoOffered && status.isMissingRuntime()) { + return "App info" + } + return when (status) { + CardStatus.GRANTED -> "Granted" + CardStatus.PARTIAL -> "Grant rest" + CardStatus.MISSING -> "Grant" + CardStatus.MANUAL_PENDING -> "Open Settings" + CardStatus.MANUAL_GRANTED -> "Enabled" + } +} + +fun cardActionEnabled( + card: PermissionCardId, + snapshot: PermissionsSnapshot, +): Boolean = when (statusFor(card, snapshot)) { + CardStatus.GRANTED, + CardStatus.MANUAL_GRANTED, + -> false + + CardStatus.PARTIAL, + CardStatus.MISSING, + CardStatus.MANUAL_PENDING, + -> true +} + +fun cardStatusLine( + card: PermissionCardId, + snapshot: PermissionsSnapshot, +): String { + val status = statusFor(card, snapshot) + val permissions = runtimePermissions(card, snapshot.sdkInt) + if (card in snapshot.appInfoOffered && status.isMissingRuntime()) { + return "Android won't ask again. Open app settings, then Permissions → ${settingsPathName(card)}." + } + + return when (status) { + CardStatus.GRANTED -> "Granted." + CardStatus.MANUAL_GRANTED -> "Enabled in system Settings." + CardStatus.MANUAL_PENDING -> "Android requires this to be switched on in Settings." + CardStatus.PARTIAL -> { + val grantedCount = permissions.count { permission -> permission in snapshot.granted } + "$grantedCount of ${permissions.size} granted." + } + + CardStatus.MISSING -> when { + permissions.any { permission -> permissionIsBlocked(snapshot, permission) } -> + "Blocked in system settings." + + permissions.any { permission -> permission in snapshot.asked } -> + "Declined. Tap Grant to ask again." + + else -> "" + } + } +} + +fun cardStatusHasError( + card: PermissionCardId, + snapshot: PermissionsSnapshot, +): Boolean = + card in snapshot.appInfoOffered && statusFor(card, snapshot).isMissingRuntime() + +fun allVisibleRequirementsHeld(snapshot: PermissionsSnapshot): Boolean = + visibleCards(snapshot.sdkInt).all { card -> + statusFor(card, snapshot) in setOf(CardStatus.GRANTED, CardStatus.MANUAL_GRANTED) + } + +private fun statusFor( + card: PermissionCardId, + snapshot: PermissionsSnapshot, +): CardStatus = cardStatus( + card = card, + granted = snapshot.granted, + sdkInt = snapshot.sdkInt, + manualHeld = card in snapshot.manualHeld, +) + +private fun CardStatus.isMissingRuntime(): Boolean = + this == CardStatus.MISSING || this == CardStatus.PARTIAL + +private fun permissionIsBlocked( + snapshot: PermissionsSnapshot, + permission: String, +): Boolean = isBlocked( + permission = permission, + granted = permission in snapshot.granted, + asked = permission in snapshot.asked, + showRationale = snapshot.rationale[permission], +) + +private fun returnedSummary( + snapshot: PermissionsSnapshot, + lastBatch: Set, +): String { + if (lastBatch.isEmpty()) { + return "No runtime permissions were requested." + } + + val grantedCount = lastBatch.count { permission -> permission in snapshot.granted } + if (grantedCount == lastBatch.size) { + val noun = if (lastBatch.size == 1) "permission" else "permissions" + return "All ${lastBatch.size} $noun granted." + } + + val blockedCount = lastBatch.count { permission -> + permissionIsBlocked(snapshot, permission) + } + val declinedCount = lastBatch.size - grantedCount - blockedCount + val prefix = if (grantedCount == 0) { + "None granted." + } else { + "$grantedCount of ${lastBatch.size} granted." + } + + return when { + blockedCount == 0 -> + "$prefix $declinedCount declined → tap Grant on a card to ask again." + + declinedCount == 0 && grantedCount == 0 -> + "$prefix $blockedCount blocked in system settings → use App info below." + + declinedCount == 0 -> + "$prefix $blockedCount blocked → use App info below." + + else -> + "$prefix $declinedCount declined, $blockedCount blocked → use App info below." + } +} + +private fun settingsPathName(card: PermissionCardId): String = when (card) { + PermissionCardId.MICROPHONE -> "Microphone" + PermissionCardId.LOCATION -> "Location" + PermissionCardId.SMS_TELEPHONY -> "SMS & telephony" + PermissionCardId.CONTACTS_CALENDAR -> "Contacts & calendar" + PermissionCardId.CAMERA -> "Camera" + PermissionCardId.NOTIFICATIONS -> "Notifications" + PermissionCardId.STORAGE -> "Files and media" + PermissionCardId.WRITE_SETTINGS -> "Modify system settings" + PermissionCardId.ACCESSIBILITY -> "Accessibility" +} diff --git a/app/src/main/java/com/opendroid/ai/core/util/NetworkErrorFormatter.kt b/app/src/main/java/com/opendroid/ai/core/util/NetworkErrorFormatter.kt index 2ff8eb6..ab70014 100644 --- a/app/src/main/java/com/opendroid/ai/core/util/NetworkErrorFormatter.kt +++ b/app/src/main/java/com/opendroid/ai/core/util/NetworkErrorFormatter.kt @@ -1,13 +1,27 @@ package com.opendroid.ai.core.util import android.util.Log +import com.opendroid.ai.core.agent.ChatErrorUiState +import com.opendroid.ai.core.agent.guidance +import com.opendroid.ai.core.agent.title +import com.opendroid.ai.core.llm.error.LLMException object NetworkErrorFormatter { private const val TAG = "NetworkErrorFormatter" fun toUserMessage(error: Throwable?): String { - val message = error?.localizedMessage ?: error?.message ?: return "Something went wrong. Please try again." + if (error is LLMException) { + val state = ChatErrorUiState.fromException( + sessionId = "network", + requestId = "network", + runId = "network", + failure = error + ) + return "${state.title()} ${state.guidance()}" + } + val message = error?.localizedMessage ?: error?.message + ?: return "Something went wrong. Please try again." return toUserMessage(message) } diff --git a/app/src/main/java/com/opendroid/ai/data/crash/RoomCrashLogSink.kt b/app/src/main/java/com/opendroid/ai/data/crash/RoomCrashLogSink.kt index faea853..7d384dc 100644 --- a/app/src/main/java/com/opendroid/ai/data/crash/RoomCrashLogSink.kt +++ b/app/src/main/java/com/opendroid/ai/data/crash/RoomCrashLogSink.kt @@ -72,12 +72,7 @@ fun CrashLogEntity.toRecord(): CrashLogRecord = CrashLogRecord( message = message, threadName = threadName, stackTrace = stackTrace, - appVersionName = appVersionName, - appVersionCode = appVersionCode, - androidRelease = androidRelease, - androidSdkInt = androidSdkInt, - deviceManufacturer = deviceManufacturer, - deviceModel = deviceModel + device = device ) fun CrashLogRecord.toEntity(): CrashLogEntity = CrashLogEntity( @@ -86,10 +81,5 @@ fun CrashLogRecord.toEntity(): CrashLogEntity = CrashLogEntity( message = message, threadName = threadName, stackTrace = stackTrace, - appVersionName = appVersionName, - appVersionCode = appVersionCode, - androidRelease = androidRelease, - androidSdkInt = androidSdkInt, - deviceManufacturer = deviceManufacturer, - deviceModel = deviceModel + device = device ) diff --git a/app/src/main/java/com/opendroid/ai/data/db/entities/CrashLogEntity.kt b/app/src/main/java/com/opendroid/ai/data/db/entities/CrashLogEntity.kt index d9f777b..5203f27 100644 --- a/app/src/main/java/com/opendroid/ai/data/db/entities/CrashLogEntity.kt +++ b/app/src/main/java/com/opendroid/ai/data/db/entities/CrashLogEntity.kt @@ -1,8 +1,10 @@ package com.opendroid.ai.data.db.entities +import androidx.room.Embedded import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import com.opendroid.ai.core.crash.DeviceMetadata @Entity( tableName = "crash_logs", @@ -15,10 +17,5 @@ data class CrashLogEntity( val message: String?, val threadName: String, val stackTrace: String, - val appVersionName: String, - val appVersionCode: Long, - val androidRelease: String, - val androidSdkInt: Int, - val deviceManufacturer: String, - val deviceModel: String + @Embedded val device: DeviceMetadata ) diff --git a/app/src/main/java/com/opendroid/ai/data/models/LLMConfig.kt b/app/src/main/java/com/opendroid/ai/data/models/LLMConfig.kt index 7af4a16..d4b5cde 100644 --- a/app/src/main/java/com/opendroid/ai/data/models/LLMConfig.kt +++ b/app/src/main/java/com/opendroid/ai/data/models/LLMConfig.kt @@ -1,12 +1,29 @@ package com.opendroid.ai.data.models +import android.util.Log import kotlinx.serialization.Serializable import com.opendroid.ai.core.llm.AIModel +import com.opendroid.ai.core.llm.ClaudeModelCatalog +import com.opendroid.ai.core.llm.ProviderCatalog + +private const val TAG = "LLMConfig" + +// android.util.Log is unavailable in plain-JVM unit tests; the warning is +// best-effort signal, never worth failing model resolution over. +private fun warnCoercion() = runCatching { + Log.w(TAG, "Unresolvable Claude model selection; coercing to ${ClaudeModelCatalog.defaultModelId}.") +} @Serializable data class LLMConfig( val activeProvider: String = "Google Gemini", val activeModel: String = "gemini-2.0-flash", + /** + * Provider/model pairs. `null` means the setting predates this field and is + * resolved lazily from [activeProvider]/[activeModel] without an upgrade + * write. An explicit empty map is a valid migrated value. + */ + val selectedModels: Map? = null, val apiKeys: Map = emptyMap(), // Provider -> API Key val customEndpoints: Map = emptyMap(), // Provider -> URL // Off by default: LLM-generated plans must be confirmed by the user before @@ -36,3 +53,60 @@ fun LLMConfig.resolvedAutoMode(): AutoMode = fun LLMConfig.effectiveGrantedActions(): Map = grantedActions ?: AutoMode.DEFAULT_GRANTS.associateWith { 0L } + +fun LLMConfig.selectedModelFor(providerName: String): String { + val provider = ProviderCatalog.canonicalName(providerName) + val migratedPairs = selectedModels + ?.entries + ?.associate { (key, value) -> ProviderCatalog.canonicalName(key) to value } + val legacySelection = activeModel.takeIf { + ProviderCatalog.canonicalName(activeProvider) == provider && it.isNotBlank() + } + val selected = migratedPairs?.get(provider)?.takeIf(String::isNotBlank) + ?: (if (selectedModels == null) legacySelection else null) + ?: ProviderCatalog.defaultModel(provider) + + return if (provider == "Anthropic Claude") { + ClaudeModelCatalog.resolve(selected) ?: run { + warnCoercion() + ClaudeModelCatalog.defaultModelId + } + } else { + selected.trim() + } +} + +fun LLMConfig.withSelectedModel(providerName: String, model: String): LLMConfig { + val provider = ProviderCatalog.canonicalName(providerName) + require(ProviderCatalog.isKnown(provider)) { "Unknown LLM provider." } + val safeModel = if (provider == "Anthropic Claude") { + ClaudeModelCatalog.resolve(model) ?: run { + warnCoercion() + ClaudeModelCatalog.defaultModelId + } + } else { + model.trim().ifBlank { ProviderCatalog.defaultModel(provider) } + } + val pairs = buildMap { + selectedModels?.forEach { (key, value) -> + put(ProviderCatalog.canonicalName(key), value) + } + if (selectedModels == null && activeModel.isNotBlank()) { + put(ProviderCatalog.canonicalName(activeProvider), activeModel) + } + put(provider, safeModel) + } + return copy( + activeModel = if (ProviderCatalog.canonicalName(activeProvider) == provider) safeModel else activeModel, + selectedModels = pairs + ) +} + +fun LLMConfig.withActiveProvider(providerName: String): LLMConfig { + val provider = ProviderCatalog.canonicalName(providerName) + require(ProviderCatalog.isKnown(provider)) { "Unknown LLM provider." } + return copy( + activeProvider = provider, + activeModel = selectedModelFor(provider) + ) +} diff --git a/app/src/main/java/com/opendroid/ai/ui/screens/BenchmarkScreen.kt b/app/src/main/java/com/opendroid/ai/ui/screens/BenchmarkScreen.kt index c00d55a..b5c5f60 100644 --- a/app/src/main/java/com/opendroid/ai/ui/screens/BenchmarkScreen.kt +++ b/app/src/main/java/com/opendroid/ai/ui/screens/BenchmarkScreen.kt @@ -14,11 +14,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.opendroid.ai.core.llm.ConnectionTestPlanner +import com.opendroid.ai.core.llm.ConnectionTestState +import com.opendroid.ai.core.llm.error.LLMError import com.opendroid.ai.ui.theme.* import com.opendroid.ai.ui.viewmodel.SettingsViewModel @@ -30,20 +32,12 @@ fun BenchmarkScreen( modifier: Modifier = Modifier ) { val config by viewModel.llmConfig.collectAsState() - - val providers = listOf( - "Google Gemini", - "OpenAI", - "Anthropic Claude", - "Groq", - "Mistral AI", - "OpenRouter", - "Together AI", - "Cohere", - "DeepSeek", - "Copilot API", - "Ollama" - ) + val connectionResults by viewModel.connectionResults.collectAsState() + val batchProgress by viewModel.connectionBatchProgress.collectAsState() + var showConfirm by remember { mutableStateOf(false) } + + val providers = ConnectionTestPlanner.cloudProviders() + .filter { it != "Custom OpenAI Compatible" } Scaffold( topBar = { @@ -64,19 +58,28 @@ fun BenchmarkScreen( } }, actions = { - Button( - onClick = { - providers.forEach { providerName -> - viewModel.testProviderLatency(providerName) - } - }, - colors = ButtonDefaults.buttonColors(containerColor = AccentNeonGreen, contentColor = DarkBackground), - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) - ) { - Icon(Icons.Default.PlayArrow, contentDescription = "Run Test", modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Test All", fontSize = 11.sp, fontWeight = FontWeight.Bold) + if (batchProgress != null) { + TextButton(onClick = { viewModel.cancelConnectionTests() }) { + Text("Cancel", fontSize = 11.sp, color = AccentRed) + } + } else { + Button( + onClick = { showConfirm = true }, + colors = ButtonDefaults.buttonColors( + containerColor = AccentNeonGreen, + contentColor = DarkBackground + ), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Run Test", + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Test all configured", fontSize = 11.sp, fontWeight = FontWeight.Bold) + } } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = DarkBackground) @@ -85,6 +88,31 @@ fun BenchmarkScreen( containerColor = DarkBackground, modifier = modifier ) { padding -> + if (showConfirm) { + val configuredCount = ConnectionTestPlanner.configuredProviders(config).size + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text("Test all configured?") }, + text = { + Text( + "This will send $configuredCount sequential provider requests. " + + "Provider charges may apply." + ) + }, + confirmButton = { + TextButton( + onClick = { + showConfirm = false + viewModel.testAllConfigured() + } + ) { Text("Continue") } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { Text("Cancel") } + } + ) + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -110,7 +138,10 @@ fun BenchmarkScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "This utility performs a standard API ping/completion on each LLM provider to measure round-trip response latency. Configure keys in settings before running.", + text = batchProgress?.let { + "Testing ${it.index} of ${it.total}: ${it.provider}" + } ?: "Explicit connection tests use each provider's own selected model. " + + "Missing keys surface as configuration errors instead of silent skips.", fontSize = 12.sp, color = TextSecondary ) @@ -119,35 +150,42 @@ fun BenchmarkScreen( } items(providers) { provider -> - val latency = config.latencyBenchmarks[provider] - ProviderLatencyRow(providerName = provider, latencyMs = latency) + val result = connectionResults[provider] + val legacyLatency = config.latencyBenchmarks[provider] + ProviderConnectionRow( + providerName = provider, + state = result, + legacyLatencyMs = legacyLatency, + onTest = { viewModel.testConnection(provider) } + ) } } } } @Composable -fun ProviderLatencyRow(providerName: String, latencyMs: Long?) { - val barColor = when { - latencyMs == null -> BorderColor - latencyMs == 9999L -> AccentRed - latencyMs < 500L -> AccentNeonGreen - latencyMs < 1500L -> AccentCyan - else -> AccentPurple - } - - val ratingText = when { - latencyMs == null -> "NO DATA / UNTESTED" - latencyMs == 9999L -> "ERROR / OFFLINE" - latencyMs < 500L -> "EXCELLENT (<500ms)" - latencyMs < 1500L -> "MODERATE (0.5s - 1.5s)" - else -> "SLOW (>1.5s)" +fun ProviderConnectionRow( + providerName: String, + state: ConnectionTestState?, + legacyLatencyMs: Long?, + onTest: () -> Unit +) { + val statusText = when (state) { + is ConnectionTestState.Testing -> "Testing…" + is ConnectionTestState.Connected -> "Connected · ${state.latencyMs} ms · ${state.model}" + is ConnectionTestState.Failed -> connectionFailureLabel(state.error) + is ConnectionTestState.ConfigMissing -> when (state.reason) { + LLMError.AuthMissing -> "Key required" + else -> "Configuration required" + } + else -> legacyLatencyMs?.takeIf { it > 0 && it != 9999L }?.let { "Last latency $it ms" } + ?: "Not tested" } - - val fraction = when { - latencyMs == null -> 0.05f - latencyMs == 9999L -> 1f - else -> (latencyMs / 3000f).coerceIn(0.1f, 1f) + val barColor = when (state) { + is ConnectionTestState.Connected -> AccentNeonGreen + is ConnectionTestState.Failed, is ConnectionTestState.ConfigMissing -> AccentRed + is ConnectionTestState.Testing -> AccentCyan + else -> BorderColor } Card( @@ -168,28 +206,18 @@ fun ProviderLatencyRow(providerName: String, latencyMs: Long?) { fontWeight = FontWeight.Bold, color = TextPrimary ) - Text( - text = when { - latencyMs == null -> "—" - latencyMs == 9999L -> "Offline" - else -> "$latencyMs ms" - }, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - color = barColor, - fontFamily = FontFamily.Monospace - ) + TextButton(onClick = onTest) { + Text("Test", fontSize = 11.sp) + } } Spacer(modifier = Modifier.height(4.dp)) Text( - text = ratingText, + text = statusText, fontSize = 10.sp, color = TextSecondary, fontFamily = FontFamily.Monospace ) Spacer(modifier = Modifier.height(10.dp)) - - // Draw custom colored bar Box( modifier = Modifier .fillMaxWidth() @@ -197,6 +225,13 @@ fun ProviderLatencyRow(providerName: String, latencyMs: Long?) { .clip(RoundedCornerShape(4.dp)) .background(BorderColor) ) { + val fraction = when (state) { + is ConnectionTestState.Connected -> + (state.latencyMs / 3000f).coerceIn(0.1f, 1f) + is ConnectionTestState.Failed, is ConnectionTestState.ConfigMissing -> 1f + is ConnectionTestState.Testing -> 0.35f + else -> 0.05f + } Box( modifier = Modifier .fillMaxHeight() @@ -208,3 +243,16 @@ fun ProviderLatencyRow(providerName: String, latencyMs: Long?) { } } } + +private fun connectionFailureLabel(error: LLMError): String = when (error) { + LLMError.AuthMissing -> "Key required" + LLMError.AuthInvalid -> "Invalid key" + LLMError.QuotaExhausted -> "Quota exhausted" + LLMError.RateLimited -> "Rate limited" + LLMError.ModelUnavailable -> "Model unavailable" + LLMError.RequestInvalid -> "Invalid request" + LLMError.Network -> "Network error" + LLMError.ServerError -> "Server error" + LLMError.MalformedResponse -> "Malformed response" + LLMError.Unknown -> "Failed" +} diff --git a/app/src/main/java/com/opendroid/ai/ui/screens/ChatScreen.kt b/app/src/main/java/com/opendroid/ai/ui/screens/ChatScreen.kt index d368c33..9d93971 100644 --- a/app/src/main/java/com/opendroid/ai/ui/screens/ChatScreen.kt +++ b/app/src/main/java/com/opendroid/ai/ui/screens/ChatScreen.kt @@ -47,6 +47,11 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import com.opendroid.ai.core.agent.AgentState import com.opendroid.ai.core.agent.AutoApprovalPolicy +import com.opendroid.ai.core.agent.ChatErrorPrimaryAction +import com.opendroid.ai.core.agent.ChatErrorUiState +import com.opendroid.ai.core.agent.guidance +import com.opendroid.ai.core.agent.primaryAction +import com.opendroid.ai.core.agent.title import com.opendroid.ai.core.voice.SpeechRecognitionEngine import com.opendroid.ai.data.models.AutoMode import com.opendroid.ai.data.models.ChatMessage @@ -56,6 +61,7 @@ import com.opendroid.ai.data.repository.ChatSession import com.opendroid.ai.ui.components.ContactPickerCard import com.opendroid.ai.ui.theme.* import com.opendroid.ai.ui.viewmodel.ChatViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import java.text.SimpleDateFormat @@ -76,6 +82,7 @@ fun ChatScreen( // not wherever the user has navigated to) must never be displayed as if it were // happening here. See ChatViewModel.visibleAgentState. val visibleAgentState by viewModel.visibleAgentState.collectAsState() + val chatError by viewModel.chatError.collectAsState() // Id of whichever chat (if any) has a task actively running, regardless of which // chat is currently displayed - drives the chat-picker's "still running" indicator. val runningSessionId by viewModel.runningSessionId.collectAsState() @@ -376,6 +383,35 @@ fun ChatScreen( ThinkingBubble() } } + + chatError?.let { error -> + item(key = "chat-error-${error.requestId}-${error.runId}") { + ChatErrorRecoveryCard( + error = error, + onPrimary = { + when (error.primaryAction()) { + ChatErrorPrimaryAction.RETRY -> + viewModel.retryAfterChatError(context) + ChatErrorPrimaryAction.EDIT_MESSAGE -> { + // Edit the exact message the error is about; + // fall back to the last user message only if + // the requestId no longer resolves to one. + val target = history.firstOrNull { + it.id == error.requestId && + it.sender == ChatMessage.Sender.USER + } ?: history.lastOrNull { + it.sender == ChatMessage.Sender.USER + } + target?.let { startEditingMessage(it) } + viewModel.dismissChatError() + } + else -> viewModel.dismissChatError() + } + }, + onDismiss = { viewModel.dismissChatError() } + ) + } + } } // If agent proposed a plan for THIS chat, show a modal prompt to approve or @@ -697,7 +733,7 @@ fun ChatBubble( } if (matches.isNotEmpty()) { - // Extract query from text ("Which 'dad' do you mean?" → "dad") + // Extract query from text ("Which 'dad' do you mean?" ? "dad") val query = Regex("Which '(.*?)'").find(message.text)?.groupValues?.getOrNull(1) ?: "contact" ContactPickerCard( @@ -1078,3 +1114,122 @@ fun VoiceWaveform(text: String, modifier: Modifier = Modifier) { ) } } + +@Composable +private fun ChatErrorRecoveryCard( + error: ChatErrorUiState, + onPrimary: () -> Unit, + onDismiss: () -> Unit +) { + var detailsExpanded by remember { mutableStateOf(false) } + // Countdown for rate-limited errors: while the provider's retry-after window is + // open, the Retry button is disabled and the remaining seconds tick down here. + val phase = error.phase + var waitSecondsLeft by remember(phase) { + mutableStateOf( + if (phase is ChatErrorUiState.Phase.WaitingUntil) { + ((phase.epochMillis - System.currentTimeMillis()) / 1000L).coerceAtLeast(0L) + } else { + 0L + } + ) + } + if (phase is ChatErrorUiState.Phase.WaitingUntil) { + LaunchedEffect(phase) { + while (true) { + val remainingMillis = phase.epochMillis - System.currentTimeMillis() + waitSecondsLeft = (remainingMillis / 1000L).coerceAtLeast(0L) + if (remainingMillis <= 0L) break + delay(1000L) + } + } + } + val retryHeld = phase is ChatErrorUiState.Phase.Retrying || + (phase is ChatErrorUiState.Phase.WaitingUntil && waitSecondsLeft > 0L) + val actionLabel = when (error.primaryAction()) { + ChatErrorPrimaryAction.OPEN_SETTINGS -> "Open Settings" + ChatErrorPrimaryAction.CHOOSE_PROVIDER -> "Choose provider" + ChatErrorPrimaryAction.CHOOSE_MODEL -> "Choose model" + ChatErrorPrimaryAction.EDIT_MESSAGE -> "Edit message" + ChatErrorPrimaryAction.RETRY -> "Retry" + ChatErrorPrimaryAction.NONE -> null + } + Card( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, AccentRed.copy(alpha = 0.5f), RoundedCornerShape(12.dp)), + colors = CardDefaults.cardColors(containerColor = CardBackground), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Warning, contentDescription = null, tint = AccentRed) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = error.title(), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + if (error.partialMessageId != null) { + Text( + text = "Incomplete response", + color = AccentCyan, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + Text(text = error.guidance(), color = TextSecondary, fontSize = 13.sp) + if (phase is ChatErrorUiState.Phase.WaitingUntil && waitSecondsLeft > 0L) { + Text( + text = "Retry available in ${waitSecondsLeft}s", + color = TextSecondary, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (actionLabel != null) { + Button( + onClick = onPrimary, + enabled = !(retryHeld && error.primaryAction() == ChatErrorPrimaryAction.RETRY), + colors = ButtonDefaults.buttonColors( + containerColor = AccentNeonGreen, + contentColor = DarkBackground + ), + modifier = Modifier.heightIn(min = 48.dp) + ) { + Text(actionLabel) + } + } + TextButton(onClick = { detailsExpanded = !detailsExpanded }) { + Text(if (detailsExpanded) "Hide details" else "Technical details") + } + TextButton(onClick = onDismiss) { Text("Dismiss") } + } + if (detailsExpanded) { + val detail = buildString { + append(error.category.code) + append(" · ") + append(error.provider) + error.httpStatus?.let { append(" · HTTP "); append(it) } + error.model.takeIf { it.isNotBlank() }?.let { append(" · "); append(it) } + error.redactedDetail?.toString()?.takeIf { it.isNotBlank() }?.let { + append(" · ") + append(it) + } + } + Text( + text = detail, + color = TextSecondary, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } +} diff --git a/app/src/main/java/com/opendroid/ai/ui/screens/CrashLogScreen.kt b/app/src/main/java/com/opendroid/ai/ui/screens/CrashLogScreen.kt index 17611b5..3d46882 100644 --- a/app/src/main/java/com/opendroid/ai/ui/screens/CrashLogScreen.kt +++ b/app/src/main/java/com/opendroid/ai/ui/screens/CrashLogScreen.kt @@ -243,8 +243,8 @@ private fun CrashCard( Spacer(modifier = Modifier.height(6.dp)) Text( - text = "v${crash.appVersionName} · Android ${crash.androidRelease} · " + - "${crash.deviceManufacturer} ${crash.deviceModel} · ${crash.threadName}", + text = "v${crash.device.appVersionName} · Android ${crash.device.androidRelease} · " + + "${crash.device.deviceManufacturer} ${crash.device.deviceModel} · ${crash.threadName}", fontSize = 11.sp, color = themeColors.textSecondary.copy(alpha = 0.7f) ) diff --git a/app/src/main/java/com/opendroid/ai/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/opendroid/ai/ui/screens/PermissionsScreen.kt index 770eee6..574e254 100644 --- a/app/src/main/java/com/opendroid/ai/ui/screens/PermissionsScreen.kt +++ b/app/src/main/java/com/opendroid/ai/ui/screens/PermissionsScreen.kt @@ -1,44 +1,103 @@ package com.opendroid.ai.ui.screens -import android.Manifest -import android.os.Build +import android.content.ActivityNotFoundException +import android.content.ComponentName import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment import android.provider.Settings +import android.text.TextUtils +import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import com.opendroid.ai.ui.theme.* +import com.opendroid.ai.accessibility.OpenDroidAccessibilityService +import com.opendroid.ai.core.permissions.CardStatus +import com.opendroid.ai.core.permissions.GrantAllState +import com.opendroid.ai.core.permissions.PermissionAskedStore +import com.opendroid.ai.core.permissions.PermissionCardId +import com.opendroid.ai.core.permissions.PermissionsSnapshot +import com.opendroid.ai.core.permissions.allRuntimePermissions +import com.opendroid.ai.core.permissions.allVisibleRequirementsHeld +import com.opendroid.ai.core.permissions.cardActionEnabled +import com.opendroid.ai.core.permissions.cardButtonLabel +import com.opendroid.ai.core.permissions.cardStatus +import com.opendroid.ai.core.permissions.cardStatusHasError +import com.opendroid.ai.core.permissions.cardStatusLine +import com.opendroid.ai.core.permissions.grantAllButton +import com.opendroid.ai.core.permissions.isBlocked +import com.opendroid.ai.core.permissions.requestPlan +import com.opendroid.ai.core.permissions.runtimePermissions +import com.opendroid.ai.core.permissions.summaryHasBlocked +import com.opendroid.ai.core.permissions.summaryLine +import com.opendroid.ai.core.permissions.visibleCards +import com.opendroid.ai.ui.theme.AccentNeonGreen +import com.opendroid.ai.ui.theme.AccentRed +import com.opendroid.ai.ui.theme.BorderColor +import com.opendroid.ai.ui.theme.CardBackground +import com.opendroid.ai.ui.theme.DarkBackground +import com.opendroid.ai.ui.theme.TextPrimary +import com.opendroid.ai.ui.theme.TextSecondary -/** - * Settings entry point for the permissions panel. Reachable from Settings so a user who - * declined a permission during onboarding (or wants to grant a new one, e.g. Accessibility) - * has an in-app way back in, without needing to dig through system App Info. - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun PermissionsScreen( - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, ) { Scaffold( topBar = { @@ -50,86 +109,126 @@ fun PermissionsScreen( fontWeight = FontWeight.Bold, color = AccentNeonGreen, fontSize = 20.sp, - letterSpacing = 2.sp + letterSpacing = 2.sp, ) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( - imageVector = Icons.Default.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", - tint = AccentNeonGreen + tint = AccentNeonGreen, ) } }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = DarkBackground) + colors = TopAppBarDefaults.topAppBarColors(containerColor = DarkBackground), ) }, - containerColor = DarkBackground + containerColor = DarkBackground, ) { padding -> PermissionsPanel(padding = padding, onFinished = null) } } /** - * Stateful permissions panel shared by onboarding's PERMISSIONS stage and the Settings - * "Permissions" destination ([PermissionsScreen]). Owns live granted/not-granted state for - * microphone, location, SMS/telephony, contacts/calendar, camera, notifications, storage, - * write-settings, and accessibility, and refreshes that state whenever the screen resumes - * (e.g. returning from the system Settings app after toggling Accessibility or Storage access). - * - * @param onFinished non-null only for the onboarding flow: when set, a "Proceed to OpenDroid - * Agent" button is rendered and invokes this to mark onboarding complete. When null (the - * Settings entry point) no finish button is rendered - the caller supplies its own back - * affordance instead. + * Shared onboarding and Settings permissions surface. Runtime grants are always read back from + * Android; manual Settings capabilities are probed again on every resume. */ @Composable fun PermissionsPanel( padding: PaddingValues, - onFinished: (() -> Unit)? + onFinished: (() -> Unit)?, ) { val context = LocalContext.current - - // Core permissions status state - var recordAudioGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.RECORD_AUDIO)) } - var locationGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.ACCESS_FINE_LOCATION)) } - var smsGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.SEND_SMS)) } - var phoneGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.CALL_PHONE)) } - var contactsGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.READ_CONTACTS)) } - var calendarGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.READ_CALENDAR)) } - var cameraGranted by remember { mutableStateOf(checkPerm(context, Manifest.permission.CAMERA)) } - var notificationsGranted by remember { + val lifecycleOwner = LocalLifecycleOwner.current + val sdkInt = remember { Build.VERSION.SDK_INT } + // Saveable so an activity recreation while the Android permission dialog is up + // (rotation, process death behind the dialog) doesn't forget which batch is + // outstanding or that a grant-all round-trip is in flight. + var pendingRequest by rememberSaveable(stateSaver = pendingPermissionRequestSaver) { + mutableStateOf(null) + } + var showGrantAllConfirm by rememberSaveable { mutableStateOf(false) } + var snapshot by remember(context, sdkInt) { mutableStateOf( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkPerm(context, Manifest.permission.POST_NOTIFICATIONS) - } else { - true - } + readPermissionsSnapshot( + context = context, + sdkInt = sdkInt, + grantAll = if (pendingRequest?.isGrantAll == true) { + GrantAllState.InFlight + } else { + GrantAllState.Idle + }, + appInfoOffered = emptySet(), + ), ) } - var storageGranted by remember { mutableStateOf(hasStoragePermission(context)) } - var accessibilityGranted by remember { mutableStateOf(isAccessibilityServiceEnabled(context)) } - var writeSettingsGranted by remember { mutableStateOf(Settings.System.canWrite(context)) } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { + val runtimeLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { + val pending = pendingRequest + val current = snapshot + // Persist "asked" only once Android has actually shown (and resolved) the + // dialog - persisting before launch let a request that never came back count + // as asked, which flips still-unseen permissions straight to "blocked". + PermissionAskedStore.markAsked(context, pending?.permissions.orEmpty()) + val grantAllState = if (pending?.isGrantAll == true) { + GrantAllState.Returned(pending.permissions) + } else { + current.grantAll + } + var refreshed = readPermissionsSnapshot( + context = context, + sdkInt = sdkInt, + grantAll = grantAllState, + appInfoOffered = current.appInfoOffered, + ) + refreshed = refreshed.copy( + appInfoOffered = refreshed.appInfoOffered + + earnedAppInfoCards(refreshed, pending?.permissions.orEmpty()), + ) + snapshot = refreshed + pendingRequest = null + } + + fun launchRuntimePlan( + plan: List, + isGrantAll: Boolean, + ) { + if (plan.isEmpty()) return + + pendingRequest = PendingPermissionRequest( + permissions = plan.toSet(), + isGrantAll = isGrantAll, + ) + snapshot = snapshot.copy( + asked = snapshot.asked + plan, + grantAll = if (isGrantAll) GrantAllState.InFlight else snapshot.grantAll, + ) + runtimeLauncher.launch(plan.toTypedArray()) + } + + LaunchedEffect(context, sdkInt) { + val current = snapshot + snapshot = readPermissionsSnapshot( + context = context, + sdkInt = sdkInt, + grantAll = current.grantAll, + appInfoOffered = current.appInfoOffered, + ) + } + + DisposableEffect(lifecycleOwner, context, sdkInt) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - accessibilityGranted = isAccessibilityServiceEnabled(context) - recordAudioGranted = checkPerm(context, Manifest.permission.RECORD_AUDIO) - locationGranted = checkPerm(context, Manifest.permission.ACCESS_FINE_LOCATION) - smsGranted = checkPerm(context, Manifest.permission.SEND_SMS) - phoneGranted = checkPerm(context, Manifest.permission.CALL_PHONE) - contactsGranted = checkPerm(context, Manifest.permission.READ_CONTACTS) - calendarGranted = checkPerm(context, Manifest.permission.READ_CALENDAR) - cameraGranted = checkPerm(context, Manifest.permission.CAMERA) - notificationsGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkPerm(context, Manifest.permission.POST_NOTIFICATIONS) - } else { - true - } - storageGranted = hasStoragePermission(context) - writeSettingsGranted = Settings.System.canWrite(context) + val current = snapshot + snapshot = readPermissionsSnapshot( + context = context, + sdkInt = sdkInt, + grantAll = current.grantAll, + appInfoOffered = current.appInfoOffered, + ) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -138,305 +237,508 @@ fun PermissionsPanel( } } - val audioLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - recordAudioGranted = it - } - val locationLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - locationGranted = it - } - val smsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - smsGranted = it - } - val phoneLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - phoneGranted = it - } - val contactsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - contactsGranted = it - } - val calendarLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - calendarGranted = it - } - val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - cameraGranted = it - } - val notificationsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { - notificationsGranted = it - } - val legacyStorageLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - storageGranted = permissions[Manifest.permission.READ_EXTERNAL_STORAGE] == true && - permissions[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true + if (showGrantAllConfirm) { + // Mirrors the Benchmark "Test all configured?" confirmation: one explicit + // Cancel/Continue gate before the single batched Android dialog fires. + val pendingGroups = visibleCards(sdkInt) + .filter { card -> requestPlan(snapshot.granted, sdkInt, card).isNotEmpty() } + .map { card -> cardTitle(card) } + AlertDialog( + onDismissRequest = { showGrantAllConfirm = false }, + title = { Text("Grant all permissions?") }, + text = { + Text( + "Android will ask for the remaining runtime permissions in one batch:\n\n" + + pendingGroups.joinToString("\n") { group -> "• $group" }, + ) + }, + confirmButton = { + TextButton( + onClick = { + showGrantAllConfirm = false + launchRuntimePlan( + plan = requestPlan(snapshot.granted, sdkInt), + isGrantAll = true, + ) + }, + ) { Text("Continue") } + }, + dismissButton = { + TextButton(onClick = { showGrantAllConfirm = false }) { Text("Cancel") } + }, + ) } PermissionsPanelContent( padding = padding, - recordAudioGranted = recordAudioGranted, - locationGranted = locationGranted, - smsGranted = smsGranted, - phoneGranted = phoneGranted, - contactsGranted = contactsGranted, - calendarGranted = calendarGranted, - cameraGranted = cameraGranted, - notificationsGranted = notificationsGranted, - storageGranted = storageGranted, - accessibilityGranted = accessibilityGranted, - writeSettingsGranted = writeSettingsGranted, - onAudioGrant = { audioLauncher.launch(Manifest.permission.RECORD_AUDIO) }, - onLocationGrant = { locationLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }, - onSmsPhoneGrant = { - smsLauncher.launch(Manifest.permission.SEND_SMS) - phoneLauncher.launch(Manifest.permission.CALL_PHONE) - }, - onContactsCalendarGrant = { - contactsLauncher.launch(Manifest.permission.READ_CONTACTS) - calendarLauncher.launch(Manifest.permission.READ_CALENDAR) - }, - onCameraGrant = { cameraLauncher.launch(Manifest.permission.CAMERA) }, - onNotificationsGrant = { notificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }, - onWriteSettingsGrant = { - val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { - data = android.net.Uri.parse("package:${context.packageName}") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - }, - onStorageGrant = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { - data = android.net.Uri.parse("package:${context.packageName}") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } catch (e: Exception) { - val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } - } else { - legacyStorageLauncher.launch( - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) - ) - } - }, - onAccessibilityGrant = { - context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) + snapshot = snapshot, + onGrantAll = { showGrantAllConfirm = true }, + onRuntimeCard = { card -> + launchRuntimePlan( + plan = requestPlan(snapshot.granted, sdkInt, card), + isGrantAll = false, + ) }, - onFinished = onFinished + onManualCard = { card -> openManualSettings(context, sdkInt, card) }, + onAppInfo = { card -> openAppInfo(context, card) }, + onFinished = onFinished, ) } @Composable -fun PermissionsPanelContent( +private fun PermissionsPanelContent( padding: PaddingValues, - recordAudioGranted: Boolean, - locationGranted: Boolean, - smsGranted: Boolean, - phoneGranted: Boolean, - contactsGranted: Boolean, - calendarGranted: Boolean, - cameraGranted: Boolean, - notificationsGranted: Boolean, - storageGranted: Boolean, - accessibilityGranted: Boolean, - writeSettingsGranted: Boolean, - onAudioGrant: () -> Unit, - onLocationGrant: () -> Unit, - onSmsPhoneGrant: () -> Unit, - onContactsCalendarGrant: () -> Unit, - onCameraGrant: () -> Unit, - onNotificationsGrant: () -> Unit, - onWriteSettingsGrant: () -> Unit, - onStorageGrant: () -> Unit, - onAccessibilityGrant: () -> Unit, - onFinished: (() -> Unit)? + snapshot: PermissionsSnapshot, + onGrantAll: () -> Unit, + onRuntimeCard: (PermissionCardId) -> Unit, + onManualCard: (PermissionCardId) -> Unit, + onAppInfo: (PermissionCardId) -> Unit, + onFinished: (() -> Unit)?, ) { + val grantAll = grantAllButton(snapshot) + val cards = visibleCards(snapshot.sdkInt) + val firstManualIndex = cards.indexOfFirst { card -> + runtimePermissions(card, snapshot.sdkInt).isEmpty() + } + val allRequirementsHeld = allVisibleRequirementsHeld(snapshot) + Column( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(24.dp) + .padding(24.dp), ) { Text( text = "Required Permissions", fontSize = 22.sp, fontWeight = FontWeight.Bold, - color = TextPrimary + color = TextPrimary, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "Configure permissions below to enable full autonomous features.", fontSize = 13.sp, - color = TextSecondary + color = TextSecondary, + ) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onGrantAll, + enabled = grantAll.enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AccentNeonGreen, + contentColor = DarkBackground, + disabledContainerColor = BorderColor, + disabledContentColor = TextSecondary, + ), + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = grantAll.label, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = summaryLine(snapshot), + fontSize = 12.sp, + color = if (summaryHasBlocked(snapshot)) AccentRed else TextSecondary, ) Spacer(modifier = Modifier.height(16.dp)) LazyColumn( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { - PermissionCard( - title = "Microphone", - desc = "Needed for wake word and speech recognition.", - granted = recordAudioGranted, - onGrant = onAudioGrant - ) - } - item { - PermissionCard( - title = "Location", - desc = "Needed to fetch weather, directions, and maps.", - granted = locationGranted, - onGrant = onLocationGrant - ) - } - item { - PermissionCard( - title = "SMS & Telephony", - desc = "Needed to read and send messages, and place calls.", - granted = smsGranted && phoneGranted, - onGrant = onSmsPhoneGrant - ) - } - item { - PermissionCard( - title = "Contacts & Calendar", - desc = "Needed to resolve recipient names and manage events.", - granted = contactsGranted && calendarGranted, - onGrant = onContactsCalendarGrant - ) - } - item { - PermissionCard( - title = "Camera", - desc = "Needed for image input and vision capabilities.", - granted = cameraGranted, - onGrant = onCameraGrant - ) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - item { + cards.forEachIndexed { index, card -> + if (index == firstManualIndex) { + item(key = "manual-settings-header") { + ManualSettingsHeader() + } + } + item(key = card.name) { + val runtime = runtimePermissions(card, snapshot.sdkInt) + val status = cardStatus( + card = card, + granted = snapshot.granted, + sdkInt = snapshot.sdkInt, + manualHeld = card in snapshot.manualHeld, + ) + val appInfoAction = card in snapshot.appInfoOffered && + status in setOf(CardStatus.MISSING, CardStatus.PARTIAL) PermissionCard( - title = "Notifications", - desc = "Needed to post system notifications and service status.", - granted = notificationsGranted, - onGrant = onNotificationsGrant + title = cardTitle(card), + description = cardDescription(card), + statusLine = cardStatusLine(card, snapshot), + statusHasError = cardStatusHasError(card, snapshot), + buttonLabel = cardButtonLabel(card, snapshot), + buttonEnabled = cardActionEnabled(card, snapshot), + buttonHasError = appInfoAction, + onAction = { + when { + appInfoAction -> onAppInfo(card) + runtime.isNotEmpty() -> onRuntimeCard(card) + else -> onManualCard(card) + } + }, ) } } - item { - PermissionCard( - title = "Storage / Files Access", - desc = "Needed for agent to list, read, write, and delete files.", - granted = storageGranted, - onGrant = onStorageGrant - ) - } - item { - PermissionCard( - title = "System Settings Control", - desc = "Needed to adjust brightness, volume, and other system settings.", - granted = writeSettingsGranted, - onGrant = onWriteSettingsGrant - ) - } - item { - PermissionCard( - title = "Accessibility Service", - desc = "Enables full agent screen automation (clicks & inputs).", - granted = accessibilityGranted, - onGrant = onAccessibilityGrant - ) - } } if (onFinished != null) { Spacer(modifier = Modifier.height(16.dp)) - + if (!allRequirementsHeld) { + Text( + text = "You can continue now and grant the rest later in Settings → Permissions.", + fontSize = 12.sp, + color = TextSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + } Button( onClick = onFinished, - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = ButtonDefaults.buttonColors(containerColor = AccentNeonGreen, contentColor = DarkBackground), - shape = RoundedCornerShape(8.dp) + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (allRequirementsHeld) AccentNeonGreen else CardBackground, + contentColor = if (allRequirementsHeld) DarkBackground else TextPrimary, + ), + border = if (allRequirementsHeld) null else BorderStroke(1.dp, BorderColor), + shape = RoundedCornerShape(8.dp), ) { - Text("Proceed to OpenDroid Agent", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text( + text = "Proceed to OpenDroid Agent", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) } } } } @Composable -fun PermissionCard( +private fun ManualSettingsHeader() { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "NEEDS A TRIP TO SETTINGS", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + letterSpacing = 1.sp, + color = AccentNeonGreen, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Android does not allow these to be granted from inside an app. " + + "\"Grant all permissions\" cannot cover them → open each one yourself.", + fontSize = 12.sp, + color = TextSecondary, + ) + } +} + +@Composable +private fun PermissionCard( title: String, - desc: String, - granted: Boolean, - onGrant: () -> Unit + description: String, + statusLine: String, + statusHasError: Boolean, + buttonLabel: String, + buttonEnabled: Boolean, + buttonHasError: Boolean, + onAction: () -> Unit, ) { + val semanticsModifier = if (statusLine.isBlank()) { + Modifier + } else { + Modifier.semantics { + stateDescription = statusLine + } + } Row( modifier = Modifier .fillMaxWidth() + .then(semanticsModifier) .clip(RoundedCornerShape(12.dp)) .background(CardBackground) .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = TextPrimary) + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary, + ) Spacer(modifier = Modifier.height(4.dp)) - Text(desc, fontSize = 12.sp, color = TextSecondary) + Text( + text = description, + fontSize = 12.sp, + color = TextSecondary, + ) + if (statusLine.isNotBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = statusLine, + fontSize = 12.sp, + fontWeight = if (statusHasError) FontWeight.SemiBold else FontWeight.Normal, + color = if (statusHasError) AccentRed else TextSecondary, + ) + } } Spacer(modifier = Modifier.width(16.dp)) Button( - onClick = onGrant, + onClick = onAction, + enabled = buttonEnabled, colors = ButtonDefaults.buttonColors( - containerColor = if (granted) BorderColor else AccentNeonGreen, - contentColor = if (granted) TextSecondary else DarkBackground + containerColor = if (buttonHasError) AccentRed else AccentNeonGreen, + contentColor = if (buttonHasError) TextPrimary else DarkBackground, + disabledContainerColor = BorderColor, + disabledContentColor = TextSecondary, ), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { - Text(if (granted) "Granted" else "Grant", fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text( + text = buttonLabel, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} + +private data class PendingPermissionRequest( + val permissions: Set, + val isGrantAll: Boolean, +) + +/** + * Saver for [PendingPermissionRequest] so the outstanding batch survives activity + * recreation while the Android permission dialog is showing. Encoded as + * [isGrantAll, permission...]; an empty list encodes null. + */ +private val pendingPermissionRequestSaver = listSaver( + save = { value -> + if (value == null) { + emptyList() + } else { + listOf(value.isGrantAll) + value.permissions.toList() + } + }, + restore = { saved -> + if (saved.isEmpty()) { + null + } else { + PendingPermissionRequest( + permissions = saved.drop(1).filterIsInstance().toSet(), + isGrantAll = saved.first() == true, + ) + } + }, +) + +private fun readPermissionsSnapshot( + context: Context, + sdkInt: Int, + grantAll: GrantAllState, + appInfoOffered: Set, +): PermissionsSnapshot { + val runtimePermissions = allRuntimePermissions(sdkInt) + val granted = runtimePermissions.filterTo(mutableSetOf()) { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + val asked = PermissionAskedStore.asked(context) + val activity = context.findActivity() + val rationale = runtimePermissions.associateWith { permission -> + activity?.let { + ActivityCompat.shouldShowRequestPermissionRationale(it, permission) + } + } + val manualHeld = buildSet { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) { + add(PermissionCardId.STORAGE) + } + if (Settings.System.canWrite(context)) { + add(PermissionCardId.WRITE_SETTINGS) + } + if (isAccessibilityServiceEnabled(context)) { + add(PermissionCardId.ACCESSIBILITY) + } + } + val stillOffered = appInfoOffered.filterTo(mutableSetOf()) { card -> + cardStatus( + card = card, + granted = granted, + sdkInt = sdkInt, + manualHeld = card in manualHeld, + ) in setOf(CardStatus.MISSING, CardStatus.PARTIAL) + } + + return PermissionsSnapshot( + sdkInt = sdkInt, + granted = granted, + asked = asked, + rationale = rationale, + manualHeld = manualHeld, + grantAll = grantAll, + appInfoOffered = stillOffered, + ) +} + +private fun earnedAppInfoCards( + snapshot: PermissionsSnapshot, + attempted: Set, +): Set = visibleCards(snapshot.sdkInt) + .filterTo(mutableSetOf()) { card -> + runtimePermissions(card, snapshot.sdkInt).any { permission -> + permission in attempted && + isBlocked( + permission = permission, + granted = permission in snapshot.granted, + asked = permission in snapshot.asked, + showRationale = snapshot.rationale[permission], + ) } } + +private fun openManualSettings( + context: Context, + sdkInt: Int, + card: PermissionCardId, +) { + when (card) { + PermissionCardId.STORAGE -> { + if (sdkInt < 30) return + try { + context.startActivity( + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } catch (_: ActivityNotFoundException) { + context.startActivity( + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + } + + PermissionCardId.WRITE_SETTINGS -> context.startActivity( + Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + + PermissionCardId.ACCESSIBILITY -> context.startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + + else -> Unit + } } -private fun checkPerm(context: Context, permission: String): Boolean { - return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +private fun openAppInfo( + context: Context, + card: PermissionCardId, +) { + if (card == PermissionCardId.NOTIFICATIONS) { + try { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + return + } catch (_: ActivityNotFoundException) { + // The explicit app-details fallback remains user initiated. + } + } + + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) } -private fun hasStoragePermission(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - android.os.Environment.isExternalStorageManager() - } else { - checkPerm(context, Manifest.permission.READ_EXTERNAL_STORAGE) && - checkPerm(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) +fun Context.findActivity(): ComponentActivity? { + var current: Context? = this + while (current != null) { + when (current) { + is ComponentActivity -> return current + is ContextWrapper -> { + val base = current.baseContext + current = if (base === current) null else base + } + + else -> current = null + } } + return null } private fun isAccessibilityServiceEnabled(context: Context): Boolean { - if (com.opendroid.ai.accessibility.OpenDroidAccessibilityService.getInstance() != null) { + if (OpenDroidAccessibilityService.getInstance() != null) { return true } - val expectedComponentName = android.content.ComponentName(context, com.opendroid.ai.accessibility.OpenDroidAccessibilityService::class.java).flattenToString() + val expectedComponentName = + ComponentName(context, OpenDroidAccessibilityService::class.java).flattenToString() val enabledServices = Settings.Secure.getString( context.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, ) ?: return false - val colonSplitter = android.text.TextUtils.SimpleStringSplitter(':') - colonSplitter.setString(enabledServices) - while (colonSplitter.hasNext()) { - val componentNameString = colonSplitter.next() - if (componentNameString.equals(expectedComponentName, ignoreCase = true)) { + val splitter = TextUtils.SimpleStringSplitter(':') + splitter.setString(enabledServices) + while (splitter.hasNext()) { + if (splitter.next().equals(expectedComponentName, ignoreCase = true)) { return true } } return false } + +private fun cardTitle(card: PermissionCardId): String = when (card) { + PermissionCardId.MICROPHONE -> "Microphone" + PermissionCardId.LOCATION -> "Location" + PermissionCardId.SMS_TELEPHONY -> "SMS & Telephony" + PermissionCardId.CONTACTS_CALENDAR -> "Contacts & Calendar" + PermissionCardId.CAMERA -> "Camera" + PermissionCardId.NOTIFICATIONS -> "Notifications" + PermissionCardId.STORAGE -> "Storage / Files Access" + PermissionCardId.WRITE_SETTINGS -> "System Settings Control" + PermissionCardId.ACCESSIBILITY -> "Accessibility Service" +} + +private fun cardDescription(card: PermissionCardId): String = when (card) { + PermissionCardId.MICROPHONE -> "Needed for wake word and speech recognition." + PermissionCardId.LOCATION -> "Needed to fetch weather, directions, and maps." + PermissionCardId.SMS_TELEPHONY -> "Needed to read and send messages, and place calls." + PermissionCardId.CONTACTS_CALENDAR -> + "Needed to resolve recipient names and manage events." + + PermissionCardId.CAMERA -> "Needed for image input and vision capabilities." + PermissionCardId.NOTIFICATIONS -> + "Needed to post system notifications and service status." + + PermissionCardId.STORAGE -> "Needed for agent to list, read, write, and delete files." + PermissionCardId.WRITE_SETTINGS -> + "Needed to adjust brightness, volume, and other system settings." + + PermissionCardId.ACCESSIBILITY -> + "Enables full agent screen automation (clicks & inputs)." +} diff --git a/app/src/main/java/com/opendroid/ai/ui/screens/SettingsScreen.kt b/app/src/main/java/com/opendroid/ai/ui/screens/SettingsScreen.kt index e374d38..eef3876 100644 --- a/app/src/main/java/com/opendroid/ai/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/opendroid/ai/ui/screens/SettingsScreen.kt @@ -39,6 +39,8 @@ import com.opendroid.ai.data.models.effectiveGrantedActions import com.opendroid.ai.data.models.resolvedAutoMode import com.opendroid.ai.core.llm.OnDeviceModelRegistry import com.opendroid.ai.core.llm.OnDeviceBackend +import com.opendroid.ai.core.llm.ConnectionTestState +import com.opendroid.ai.core.llm.error.LLMError import com.google.mlkit.genai.prompt.* import com.google.mlkit.genai.common.FeatureStatus import com.opendroid.ai.ui.theme.* @@ -75,6 +77,7 @@ fun SettingsScreen( modifier: Modifier = Modifier ) { val config by viewModel.llmConfig.collectAsState() + val connectionResults by viewModel.connectionResults.collectAsState() val dbModels by viewModel.allModels.collectAsState() val storageInfo by viewModel.storageInfo.collectAsState() val hfToken by viewModel.huggingFaceToken.collectAsState() @@ -1294,11 +1297,35 @@ fun SettingsScreen( val inputProviders = providers.filter { it != "Ollama" && it != "On-Device AI" } inputProviders.forEach { providerName -> val keyVal = config.apiKeys[providerName] ?: "" + val connectionState = connectionResults[providerName] SecureApiKeyField( value = keyVal, onValueChange = { viewModel.updateApiKey(providerName, it) }, label = "$providerName API Key" ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = connectionStatusLabel(connectionState), + fontSize = 10.sp, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { viewModel.testConnection(providerName) } + ) { + Text("Test connection", fontSize = 11.sp) + } + } + Text( + text = "Sends one minimal request to $providerName; provider charges may apply.", + fontSize = 10.sp, + color = TextSecondary + ) } } } @@ -2120,6 +2147,25 @@ fun SettingsScreen( } } +private fun connectionStatusLabel(state: ConnectionTestState?): String = when (state) { + is ConnectionTestState.Testing -> "Testing…" + is ConnectionTestState.Connected -> + "Connected with ${state.model} · ${state.latencyMs} ms" + is ConnectionTestState.Failed -> when (state.error) { + LLMError.AuthInvalid -> "Key rejected" + LLMError.AuthMissing -> "Key required" + LLMError.QuotaExhausted -> "Quota exhausted" + LLMError.RateLimited -> "Rate limited" + LLMError.Network -> "Network error" + else -> "Connection failed" + } + is ConnectionTestState.ConfigMissing -> when (state.reason) { + LLMError.AuthMissing -> "Key required" + else -> "Configuration required" + } + else -> "Not tested" +} + private fun formatBytes(bytes: Long): String { if (bytes <= 0) return "0 B" val units = arrayOf("B", "KB", "MB", "GB", "TB") diff --git a/app/src/main/java/com/opendroid/ai/ui/viewmodel/ChatViewModel.kt b/app/src/main/java/com/opendroid/ai/ui/viewmodel/ChatViewModel.kt index 5e908ff..eb15112 100644 --- a/app/src/main/java/com/opendroid/ai/ui/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/opendroid/ai/ui/viewmodel/ChatViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.opendroid.ai.core.agent.AgentLoop import com.opendroid.ai.core.agent.AgentState +import com.opendroid.ai.core.agent.ChatErrorPrimaryAction +import com.opendroid.ai.core.agent.ChatErrorUiState +import com.opendroid.ai.core.agent.primaryAction import com.opendroid.ai.data.models.AutoMode import com.opendroid.ai.data.models.ChatMessage import com.opendroid.ai.data.models.LLMConfig @@ -59,6 +62,42 @@ class ChatViewModel @Inject constructor( val agentState: StateFlow = agentLoop.agentState + /** + * [AgentLoop.chatError], but scoped to whichever chat is on screen - the same rule + * [visibleAgentState] applies to the shared agent state. An error raised by a task + * in chat A must never render its recovery card inside chat B; the underlying error + * stays published so switching back to its own chat shows it again. + */ + val chatError: StateFlow = combine( + agentLoop.chatError, sessions + ) { error, sessionList -> + val current = sessionList.firstOrNull { it.isCurrent }?.id + error?.takeIf { it.sessionId == current } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null + ) + + fun dismissChatError() { + agentLoop.dismissChatError() + } + + fun retryAfterChatError(context: Context) { + val error = agentLoop.chatError.value ?: return + when (error.primaryAction()) { + ChatErrorPrimaryAction.RETRY -> { + agentLoop.dismissChatError() + // Re-execute the request the error actually describes, in ITS session - + // never re-send the visible chat's last message, and never insert a + // duplicate user bubble: the original message is already persisted. + _taskSessionId.value = error.sessionId + agentLoop.retryRequest(error.requestId, error.sessionId, context) + } + else -> agentLoop.dismissChatError() + } + } + // Session the most recently started task was pinned to. Set once, right when a task // is kicked off (sendMessage for a genuinely new query, approvePlan, or // editAndResend's resend) - mirroring AgentLoop's own "resolve the session once, at diff --git a/app/src/main/java/com/opendroid/ai/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/opendroid/ai/ui/viewmodel/SettingsViewModel.kt index 96c17e7..b124acb 100644 --- a/app/src/main/java/com/opendroid/ai/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/opendroid/ai/ui/viewmodel/SettingsViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.opendroid.ai.data.models.AutoMode import com.opendroid.ai.data.models.LLMConfig import com.opendroid.ai.data.models.effectiveGrantedActions +import com.opendroid.ai.data.models.withActiveProvider +import com.opendroid.ai.data.models.withSelectedModel import com.opendroid.ai.data.repository.SettingsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow @@ -18,15 +20,24 @@ import kotlinx.coroutines.delay import javax.inject.Inject import com.opendroid.ai.core.llm.ClaudeModelCatalog +import com.opendroid.ai.core.llm.ConnectionTestPlanner +import com.opendroid.ai.core.llm.ConnectionTestState import com.opendroid.ai.core.llm.ImportLocalModelResult import com.opendroid.ai.core.llm.LLMRequest +import com.opendroid.ai.core.llm.ProviderCatalog import com.opendroid.ai.core.llm.ResponseFormat +import com.opendroid.ai.core.llm.RetryPolicy +import com.opendroid.ai.core.llm.error.SecretRegistry +import com.opendroid.ai.data.models.selectedModelFor import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.OkHttpClient import okhttp3.Request import dagger.Lazy import com.opendroid.ai.data.models.ChatMessage +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlin.coroutines.coroutineContext @HiltViewModel class SettingsViewModel @Inject constructor( @@ -57,6 +68,15 @@ class SettingsViewModel @Inject constructor( private val _modelsLoading = MutableStateFlow(false) val modelsLoading: StateFlow = _modelsLoading + private val _connectionResults = + MutableStateFlow>(emptyMap()) + val connectionResults: StateFlow> = _connectionResults.asStateFlow() + + private val _connectionBatchProgress = MutableStateFlow(null) + val connectionBatchProgress: StateFlow = + _connectionBatchProgress.asStateFlow() + + private var connectionTestJob: Job? = null private val apiKeyUpdateJobs = mutableMapOf() private var activeModelJob: Job? = null private var elevenLabsApiKeyJob: Job? = null @@ -239,30 +259,12 @@ class SettingsViewModel @Inject constructor( } fun updateActiveProvider(provider: String) { - val defaultModel = when (provider) { - "Google Gemini" -> "gemini-2.0-flash" - "OpenAI" -> "gpt-4o" - "Anthropic Claude" -> ClaudeModelCatalog.defaultModelId - "OpenRouter" -> "google/gemini-2.0-flash-exp:free" - "Groq" -> "llama-3.3-70b-specdec" - "Together AI" -> "meta-llama/Llama-3-70b-chat-hf" - "DeepSeek" -> "deepseek-chat" - "Cohere" -> "command-r-plus" - "Ollama" -> "llama3" - "Copilot API" -> "gpt-4o" - "Custom OpenAI Compatible" -> "gpt-4o" - "On-Device AI", - "Gemma 4 (On-device)" -> "gemma-4-on-device" - "Mistral AI" -> "mistral-large-latest" - else -> "gemini-2.0-flash" - } - // Normalize legacy name to the new unified name - val normalizedProvider = if (provider == "Gemma 4 (On-device)") "On-Device AI" else provider - _llmConfig.value = _llmConfig.value.copy(activeProvider = normalizedProvider, activeModel = defaultModel) + val updated = _llmConfig.value.withActiveProvider(provider) + _llmConfig.value = updated viewModelScope.launch { try { settingsRepository.updateConfig { current -> - current.copy(activeProvider = normalizedProvider, activeModel = defaultModel) + current.withActiveProvider(provider) } refreshModels(force = false) } catch (e: Exception) { @@ -272,13 +274,15 @@ class SettingsViewModel @Inject constructor( } fun updateActiveModel(model: String) { - _llmConfig.value = _llmConfig.value.copy(activeModel = model) + val provider = _llmConfig.value.activeProvider + val updated = _llmConfig.value.withSelectedModel(provider, model) + _llmConfig.value = updated activeModelJob?.cancel() activeModelJob = viewModelScope.launch { try { delay(500) settingsRepository.updateConfig { current -> - current.copy(activeModel = model) + current.withSelectedModel(current.activeProvider, model) } } catch (e: Exception) { if (e !is kotlinx.coroutines.CancellationException) { @@ -403,39 +407,127 @@ class SettingsViewModel @Inject constructor( } } - fun testProviderLatency(providerName: String) { - viewModelScope.launch { - try { - val factory = llmProviderFactory.get() - val provider = factory.getProviderByName(providerName) - if (provider.isAvailable()) { - val request = LLMRequest( - systemPrompt = "You are a speed test server. Respond with 'pong'.", - messages = listOf(ChatMessage(id = "1", text = "ping", sender = ChatMessage.Sender.USER)), - responseFormat = ResponseFormat.TEXT - ) - val response = provider.complete(request) - val updatedBenchmarks = _llmConfig.value.latencyBenchmarks.toMutableMap() - updatedBenchmarks[providerName] = response.latencyMs - _llmConfig.value = _llmConfig.value.copy(latencyBenchmarks = updatedBenchmarks) - settingsRepository.updateConfig { current -> - val currentBenchmarks = current.latencyBenchmarks.toMutableMap() - currentBenchmarks[providerName] = response.latencyMs - current.copy(latencyBenchmarks = currentBenchmarks) - } - } - } catch (e: Exception) { - // Keep the record but fail with high number - val updatedBenchmarks = _llmConfig.value.latencyBenchmarks.toMutableMap() - updatedBenchmarks[providerName] = 9999L - _llmConfig.value = _llmConfig.value.copy(latencyBenchmarks = updatedBenchmarks) - settingsRepository.updateConfig { current -> - val currentBenchmarks = current.latencyBenchmarks.toMutableMap() - currentBenchmarks[providerName] = 9999L - current.copy(latencyBenchmarks = currentBenchmarks) - } + fun testConnection(providerName: String) { + connectionTestJob?.cancel() + clearInFlightConnectionState() + connectionTestJob = viewModelScope.launch { + runConnectionTest(providerName, index = 1, total = 1) + } + } + + fun testAllConfigured() { + connectionTestJob?.cancel() + clearInFlightConnectionState() + connectionTestJob = viewModelScope.launch { + val snapshot = _llmConfig.value + val providers = ConnectionTestPlanner.configuredProviders(snapshot) + providers.forEachIndexed { index, providerName -> + if (!coroutineContext.isActive) return@launch + _connectionBatchProgress.value = ConnectionTestState.Testing( + provider = providerName, + index = index + 1, + total = providers.size + ) + runConnectionTest(providerName, index = index + 1, total = providers.size) } + _connectionBatchProgress.value = null + } + } + + fun cancelConnectionTests() { + connectionTestJob?.cancel() + connectionTestJob = null + clearInFlightConnectionState() + } + + /** + * Resets everything a cancelled test run would otherwise leave dangling: the batch + * progress banner ("Testing X of Y") and any provider row still stuck at Testing. + * Cancelled in-flight providers return to their terminal not-tested presentation + * rather than being mislabeled as failures. + */ + private fun clearInFlightConnectionState() { + _connectionBatchProgress.value = null + val results = _connectionResults.value + if (results.values.any { it is ConnectionTestState.Testing }) { + _connectionResults.value = results.filterValues { it !is ConnectionTestState.Testing } + } + } + + private suspend fun runConnectionTest(providerName: String, index: Int, total: Int) { + val provider = ProviderCatalog.canonicalName(providerName) + val snapshot = _llmConfig.value + val model = snapshot.selectedModelFor(provider) + val now = System.currentTimeMillis() + val gap = ConnectionTestPlanner.configurationGap(snapshot, provider) + if (gap != null) { + publishConnectionResult(ConnectionTestPlanner.stamp(gap, now)) + return + } + + publishConnectionResult(ConnectionTestState.Testing(provider, index, total)) + val candidateKey = snapshot.apiKeys[provider].orEmpty() + val candidateEndpoint = when (provider) { + "Ollama" -> snapshot.ollamaUrl + "Copilot API" -> snapshot.copilotUrl + else -> snapshot.customEndpoints[provider].orEmpty() + } + val registrations = buildList { + if (candidateKey.isNotBlank()) add(SecretRegistry.register(candidateKey)) + if (candidateEndpoint.isNotBlank()) add(SecretRegistry.register(candidateEndpoint)) + } + try { + val factory = llmProviderFactory.get() + val llmProvider = factory.getProviderByName(provider) + val request = LLMRequest( + systemPrompt = "You are a speed test server. Respond with 'pong'.", + messages = listOf( + ChatMessage(id = "1", text = "ping", sender = ChatMessage.Sender.USER) + ), + responseFormat = ResponseFormat.TEXT, + retryPolicy = RetryPolicy.NONE + ) + val response = llmProvider.complete(request) + val connected = ConnectionTestPlanner.success( + provider = provider, + model = response.model.ifBlank { model }, + latencyMs = response.latencyMs, + testedAtMillis = System.currentTimeMillis() + ) + publishConnectionResult(connected) + val updatedBenchmarks = _llmConfig.value.latencyBenchmarks.toMutableMap() + updatedBenchmarks[provider] = connected.latencyMs + _llmConfig.value = _llmConfig.value.copy(latencyBenchmarks = updatedBenchmarks) + settingsRepository.updateConfig { current -> + current.copy( + latencyBenchmarks = current.latencyBenchmarks + (provider to connected.latencyMs) + ) + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + publishConnectionResult( + ConnectionTestPlanner.fromException( + provider = provider, + model = model, + throwable = e, + testedAtMillis = System.currentTimeMillis() + ) + ) + } finally { + registrations.asReversed().forEach(AutoCloseable::close) + } + } + + private fun publishConnectionResult(state: ConnectionTestState) { + val provider = when (state) { + is ConnectionTestState.Idle -> return + is ConnectionTestState.Testing -> state.provider + is ConnectionTestState.Connected -> state.provider + is ConnectionTestState.Failed -> state.provider + is ConnectionTestState.ConfigMissing -> state.provider } + _connectionResults.value = _connectionResults.value + (provider to state) } fun setAutoMode(mode: AutoMode) { diff --git a/app/src/test/java/com/opendroid/ai/core/agent/ChatErrorUiStateTest.kt b/app/src/test/java/com/opendroid/ai/core/agent/ChatErrorUiStateTest.kt new file mode 100644 index 0000000..6cbd8bf --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/agent/ChatErrorUiStateTest.kt @@ -0,0 +1,88 @@ +package com.opendroid.ai.core.agent + +import com.opendroid.ai.core.llm.error.LLMError +import com.opendroid.ai.core.llm.error.LLMErrorMapper +import com.opendroid.ai.core.llm.error.ProviderErrorDetail +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatErrorUiStateTest { + + @Test + fun `auth failures open settings and never offer blind retry`() { + val state = ChatErrorUiState.fromException( + sessionId = "session", + requestId = "req", + runId = "run", + failure = LLMErrorMapper.authMissing("OpenAI", "gpt-4o") + ) + assertEquals(ChatErrorPrimaryAction.OPEN_SETTINGS, state.primaryAction()) + assertEquals(LLMError.AuthMissing, state.category) + } + + @Test + fun `retryable network failures offer retry`() { + val state = ChatErrorUiState.fromException( + sessionId = "session", + requestId = "req", + runId = "run", + failure = LLMErrorMapper.fromThrowable( + "OpenAI", + "gpt-4o", + java.net.UnknownHostException("offline") + ) + ) + assertEquals(ChatErrorPrimaryAction.RETRY, state.primaryAction()) + assertEquals(LLMError.Network, state.category) + } + + @Test + fun `error carries the session it was raised in`() { + val state = ChatErrorUiState.fromException( + sessionId = "session-a", + requestId = "req", + runId = "run", + failure = LLMErrorMapper.authMissing("OpenAI", "gpt-4o") + ) + assertEquals("session-a", state.sessionId) + } + + @Test + fun `rate limited failure with retry-after enters waiting phase until the window closes`() { + val now = 1_000_000L + val state = ChatErrorUiState.fromException( + sessionId = "session", + requestId = "req", + runId = "run", + failure = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.OPENAI, + model = "gpt-4o", + httpStatus = 429, + headers = mapOf("Retry-After" to "30"), + rawBody = """{"error":{"type":"rate_limit_error"}}""" + ), + nowMillis = now + ) + assertEquals(LLMError.RateLimited, state.category) + assertEquals(ChatErrorPrimaryAction.RETRY, state.primaryAction()) + val phase = state.phase + assertTrue(phase is ChatErrorUiState.Phase.WaitingUntil) + assertEquals(now + 30_000L, (phase as ChatErrorUiState.Phase.WaitingUntil).epochMillis) + } + + @Test + fun `failure without retry-after stays in final phase`() { + val state = ChatErrorUiState.fromException( + sessionId = "session", + requestId = "req", + runId = "run", + failure = LLMErrorMapper.fromThrowable( + "OpenAI", + "gpt-4o", + java.net.UnknownHostException("offline") + ) + ) + assertEquals(ChatErrorUiState.Phase.Final, state.phase) + } +} diff --git a/app/src/test/java/com/opendroid/ai/core/crash/CrashLogRecorderTest.kt b/app/src/test/java/com/opendroid/ai/core/crash/CrashLogRecorderTest.kt index 8805181..70a2d62 100644 --- a/app/src/test/java/com/opendroid/ai/core/crash/CrashLogRecorderTest.kt +++ b/app/src/test/java/com/opendroid/ai/core/crash/CrashLogRecorderTest.kt @@ -102,12 +102,7 @@ class CrashLogRecorderTest { recorder(sink).record(Thread.currentThread(), RuntimeException("boom")) val record = sink.recorded[0] - assertEquals("1.2.3", record.appVersionName) - assertEquals(42L, record.appVersionCode) - assertEquals("14", record.androidRelease) - assertEquals(34, record.androidSdkInt) - assertEquals("Google", record.deviceManufacturer) - assertEquals("Pixel 8", record.deviceModel) + assertEquals(metadata, record.device) } @Test diff --git a/app/src/test/java/com/opendroid/ai/core/crash/CrashReportExporterTest.kt b/app/src/test/java/com/opendroid/ai/core/crash/CrashReportExporterTest.kt index 68664b3..5bdaac7 100644 --- a/app/src/test/java/com/opendroid/ai/core/crash/CrashReportExporterTest.kt +++ b/app/src/test/java/com/opendroid/ai/core/crash/CrashReportExporterTest.kt @@ -12,12 +12,14 @@ class CrashReportExporterTest { message = "boom", threadName = "main", stackTrace = "java.lang.IllegalStateException: boom\n\tat com.opendroid.ai.Thing.go(Thing.kt:12)", - appVersionName = "1.2.3", - appVersionCode = 42L, - androidRelease = "14", - androidSdkInt = 34, - deviceManufacturer = "Google", - deviceModel = "Pixel 8" + device = DeviceMetadata( + appVersionName = "1.2.3", + appVersionCode = 42L, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel 8", + ), ) private val fixedTime: (Long) -> String = { "2023-11-14 22:13:20" } diff --git a/app/src/test/java/com/opendroid/ai/core/llm/ClaudeModelCatalogTest.kt b/app/src/test/java/com/opendroid/ai/core/llm/ClaudeModelCatalogTest.kt index 63b0a29..c92ddcb 100644 --- a/app/src/test/java/com/opendroid/ai/core/llm/ClaudeModelCatalogTest.kt +++ b/app/src/test/java/com/opendroid/ai/core/llm/ClaudeModelCatalogTest.kt @@ -20,22 +20,41 @@ class ClaudeModelCatalogTest { @Test fun `each supported model id resolves to itself`() { + assertEquals("claude-fable-5", ClaudeModelCatalog.resolve("claude-fable-5")) assertEquals("claude-opus-5", ClaudeModelCatalog.resolve("claude-opus-5")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-sonnet-5")) - assertEquals("claude-haiku-4-5", ClaudeModelCatalog.resolve("claude-haiku-4-5")) assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-opus-4-8")) + assertEquals("claude-opus-4-7", ClaudeModelCatalog.resolve("claude-opus-4-7")) + assertEquals("claude-opus-4-6", ClaudeModelCatalog.resolve("claude-opus-4-6")) + assertEquals( + "claude-opus-4-5-20251101", + ClaudeModelCatalog.resolve("claude-opus-4-5-20251101") + ) + assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-sonnet-5")) assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-sonnet-4-6")) + assertEquals( + "claude-sonnet-4-5-20250929", + ClaudeModelCatalog.resolve("claude-sonnet-4-5-20250929") + ) + assertEquals( + "claude-haiku-4-5-20251001", + ClaudeModelCatalog.resolve("claude-haiku-4-5-20251001") + ) } @Test fun `catalog lists exactly the supported models`() { assertEquals( listOf( + "claude-fable-5", "claude-opus-5", - "claude-sonnet-5", - "claude-haiku-4-5", "claude-opus-4-8", - "claude-sonnet-4-6" + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-5-20251101", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20251001" ).sorted(), ClaudeModelCatalog.models.map { it.id }.sorted() ) @@ -47,41 +66,67 @@ class ClaudeModelCatalogTest { fun `unversioned family aliases resolve to the current family member`() { assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-opus-4")) assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-sonnet-4")) - assertEquals("claude-haiku-4-5", ClaudeModelCatalog.resolve("claude-haiku-4")) + assertEquals( + "claude-haiku-4-5-20251001", + ClaudeModelCatalog.resolve("claude-haiku-4") + ) } @Test - fun `date-suffixed haiku id resolves to the canonical unversioned id`() { - assertEquals("claude-haiku-4-5", ClaudeModelCatalog.resolve("claude-haiku-4-5-20251001")) + fun `previous haiku alias resolves to the pinned active id`() { + assertEquals( + "claude-haiku-4-5-20251001", + ClaudeModelCatalog.resolve("claude-haiku-4-5") + ) } @Test fun `retired 3x model ids resolve to their replacements`() { assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-3-opus-20240229")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-3-7-sonnet-20250219")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-3-5-sonnet-20241022")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-3-5-sonnet-20240620")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-3-sonnet-20240229")) - assertEquals("claude-haiku-4-5", ClaudeModelCatalog.resolve("claude-3-5-haiku-20241022")) - assertEquals("claude-haiku-4-5", ClaudeModelCatalog.resolve("claude-3-haiku-20240307")) + assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-3-7-sonnet-20250219")) + assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-3-5-sonnet-20241022")) + assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-3-5-sonnet-20240620")) + assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-3-sonnet-20240229")) + assertEquals( + "claude-haiku-4-5-20251001", + ClaudeModelCatalog.resolve("claude-3-5-haiku-20241022") + ) + assertEquals( + "claude-haiku-4-5-20251001", + ClaudeModelCatalog.resolve("claude-3-haiku-20240307") + ) } @Test fun `retired 4x model ids resolve to their replacements`() { - // These match the well-formed-ID shape, so without an explicit alias they - // would be forwarded verbatim and fail at request time. assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-opus-4-0")) assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-opus-4-20250514")) - assertEquals("claude-opus-5", ClaudeModelCatalog.resolve("claude-opus-4-1")) - assertEquals("claude-opus-5", ClaudeModelCatalog.resolve("claude-opus-4-1-20250805")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-sonnet-4-0")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-sonnet-4-20250514")) + assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-opus-4-1")) + assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-opus-4-1-20250805")) + assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-sonnet-4-0")) + assertEquals("claude-sonnet-4-6", ClaudeModelCatalog.resolve("claude-sonnet-4-20250514")) } @Test fun `retired claude 2 ids resolve to their documented replacement`() { - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-2.1")) - assertEquals("claude-sonnet-5", ClaudeModelCatalog.resolve("claude-2.0")) + assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-2.1")) + assertEquals("claude-opus-4-8", ClaudeModelCatalog.resolve("claude-2.0")) + } + + @Test + fun `retired claude 1 and instant ids resolve to pinned haiku 4 5`() { + val replacement = "claude-haiku-4-5-20251001" + listOf( + "claude-1.0", + "claude-1.1", + "claude-1.2", + "claude-1.3", + "claude-instant-1.0", + "claude-instant-1.1", + "claude-instant-1.2" + ).forEach { retiredId -> + assertEquals(replacement, ClaudeModelCatalog.resolve(retiredId)) + } } @Test @@ -115,17 +160,12 @@ class ClaudeModelCatalogTest { } } - // ── Models Anthropic serves but OpenDroid doesn't know yet ────────── + // ── Untrusted persisted model IDs ─────────────────────────────────── @Test - fun `an unknown but well-formed claude id resolves to itself`() { - // A model Anthropic releases before OpenDroid ships a catalog update - // must stay selectable rather than failing at request time. - assertEquals("claude-opus-9", ClaudeModelCatalog.resolve("claude-opus-9")) - assertEquals( - "claude-sonnet-5-20260615", - ClaudeModelCatalog.resolve("claude-sonnet-5-20260615") - ) + fun `unknown well formed ids from persisted settings are rejected`() { + assertNull(ClaudeModelCatalog.resolve("claude-opus-9")) + assertNull(ClaudeModelCatalog.resolve("claude-sonnet-5-20260615")) } @Test @@ -137,7 +177,6 @@ class ClaudeModelCatalogTest { @Test fun `retired models with no replacement resolve to null`() { - assertNull(ClaudeModelCatalog.resolve("claude-instant-1.2")) assertNull(ClaudeModelCatalog.resolve("claude-instant-1")) assertNull(ClaudeModelCatalog.resolve("claude-instant-v1")) } @@ -174,17 +213,15 @@ class ClaudeModelCatalogTest { } @Test - fun `opus 5 is the premium model`() { + fun `fable 5 is the premium model`() { val premium = ClaudeModelCatalog.models.filter { it.isPremium } assertEquals(1, premium.size) - assertEquals("claude-opus-5", premium.single().id) + assertEquals("claude-fable-5", premium.single().id) } @Test - fun `haiku 4 5 is flagged as the cheap option`() { - val cheap = ClaudeModelCatalog.models.filter { it.isFree } - assertEquals(1, cheap.size) - assertEquals("claude-haiku-4-5", cheap.single().id) + fun `paid anthropic models are never flagged free`() { + assertTrue(ClaudeModelCatalog.models.none { it.isFree }) } @Test @@ -196,11 +233,25 @@ class ClaudeModelCatalogTest { @Test fun `display names use the marketing names`() { + assertEquals("Claude Fable 5", ClaudeModelCatalog.specFor("claude-fable-5")?.displayName) assertEquals("Claude Opus 5", ClaudeModelCatalog.specFor("claude-opus-5")?.displayName) - assertEquals("Claude Sonnet 5", ClaudeModelCatalog.specFor("claude-sonnet-5")?.displayName) - assertEquals("Claude Haiku 4.5", ClaudeModelCatalog.specFor("claude-haiku-4-5")?.displayName) assertEquals("Claude Opus 4.8", ClaudeModelCatalog.specFor("claude-opus-4-8")?.displayName) + assertEquals("Claude Opus 4.7", ClaudeModelCatalog.specFor("claude-opus-4-7")?.displayName) + assertEquals("Claude Opus 4.6", ClaudeModelCatalog.specFor("claude-opus-4-6")?.displayName) + assertEquals( + "Claude Opus 4.5", + ClaudeModelCatalog.specFor("claude-opus-4-5-20251101")?.displayName + ) + assertEquals("Claude Sonnet 5", ClaudeModelCatalog.specFor("claude-sonnet-5")?.displayName) assertEquals("Claude Sonnet 4.6", ClaudeModelCatalog.specFor("claude-sonnet-4-6")?.displayName) + assertEquals( + "Claude Sonnet 4.5", + ClaudeModelCatalog.specFor("claude-sonnet-4-5-20250929")?.displayName + ) + assertEquals( + "Claude Haiku 4.5", + ClaudeModelCatalog.specFor("claude-haiku-4-5-20251001")?.displayName + ) } @Test @@ -213,15 +264,20 @@ class ClaudeModelCatalogTest { @Test fun `current models reject sampling parameters`() { + assertFalse(ClaudeModelCatalog.acceptsSamplingParameters("claude-fable-5")) assertFalse(ClaudeModelCatalog.acceptsSamplingParameters("claude-opus-5")) assertFalse(ClaudeModelCatalog.acceptsSamplingParameters("claude-sonnet-5")) assertFalse(ClaudeModelCatalog.acceptsSamplingParameters("claude-opus-4-8")) + assertFalse(ClaudeModelCatalog.acceptsSamplingParameters("claude-opus-4-7")) } @Test fun `older models still accept sampling parameters`() { + assertTrue(ClaudeModelCatalog.acceptsSamplingParameters("claude-opus-4-6")) + assertTrue(ClaudeModelCatalog.acceptsSamplingParameters("claude-opus-4-5-20251101")) assertTrue(ClaudeModelCatalog.acceptsSamplingParameters("claude-sonnet-4-6")) - assertTrue(ClaudeModelCatalog.acceptsSamplingParameters("claude-haiku-4-5")) + assertTrue(ClaudeModelCatalog.acceptsSamplingParameters("claude-sonnet-4-5-20250929")) + assertTrue(ClaudeModelCatalog.acceptsSamplingParameters("claude-haiku-4-5-20251001")) } @Test diff --git a/app/src/test/java/com/opendroid/ai/core/llm/ConnectionTestPlannerTest.kt b/app/src/test/java/com/opendroid/ai/core/llm/ConnectionTestPlannerTest.kt new file mode 100644 index 0000000..9616e8d --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/llm/ConnectionTestPlannerTest.kt @@ -0,0 +1,71 @@ +package com.opendroid.ai.core.llm + +import com.opendroid.ai.core.llm.error.LLMError +import com.opendroid.ai.data.models.LLMConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionTestPlannerTest { + + @Test + fun `missing api key is AuthMissing without probing`() { + val gap = ConnectionTestPlanner.configurationGap( + LLMConfig(activeProvider = "OpenAI", apiKeys = emptyMap()), + "OpenAI" + ) + assertEquals(LLMError.AuthMissing, gap?.reason) + assertEquals("OpenAI", gap?.provider) + } + + @Test + fun `configured providers exclude incomplete snapshots`() { + val config = LLMConfig( + apiKeys = mapOf("OpenAI" to "sk-test"), + ollamaUrl = "", + copilotUrl = "" + ) + val configured = ConnectionTestPlanner.configuredProviders(config) + assertTrue(configured.contains("OpenAI")) + assertTrue(!configured.contains("Ollama")) + assertTrue(!configured.contains("Copilot API")) + assertNull(ConnectionTestPlanner.configurationGap(config, "OpenAI")) + } + + @Test + fun `custom endpoint missing is RequestInvalid`() { + val gap = ConnectionTestPlanner.configurationGap( + LLMConfig(apiKeys = mapOf("Custom OpenAI Compatible" to "sk")), + "Custom OpenAI Compatible" + ) + assertEquals(LLMError.RequestInvalid, gap?.reason) + } + + @Test + fun `cloud providers derive from the catalog and exclude on-device entries`() { + val cloud = ConnectionTestPlanner.cloudProviders() + val expected = ProviderCatalog.providers + .filter { spec -> spec.canonicalName != ProviderCatalog.ON_DEVICE } + .filter { spec -> spec.canonicalName != "LiteRT-LM (On-device)" } + .map { spec -> spec.displayName } + + assertEquals(expected, cloud) + assertTrue(cloud.contains("Ollama")) + assertTrue(!cloud.contains(ProviderCatalog.ON_DEVICE)) + assertTrue(!cloud.contains(ProviderCatalog.LEGACY_ON_DEVICE)) + assertTrue(!cloud.contains("LiteRT-LM (On-device)")) + } + + @Test + fun `success never encodes failure as latency`() { + val connected = ConnectionTestPlanner.success( + provider = "OpenAI", + model = "gpt-4o", + latencyMs = 342L, + testedAtMillis = 1L + ) + assertEquals(342L, connected.latencyMs) + assertEquals("gpt-4o", connected.model) + } +} diff --git a/app/src/test/java/com/opendroid/ai/core/llm/ProviderCatalogTest.kt b/app/src/test/java/com/opendroid/ai/core/llm/ProviderCatalogTest.kt new file mode 100644 index 0000000..a494ad8 --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/llm/ProviderCatalogTest.kt @@ -0,0 +1,92 @@ +package com.opendroid.ai.core.llm + +import com.opendroid.ai.data.models.LLMConfig +import com.opendroid.ai.data.models.selectedModelFor +import com.opendroid.ai.data.models.withActiveProvider +import com.opendroid.ai.data.models.withSelectedModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProviderCatalogTest { + + @Test + fun `catalog has one default for every stable provider`() { + assertEquals(15, ProviderCatalog.providers.size) + ProviderCatalog.providers.forEach { provider -> + assertTrue(provider.displayName, ProviderCatalog.defaultModel(provider.displayName).isNotBlank()) + } + } + + @Test + fun `nullable model map lazily migrates only the legacy active provider pair`() { + val legacy = LLMConfig( + activeProvider = "OpenAI", + activeModel = "gpt-4-turbo", + selectedModels = null + ) + + assertEquals("gpt-4-turbo", legacy.selectedModelFor("OpenAI")) + assertEquals( + ProviderCatalog.defaultModel("Google Gemini"), + legacy.selectedModelFor("Google Gemini") + ) + assertNull(legacy.selectedModels) + } + + @Test + fun `switching providers restores remembered model without losing other pairs`() { + val config = LLMConfig() + .withSelectedModel("OpenAI", "o3-mini") + .withSelectedModel("Google Gemini", "gemini-1.5-pro") + .withActiveProvider("OpenAI") + .withActiveProvider("Google Gemini") + + assertEquals("gemini-1.5-pro", config.activeModel) + assertEquals("o3-mini", config.selectedModelFor("OpenAI")) + assertEquals("gemini-1.5-pro", config.selectedModelFor("Google Gemini")) + } + + @Test + fun `legacy on-device name normalizes to one canonical key`() { + val config = LLMConfig(activeProvider = "Gemma 4 (On-device)", activeModel = "gemma-3n-multimodal") + .withSelectedModel("Gemma 4 (On-device)", "gemma-4-on-device") + + assertEquals("On-Device AI", ProviderCatalog.canonicalName("Gemma 4 (On-device)")) + assertEquals("gemma-4-on-device", config.selectedModelFor("On-Device AI")) + assertTrue(config.selectedModels?.containsKey("Gemma 4 (On-device)") == false) + } + + @Test + fun `untrusted persisted Claude ids never cross the provider boundary`() { + val config = LLMConfig( + activeProvider = "Anthropic Claude", + activeModel = "claude-attacker-controlled", + selectedModels = mapOf("Anthropic Claude" to "claude-attacker-controlled") + ) + + assertEquals(ClaudeModelCatalog.defaultModelId, config.selectedModelFor("Anthropic Claude")) + } + + @Test + fun `non-active provider resolves its own remembered model for Test All pairing`() { + val config = LLMConfig( + activeProvider = "OpenAI", + activeModel = "gpt-4o", + selectedModels = mapOf( + "OpenAI" to "gpt-4o", + "Google Gemini" to "gemini-1.5-pro", + "Anthropic Claude" to ClaudeModelCatalog.defaultModelId + ) + ) + + assertEquals("gpt-4o", config.selectedModelFor("OpenAI")) + assertEquals("gemini-1.5-pro", config.selectedModelFor("Google Gemini")) + assertEquals(ClaudeModelCatalog.defaultModelId, config.selectedModelFor("Anthropic Claude")) + assertEquals( + ProviderCatalog.defaultModel("Groq"), + config.selectedModelFor("Groq") + ) + } +} diff --git a/app/src/test/java/com/opendroid/ai/core/llm/WrappedLLMProviderTest.kt b/app/src/test/java/com/opendroid/ai/core/llm/WrappedLLMProviderTest.kt new file mode 100644 index 0000000..f36fb43 --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/llm/WrappedLLMProviderTest.kt @@ -0,0 +1,321 @@ +package com.opendroid.ai.core.llm + +import com.opendroid.ai.core.llm.error.LLMError +import com.opendroid.ai.core.llm.error.LLMException +import com.opendroid.ai.data.models.ChatMessage +import com.opendroid.ai.data.models.LLMConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WrappedLLMProviderTest { + + @Test + fun `complete snapshots provider model and retries only retryable failures`() = runBlocking { + var config = LLMConfig( + activeProvider = "OpenAI", + activeModel = "gpt-4o", + selectedModels = mapOf("OpenAI" to "gpt-4-turbo"), + apiKeys = mapOf("OpenAI" to "test-key") + ) + val seenModels = mutableListOf() + var attempts = 0 + var clock = 0L + val delays = mutableListOf() + val delegate = FakeProvider("OpenAI", completion = { request -> + attempts++ + seenModels += request.model + if (attempts == 1) { + config = config.copy(selectedModels = mapOf("OpenAI" to "o3-mini")) + throw LLMException( + error = LLMError.ServerError, + provider = "OpenAI", + model = request.model.orEmpty(), + status = 503, + retryable = true + ) + } + response(request) + }) + val wrapper = WrappedLLMProvider( + delegate = delegate, + configProvider = { config }, + retryRuntime = RetryRuntime( + nowMillis = { clock }, + delayMillis = { delay -> delays += delay; clock += delay }, + jitterMillis = { upperBound -> upperBound } + ) + ) + + wrapper.complete(request()) + + assertEquals(2, attempts) + assertEquals(listOf("gpt-4-turbo", "gpt-4-turbo"), seenModels) + assertEquals(listOf(750L), delays) + } + + @Test + fun `none policy performs exactly one attempt`() = runBlocking { + var attempts = 0 + val delegate = FakeProvider("OpenAI", completion = { + attempts++ + throw LLMException( + error = LLMError.RateLimited, + provider = "OpenAI", + model = "gpt-4o", + status = 429, + retryable = true + ) + }) + val wrapper = wrapper(delegate) + + runCatching { wrapper.complete(request().copy(retryPolicy = RetryPolicy.NONE)) } + + assertEquals(1, attempts) + } + + @Test + fun `cancellation is never classified or retried`() = runBlocking { + var attempts = 0 + val delegate = FakeProvider("OpenAI", completion = { + attempts++ + throw CancellationException("stop") + }) + val wrapper = wrapper(delegate) + + val failure = runCatching { wrapper.complete(request()) }.exceptionOrNull() + + assertTrue(failure is CancellationException) + assertEquals(1, attempts) + } + + @Test + fun `stream retries before emission but never after a partial response`() = runBlocking { + var preEmissionAttempts = 0 + val preEmission = FakeProvider( + name = "OpenAI", + stream = { + preEmissionAttempts++ + flow { + if (preEmissionAttempts == 1) { + throw LLMException( + error = LLMError.Network, + provider = "OpenAI", + model = "gpt-4o", + retryable = true + ) + } + emit("ok") + } + } + ) + val retried = wrapper(preEmission).streamComplete(request()).toList() + + var partialAttempts = 0 + val partial = FakeProvider( + name = "OpenAI", + stream = { + partialAttempts++ + flow { + emit("partial") + throw LLMException( + error = LLMError.Network, + provider = "OpenAI", + model = "gpt-4o", + retryable = true + ) + } + } + ) + val partialFailure = runCatching { + wrapper(partial).streamComplete(request()).toList() + }.exceptionOrNull() + + assertEquals(listOf("ok"), retried) + assertEquals(2, preEmissionAttempts) + assertTrue(partialFailure is LLMException) + assertEquals(1, partialAttempts) + } + + @Test + fun `retry validates and executes the same proposed delay`() = runBlocking { + var attempts = 0 + var jitterCalls = 0 + val delays = mutableListOf() + val delegate = FakeProvider("OpenAI", completion = { request -> + attempts++ + if (attempts == 1) { + throw LLMException( + error = LLMError.ServerError, + provider = "OpenAI", + model = request.model.orEmpty(), + status = 503, + retryable = true + ) + } + response(request) + }) + val wrapper = WrappedLLMProvider( + delegate = delegate, + configProvider = { + LLMConfig( + activeProvider = "OpenAI", + activeModel = "gpt-4o", + apiKeys = mapOf("OpenAI" to "test-key") + ) + }, + retryRuntime = RetryRuntime( + nowMillis = { 0L }, + delayMillis = { delays += it }, + jitterMillis = { upperBound -> jitterCalls++; upperBound } + ) + ) + + wrapper.complete(request()) + + assertEquals(2, attempts) + assertEquals(1, jitterCalls) + assertEquals(listOf(750L), delays) + } + + @Test + fun `empty stream is retried as a transient malformed response`() = runBlocking { + var attempts = 0 + val delegate = FakeProvider( + name = "OpenAI", + stream = { + attempts++ + flow { + if (attempts > 1) emit("ok") + } + } + ) + + val chunks = wrapper(delegate).streamComplete(request()).toList() + + assertEquals(listOf("ok"), chunks) + assertEquals(2, attempts) + } + + @Test + fun `persistently empty stream still fails as malformed after retries`() = runBlocking { + var attempts = 0 + val delegate = FakeProvider( + name = "OpenAI", + stream = { + attempts++ + flow { } + } + ) + + val failure = runCatching { + wrapper(delegate).streamComplete(request()).toList() + }.exceptionOrNull() + + assertTrue(failure is LLMException) + assertEquals(LLMError.MalformedResponse, (failure as LLMException).error) + assertEquals(3, attempts) + } + + @Test + fun `custom provider cannot inherit an implicit OpenAI endpoint`() = runBlocking { + var called = false + val delegate = FakeProvider("Custom OpenAI Compatible", completion = { + called = true + response(it) + }) + val wrapper = WrappedLLMProvider( + delegate = delegate, + configProvider = { + LLMConfig( + activeProvider = "Custom OpenAI Compatible", + apiKeys = mapOf("Custom OpenAI Compatible" to "test-key"), + customEndpoints = emptyMap() + ) + } + ) + + val failure = runCatching { wrapper.complete(request()) }.exceptionOrNull() + + assertTrue(failure is LLMException) + assertEquals(LLMError.RequestInvalid, (failure as LLMException).error) + assertFalse(called) + } + + @Test + fun `non-active provider complete uses that provider selected model`() = runBlocking { + val seen = mutableListOf() + val delegate = FakeProvider("Google Gemini", completion = { req -> + seen += req.model + response(req) + }) + val wrapper = WrappedLLMProvider( + delegate = delegate, + configProvider = { + LLMConfig( + activeProvider = "OpenAI", + activeModel = "gpt-4o", + selectedModels = mapOf( + "OpenAI" to "gpt-4o", + "Google Gemini" to "gemini-1.5-pro" + ), + apiKeys = mapOf("Google Gemini" to "gemini-key") + ) + } + ) + + wrapper.complete(request()) + + assertEquals(listOf("gemini-1.5-pro"), seen) + } + + private fun wrapper(delegate: LLMProvider) = WrappedLLMProvider( + delegate = delegate, + configProvider = { + LLMConfig( + activeProvider = delegate.name, + activeModel = ProviderCatalog.defaultModel(delegate.name), + apiKeys = mapOf(delegate.name to "test-key") + ) + }, + retryRuntime = RetryRuntime( + nowMillis = { 0L }, + delayMillis = {}, + jitterMillis = { 0L } + ) + ) + + private fun request() = LLMRequest( + systemPrompt = "constant", + messages = listOf(ChatMessage("1", "ping", ChatMessage.Sender.USER)), + responseFormat = ResponseFormat.TEXT + ) + + private fun response(request: LLMRequest) = LLMResponse( + content = "pong", + tokensUsed = 1, + model = request.model.orEmpty(), + provider = "OpenAI", + latencyMs = 1 + ) + + private class FakeProvider( + override val name: String, + private val stream: ((LLMRequest) -> Flow)? = null, + private val completion: suspend (LLMRequest) -> LLMResponse = { request -> + LLMResponse("ok", 1, request.model.orEmpty(), name, 1) + } + ) : LLMProvider { + override val availableModels: List = emptyList() + override suspend fun complete(request: LLMRequest): LLMResponse = completion(request) + override fun streamComplete(request: LLMRequest): Flow = + stream?.invoke(request) ?: flow { emit(completion(request).content) } + override suspend fun isAvailable(): Boolean = true + } +} diff --git a/app/src/test/java/com/opendroid/ai/core/llm/error/LLMErrorMapperTest.kt b/app/src/test/java/com/opendroid/ai/core/llm/error/LLMErrorMapperTest.kt new file mode 100644 index 0000000..ac04794 --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/llm/error/LLMErrorMapperTest.kt @@ -0,0 +1,204 @@ +package com.opendroid.ai.core.llm.error + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +class LLMErrorMapperTest { + + @Test + fun `maps provider dialect evidence into all non-local HTTP categories`() { + val cases = listOf( + Case(401, """{"error":{"type":"authentication_error"}}""", LLMError.AuthInvalid), + Case(402, """{"error":{"code":"billing_required"}}""", LLMError.QuotaExhausted), + Case(429, """{"error":{"code":"insufficient_quota"}}""", LLMError.QuotaExhausted), + Case(429, """{"error":{"type":"rate_limit_error"}}""", LLMError.RateLimited), + Case(429, """{"error":{"message":"quota of credit-based requests exceeded"}}""", LLMError.RateLimited), + Case(404, """{"error":{"code":"model_not_found"}}""", LLMError.ModelUnavailable), + Case(400, """{"error":{"type":"invalid_request_error"}}""", LLMError.RequestInvalid), + Case(529, """{"error":{"type":"overloaded_error"}}""", LLMError.ServerError), + Case(418, """{"error":{"type":"unexpected"}}""", LLMError.Unknown) + ) + + cases.forEach { case -> + val exception = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.CLAUDE, + model = "claude-sonnet-5", + httpStatus = case.status, + rawBody = case.body + ) + assertEquals("status=${case.status}", case.error, exception.error) + } + } + + @Test + fun `Gemini invalid key 400 and context 5xx use dialect evidence instead of status alone`() { + val invalidKey = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.GEMINI, + model = "gemini-2.0-flash", + httpStatus = 400, + rawBody = """{"error":{"status":"INVALID_ARGUMENT","message":"API key not valid"}}""" + ) + val tooLong = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.GEMINI, + model = "gemini-2.0-flash", + httpStatus = 504, + rawBody = """{"error":{"status":"DEADLINE_EXCEEDED","message":"input context length exceeded"}}""" + ) + + assertEquals(LLMError.AuthInvalid, invalidKey.error) + assertEquals(LLMError.RequestInvalid, tooLong.error) + assertFalse(tooLong.retryable) + } + + @Test + fun `retry after accepts fractional seconds and Go duration syntax`() { + val fractional = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.OPENAI, + model = "gpt-4o", + httpStatus = 429, + headers = mapOf("Retry-After" to "1.25"), + rawBody = """{"error":{"type":"rate_limit_error"}}""" + ) + val goDuration = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.CLAUDE, + model = "claude-sonnet-5", + httpStatus = 529, + headers = mapOf("retry-after" to "2m59.56s"), + rawBody = """{"error":{"type":"overloaded_error"}}""" + ) + + assertEquals(1_250L, fractional.retryAfterMillis) + assertEquals(179_560L, goDuration.retryAfterMillis) + } + + @Test + fun `malformed non-json error retains no body text or secrets`() { + val key = "sk-secret-value-ABCD6789" + val raw = "Bearer $key at https://user:password@example.test?key=$key" + + val exception = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.OPENAI, + model = "gpt-4o", + httpStatus = 500, + rawBody = raw, + knownSecrets = listOf(key) + ) + + val observable = listOf(exception.message, exception.detail?.toString(), exception.toString()) + .joinToString() + assertEquals(LLMError.ServerError, exception.error) + assertFalse(observable.contains(raw)) + assertFalse(observable.contains(key)) + assertFalse(observable.contains("ABCD")) + assertFalse(observable.contains("6789")) + assertFalse(observable.contains("password")) + } + + @Test + fun `transport errors map to network without retaining hostile cause`() { + val cause = SocketTimeoutException("Bearer sk-do-not-retain at secret.example") + + val exception = LLMErrorMapper.fromThrowable("OpenAI", "gpt-4o", cause) + + assertEquals(LLMError.Network, exception.error) + assertNull(exception.cause) + assertFalse(exception.toString().contains("sk-do-not-retain")) + assertFalse(exception.toString().contains("secret.example")) + } + + @Test + fun `only connect-phase network failures are retryable`() { + val refused = LLMErrorMapper.fromThrowable("OpenAI", "gpt-4o", ConnectException("refused")) + val unknownHost = LLMErrorMapper.fromThrowable("OpenAI", "gpt-4o", UnknownHostException("api.test")) + val timeout = LLMErrorMapper.fromThrowable("OpenAI", "gpt-4o", SocketTimeoutException("read timed out")) + val io = LLMErrorMapper.fromThrowable("OpenAI", "gpt-4o", java.io.IOException("stream reset")) + + assertEquals(LLMError.Network, refused.error) + assertTrue(refused.retryable) + assertTrue(unknownHost.retryable) + assertEquals(LLMError.Network, timeout.error) + assertFalse(timeout.retryable) + assertEquals(LLMError.Network, io.error) + assertFalse(io.retryable) + } + + @Test + fun `missing auth and malformed responses cover local non-HTTP failures`() { + val missing = LLMErrorMapper.fromThrowable( + "OpenAI", + "gpt-4o", + IllegalStateException("API Key for OpenAI is not set.") + ) + val malformed = LLMErrorMapper.fromThrowable( + "OpenAI", + "gpt-4o", + IllegalStateException("Empty response body from OpenAI") + ) + + assertEquals(LLMError.AuthMissing, missing.error) + assertEquals(LLMError.MalformedResponse, malformed.error) + assertFalse(missing.retryable) + assertFalse(malformed.retryable) + assertNull(missing.cause) + assertNull(malformed.cause) + } + + @Test + fun `provider boundary refuses oversized bodies before classification`() { + val response = Response.Builder() + .request(Request.Builder().url("https://example.test").build()) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message("Server error") + .body("""{"error":{"type":"${"x".repeat(40_000)}"}}""".toResponseBody()) + .build() + + response.use { + assertNull(it.consumeBoundedErrorBody()) + } + } + + @Test + fun `model metadata strips controls before it can reach UI or audit`() { + val exception = LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.OPENAI, + model = "gpt-4o\r\nInjected: secret", + httpStatus = 500, + rawBody = null + ) + + assertEquals("gpt-4oInjected: secret", exception.model) + } + + @Test + fun `secret registry scrubs candidate credentials for the duration of a connection test`() { + val candidate = "candidate-credential-12345678" + val registration = SecretRegistry.register(candidate) + val exception = try { + LLMErrorMapper.fromHttpFailure( + provider = ProviderErrorDetail.Provider.COHERE, + model = "command-r-plus", + httpStatus = 401, + rawBody = """{"error":{"type":"${candidate.take(8)}","code":"${candidate.takeLast(4)}"}}""" + ) + } finally { + registration.close() + } + + assertNull(exception.detail?.vendorType) + assertNull(exception.detail?.vendorCode) + assertFalse(SecretRegistry.snapshot().contains(candidate)) + } + + private data class Case(val status: Int, val body: String, val error: LLMError) +} diff --git a/app/src/test/java/com/opendroid/ai/core/llm/error/ProviderErrorDetailTest.kt b/app/src/test/java/com/opendroid/ai/core/llm/error/ProviderErrorDetailTest.kt new file mode 100644 index 0000000..7ab4a66 --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/llm/error/ProviderErrorDetailTest.kt @@ -0,0 +1,89 @@ +package com.opendroid.ai.core.llm.error + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class ProviderErrorDetailTest { + + @Test + fun `promotes only safe vendor tokens from an OpenAI error body`() { + val knownKey = "sk-invalid-secret-value-6789" + val rawBody = """ + { + "error": { + "message": "Prompt PROMPT_CANARY failed at https://secret.example/v1 for sk-inval**********6789", + "type": "invalid_request_error", + "code": "invalid_api_key" + } + } + """.trimIndent() + + val detail = ProviderErrorDetail.fromHttpFailure( + provider = ProviderErrorDetail.Provider.OPENAI, + httpStatus = 401, + rawBody = rawBody, + knownSecrets = listOf(knownKey), + forbiddenText = listOf("PROMPT_CANARY", "https://secret.example/v1") + ) + + assertEquals(ProviderErrorDetail.Provider.OPENAI, detail.provider) + assertEquals(401, detail.httpStatus) + assertEquals("invalid_request_error", detail.vendorType) + assertEquals("invalid_api_key", detail.vendorCode) + assertEquals( + "OpenAI request failed with HTTP 401 (type=invalid_request_error, code=invalid_api_key).", + detail.message + ) + + val observableText = detail.toString() + detail.message + listOf( + rawBody, + "PROMPT_CANARY", + "https://secret.example/v1", + knownKey, + "sk-inval", + "6789" + ).forEach { forbidden -> + assertFalse("$forbidden survived in $observableText", observableText.contains(forbidden)) + } + } + + @Test + fun `rejects prompt and known key fragments disguised as vendor tokens`() { + val promptCanary = "PROMPT_CANARY" + val detail = ProviderErrorDetail.fromHttpFailure( + provider = ProviderErrorDetail.Provider.OPENAI, + httpStatus = 401, + rawBody = """ + { + "error": { + "type": "$promptCanary", + "code": "tail6789" + } + } + """.trimIndent(), + knownSecrets = listOf("sk-invalid-secret-value-6789"), + forbiddenText = listOf(promptCanary) + ) + + assertEquals(null, detail.vendorType) + assertEquals(null, detail.vendorCode) + assertEquals("OpenAI request failed with HTTP 401.", detail.message) + } + + @Test + fun `rejects four character fragments of an unprefixed known key`() { + val detail = ProviderErrorDetail.fromHttpFailure( + provider = ProviderErrorDetail.Provider.COHERE, + httpStatus = 401, + rawBody = """{"type":"Ab1C","code":"6789"}""", + knownSecrets = listOf("Ab1Cd2Ef3Gh4Ij5Kl6Mn6789"), + forbiddenText = emptyList() + ) + + assertEquals(null, detail.vendorType) + assertEquals(null, detail.vendorCode) + assertEquals("Cohere request failed with HTTP 401.", detail.message) + } +} diff --git a/app/src/test/java/com/opendroid/ai/core/permissions/PermissionModelTest.kt b/app/src/test/java/com/opendroid/ai/core/permissions/PermissionModelTest.kt new file mode 100644 index 0000000..93f7daa --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/core/permissions/PermissionModelTest.kt @@ -0,0 +1,766 @@ +package com.opendroid.ai.core.permissions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PermissionModelTest { + private val SDKS = listOf(26, 29, 30, 32, 33, 35) + + private fun snapshot( + sdkInt: Int = 35, + granted: Set = emptySet(), + asked: Set = emptySet(), + rationale: Map = emptyMap(), + manualHeld: Set = emptySet(), + grantAll: GrantAllState = GrantAllState.Idle, + appInfoOffered: Set = emptySet(), + ) = PermissionsSnapshot( + sdkInt = sdkInt, + granted = granted, + asked = asked, + rationale = rationale, + manualHeld = manualHeld, + grantAll = grantAll, + appInfoOffered = appInfoOffered, + ) + + @Test + fun `visible cards below API 33 omit notifications and preserve consent order`() { + val expected = listOf( + PermissionCardId.MICROPHONE, + PermissionCardId.LOCATION, + PermissionCardId.SMS_TELEPHONY, + PermissionCardId.CONTACTS_CALENDAR, + PermissionCardId.CAMERA, + PermissionCardId.STORAGE, + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + ) + + listOf(26, 29, 30, 32).forEach { sdkInt -> + assertEquals("SDK $sdkInt", expected, visibleCards(sdkInt)) + } + } + + @Test + fun `visible cards at API 33 and above include all cards in consent order`() { + val expected = listOf( + PermissionCardId.MICROPHONE, + PermissionCardId.LOCATION, + PermissionCardId.SMS_TELEPHONY, + PermissionCardId.CONTACTS_CALENDAR, + PermissionCardId.CAMERA, + PermissionCardId.NOTIFICATIONS, + PermissionCardId.STORAGE, + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + ) + + listOf(33, 35).forEach { sdkInt -> + assertEquals("SDK $sdkInt", expected, visibleCards(sdkInt)) + } + } + + @Test + fun `microphone permission set is exact across the SDK matrix`() { + SDKS.forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + listOf("android.permission.RECORD_AUDIO"), + runtimePermissions(PermissionCardId.MICROPHONE, sdkInt), + ) + } + } + + @Test + fun `location permission set includes fine then coarse across the SDK matrix`() { + val expected = listOf( + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ) + + SDKS.forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + expected, + runtimePermissions(PermissionCardId.LOCATION, sdkInt), + ) + } + } + + @Test + fun `SMS and telephony permission set includes every used permission in order`() { + val expected = listOf( + "android.permission.SEND_SMS", + "android.permission.CALL_PHONE", + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + ) + + SDKS.forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + expected, + runtimePermissions(PermissionCardId.SMS_TELEPHONY, sdkInt), + ) + } + } + + @Test + fun `contacts and calendar permission set includes contact write but not calendar write`() { + val expected = listOf( + "android.permission.READ_CONTACTS", + "android.permission.WRITE_CONTACTS", + "android.permission.READ_CALENDAR", + ) + + SDKS.forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + expected, + runtimePermissions(PermissionCardId.CONTACTS_CALENDAR, sdkInt), + ) + } + } + + @Test + fun `camera permission set is exact across the SDK matrix`() { + SDKS.forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + listOf("android.permission.CAMERA"), + runtimePermissions(PermissionCardId.CAMERA, sdkInt), + ) + } + } + + @Test + fun `notifications route changes exactly at API 33`() { + listOf(26, 29, 30, 32).forEach { sdkInt -> + assertTrue( + "SDK $sdkInt", + runtimePermissions(PermissionCardId.NOTIFICATIONS, sdkInt).isEmpty(), + ) + } + listOf(33, 35).forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + listOf("android.permission.POST_NOTIFICATIONS"), + runtimePermissions(PermissionCardId.NOTIFICATIONS, sdkInt), + ) + } + } + + @Test + fun `storage route changes exactly at API 30`() { + val legacyStorage = listOf( + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + ) + + listOf(26, 29).forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + legacyStorage, + runtimePermissions(PermissionCardId.STORAGE, sdkInt), + ) + } + listOf(30, 32, 33, 35).forEach { sdkInt -> + assertTrue( + "SDK $sdkInt", + runtimePermissions(PermissionCardId.STORAGE, sdkInt).isEmpty(), + ) + } + } + + @Test + fun `write settings and accessibility always use explicit settings routes`() { + SDKS.forEach { sdkInt -> + assertTrue( + "write settings SDK $sdkInt", + runtimePermissions(PermissionCardId.WRITE_SETTINGS, sdkInt).isEmpty(), + ) + assertTrue( + "accessibility SDK $sdkInt", + runtimePermissions(PermissionCardId.ACCESSIBILITY, sdkInt).isEmpty(), + ) + } + } + + @Test + fun `empty runtime set on a visible card identifies only an intent route`() { + SDKS.forEach { sdkInt -> + val expected = if (sdkInt < 30) { + listOf( + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + ) + } else { + listOf( + PermissionCardId.STORAGE, + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + ) + } + val actual = visibleCards(sdkInt).filter { card -> + runtimePermissions(card, sdkInt).isEmpty() + } + + assertEquals("SDK $sdkInt", expected, actual) + } + } + + @Test + fun `ordered runtime union is the empty-grant request plan without duplicates`() { + SDKS.forEach { sdkInt -> + val union = allRuntimePermissions(sdkInt) + + assertEquals("SDK $sdkInt plan", union, requestPlan(emptySet(), sdkInt)) + assertEquals("SDK $sdkInt unique", union.size, union.toSet().size) + } + } + + @Test + fun `fresh API 26 request plan includes legacy storage last`() { + assertEquals( + listOf( + "android.permission.RECORD_AUDIO", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.SEND_SMS", + "android.permission.CALL_PHONE", + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + "android.permission.READ_CONTACTS", + "android.permission.WRITE_CONTACTS", + "android.permission.READ_CALENDAR", + "android.permission.CAMERA", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + ), + requestPlan(emptySet(), 26), + ) + } + + @Test + fun `fresh API 35 request plan matches the specified batch order`() { + assertEquals( + listOf( + "android.permission.RECORD_AUDIO", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.SEND_SMS", + "android.permission.CALL_PHONE", + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + "android.permission.READ_CONTACTS", + "android.permission.WRITE_CONTACTS", + "android.permission.READ_CALENDAR", + "android.permission.CAMERA", + "android.permission.POST_NOTIFICATIONS", + ), + requestPlan(emptySet(), 35), + ) + } + + @Test + fun `partial grants are subtracted without disturbing request order`() { + val granted = setOf( + "android.permission.RECORD_AUDIO", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.CALL_PHONE", + "android.permission.READ_CONTACTS", + "android.permission.POST_NOTIFICATIONS", + ) + + assertEquals( + listOf( + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.SEND_SMS", + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + "android.permission.WRITE_CONTACTS", + "android.permission.READ_CALENDAR", + "android.permission.CAMERA", + ), + requestPlan(granted, 35), + ) + } + + @Test + fun `everything held produces no runtime request`() { + SDKS.forEach { sdkInt -> + assertTrue( + "SDK $sdkInt", + requestPlan(allRuntimePermissions(sdkInt).toSet(), sdkInt).isEmpty(), + ) + } + } + + @Test + fun `unknown granted strings do not change the request plan`() { + SDKS.forEach { sdkInt -> + assertEquals( + "SDK $sdkInt", + requestPlan(emptySet(), sdkInt), + requestPlan(setOf("com.example.permission.UNKNOWN"), sdkInt), + ) + } + } + + @Test + fun `manual routes never enter the runtime request plan`() { + listOf(30, 32, 33, 35).forEach { sdkInt -> + val plan = requestPlan(emptySet(), sdkInt) + + assertTrue(runtimePermissions(PermissionCardId.STORAGE, sdkInt).isEmpty()) + assertTrue(runtimePermissions(PermissionCardId.WRITE_SETTINGS, sdkInt).isEmpty()) + assertTrue(runtimePermissions(PermissionCardId.ACCESSIBILITY, sdkInt).isEmpty()) + assertFalse("SDK $sdkInt has no all-files pseudo-permission", "android.permission.MANAGE_EXTERNAL_STORAGE" in plan) + assertFalse("SDK $sdkInt has no write-settings pseudo-permission", "android.permission.WRITE_SETTINGS" in plan) + assertFalse("SDK $sdkInt has no accessibility pseudo-permission", "android.permission.BIND_ACCESSIBILITY_SERVICE" in plan) + } + } + + @Test + fun `single-card plan requests only missing SMS and telephony permissions`() { + assertEquals( + listOf( + "android.permission.CALL_PHONE", + "android.permission.READ_SMS", + "android.permission.RECEIVE_SMS", + ), + requestPlan( + granted = setOf("android.permission.SEND_SMS"), + sdkInt = 35, + card = PermissionCardId.SMS_TELEPHONY, + ), + ) + } + + @Test + fun `single-card plan is empty for held and manual cards`() { + assertTrue( + requestPlan( + granted = setOf("android.permission.CAMERA"), + sdkInt = 35, + card = PermissionCardId.CAMERA, + ).isEmpty(), + ) + assertTrue(requestPlan(emptySet(), 35, PermissionCardId.STORAGE).isEmpty()) + assertTrue(requestPlan(emptySet(), 35, PermissionCardId.WRITE_SETTINGS).isEmpty()) + assertTrue(requestPlan(emptySet(), 35, PermissionCardId.ACCESSIBILITY).isEmpty()) + } + + @Test + fun `forbidden and unused permissions are excluded at every SDK`() { + val forbidden = setOf( + "android.permission.ACCESS_BACKGROUND_LOCATION", + "android.permission.READ_CALL_LOG", + "android.permission.WRITE_CALENDAR", + ) + + SDKS.forEach { sdkInt -> + assertTrue( + "SDK $sdkInt", + allRuntimePermissions(sdkInt).none { permission -> permission in forbidden }, + ) + } + } + + @Test + fun `runtime card status distinguishes missing partial and granted`() { + assertEquals( + CardStatus.MISSING, + cardStatus(PermissionCardId.LOCATION, emptySet(), 35, manualHeld = true), + ) + assertEquals( + CardStatus.PARTIAL, + cardStatus( + PermissionCardId.LOCATION, + setOf("android.permission.ACCESS_FINE_LOCATION"), + 35, + manualHeld = false, + ), + ) + assertEquals( + CardStatus.GRANTED, + cardStatus( + PermissionCardId.LOCATION, + setOf( + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ), + 35, + manualHeld = false, + ), + ) + } + + @Test + fun `manual-held probe is ignored for runtime cards`() { + assertEquals( + CardStatus.MISSING, + cardStatus(PermissionCardId.CAMERA, emptySet(), 35, manualHeld = true), + ) + assertEquals( + CardStatus.GRANTED, + cardStatus( + PermissionCardId.CAMERA, + setOf("android.permission.CAMERA"), + 35, + manualHeld = false, + ), + ) + } + + @Test + fun `storage status follows the API 29 runtime and API 30 manual split`() { + assertEquals( + CardStatus.MISSING, + cardStatus(PermissionCardId.STORAGE, emptySet(), 29, manualHeld = true), + ) + assertEquals( + CardStatus.PARTIAL, + cardStatus( + PermissionCardId.STORAGE, + setOf("android.permission.READ_EXTERNAL_STORAGE"), + 29, + manualHeld = false, + ), + ) + assertEquals( + CardStatus.GRANTED, + cardStatus( + PermissionCardId.STORAGE, + setOf( + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE", + ), + 29, + manualHeld = false, + ), + ) + assertEquals( + CardStatus.MANUAL_PENDING, + cardStatus(PermissionCardId.STORAGE, emptySet(), 30, manualHeld = false), + ) + assertEquals( + CardStatus.MANUAL_GRANTED, + cardStatus(PermissionCardId.STORAGE, emptySet(), 30, manualHeld = true), + ) + } + + @Test + fun `manual card status has no partial branch`() { + listOf( + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + ).forEach { card -> + assertEquals( + CardStatus.MANUAL_PENDING, + cardStatus( + card, + setOf("android.permission.CAMERA"), + 35, + manualHeld = false, + ), + ) + assertEquals( + CardStatus.MANUAL_GRANTED, + cardStatus(card, emptySet(), 35, manualHeld = true), + ) + } + } + + @Test + fun `hidden notifications status is total and vacuously granted`() { + assertEquals( + CardStatus.GRANTED, + cardStatus(PermissionCardId.NOTIFICATIONS, emptySet(), 32, manualHeld = false), + ) + assertEquals( + CardStatus.GRANTED, + cardStatus(PermissionCardId.NOTIFICATIONS, emptySet(), 32, manualHeld = true), + ) + } + + @Test + fun `blocked classification follows granted asked and rationale facts`() { + val permission = "android.permission.CAMERA" + + assertFalse(isBlocked(permission, granted = true, asked = true, showRationale = false)) + assertFalse(isBlocked(permission, granted = false, asked = false, showRationale = false)) + assertFalse(isBlocked(permission, granted = false, asked = true, showRationale = true)) + assertTrue(isBlocked(permission, granted = false, asked = true, showRationale = false)) + assertTrue(isBlocked(permission, granted = false, asked = true, showRationale = null)) + assertFalse(isBlocked(permission, granted = false, asked = false, showRationale = null)) + } + + @Test + fun `grant-all button starts enabled with an exact missing-count summary`() { + val snapshot = snapshot() + + assertEquals( + GrantAllButtonPresentation( + state = GrantAllButtonState.RequestAll, + label = "Grant all permissions", + enabled = true, + ), + grantAllButton(snapshot), + ) + assertEquals( + "12 runtime permissions still needed. Android asks for them in one dialog.", + summaryLine(snapshot), + ) + } + + @Test + fun `grant-all button is disabled while the opaque Android batch is in flight`() { + val presentation = grantAllButton( + snapshot(grantAll = GrantAllState.InFlight), + ) + + assertEquals(GrantAllButtonState.InFlight, presentation.state) + assertEquals("Requesting…", presentation.label) + assertFalse(presentation.enabled) + } + + @Test + fun `grant-all button reports completion and prevents an empty launch`() { + val presentation = grantAllButton( + snapshot(granted = allRuntimePermissions(35).toSet()), + ) + + assertEquals(GrantAllButtonState.Complete, presentation.state) + assertEquals("All runtime permissions granted", presentation.label) + assertFalse(presentation.enabled) + } + + @Test + fun `returned batch with askable gaps offers grant remaining`() { + val permissions = allRuntimePermissions(35) + val granted = permissions.take(8).toSet() + val missing = permissions.drop(8) + val presentation = grantAllButton( + snapshot( + granted = granted, + asked = permissions.toSet(), + rationale = missing.associateWith { true }, + grantAll = GrantAllState.Returned(permissions.toSet()), + ), + ) + + assertEquals(GrantAllButtonState.RequestRemaining, presentation.state) + assertEquals("Grant remaining permissions", presentation.label) + assertTrue(presentation.enabled) + } + + @Test + fun `all-blocked grant-all state is reachable only after a returned batch`() { + val permissions = allRuntimePermissions(35) + val facts = snapshot( + asked = permissions.toSet(), + rationale = permissions.associateWith { false }, + ) + + assertEquals(GrantAllButtonState.RequestAll, grantAllButton(facts).state) + + val returnedPresentation = grantAllButton( + facts.copy(grantAll = GrantAllState.Returned(permissions.toSet())), + ) + assertEquals(GrantAllButtonState.AllBlocked, returnedPresentation.state) + assertEquals("Blocked → use App info below", returnedPresentation.label) + assertFalse(returnedPresentation.enabled) + } + + @Test + fun `returned summary reports a completely granted batch`() { + val permissions = allRuntimePermissions(35).toSet() + + assertEquals( + "All 12 permissions granted.", + summaryLine( + snapshot( + granted = permissions, + asked = permissions, + grantAll = GrantAllState.Returned(permissions), + ), + ), + ) + } + + @Test + fun `returned summary reports ordinary declines without claiming blocks`() { + val permissions = allRuntimePermissions(35) + val granted = permissions.take(8).toSet() + val declined = permissions.drop(8) + + assertEquals( + "8 of 12 granted. 4 declined → tap Grant on a card to ask again.", + summaryLine( + snapshot( + granted = granted, + asked = permissions.toSet(), + rationale = declined.associateWith { true }, + grantAll = GrantAllState.Returned(permissions.toSet()), + ), + ), + ) + } + + @Test + fun `returned summary reports a completely blocked batch`() { + val permissions = allRuntimePermissions(35) + + val snapshot = snapshot( + asked = permissions.toSet(), + rationale = permissions.associateWith { false }, + grantAll = GrantAllState.Returned(permissions.toSet()), + ) + assertEquals( + "None granted. 12 blocked in system settings → use App info below.", + summaryLine(snapshot), + ) + assertTrue(summaryHasBlocked(snapshot)) + } + + @Test + fun `returned summary separates declined and blocked counts`() { + val permissions = allRuntimePermissions(35) + val granted = permissions.take(8).toSet() + val declined = permissions[8] + val blocked = permissions.drop(9) + + val snapshot = snapshot( + granted = granted, + asked = permissions.toSet(), + rationale = mapOf(declined to true) + blocked.associateWith { false }, + grantAll = GrantAllState.Returned(permissions.toSet()), + ) + assertEquals( + "8 of 12 granted. 1 declined, 3 blocked → use App info below.", + summaryLine(snapshot), + ) + assertTrue(summaryHasBlocked(snapshot)) + } + + @Test + fun `summary counts recompute from live facts after an App Info return`() { + val permissions = allRuntimePermissions(35) + val firstEight = permissions.take(8).toSet() + val declined = permissions[8] + val blocked = permissions.drop(9) + val base = snapshot( + granted = firstEight, + asked = permissions.toSet(), + rationale = mapOf(declined to true) + blocked.associateWith { false }, + grantAll = GrantAllState.Returned(permissions.toSet()), + ) + + assertEquals( + "10 of 12 granted. 1 declined, 1 blocked → use App info below.", + summaryLine(base.copy(granted = firstEight + blocked.take(2))), + ) + } + + @Test + fun `runtime card presentation covers granted partial and ordinary decline`() { + val allSms = runtimePermissions(PermissionCardId.SMS_TELEPHONY, 35) + val grantedSms = snapshot(granted = allSms.toSet()) + assertEquals("Granted", cardButtonLabel(PermissionCardId.SMS_TELEPHONY, grantedSms)) + assertEquals("Granted.", cardStatusLine(PermissionCardId.SMS_TELEPHONY, grantedSms)) + assertFalse(cardActionEnabled(PermissionCardId.SMS_TELEPHONY, grantedSms)) + + val partialSms = snapshot(granted = setOf("android.permission.SEND_SMS")) + assertEquals("Grant rest", cardButtonLabel(PermissionCardId.SMS_TELEPHONY, partialSms)) + assertEquals("1 of 4 granted.", cardStatusLine(PermissionCardId.SMS_TELEPHONY, partialSms)) + assertTrue(cardActionEnabled(PermissionCardId.SMS_TELEPHONY, partialSms)) + + val deniedCamera = snapshot( + asked = setOf("android.permission.CAMERA"), + rationale = mapOf("android.permission.CAMERA" to true), + ) + assertEquals("Grant", cardButtonLabel(PermissionCardId.CAMERA, deniedCamera)) + assertEquals( + "Declined. Tap Grant to ask again.", + cardStatusLine(PermissionCardId.CAMERA, deniedCamera), + ) + } + + @Test + fun `predicted block remains a neutral retry before App Info is earned`() { + val predicted = snapshot( + asked = setOf("android.permission.CAMERA"), + rationale = mapOf("android.permission.CAMERA" to false), + ) + + assertEquals("Grant", cardButtonLabel(PermissionCardId.CAMERA, predicted)) + assertEquals("Blocked in system settings.", cardStatusLine(PermissionCardId.CAMERA, predicted)) + assertFalse(cardStatusHasError(PermissionCardId.CAMERA, predicted)) + } + + @Test + fun `observed blocked card presents an explicit App Info fallback`() { + val observed = snapshot( + asked = setOf("android.permission.CAMERA"), + rationale = mapOf("android.permission.CAMERA" to false), + appInfoOffered = setOf(PermissionCardId.CAMERA), + ) + + assertEquals("App info", cardButtonLabel(PermissionCardId.CAMERA, observed)) + assertEquals( + "Android won't ask again. Open app settings, then Permissions → Camera.", + cardStatusLine(PermissionCardId.CAMERA, observed), + ) + assertTrue(cardActionEnabled(PermissionCardId.CAMERA, observed)) + assertTrue(cardStatusHasError(PermissionCardId.CAMERA, observed)) + } + + @Test + fun `manual card presentation requires an explicit settings trip and disables held actions`() { + val pending = snapshot() + assertEquals("Open Settings", cardButtonLabel(PermissionCardId.WRITE_SETTINGS, pending)) + assertEquals( + "Android requires this to be switched on in Settings.", + cardStatusLine(PermissionCardId.WRITE_SETTINGS, pending), + ) + assertTrue(cardActionEnabled(PermissionCardId.WRITE_SETTINGS, pending)) + + val held = snapshot(manualHeld = setOf(PermissionCardId.WRITE_SETTINGS)) + assertEquals("Enabled", cardButtonLabel(PermissionCardId.WRITE_SETTINGS, held)) + assertEquals( + "Enabled in system Settings.", + cardStatusLine(PermissionCardId.WRITE_SETTINGS, held), + ) + assertFalse(cardActionEnabled(PermissionCardId.WRITE_SETTINGS, held)) + } + + @Test + fun `all-visible-requirements derivation includes manual cards`() { + val runtimeGranted = allRuntimePermissions(35).toSet() + + assertFalse( + allVisibleRequirementsHeld( + snapshot( + granted = runtimeGranted, + manualHeld = setOf(PermissionCardId.STORAGE, PermissionCardId.WRITE_SETTINGS), + ), + ), + ) + assertTrue( + allVisibleRequirementsHeld( + snapshot( + granted = runtimeGranted, + manualHeld = setOf( + PermissionCardId.STORAGE, + PermissionCardId.WRITE_SETTINGS, + PermissionCardId.ACCESSIBILITY, + ), + ), + ), + ) + } +} diff --git a/app/src/test/java/com/opendroid/ai/data/crash/CrashLogMappingTest.kt b/app/src/test/java/com/opendroid/ai/data/crash/CrashLogMappingTest.kt new file mode 100644 index 0000000..3076945 --- /dev/null +++ b/app/src/test/java/com/opendroid/ai/data/crash/CrashLogMappingTest.kt @@ -0,0 +1,32 @@ +package com.opendroid.ai.data.crash + +import com.opendroid.ai.core.crash.CrashLogRecord +import com.opendroid.ai.core.crash.DeviceMetadata +import org.junit.Assert.assertEquals +import org.junit.Test + +class CrashLogMappingTest { + + private val device = DeviceMetadata( + appVersionName = "1.2.3", + appVersionCode = 42L, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel 8", + ) + + @Test + fun `record survives a Room entity round trip without unpacking device metadata`() { + val record = CrashLogRecord( + timestamp = 1_700_000_000_000L, + exceptionClass = "java.lang.IllegalStateException", + message = "boom", + threadName = "main", + stackTrace = "java.lang.IllegalStateException: boom", + device = device, + ) + + assertEquals(record, record.toEntity().toRecord()) + } +} 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 50e609d..eeee40a 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 @@ -2,214 +2,175 @@ 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.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.core.app.ApplicationProvider -import com.opendroid.ai.data.db.entities.CrashLogEntity -import kotlinx.coroutines.runBlocking -import org.junit.After +import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Rule 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. + * Avoids [com.opendroid.ai.OpenDroidApp] so Robolectric does not need the + * Android Keystore used by SecurePrefs during Application.onCreate. */ -// 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. +class MigrationTestApplication : Application() + @RunWith(RobolectricTestRunner::class) -@Config(sdk = [35], application = Application::class) +@Config(sdk = [35], application = MigrationTestApplication::class) class OpenDroidDatabaseMigrationTest { - private val context: Context = ApplicationProvider.getApplicationContext() + // Consumes the exported schemas in app/schemas (wired into test assets in + // app/build.gradle) and validates migrated databases against them. + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + OpenDroidDatabase::class.java + ) - @After - fun tearDown() { - context.deleteDatabase(TEST_DB) - context.deleteDatabase(REFERENCE_DB) - context.deleteDatabase(IDEMPOTENCY_DB) - } + // Room 2.7+ compares the open helper's configured name against the fully + // resolved database path, so the helper must be addressed by absolute path. + private val databasePath: String + get() = ApplicationProvider.getApplicationContext() + .getDatabasePath(TEST_DATABASE).absolutePath @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 + fun `migration 6 to 7 preserves existing data and creates the exact crash log schema`() { + // Creates a real version-6 database from the exported 6.json schema. + helper.createDatabase(databasePath, 6).use { db -> + db.execSQL( + """ + INSERT INTO memories (`key`, `value`, `type`, `timestamp`, `ttlHours`, `category`) + VALUES ('preferred-model', 'claude-sonnet', 'PREFERENCE', 1234, -1, 'FACT') + """.trimIndent() ) - .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() + } + + // Validates the migrated schema (crash_logs columns and index included) + // against the exported 7.json, table by table. + val migrated = helper.runMigrationsAndValidate( + databasePath, 7, true, OpenDroidDatabase.MIGRATION_6_7 + ) + + migrated.query( + "SELECT `value`, `timestamp` FROM memories WHERE `key` = 'preferred-model'" + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("claude-sonnet", cursor.getString(0)) + assertEquals(1234L, cursor.getLong(1)) } } @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() + fun `migration 6 to 7 is safe to run against an existing crash_logs table`() { + // Room wraps each migration in a transaction, so crash recovery does + // not need the IF NOT EXISTS guards. They promise something narrower: + // running the migration when crash_logs already exists and holds data + // must neither throw nor clobber the table. + val db = helper.createDatabase(databasePath, 7) + db.execSQL( + """ + INSERT INTO crash_logs (`timestamp`, `exceptionClass`, `message`, `threadName`, `stackTrace`, + `appVersionName`, `appVersionCode`, `androidRelease`, `androidSdkInt`, + `deviceManufacturer`, `deviceModel`) + VALUES (1, 'java.lang.RuntimeException', 'boom', 'main', 'trace', '1.0.2', 3, '15', 35, 'Robolectric', 'JVM') + """.trimIndent() + ) + + OpenDroidDatabase.MIGRATION_6_7.migrate(db) + + db.query("SELECT `message` FROM crash_logs").use { cursor -> + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + assertEquals("boom", cursor.getString(0)) } } - /** - * 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 + @Test + fun `full migration chain from 1 to 7 produces the current schema and keeps data`() { + val context = ApplicationProvider.getApplicationContext() + context.deleteDatabase(TEST_DATABASE) + + // Version 1 predates schema export (app/schemas only contains 6.json and + // 7.json), so the v1 database is created by hand: every table that no + // MIGRATION_* creates must already have existed at version 1. + FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(databasePath) + .callback(V1SchemaCallback()) + .build() + ).writableDatabase.use { db -> + db.execSQL( + """ + INSERT INTO conversations (`id`, `text`, `sender`, `timestamp`, `modelBadge`) + VALUES ('msg-1', 'hello', 'USER', 42, NULL) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO memories (`key`, `value`, `type`, `timestamp`, `ttlHours`, `category`) + VALUES ('preferred-model', 'claude-sonnet', 'PREFERENCE', 1234, -1, 'FACT') + """.trimIndent() ) - 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 + + // Runs every migration in sequence and validates the final schema + // against the exported 7.json. + val migrated = helper.runMigrationsAndValidate( + databasePath, 7, true, + OpenDroidDatabase.MIGRATION_1_2, + OpenDroidDatabase.MIGRATION_2_3, + OpenDroidDatabase.MIGRATION_3_4, + OpenDroidDatabase.MIGRATION_4_5, + OpenDroidDatabase.MIGRATION_5_6, + OpenDroidDatabase.MIGRATION_6_7 ) - 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 + // Pre-existing chat history survives and is attached to the session + // backfilled by MIGRATION_5_6. + migrated.query( + "SELECT `text`, `sessionId` FROM conversations WHERE `id` = 'msg-1'" + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("hello", cursor.getString(0)) + assertEquals("default_session", cursor.getString(1)) + } + migrated.query( + "SELECT `value` FROM memories WHERE `key` = 'preferred-model'" + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("claude-sonnet", cursor.getString(0)) } } - 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 class V1SchemaCallback : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + V1_CREATE_STATEMENTS.forEach(db::execSQL) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + } 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" + const val TEST_DATABASE = "migration-test" + + // The version-1 schema: the current schema minus everything the + // migrations add (contactPickerData 1->2, notifications 2->3 with + // senderEmail 3->4, models 4->5, chat_sessions + sessionId 5->6, + // crash_logs 6->7). + val V1_CREATE_STATEMENTS = listOf( + "CREATE TABLE IF NOT EXISTS `conversations` (`id` TEXT NOT NULL, `text` TEXT NOT NULL, `sender` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `modelBadge` TEXT, PRIMARY KEY(`id`))", + "CREATE TABLE IF NOT EXISTS `plans` (`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`))", + "CREATE TABLE IF NOT EXISTS `memories` (`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`))", + "CREATE TABLE IF NOT EXISTS `task_history` (`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)", + "CREATE TABLE IF NOT EXISTS `macros` (`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`))", + "CREATE TABLE IF NOT EXISTS `unknown_actions` (`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)" ) } } diff --git a/build.gradle b/build.gradle index d7fdcee..58987a5 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { dependencies { classpath 'com.android.tools:r8:9.1.31' classpath 'com.android.tools.build:gradle:8.8.2' + classpath 'androidx.room:room-gradle-plugin:2.8.4' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.0' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.58' classpath 'org.jetbrains.kotlin:kotlin-serialization:2.4.0'