diff --git a/.gitmodules b/.gitmodules index ad9dd4f3..18461bc8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ -[submodule "ai-core/subprojects/llama.cpp"] - path = ai-core/subprojects/llama.cpp +[submodule "ai-agent-local/subprojects/llama.cpp"] + path = ai-agent-local/subprojects/llama.cpp url = https://github.com/appdevforall/llama.cpp.git branch = androidide-custom diff --git a/README.md b/README.md index ab158a3a..2dd2a5d2 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`compose-preview/`](compose-preview/) | Renders Jetpack Compose `@Preview` functions on-device — no full app build or run. | | [`ai-literacy-course/`](ai-literacy-course/) | Bundles Learn AI Anywhere's offline "Introduction to AI" course (26 videos + interactive activities) and plays it full-screen, fully offline. | | [`layout-editor/`](layout-editor/) | Visual drag-and-drop editor for Android XML layouts. | -| [`ai-core/`](ai-core/) | Shared on-device LLM inference backend (bundled llama.cpp AAR) plus a Gemini API backend, exposed to other plugins as a runtime service. | -| [`ai-assistant/`](ai-assistant/) | In-IDE AI chat assistant with tool calling; talks to `ai-core` for inference over local or Gemini models. | +| [`ai-core/`](ai-core/) | The **Agent** chat (tool-calling assistant) plus the shared LLM inference **router** other plugins consume. Ships no model — install at least one backend plugin below. Mandatory for every AI feature. | +| [`ai-agent-local/`](ai-agent-local/) | On-device `.gguf` inference backend for `ai-core` (bundled llama.cpp AAR). Registers as `local`; needs no network. | +| [`ai-agent-gemini/`](ai-agent-gemini/) | Google Gemini API inference backend for `ai-core`. Registers as `gemini`; needs an API key and network access. | | [`flutter-template/`](flutter-template/) | Adds Flutter starter project templates (Basic, BLoC, Provider, GetX, Riverpod) to the New Project screen. | | [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. | | [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. | diff --git a/ai-assistant/.gitignore b/ai-agent-gemini/.gitignore similarity index 100% rename from ai-assistant/.gitignore rename to ai-agent-gemini/.gitignore diff --git a/ai-agent-gemini/README.md b/ai-agent-gemini/README.md new file mode 100644 index 00000000..4be7e9d3 --- /dev/null +++ b/ai-agent-gemini/README.md @@ -0,0 +1,69 @@ +# AI Agent Gemini plugin for CodeOnTheGo + +Google Gemini API inference for CodeOnTheGo's AI plugins. Registers itself as the +`gemini` backend with [`ai-core`](../ai-core/)'s `LlmInferenceService`, which is +what `ai-core`'s Agent chat, `code-suggestions-plugin`, `speech-to-text-plugin` and +`vector-search-plugin` actually talk to. + +Calls the Generative Language REST API directly over `HttpURLConnection` rather +than the google-genai SDK: the SDK bundles OkHttp 4.x, but plugins run in the +host IDE's classloader where `okhttp3` resolves to the host's older OkHttp, and +that mismatch crashed generation with a `NoSuchMethodError`. + +## Building + +Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with +`sdk.dir=...`. This plugin uses the shared wrapper at the repo root: + +```bash +cd ai-agent-gemini +../gradlew assemblePlugin # release -> build/plugin/ai-agent-gemini.cgp +../gradlew assemblePluginDebug # debug variant +``` + +## API key handling + +The key is entered in **AI Core → Agent settings**, not here. It is stored +encrypted (AES/GCM under a hardware-backed Android Keystore secret) and sent as +an `x-goog-api-key` **header**, never in a URL query string. + +`security/SecureApiKeyStore.kt` is the only copy of the crypto — this plugin owns +both the write and the read, so there are no constants to keep in sync with +another plugin. A key written under an earlier plugin id is adopted once by +`preferences/GeminiPreferences.kt` and re-encrypted here. + +## Installation + +Install **`ai-core` as well** — without the router this plugin has nothing to +register with. Order does not matter: this plugin re-registers when it sees +ai-core activate. Copy `build/plugin/ai-agent-gemini.cgp` to the device, install +via CodeOnTheGo's Plugin Manager, then restart the IDE. + +## Cross-plugin contract + +This plugin's own settings pane calls `GeminiBackend.listModels()` and +`listModels(String)` directly (see `BackendGeminiCatalogGateway` in +`settings/GeminiCatalogGateway.kt`) to populate the model picker and to verify a +key before it is saved. Those two signatures, and the `ListModels HTTP ` +message shape thrown by `fetchAvailableModels`, are a contract — the pane is +mounted by ai-core across the plugin classloader boundary, so +`proguard-rules.pro` pins the class and its public methods. + +## Key classes + +Every source file sits in a package named for its layer; nothing is loose at the +root of `com/itsaky/androidide/plugins/aiagentgemini/`. + +- `plugin/GeminiPlugin.kt` — plugin entry point; registers the backend with ai-core +- `backend/GeminiBackend.kt` — the REST transport, streaming (SSE) and model catalog +- `errors/GeminiErrorFormatter.kt` — turns an API failure into one translated sentence +- `security/SecureApiKeyStore.kt` — AES/GCM at rest +- `preferences/GeminiPreferences.kt` — this plugin's settings store, plus the + one-time adoption of settings written under earlier plugin ids +- `prompt/GeminiSystemPrompt.kt` — the system prompt this cloud model is given +- `logging/` — `LOG_PREFIX` (`AiAgentGemini`), prefixing every logcat tag this plugin writes +- `settings/` — the settings pane this backend contributes to the selector + +## License + +GPL-3.0 — same as AndroidIDE / CodeOnTheGo. diff --git a/ai-agent-gemini/ai-agent-gemini.html b/ai-agent-gemini/ai-agent-gemini.html new file mode 100644 index 00000000..9a228040 --- /dev/null +++ b/ai-agent-gemini/ai-agent-gemini.html @@ -0,0 +1,126 @@ + + + + + +AI Agent Gemini Plugin + + + +

AI Agent Gemini Plugin

+ +

Executive overview

+

AI Agent Gemini adds Google's Gemini models to CodeOnTheGo's + AI features. It registers itself as the gemini inference backend + with AI Core, which routes requests from the Agent chat, Code + Suggestions, Speech to Text and Vector Search. It also + contributes its own settings pane — the API key field and the model + picker — which AI Core mounts inside Agent settings.

+

Install AI Core alongside it — without the router this plugin has + nothing to register with. Install order does not matter.

+

Using this backend sends prompts, and any file contents a plugin includes in + them, to Google over HTTPS. Choose AI Agent Local instead if inference + must stay on the device.

+ +

Core functionality

+ + +

Technical architecture

+ + + + + + + +
ComponentRole
GeminiPluginPlugin entry point. Registers the + backend with AI Core on activation, re-registering if AI Core activates + later; cancels in-flight requests and drops the decrypted key on + dispose.
GeminiBackendThe transport. Calls the + Generative Language REST API over HttpURLConnection, parses the + streaming response, and fetches the model catalog.
GeminiErrorFormatterClassifies a failure + (retired model, quota, refused key, outage, unreachable) so it can be + reported as one translated sentence.
SecureApiKeyStoreAES/GCM encryption of the API + key under a hardware-backed Android Keystore secret owned by this + plugin.
GeminiSettingsFragmentThe settings pane AI Core + mounts: key entry and verification, visibility toggle, and the model picker + driven by the live catalog.
+

No third-party HTTP SDK. The google-genai SDK bundles OkHttp 4.x, but + plugins run in the host IDE's classloader where okhttp3 resolves to + the host's older OkHttp — a mismatch that crashed generation with a + NoSuchMethodError. HttpURLConnection has no + third-party dependency and works regardless of the host's OkHttp version.

+ +

Usage

+
    +
  1. Obtain a Gemini API key from Google AI Studio.
  2. +
  3. Install AI Core and AI Agent Gemini via the Plugin + Manager, then restart the IDE.
  4. +
  5. Open Preferences → Configuration → Agent and select the + gemini backend. This plugin's own pane appears below it.
  6. +
  7. Enter the key and tap Save — it is verified against the API before + it is stored — then pick a model from the live list.
  8. +
+
+ The key is sent as an x-goog-api-key request header, never in a + URL query string — query strings leak into logs, proxies and crash reports. +
+ +

Key benefits

+ + + diff --git a/ai-assistant/build.gradle.kts b/ai-agent-gemini/build.gradle.kts similarity index 51% rename from ai-assistant/build.gradle.kts rename to ai-agent-gemini/build.gradle.kts index 398554b3..d3ecedf3 100644 --- a/ai-assistant/build.gradle.kts +++ b/ai-agent-gemini/build.gradle.kts @@ -5,29 +5,23 @@ plugins { } pluginBuilder { - pluginName = "ai-assistant" + pluginName = "ai-agent-gemini" } android { - namespace = "com.itsaky.androidide.plugins.aiassistant" + namespace = "com.itsaky.androidide.plugins.aiagentgemini" compileSdk = 36 defaultConfig { - applicationId = "com.itsaky.androidide.plugins.aiassistant" + applicationId = "com.itsaky.androidide.plugins.aiagentgemini" minSdk = 33 targetSdk = 36 - versionCode = 2 - versionName = "1.1.0" - } - - buildFeatures { - viewBinding = true - buildConfig = true + versionCode = 1 + versionName = "1.0.0" } buildTypes { release { - // Disable minification to avoid lambda obfuscation issues with ClassLoader isolation isMinifyEnabled = false isShrinkResources = false signingConfig = signingConfigs.getByName("debug") @@ -46,10 +40,6 @@ android { } } - testOptions { - unitTests.isReturnDefaultValues = true - } - packaging { resources { excludes += setOf( @@ -57,7 +47,8 @@ android { "META-INF/LICENSE", "META-INF/LICENSE.txt", "META-INF/NOTICE", - "META-INF/NOTICE.txt" + "META-INF/NOTICE.txt", + "META-INF/INDEX.LIST" ) } } @@ -66,26 +57,25 @@ android { dependencies { compileOnly(files("../libs/plugin-api.jar")) - // Use 'implementation' (not 'compileOnly') for androidx libraries. - // This is required for XML layouts: AAPT2 needs these dependencies at compile-time to process - // resource attributes and resolve xmlns declarations. This is standard across all CoGo plugins - // with XML layouts (random-xkcd, sketch-to-ui-plugin, Beepy). See investigation in Task 4. + // 'implementation' (not 'compileOnly') for the androidx/Material libraries: AAPT2 needs them + // at compile time to process the settings pane's layout, as in every CoGo plugin with XML. implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.fragment:fragment-ktx:1.8.8") implementation("com.google.android.material:material:1.10.0") - implementation("androidx.recyclerview:recyclerview:1.3.2") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - - // Markdown rendering - plugin-specific library - implementation("io.noties.markwon:core:4.6.2") - - // JSON serialization for session persistence - implementation("com.google.code.gson:gson:2.10.1") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation(files("../libs/plugin-api.jar")) testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") - testImplementation("org.json:json:20240303") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") - testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("org.json:json:20231013") } + +// SecureApiKeyStore is no longer duplicated: this plugin holds the only copy, so there is nothing +// left to drift against. The parity check that guarded the ai-assistant copy went with that plugin. + +// AAR metadata checks are disabled by convention for these application-as-library plugins. +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { enabled = false } diff --git a/ai-assistant/gradle.properties b/ai-agent-gemini/gradle.properties similarity index 100% rename from ai-assistant/gradle.properties rename to ai-agent-gemini/gradle.properties diff --git a/ai-agent-gemini/proguard-rules.pro b/ai-agent-gemini/proguard-rules.pro new file mode 100644 index 00000000..d67c3e38 --- /dev/null +++ b/ai-agent-gemini/proguard-rules.pro @@ -0,0 +1,21 @@ +# AI Agent Gemini Plugin ProGuard Rules + +# Keep plugin entry point +-keep public class com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin { + public ; +} + +# Keep the backend: AI Core's settings pane calls listModels across the plugin +# classloader boundary, because listModels is not on LlmBackend. Renaming or +# stripping it breaks the model picker and key verification silently. +-keep public class com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend { + public ; +} + +# Keep the settings fragment: it is instantiated by name from getSettingsFragmentClassName(). +-keep public class com.itsaky.androidide.plugins.aiagentgemini.settings.GeminiSettingsFragment { + public (...); +} + +# Keep plugin-api interfaces +-keep interface com.itsaky.androidide.plugins.** { *; } diff --git a/ai-assistant/settings.gradle.kts b/ai-agent-gemini/settings.gradle.kts similarity index 95% rename from ai-assistant/settings.gradle.kts rename to ai-agent-gemini/settings.gradle.kts index a529bf5f..76821e74 100644 --- a/ai-assistant/settings.gradle.kts +++ b/ai-agent-gemini/settings.gradle.kts @@ -32,4 +32,4 @@ dependencyResolutionManagement { } } -rootProject.name = "ai-assistant" +rootProject.name = "ai-agent-gemini" diff --git a/ai-agent-gemini/src/main/AndroidManifest.xml b/ai-agent-gemini/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5c721c7b --- /dev/null +++ b/ai-agent-gemini/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai-agent-gemini/src/main/assets/docs/index.html b/ai-agent-gemini/src/main/assets/docs/index.html new file mode 100644 index 00000000..2fcb90f0 --- /dev/null +++ b/ai-agent-gemini/src/main/assets/docs/index.html @@ -0,0 +1,79 @@ + + + + + +AI Agent Gemini — Guide + + + +

AI Agent Gemini — Cloud Inference

+ +

AI Agent Gemini adds the gemini backend to AI + Core, which routes requests from the Agent chat, Code + Suggestions, Speech to Text and Vector Search. Install AI + Core as well — without it this plugin has nothing to register with.

+ +

Setup

+
    +
  • Get a Gemini API key from Google AI Studio.
  • +
  • Open Preferences → Configuration → Agent and select the + gemini backend. This plugin's own pane appears below it.
  • +
  • Enter the key there and tap Save. It is verified against Google's + model catalog before it is stored, so a refused key is reported immediately + rather than at the first prompt.
  • +
  • Pick a model in the same pane. The list is fetched live, so it never + offers a retired model.
  • +
+ +
+ The key is stored encrypted with AES/GCM under a hardware-backed Android + Keystore secret, and is sent as an x-goog-api-key header — never + in a URL, where it would leak into logs and proxies. +
+ +

Privacy

+

Prompts, and any file contents a plugin includes in them, are transmitted to + Google over HTTPS. If you need inference that stays on the device, install + AI Agent Local instead and select it in AI Settings.

+ +

Troubleshooting

+
    +
  • "Google refused your Gemini API key" — the key is wrong or lacks + access; re-enter it in AI Settings.
  • +
  • "The model … is no longer available" — tap Refresh Models in + AI Settings and pick another one.
  • +
  • "Could not reach Gemini" — no network route; check the connection.
  • +
  • Rate limit or quota — wait, or check usage in Google AI Studio.
  • +
  • The gemini backend never appears — AI Core isn't + installed or activated; install it and restart the IDE.
  • +
+ + diff --git a/ai-agent-gemini/src/main/assets/icon_day.png b/ai-agent-gemini/src/main/assets/icon_day.png new file mode 100644 index 00000000..3ee81175 Binary files /dev/null and b/ai-agent-gemini/src/main/assets/icon_day.png differ diff --git a/ai-agent-gemini/src/main/assets/icon_night.png b/ai-agent-gemini/src/main/assets/icon_night.png new file mode 100644 index 00000000..9bd7507b Binary files /dev/null and b/ai-agent-gemini/src/main/assets/icon_night.png differ diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt similarity index 83% rename from ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt rename to ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt index 2b5f07fb..c8333807 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt @@ -1,11 +1,20 @@ -package com.itsaky.androidide.plugins.aicore +package com.itsaky.androidide.plugins.aiagentgemini.backend import android.content.SharedPreferences import android.os.Looper import com.itsaky.androidide.plugins.PluginContext -import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.aiagentgemini.R +import com.itsaky.androidide.plugins.aiagentgemini.errors.GeminiErrorFormatter +import com.itsaky.androidide.plugins.aiagentgemini.errors.GeminiFailure +import com.itsaky.androidide.plugins.aiagentgemini.preferences.GeminiPreferences +import com.itsaky.androidide.plugins.aiagentgemini.prompt.GeminiSystemPrompt +import com.itsaky.androidide.plugins.aiagentgemini.security.SecureApiKeyStore import com.itsaky.androidide.plugins.services.LlmInferenceService.* -import com.itsaky.androidide.plugins.services.SharedServices +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,13 +23,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlin.coroutines.coroutineContext import org.json.JSONArray import org.json.JSONObject -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.concurrent.CompletableFuture /** * Gemini API backend for cloud-based LLM inference. @@ -31,7 +35,9 @@ import java.util.concurrent.CompletableFuture * OkHttp (no such overload) — that mismatch crashed generation with a NoSuchMethodError. * HttpURLConnection has no third-party dependency, so it works regardless of the host's OkHttp. */ -class GeminiBackend(private val context: PluginContext) : LlmBackend, CancellableBackend { +class GeminiBackend( + private val context: PluginContext +) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend { private val scope = CoroutineScope(Dispatchers.IO) @@ -50,9 +56,6 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl /** Current default model. gemini-1.5-* is retired on v1beta and now 404s. */ const val DEFAULT_MODEL = "gemini-2.5-flash" - /** Pref key holding the (encrypted) Gemini API key, written by ai-assistant. */ - private const val KEY_API_KEY = "gemini_api_key" - /** Base URL for the v1beta models API (ListModels, generateContent, streaming). */ private const val MODELS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models" @@ -64,10 +67,9 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl private const val METHOD_STREAM_GENERATE_CONTENT = "streamGenerateContent" } - /** ai-assistant's shared prefs, where the Gemini settings live, or null if unreachable. */ + /** This plugin's own settings, written by its settings pane and read here at request time. */ private fun agentPrefs(): SharedPreferences? = try { - SharedServices.get(PluginContext::class.java) - ?.getPluginSharedPreferences("AgentSettings") + GeminiPreferences.of(context) } catch (e: Exception) { context.logger.error("GeminiBackend: Error getting preferences", e) null @@ -77,10 +79,10 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl * Get the model name from preferences, or use the current default. */ private fun getModelName(): String = - agentPrefs()?.getString("gemini_model", DEFAULT_MODEL) ?: DEFAULT_MODEL + agentPrefs()?.getString(GeminiPreferences.KEY_MODEL, DEFAULT_MODEL) ?: DEFAULT_MODEL /** - * Read the saved Gemini API key from ai-assistant's shared prefs, or null. + * Read the saved Gemini API key from AI Core's shared prefs, or null. * * Decryption is Keystore IPC + AES/GCM and must not run on the main thread. Every caller * today reaches this from [Dispatchers.IO], but [LlmBackend.isAvailable] is a synchronous @@ -89,7 +91,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl * rather than blocking; [warmKeyCache] fills the cache first so that never reports "no key". */ private fun readGeminiApiKey(): String? { - val stored = agentPrefs()?.getString(KEY_API_KEY, null) + val stored = agentPrefs()?.getString(GeminiPreferences.KEY_API_KEY, null) if (stored.isNullOrBlank()) { keyCache = null return null @@ -114,9 +116,9 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl */ private fun refreshKeyCache(): String? { val prefs = agentPrefs() - val plain = SecureApiKeyStore.readAndMigrate(prefs, KEY_API_KEY) + val plain = SecureApiKeyStore.readAndMigrate(prefs, GeminiPreferences.KEY_API_KEY) ?.trim()?.takeIf { it.isNotBlank() } - val raw = prefs?.getString(KEY_API_KEY, null) + val raw = prefs?.getString(GeminiPreferences.KEY_API_KEY, null) keyCache = raw?.let { it to plain } return plain } @@ -124,7 +126,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl /** * Warm [keyCache] off-thread, so the synchronous [isAvailable] never reports "no key" for a * stored, decryptable key just because it was first called from the main thread. Invoked from - * AiCorePlugin.activate(). + * [GeminiPlugin.activate]. */ fun warmKeyCache() { if (!scope.isActive) return @@ -144,6 +146,23 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl override fun getName(): String = "Gemini API" + /** + * Written for a large cloud model; see [GeminiSystemPrompt] for why the wording belongs here + * rather than with the caller. + */ + override fun getSystemPrompt(request: SystemPromptRequest): String = + GeminiSystemPrompt.build(request) + + /** Room to plan, matching the high-autonomy prompt this backend asks for. */ + override fun getDefaultTemperature(): Float = 0.7f + + /** + * This backend draws its own settings, so the consumer needs no knowledge of API keys, AI + * Studio or Google's model catalog. + */ + override fun getSettingsFragmentClassName(): String = + "com.itsaky.androidide.plugins.aiagentgemini.settings.GeminiSettingsFragment" + override fun isAvailable(): Boolean { // Available once a (decryptable) API key is configured. val apiKey = readGeminiApiKey() @@ -191,6 +210,57 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl prompt: String, config: LlmConfig, callback: StreamCallback + ) { + streamContents(JSONArray().put(contentJson("user", buildPrompt(prompt, config))), config, callback) + } + + /** + * Builds the `contents[]` array for a multi-turn request. + * + * Gemini has no system role, so the system prompt is carried as a leading user turn the model + * acknowledges — the same shape [generateWithHistory] uses, kept in one place so the two + * transports cannot drift apart. + * + * @param history the conversation so far, oldest first + * @param prompt the current user turn, appended last + * @param config supplies the optional system prompt + */ + private fun buildContents( + history: List, + prompt: String, + config: LlmConfig + ): JSONArray { + val contents = JSONArray() + config.systemPrompt?.let { systemPrompt -> + contents.put(contentJson("user", systemPrompt)) + contents.put(contentJson("model", "Understood.")) + } + for (msg in history) { + val role = when (msg.role) { + ChatMessage.Role.USER -> "user" + ChatMessage.Role.ASSISTANT -> "model" + // Gemini has no system role; a mid-conversation system note goes as a user turn. + ChatMessage.Role.SYSTEM -> "user" + // No native function calling here, so a tool result rides in as a user turn. + ChatMessage.Role.TOOL -> "user" + } + contents.put(contentJson(role, msg.content)) + } + contents.put(contentJson("user", prompt)) + return contents + } + + /** + * Streams one `streamGenerateContent` request over the already-built [contents]. + * + * @param contents the request's `contents[]` turns + * @param config sampling settings for this request + * @param callback receives tokens, completion, and errors + */ + private fun streamContents( + contents: JSONArray, + config: LlmConfig, + callback: StreamCallback ) { currentJob = scope.launch { try { @@ -201,9 +271,8 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl } val startTime = System.currentTimeMillis() - context.logger.info("GeminiBackend: Streaming response for prompt (${prompt.length} chars)") + context.logger.info("GeminiBackend: Streaming response over ${contents.length()} turns") - val contents = JSONArray().put(contentJson("user", buildPrompt(prompt, config))) val body = buildRequestJson(contents, config) val fullText = StringBuilder() @@ -276,24 +345,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl val startTime = System.currentTimeMillis() - val contents = JSONArray() - - // Gemini has no system role; carry the system prompt as a leading user turn - // acknowledged by the model, matching the SDK behavior this replaced. - config.systemPrompt?.let { systemPrompt -> - contents.put(contentJson("user", systemPrompt)) - contents.put(contentJson("model", "Understood.")) - } - - for (msg in history) { - val role = when (msg.role) { - ChatMessage.Role.USER -> "user" - ChatMessage.Role.ASSISTANT -> "model" - ChatMessage.Role.SYSTEM -> "user" // System messages go as user - } - contents.put(contentJson(role, msg.content)) - } - contents.put(contentJson("user", prompt)) + val contents = buildContents(history, prompt, config) val text = requestText(getModelName(), apiKey, buildRequestJson(contents, config)) @@ -328,6 +380,12 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl */ fun listModels(): CompletableFuture> { val future = CompletableFuture>() + // close() cancels the scope, making launch a silent no-op; fail loudly instead, or the + // gateway's blocking get() would sit at "Loading" for its full 60-second timeout. + if (!scope.isActive) { + future.completeExceptionally(IllegalStateException("Gemini backend is closed")) + return future + } val job = scope.launch { try { @@ -356,7 +414,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl /** * List the models a caller-supplied [apiKey] can use, instead of the one saved on disk. * - * Lets ai-assistant check a just-typed key *before* it is persisted; the no-arg [listModels] + * Lets the settings pane check a just-typed key *before* it is persisted; the no-arg [listModels] * reads the stored key. Nothing here touches the stored key or [keyCache]. * * @param apiKey the candidate key to authenticate the request with; never logged @@ -461,40 +519,20 @@ User: $userPrompt""" } /** - * Generate streaming response with native Gemini function calling. - * This method replaces text-based tool call parsing with structured function calling. + * Streams a reply for a multi-turn conversation, sending [history] as real `contents[]` turns. + * + * @param history the conversation so far, oldest first + * @param prompt the current user turn + * @param config sampling settings; its system prompt becomes the leading turn pair + * @param callback receives tokens, completion, and errors */ - fun generateStreamingWithTools( - prompt: String, + override fun generateStreamingWithHistory( history: List, + prompt: String, config: LlmConfig, - tools: List, - callback: LlmInferenceService.ToolStreamCallback + callback: StreamCallback ) { - currentJob = scope.launch { - try { - context.logger.info("GeminiBackend: Streaming with tools - ${tools.size} tools available") - - // For Phase 1, we delegate to streaming without tools - // Full function calling integration requires structured FunctionDeclaration support - // This is a placeholder that uses the text-based approach - // TODO: Full implementation in next iteration with proper FunctionDeclaration support - - val streamCallback = object : StreamCallback { - override fun onToken(token: String) = callback.onToken(token) - override fun onComplete(response: LlmResponse) = callback.onComplete(response) - override fun onError(error: String) = callback.onError(error) - } - - generateStreaming(prompt, config, streamCallback) - - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - context.logger.error("GeminiBackend: Error in streaming with tools", e) - callback.onError(formatErrorMessage(e)) - } - } + streamContents(buildContents(history, prompt, config), config, callback) } /** Cancel any in-flight generation (user pressed Stop). */ @@ -505,7 +543,7 @@ User: $userPrompt""" /** * Release all resources: cancel the backend scope and any in-flight - * request. Called from AiCorePlugin.dispose(). + * request. Called from [GeminiPlugin.dispose]. * * [keyCache] holds the *decrypted* API key, so it is dropped here too — otherwise the * plaintext stays reachable on the host process heap for as long as the IDE runs, long diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt similarity index 99% rename from ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt rename to ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt index 3348f98a..677a1100 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.plugins.aicore +package com.itsaky.androidide.plugins.aiagentgemini.errors import org.json.JSONObject import java.io.IOException diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/logging/LogTags.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/logging/LogTags.kt new file mode 100644 index 00000000..514b7ff8 --- /dev/null +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/logging/LogTags.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.plugins.aiagentgemini.logging + +/** + * Prefix on every logcat tag this plugin writes, so a line names the plugin that emitted it — every + * AI feature shares the host IDE's process, where a bare `SecureApiKeyStore` tag names no `.cgp`. + * Tags read `"$LOG_PREFIX.ClassName"`, so `adb logcat -s AiAgentGemini.*` is this plugin's log. + */ +internal const val LOG_PREFIX = "AiAgentGemini" diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt new file mode 100644 index 00000000..c68aa23c --- /dev/null +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt @@ -0,0 +1,286 @@ +package com.itsaky.androidide.plugins.aiagentgemini.plugin + +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend +import com.itsaky.androidide.plugins.aiagentgemini.preferences.GeminiPreferences +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.SharedServices + +/** + * Registers the Google Gemini API backend with AI Core's inference router. + * + * Owns the transport *and* the UI that configures it: the backend names a settings Fragment that + * ships in this plugin, which whichever screen offers a backend selector mounts under its own + * selector. AI Core owns routing; nothing outside this plugin handles the API key. + */ +class GeminiPlugin : IPlugin, DocumentationExtension { + + private lateinit var context: PluginContext + + /** + * Volatile because [activate] writes it on the loading thread while the host may deliver + * `onPluginActivated` on another: a plain field lets [registerBackend] read null and give up + * without scheduling a retry, leaving the selector permanently empty. + */ + @Volatile private var backend: GeminiBackend? = null + + /** True once [backend] is registered with the router, so re-registration is idempotent. */ + @Volatile private var registered = false + + companion object { + const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentgemini" + + /** Provider of [LlmInferenceService]; this plugin is useless without it. */ + private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" + + /** + * The whole-plugin entry, and the only one carrying the Tier-3 guide button. Anchored to + * the key status line on this backend's settings pane — the one element this plugin always + * draws, and an entry no element long-presses is an entry nobody can read. + */ + const val TOOLTIP_TAG_PLUGIN = "plugin_ai_agent_gemini" + + /** + * Category the host registers this plugin's tooltips under. Must be `"plugin_"` + the full + * plugin id, or a long-press renders the literal string `n/a`. + */ + const val TOOLTIP_CATEGORY = "plugin_$PLUGIN_ID" + + // Tags for the controls on this backend's settings pane (see GeminiSettingsFragment). + const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_gemini_key" + const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_gemini_model" + const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_gemini_get_free_key" + + @Volatile + private var pluginContext: PluginContext? = null + + @Volatile + private var activeBackend: GeminiBackend? = null + + /** This plugin's context, for the settings pane the backend contributes. */ + fun getContext(): PluginContext? = pluginContext + + /** + * The live backend, so the settings pane can check a key and list models against the same + * transport that serves generation. Null before activation and after disposal. + */ + fun getBackend(): GeminiBackend? = activeBackend + } + + /** + * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so + * [activate] may run before AI Core has published its service; this closes that race + * instead of polling for it. + */ + private val aiCoreLifecycle = object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + if (pluginId == AI_CORE_PLUGIN_ID) registerBackend() + } + + override fun onPluginDeactivated(pluginId: String) { + // The router went away and took the registration with it; allow a fresh one. + if (pluginId == AI_CORE_PLUGIN_ID) registered = false + } + + override fun onPluginUninstalled(pluginId: String) { + if (pluginId == AI_CORE_PLUGIN_ID) registered = false + } + } + + override fun initialize(context: PluginContext): Boolean { + return try { + this.context = context + // Published for the settings pane, which the hosting screen constructs directly. + pluginContext = context + context.logger.info("GeminiPlugin: Plugin initialized successfully") + true + } catch (e: Exception) { + context.logger.error("GeminiPlugin: Plugin initialization failed", e) + false + } + } + + override fun activate(): Boolean { + context.logger.info("GeminiPlugin: Activating plugin") + + return try { + // Before the backend can read anything: takes this plugin's settings out of the agent + // plugin's shared file, where they lived until each backend owned its own. + GeminiPreferences.migrateIfNeeded(context) + + // A half-failed activation can leave a backend behind; keep at most one live. + releaseBackend() + + val gemini = GeminiBackend(context) + backend = gemini + activeBackend = gemini + + // Decrypt the key off-thread now, so a main-thread isAvailable() can't say "no key". + gemini.warmKeyCache() + + // Listen first, then try: a listener added after a successful attempt would still be + // needed for a later AI Core restart, and one added before costs nothing. + context.addPluginLifecycleListener(aiCoreLifecycle) + if (!registerBackend()) { + context.logger.info( + "GeminiPlugin: AI Core is not active yet; will register when it activates" + ) + } + + true + } catch (e: Exception) { + context.logger.error("GeminiPlugin: Activation failed", e) + false + } + } + + /** + * Registers the Gemini backend with AI Core's router, if the router is reachable. + * + * @return true when the backend is registered (now or already), false when AI Core is absent + */ + private fun registerBackend(): Boolean { + if (registered) return true + val gemini = backend ?: return false + + val service = resolveInferenceService() + if (service == null) { + context.logger.debug("GeminiPlugin: LlmInferenceService not available yet") + return false + } + + return try { + service.registerBackend(gemini) + registered = true + context.logger.info("GeminiPlugin: Registered '${gemini.getId()}' backend with AI Core") + true + } catch (e: Exception) { + context.logger.error("GeminiPlugin: Could not register the Gemini backend", e) + false + } + } + + /** + * Resolves AI Core's router, preferring the process-global registry and falling back to the + * provider-scoped lookup so a registry cleared by another plugin is not fatal. + */ + private fun resolveInferenceService(): LlmInferenceService? = try { + SharedServices.get(LlmInferenceService::class.java) + ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java) + } catch (e: Exception) { + context.logger.warn("GeminiPlugin: Could not resolve LlmInferenceService: ${e.message}") + null + } + + override fun deactivate(): Boolean { + context.logger.info("GeminiPlugin: Deactivating plugin") + + return try { + context.removePluginLifecycleListener(aiCoreLifecycle) + + val gemini = backend + if (gemini != null && registered) { + resolveInferenceService()?.unregisterBackend(gemini.getId()) + registered = false + context.logger.info("GeminiPlugin: Unregistered '${gemini.getId()}' backend") + } + + // A disabled plugin must not keep the decrypted key on the host heap. + releaseBackend() + + true + } catch (e: Exception) { + context.logger.error("GeminiPlugin: Deactivation failed", e) + false + } + } + + /** + * Cancels in-flight requests, drops the decrypted key from the heap, and clears the published + * backend. Idempotent, so a [deactivate] followed by [dispose] closes nothing twice. + */ + private fun releaseBackend() { + backend?.close() + backend = null + activeBackend = null + registered = false + } + + override fun dispose() { + context.logger.info("GeminiPlugin: Disposing plugin") + + // deactivate() removes this too; a dispose without one would leave the host holding this. + runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } + + releaseBackend() + pluginContext = null + context.logger.info("GeminiPlugin: Released Gemini backend") + } + + override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID" + + override fun getTooltipEntries(): List = listOf( + PluginTooltipEntry( + tag = TOOLTIP_TAG_PLUGIN, + summary = "Sends prompts to Google's Gemini API. Needs an API key and a network connection.", + detail = """ +

AI Agent Gemini adds the gemini backend to + AI Core, calling Google's Generative Language API over + HTTPS.

+

Install AI Core as well, then select the gemini + backend in Agent settings and enter your API key in the pane + this plugin adds there. Prompts and any file contents a plugin + sends are transmitted to Google.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "AI Agent Gemini guide", + uri = "index.html", + order = 0 + ) + ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GEMINI_KEY, + summary = "Your Google AI Studio API key, checked with Google before it is stored.", + detail = """ +

The key is verified against Google's model list before being + saved, so a mistyped key is caught here rather than mid-chat. It + is then encrypted with the Android Keystore and only the + ciphertext is written to disk.

+

A key that cannot be checked — no network, for instance — can + still be saved, but is marked unverified rather than claiming a + check that never happened.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GEMINI_MODEL, + summary = "Which Gemini model to use. Refresh lists the models your key can reach.", + detail = """ +

Refresh Models asks Google which chat-capable models the + saved key can actually use, so the list never offers a model that + would fail with a 404. Without a key, or offline, a short list of + current models is shown instead.

+ """.trimIndent(), + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GET_KEY, + summary = "Opens Google AI Studio in your browser, where API keys are free to create.", + detail = """ +

Opens aistudio.google.com/apikey in your own + browser — never an embedded WebView, so you can see Google's URL + bar and Google's sign-in works. Sign in, create a key, copy it, + and paste it into the field here.

+

This plugin never sees your Google password and never reads + your clipboard.

+ """.trimIndent(), + ), + ) + + override fun getTier3DocsAssetPath(): String = "docs" +} diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt new file mode 100644 index 00000000..f71a160b --- /dev/null +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/preferences/GeminiPreferences.kt @@ -0,0 +1,120 @@ +package com.itsaky.androidide.plugins.aiagentgemini.preferences + +import android.content.SharedPreferences +import com.itsaky.androidide.plugins.PluginContext + +/** + * This plugin's own settings store, and the one-time move of its settings out of AI Core's. + * + * The API key and the chosen model describe *this* backend, so they belong in this plugin's + * storage. They used to live in the agent plugin's preferences, which meant this backend could not + * be configured — or even report itself available — unless that plugin happened to be installed + * and to have published its own context first. + */ +internal object GeminiPreferences { + + /** This plugin's preferences file. Namespaced to this plugin by the host. */ + private const val FILE = "GeminiSettings" + + const val KEY_API_KEY = "gemini_api_key" + const val KEY_API_KEY_TIMESTAMP = "gemini_api_key_timestamp" + const val KEY_API_KEY_VERIFIED = "gemini_api_key_verified" + const val KEY_MODEL = "gemini_model" + + /** Set once [migrateIfNeeded] has run, so a value changed since is never overwritten. */ + private const val KEY_MIGRATED = "migrated_from_agent_settings" + + /** Everything this backend owns; anything else in the old shared file is not ours to take. */ + private val OWNED_KEYS = listOf( + KEY_API_KEY, KEY_API_KEY_TIMESTAMP, KEY_API_KEY_VERIFIED, KEY_MODEL, + ) + + /** This plugin's id before it was renamed from `ai-backend-gemini`; see [OWN_LEGACY_FILE]. */ + private const val LEGACY_PLUGIN_ID = "com.itsaky.androidide.plugins.aigemini" + + /** + * This same file, under the plugin id this plugin had before the rename. The host namespaces a + * plugin's preferences by plugin id, so changing the id points [of] at an empty file and a + * device with a verified key would look unconfigured. + */ + private val OWN_LEGACY_FILE = "plugin_${LEGACY_PLUGIN_ID}_$FILE" + + /** + * Files that may still hold this backend's values, newest first. + * + * Every one is read because plugins load in parallel with no ordering: AI Core adopts the older + * plugin's settings into its own file on activation, but this backend may migrate before that + * has happened, and would otherwise find the newer file empty and conclude there is nothing to + * take. + */ + private val LEGACY_FILES = listOf( + OWN_LEGACY_FILE, + legacyFileName(LEGACY_PLUGIN_ID), + legacyFileName("com.itsaky.androidide.plugins.aicore"), + legacyFileName("com.itsaky.androidide.plugins.aiassistant"), + ) + + /** Name the host gives a plugin's preferences file; see `PluginContextImpl`. */ + private fun legacyFileName(pluginId: String) = "plugin_${pluginId}_AgentSettings" + + /** + * This plugin's preferences. + * + * @param context this plugin's own context — never another plugin's + */ + fun of(context: PluginContext): SharedPreferences = + context.getPluginSharedPreferences(FILE) + + /** + * Copies this backend's settings out of every store in [LEGACY_FILES], once. + * + * The API key moves as ciphertext and stays readable: it is encrypted under a Keystore alias + * (see [SecureApiKeyStore]) rather than under anything plugin-specific, and every plugin runs + * in the host's process and UID. Copies rather than moves, so downgrading still finds the old + * values. Call before anything reads a setting. + * + * @param context this plugin's own context + */ + fun migrateIfNeeded(context: PluginContext) { + val prefs = of(context) + if (prefs.getBoolean(KEY_MIGRATED, false)) return + + try { + for (fileName in LEGACY_FILES) { + val legacy = context.getAppSharedPreferences(fileName) ?: continue + if (copyOwnedValues(legacy, prefs)) { + context.logger.info("GeminiPreferences: adopted settings from $fileName") + } + } + prefs.edit().putBoolean(KEY_MIGRATED, true).apply() + } catch (e: Exception) { + // Not fatal, and deliberately not marked migrated: a fresh install has nothing to + // copy, and a failure here should get another chance rather than stranding the user + // with a backend that has lost its key. + context.logger.error("GeminiPreferences: could not migrate settings", e) + } + } + + /** + * @return true when at least one value was taken from [legacy] + */ + private fun copyOwnedValues(legacy: SharedPreferences, into: SharedPreferences): Boolean { + val editor = into.edit() + var copied = 0 + for (key in OWNED_KEYS) { + // A value set here already post-dates the old one, so it must not be overwritten. + if (!legacy.contains(key) || into.contains(key)) continue + when (val value = legacy.all[key]) { + is String -> editor.putString(key, value) + is Boolean -> editor.putBoolean(key, value) + is Long -> editor.putLong(key, value) + is Int -> editor.putInt(key, value) + is Float -> editor.putFloat(key, value) + else -> continue + } + copied++ + } + editor.apply() + return copied > 0 + } +} diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt new file mode 100644 index 00000000..cf69c324 --- /dev/null +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt @@ -0,0 +1,96 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt + +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * The system prompt this backend asks for. + * + * Written for a large cloud model: it states a goal and a workflow and trusts the model to plan + * within them, where a small on-device model needs each step spelled out. That difference is a + * property of the model, so the prompt lives with the backend that talks to it. + * + * Pure and free of Android types, so it is unit-testable without a device or a network. + */ +internal object GeminiSystemPrompt { + + /** + * Path used in the examples when the caller names none, so they still show a concrete shape. + */ + private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + + /** + * Builds the prompt for [request]. + * + * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce + * replies nothing reads — and a null one means the caller parses no envelope, so the format + * section and its examples are left out rather than taught in a syntax nothing reads back. + * + * @return the system prompt, without the caller's IDE-context block + */ + fun build(request: SystemPromptRequest): String { + val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" } + val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH + val exampleStem = examplePath.substringAfterLast('/').substringBeforeLast('.') + + val head = """ + You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions. + + AVAILABLE TOOLS: + $toolDescriptions + + BEHAVIOR: + - Create complete, production-ready code + - Call tools proactively to build, test, and verify your work + - Read files to understand project structure before making changes + - After each file modification, verify the build compiles + - Generate apps that actually run and work as described + + RULES: + - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet. + - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one. + - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line. + - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk. + - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists. + - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected. + - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing. + - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored. + - Paths are relative to the project root and must be complete. If you don't know a file's exact path, find it with search_project or list_files first, then act on the real path — don't guess. + - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond. + """.trimIndent() + + val workflow = """ + WORKFLOW: + 1. Understand the user's request + 2. List files to understand the project structure + 3. Create/modify files with complete implementations + 4. Add dependencies if needed + 5. Sync gradle and verify compilation + 6. Run the app to confirm it works + 7. Report success and what was built + """.trimIndent() + + val syntax = request.toolCallSyntax ?: return head + "\n\n" + workflow + + val callFormat = """ + TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it: + $syntax + Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. + The tool only runs when you emit the tool call line itself. + + FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path + only when it is the file you actually mean): + Report the finished task (the summary goes in "message"): + {"tool":"respond","args":{"message":"Renamed count to itemCount."}} + Open a file once you know its path: + {"tool":"open_file","args":{"file_path":"$examplePath"}} + Find a file by name: + {"tool":"search_project","args":{"query":"$exampleStem"}} + List the project's top-level files (an empty directory means the project root): + {"tool":"list_files","args":{"directory":""}} + Change part of a file (line breaks inside a value MUST be written as \n): + {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}} + """.trimIndent() + + return head + "\n\n" + callFormat + "\n\n" + workflow + } +} diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt similarity index 91% rename from ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt rename to ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt index 229f17a3..198b65e0 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/security/SecureApiKeyStore.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.plugins.aicore +package com.itsaky.androidide.plugins.aiagentgemini.security import android.content.SharedPreferences import android.security.keystore.KeyGenParameterSpec @@ -6,6 +6,7 @@ import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.util.Base64 import android.util.Log +import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX import java.security.GeneralSecurityException import java.security.KeyStore import javax.crypto.Cipher @@ -19,14 +20,14 @@ import javax.crypto.spec.GCMParameterSpec * written to SharedPreferences, so a copied prefs file (root, `adb backup`, * forensic dump) is useless without this device's Keystore. * - * The alias and transform below are mirrored verbatim in ai-assistant's - * `SecureApiKeyStore` so a key written there can be decrypted here — both - * plugins run in the host app's process (same UID) and therefore share one - * Android Keystore. Keep the two copies in sync. + * The [ALIAS] must stay stable across releases: a key encrypted under one + * alias cannot be read under another, so changing it silently invalidates + * every stored key. It is also what lets a key written before the AI plugins + * were reorganised still decrypt today — every plugin runs in the host app's + * process and UID, so they all share one Android Keystore. */ object SecureApiKeyStore { - // Drift in the constants below fails ai-core's verifySecureApiKeyStoreParity build task. - private const val TAG = "SecureApiKeyStore" + private const val TAG = "$LOG_PREFIX.SecureApiKeyStore" private const val KEYSTORE = "AndroidKeyStore" private const val ALIAS = "cotg_ai_gemini_key_v1" private const val TRANSFORM = "AES/GCM/NoPadding" diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt similarity index 64% rename from ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt rename to ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt index 4fdfdcc6..ecb92ef5 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt @@ -1,9 +1,9 @@ -package com.itsaky.androidide.plugins.aiassistant.gemini +package com.itsaky.androidide.plugins.aiagentgemini.settings /** - * Outcome of one model-catalog lookup against ai-core's Gemini backend. + * Outcome of one model-catalog lookup against the Gemini backend (ai-agent-gemini). * - * A closed hierarchy, so callers cannot treat "ai-core isn't installed" and "Google refused the + * A closed hierarchy, so callers cannot treat "the backend isn't installed" and "Google refused the * key" alike — which the old `emptyList()`-on-every-failure bridge forced them to do. */ sealed interface CatalogResult { @@ -11,7 +11,8 @@ sealed interface CatalogResult { /** The backend answered. [models] may be empty, which is itself suspicious for a valid key. */ data class Success(val models: List) : CatalogResult - /** No "gemini" backend was resolvable — ai-core is missing, disabled, or not yet active. */ + /** No "gemini" backend was resolvable — ai-core or ai-agent-gemini is missing, disabled, + * or not yet active. */ data object NoBackend : CatalogResult /** diff --git a/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt new file mode 100644 index 00000000..b05860f7 --- /dev/null +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt @@ -0,0 +1,117 @@ +package com.itsaky.androidide.plugins.aiagentgemini.settings + +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend +import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * The one place this plugin's settings ask for a model catalog. + * + * An abstraction the ViewModel can fake in tests, so the blocking wait on the backend's future + * lives behind a single seam that fails in one recognisable way. + */ +interface GeminiCatalogGateway { + + /** + * Models available to the key currently saved on disk. Used to populate the model picker, + * where "which key" is never in question. + */ + fun listModelsForSavedKey(): CatalogResult + + /** + * Models available to [apiKey], which need not be — and during key entry is not — the saved + * one. This is what makes checking a key before persisting it possible. + */ + fun listModels(apiKey: String): CatalogResult +} + +/** + * [GeminiCatalogGateway] over this plugin's own [GeminiBackend]. + * + * A plain call: the backend and the settings that configure it now ship in the same `.cgp`, so the + * types are the same types. This replaces a reflective lookup through AI Core's registry that + * could only fail at runtime, and only on a device. + * + * @param backendProvider resolves the backend; injectable so tests need no plugin lifecycle + */ +class BackendGeminiCatalogGateway( + private val backendProvider: () -> GeminiBackend? = GeminiPlugin::getBackend +) : GeminiCatalogGateway { + + companion object { + private const val TAG = "$LOG_PREFIX.GeminiCatalogGateway" + + /** + * Failsafe cap, well above the backend's own budget (15 s connect + 15 s read, paginated) so a + * slow-but-live fetch is never truncated. Bounds a future that may never complete, such as + * one from an already-cancelled backend scope; not the expected wait. + */ + private const val LIST_MODELS_TIMEOUT_SECONDS = 60L + } + + /** + * This plugin's IDE-surfaced log, so a failed catalog lookup shows up in the IDE's own log view + * rather than only in logcat. Null before `initialize()` and in JVM tests. + */ + private val logger: PluginLogger? + get() = GeminiPlugin.getContext()?.logger + + override fun listModelsForSavedKey(): CatalogResult = + await { it.listModels() } + + override fun listModels(apiKey: String): CatalogResult = + await { it.listModels(apiKey) } + + /** + * Runs [request] against the backend and awaits its future. + * + * Blocks on [CompletableFuture.get], so call it from an IO dispatcher — never the main thread. + * + * @param request the catalog call to make; picks which credential is used + */ + private fun await( + request: (GeminiBackend) -> CompletableFuture> + ): CatalogResult { + val backend = try { + backendProvider() + } catch (e: Exception) { + logger?.error("$TAG: could not resolve the Gemini backend", e) + return CatalogResult.Failed(e) + } ?: return CatalogResult.NoBackend + + val future = try { + request(backend) + } catch (e: Exception) { + logger?.error("$TAG: listModels threw", e) + return CatalogResult.Failed(e) + } + + return try { + CatalogResult.Success(future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty()) + } catch (e: ExecutionException) { + // The API failure the backend reported; its message carries the HTTP status. + CatalogResult.Failed(e.cause ?: e) + } catch (e: CancellationException) { + logger?.warn("$TAG: listModels was cancelled by the backend", e) + CatalogResult.Failed(e) + } catch (e: TimeoutException) { + future.cancel(true) + logger?.error( + "$TAG: listModels did not complete within ${LIST_MODELS_TIMEOUT_SECONDS}s", + e + ) + CatalogResult.Failed(e) + } catch (e: InterruptedException) { + // Restore the flag so the cancelled coroutine's thread still sees it. + Thread.currentThread().interrupt() + future.cancel(true) + CatalogResult.Failed(e) + } + } +} diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboarding.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiKeyOnboarding.kt similarity index 89% rename from ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboarding.kt rename to ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiKeyOnboarding.kt index ba9557a4..d631b83c 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboarding.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiKeyOnboarding.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.plugins.aiassistant.gemini +package com.itsaky.androidide.plugins.aiagentgemini.settings /** * Where a Gemini API key comes from. diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt similarity index 57% rename from ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt rename to ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt index a6b5c064..bec2f7bd 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt +++ b/ai-agent-gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.plugins.aiassistant.fragments +package com.itsaky.androidide.plugins.aiagentgemini.settings import android.annotation.SuppressLint import android.content.ClipData @@ -13,27 +13,25 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.widget.* -import androidx.activity.result.contract.ActivityResultContracts +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast import androidx.annotation.DrawableRes import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.plugins.PluginContext -import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin -import com.itsaky.androidide.plugins.aiassistant.R -import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiKeyOnboarding -import com.itsaky.androidide.plugins.aiassistant.gemini.KeyVerification +import com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin +import com.itsaky.androidide.plugins.aiagentgemini.R import com.itsaky.androidide.plugins.base.PluginFragmentHelper import com.itsaky.androidide.plugins.services.IdeTooltipService -import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend -import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel -import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState -import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelLoadingState -import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelMemoryWarning import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -41,342 +39,93 @@ import java.util.Locale import kotlin.math.roundToInt /** - * The Agent settings screen, reached from Preferences → Configuration → Agent and from the Agent - * chat's own shortcuts. The host mounts it full-screen in PluginScreenActivity, which provides no - * toolbar, so this fragment brings its own app bar and closes by finishing that activity. + * This backend's settings pane, mounted by whichever screen offers a backend selector. + * + * Named to the host through `GeminiBackend.getSettingsFragmentClassName()`, loaded with this + * plugin's own classloader and inflated against this plugin's own resources — so the consumer needs + * to know nothing about API keys, AI Studio or Google's model catalog. */ -class AiSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host { +class GeminiSettingsFragment : Fragment() { - private lateinit var viewModel: AiSettingsViewModel - private lateinit var settingsToolbar: LinearLayout - private lateinit var backButton: ImageButton - private lateinit var backendSpinner: Spinner - private lateinit var backendSpecificContainer: FrameLayout + private lateinit var viewModel: GeminiSettingsViewModel private var tooltipService: IdeTooltipService? = null /** - * Set while the Gemini pane is on screen, so [onResume] can nudge the user towards **Paste - * key** after they come back from AI Studio. Cleared when the pane is replaced or the view is - * destroyed — it captures views, so holding it any longer would leak them. + * Set while this pane is on screen, so [onResume] can nudge the user towards **Paste key** + * after they come back from AI Studio. Cleared when the view is destroyed — it captures views, + * so holding it any longer would leak them. */ - private var onGeminiPaneResume: (() -> Unit)? = null + private var onPaneResume: (() -> Unit)? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Disable Material transitions to avoid resource loading issues - // Plugin uses compileOnly dependencies, so Material transition resources aren't bundled + + // This pane is replaced in and out whenever the backend selector changes, so a theme-default + // Material transition would be resolved here. Plugin resources are compileOnly, so those + // transition resources aren't bundled; nulling them keeps the swap from touching them. enterTransition = null exitTransition = null - // Resolve the IDE tooltip service so the settings controls can offer in-app help. try { - tooltipService = PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID) + tooltipService = PluginFragmentHelper.getServiceRegistry(GeminiPlugin.PLUGIN_ID) ?.get(IdeTooltipService::class.java) } catch (e: Exception) { // Tooltip help is optional; long-press simply shows nothing when it's unavailable. - AiAssistantPlugin.getContext()?.logger - ?.warn("AiSettingsFragment: tooltip service unavailable", e) - } - } - - /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide button). */ - private fun wireTooltip(view: View, tag: String) { - view.setOnLongClickListener { anchor -> - val service = tooltipService ?: return@setOnLongClickListener false - service.showTooltip(anchor, AiAssistantPlugin.TOOLTIP_CATEGORY, tag) - true + GeminiPlugin.getContext()?.logger + ?.warn("GeminiSettingsFragment: tooltip service unavailable", e) } } - private val filePickerLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - uri?.let { - try { - val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION - requireContext().contentResolver.takePersistableUriPermission(it, takeFlags) - - val uriString = it.toString() - viewModel.loadModelFromUri(uriString, requireContext()) - Toast.makeText(requireContext(), getString(R.string.model_loading_toast), Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(requireContext(), getString(R.string.state_error, e.message), Toast.LENGTH_LONG).show() - } - } - } - /** - * Route inflation through the host so this screen's views resolve against a Context whose - * Configuration tracks the IDE's day/night setting (DayNight PluginTheme + values-night/ - * colors); the raw fragment inflater pins the screen to light mode. - * - * Overridden here rather than applied inside [onCreateView] so that `layoutInflater` itself is - * the themed one — the backend panes swapped into [backendSpecificContainer] and anything else - * reaching for it get the theme for free. Same shape as ChatFragment. + * Route inflation through the host so this pane resolves against *this* plugin's resources and + * a Context whose Configuration tracks the IDE's day/night setting. The inflater inherited from + * the hosting screen belongs to that plugin and cannot see this one's layouts. */ override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { val inflater = super.onGetLayoutInflater(savedInstanceState) - return PluginFragmentHelper.getPluginInflater(AiAssistantPlugin.PLUGIN_ID, inflater) + return PluginFragmentHelper.getPluginInflater(GeminiPlugin.PLUGIN_ID, inflater) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_ai_settings, container, false) - } + ): View? = inflater.inflate(R.layout.fragment_gemini_settings, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - initializeViewModel() - initializeViews(view) - setupToolbar() - setupBackendSelector() - observeMemoryWarnings() - } - - /** - * Puts a "this model may not fit" question to the user. Collected under STARTED so the dialog is - * never shown to a stopped fragment; the event waits in the ViewModel until then. - */ - private fun observeMemoryWarnings() { - dropStaleMemoryWarning() - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.modelMemoryWarnings.collect(::showMemoryWarning) - } - } - } - - /** - * Dismiss a warning dialog the framework restored around a question that no longer exists. - * After process death the load that raised it is gone, so every button on it would be a silent - * no-op — better to take it away than to leave the user pressing a dialog that decides nothing. - */ - private fun dropStaleMemoryWarning() { - if (viewModel.hasPendingMemoryWarning) return - val restored = childFragmentManager.findFragmentByTag(MemoryWarningDialogFragment.TAG) - (restored as? MemoryWarningDialogFragment)?.dismissAllowingStateLoss() - } - - /** - * Shown as a child fragment, so it survives rotation and can still reach this host. Must stay - * idempotent: an unanswered question is re-published to every new collector by - * [com.itsaky.androidide.plugins.aiassistant.viewmodel.UserConfirmation]. - * - * @param warning the model and the figures to put to the user - */ - private fun showMemoryWarning(warning: ModelMemoryWarning) { - if (childFragmentManager.findFragmentByTag(MemoryWarningDialogFragment.TAG) != null) return - MemoryWarningDialogFragment.newInstance(warning) - .show(childFragmentManager, MemoryWarningDialogFragment.TAG) - } + viewModel = ViewModelProvider( + this, + GeminiSettingsViewModelFactory { GeminiPlugin.getContext() } + )[GeminiSettingsViewModel::class.java] - override fun onModelMemoryDecision(proceed: Boolean) { - viewModel.onMemoryWarningDecision(proceed) - // Not requireContext(): onCancel can reach us as the fragment is going away. - val ctx = context ?: return - if (!proceed) { - Toast.makeText( - ctx, - getString(R.string.llm_memory_warning_declined), - Toast.LENGTH_LONG, - ).show() - } + setupApiKeyUi(view) } override fun onResume() { super.onResume() - onGeminiPaneResume?.invoke() + onPaneResume?.invoke() } override fun onDestroyView() { - // Drops the captured Gemini pane views along with the callback. - onGeminiPaneResume = null + // Drops the captured pane views along with the callback. + onPaneResume = null setSecureWindow(false) super.onDestroyView() } - private fun initializeViewModel() { - viewModel = ViewModelProvider( - this, - AiSettingsViewModelFactory { getPluginContext() } - )[AiSettingsViewModel::class.java] - } - - private fun getPluginContext(): PluginContext? { - return com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin.getContext() - } - - private fun initializeViews(view: View) { - settingsToolbar = view.findViewById(R.id.settings_toolbar) - backButton = view.findViewById(R.id.toolbar_back_button) - backendSpinner = view.findViewById(R.id.backend_autocomplete) - backendSpecificContainer = view.findViewById(R.id.backend_specific_settings_container) - } - - private fun setupToolbar() { - backButton.setOnClickListener { - // This screen owns the whole activity, so closing it means finishing that activity. - requireActivity().finish() - } - wireTooltip(backButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACK) - } - - private fun setupBackendSelector() { - val backends = viewModel.getAvailableBackends() - val backendNames = backends.map { it.displayName } - val adapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_spinner_item, - backendNames - ) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - backendSpinner.adapter = adapter - - wireTooltip(backendSpinner, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACKEND) - - val currentBackend = viewModel.getCurrentBackend() - backendSpinner.setSelection(backends.indexOf(currentBackend)) - updateBackendSpecificUi(currentBackend) - - backendSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val selectedBackend = backends[position] - viewModel.saveBackend(selectedBackend) - updateBackendSpecificUi(selectedBackend) - } - - override fun onNothingSelected(parent: AdapterView<*>?) {} - } - } - - private fun updateBackendSpecificUi(backend: AiBackend) { - backendSpecificContainer.removeAllViews() - // The Gemini pane's views are about to go; its resume callback must not outlive them. - onGeminiPaneResume = null - - // layoutInflater is the theme-aware one (see onGetLayoutInflater), so these sub-layouts - // follow the IDE day/night theme like the rest of the screen. - when (backend) { - AiBackend.LOCAL_LLM -> { - val localLlmView = layoutInflater - .inflate(R.layout.layout_settings_local_llm, backendSpecificContainer, false) - backendSpecificContainer.addView(localLlmView) - setupLocalLlmUi(localLlmView) - } - AiBackend.GEMINI -> { - val geminiApiView = layoutInflater - .inflate(R.layout.layout_settings_gemini_api, backendSpecificContainer, false) - backendSpecificContainer.addView(geminiApiView) - setupGeminiApiUi(geminiApiView) - } - } - } - - private fun setupLocalLlmUi(view: View) { - val modelPathTextView = view.findViewById(R.id.selected_model_path) - val browseButton = view.findViewById