From dca709f8bb5285afd802620644a165c90c848319 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 3 Aug 2026 09:38:20 -0500 Subject: [PATCH 1/2] feat(gemini): verify API keys and add AI Studio onboarding Adds live key validation via ai-core and links to AI Studio for easier setup. --- ai-assistant/README.md | 40 +++ ai-assistant/ai-assistant.html | 8 +- ai-assistant/src/main/assets/docs/index.html | 57 +++- .../plugins/aiassistant/AiAssistantPlugin.kt | 50 +++- .../fragments/AiSettingsFragment.kt | 272 ++++++++++++++++-- .../aiassistant/gemini/CatalogResult.kt | 23 ++ .../gemini/GeminiCatalogGateway.kt | 147 ++++++++++ .../aiassistant/gemini/GeminiKeyOnboarding.kt | 16 ++ .../aiassistant/gemini/KeyVerification.kt | 104 +++++++ .../viewmodel/AiSettingsViewModel.kt | 204 ++++++------- .../aiassistant/viewmodel/ChatViewModel.kt | 4 +- .../src/main/res/drawable/ic_key_rejected.xml | 10 + .../main/res/drawable/ic_key_unchecked.xml | 10 + .../src/main/res/drawable/ic_key_verified.xml | 10 + .../res/layout/layout_settings_gemini_api.xml | 29 +- ai-assistant/src/main/res/values/strings.xml | 22 ++ .../gemini/GeminiKeyOnboardingTest.kt | 20 ++ .../aiassistant/gemini/KeyVerificationTest.kt | 171 +++++++++++ .../AiSettingsViewModelVerifyTest.kt | 125 ++++++++ ai-core/build.gradle.kts | 1 + .../plugins/aicore/GeminiBackend.kt | 122 +++++++- .../plugins/aicore/GeminiErrorFormatter.kt | 155 ++++++++++ ai-core/src/main/res/values/strings.xml | 14 + .../aicore/GeminiErrorFormatterTest.kt | 198 +++++++++++++ 24 files changed, 1652 insertions(+), 160 deletions(-) create mode 100644 ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt create mode 100644 ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt create mode 100644 ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboarding.kt create mode 100644 ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerification.kt create mode 100644 ai-assistant/src/main/res/drawable/ic_key_rejected.xml create mode 100644 ai-assistant/src/main/res/drawable/ic_key_unchecked.xml create mode 100644 ai-assistant/src/main/res/drawable/ic_key_verified.xml create mode 100644 ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt create mode 100644 ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt create mode 100644 ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt create mode 100644 ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatterTest.kt diff --git a/ai-assistant/README.md b/ai-assistant/README.md index e3e09f7a..346acecf 100644 --- a/ai-assistant/README.md +++ b/ai-assistant/README.md @@ -49,11 +49,51 @@ The build resolves `plugin-api.jar` from the repo-root `../libs/`. 3. Install via CodeOnTheGo's Plugin Manager, then restart the IDE. 4. Open **AI Settings** to pick a local model or configure a Gemini API key. +## Gemini key setup (ADFA-2709) + +The Gemini pane guides key acquisition instead of just showing an empty field: + +- **Get a free key** opens `https://aistudio.google.com/apikey` in the *system* + browser. AI Studio provisions the underlying Cloud project itself, so the + Google Cloud console is not part of the flow, and sign-in happens in the + browser — this process never sees a Google password. + If no browser can handle the intent, the URL is copied to the clipboard instead + so there is still a way forward. +- **The clipboard is never read.** Pasting the key is left to the field's own + long-press menu, which keeps Android 13+'s system read notice tied to a + deliberate user action instead of firing on a background probe. Returning from + AI Studio only shows a hint pointing at the field (or at **Edit**, when a key is + already stored). +- **Save checks the key with Google before storing it.** A key Google rejects + (HTTP 400/401/403) is **not** persisted. A key that can't be checked — offline, + or `ai-core` unavailable — prompts a save-anyway confirmation and is recorded as + unverified, so the status line doesn't claim more than was established. HTTP 429 + counts as valid: a rate-limited key is a working key. + +The check reuses `GeminiBackend.listModels(apiKey)` in `ai-core` (which already +holds `network.access`), so this plugin's manifest gains no new permission and no +new dependency. `gemini/` holds the pieces: `GeminiCatalogGateway` (the one +reflective seam into `ai-core`), `CatalogResult` (what one lookup returned), +`KeyVerification` (the verdict + classifier), and `GeminiKeyOnboarding` (the AI +Studio URL). + +That reflective seam means the two plugins ship as a pair: the `listModels(apiKey)` +overload is new, and against an older `ai-core` the lookup fails with +`NoSuchMethodException`, which lands in the same save-anyway prompt as being +offline. It deliberately does **not** fall back to the no-arg `listModels()` — +that call authenticates with the *saved* key, so it would clear a candidate key on +the strength of a different credential. + +**No shape check on the key.** AI Studio issues authorization-type keys that don't +match the classic `AIza…` form; Save gates on blankness alone and lets the live +check decide. + ## Key classes - `AiAssistantPlugin.kt` — plugin entry point / lifecycle - `fragments/ChatFragment.kt`, `viewmodel/ChatViewModel.kt` — chat UI + state - `fragments/AiSettingsFragment.kt`, `viewmodel/AiSettingsViewModel.kt` — model/backend config +- `gemini/` — Gemini key onboarding + pre-save verification - `tool/` — the agent tool-loop (executor, router, per-tool handlers, approval) ## Security diff --git a/ai-assistant/ai-assistant.html b/ai-assistant/ai-assistant.html index 8589741c..ed0adc24 100644 --- a/ai-assistant/ai-assistant.html +++ b/ai-assistant/ai-assistant.html @@ -110,7 +110,13 @@

Install & configure

  • Copy both .cgp files to the device and install via the CoGo Plugin Manager — AI Core first, then AI Assistant. Restart the IDE.
  • Open Settings and either select a .gguf model (Local) - or enter a Gemini API key (Gemini).
  • + or set up a Gemini API key (Gemini). +
  • For Gemini, tap Get a free key to open Google AI Studio in your + browser — it creates the underlying Cloud project for you, so no Google + Cloud console visit is needed. Copy the key, paste it into the key field, + then tap Save Key. Saving checks the key with Google first: a key Google rejects + is not stored, and one that can't be checked (offline, or AI Core disabled) + is kept only if you confirm.
  • Open the Agent tab and start chatting.
  • diff --git a/ai-assistant/src/main/assets/docs/index.html b/ai-assistant/src/main/assets/docs/index.html index c17a60a8..1df547e5 100644 --- a/ai-assistant/src/main/assets/docs/index.html +++ b/ai-assistant/src/main/assets/docs/index.html @@ -45,9 +45,9 @@

    Choosing a backend

  • Local (on-device) — runs a .gguf model via llama.cpp. Open Settings, pick a model file from your Downloads folder. Nothing leaves the device.
  • -
  • Gemini (cloud) — enter a Gemini API key in Settings. - Prompts and any file contents the agent reads are sent to Google over - HTTPS.
  • +
  • Gemini (cloud) — enter a Gemini API key in Settings; see + Getting a free Gemini key below. Prompts and any file contents the + agent reads are sent to Google over HTTPS.
  • What the agent can do

    @@ -75,6 +75,51 @@

    Attaching context files

  • On the Gemini backend, attached file contents leave the device.
  • +

    Getting a free Gemini key

    +

    You do not need the Google Cloud console. Keys are created at + aistudio.google.com/apikey, and Google AI Studio sets up the underlying + Cloud project for you the first time you accept its terms.

    +
      +
    1. In Settings with the Gemini backend selected, tap + Get a free key. Your normal browser opens at AI Studio.
    2. +
    3. Sign in with your Google account in the browser and tap + Create API key. This plugin never sees your Google password.
    4. +
    5. Copy the key Google shows you, return to the IDE and paste it into the + Gemini API Key field (long-press the field, then Paste).
    6. +
    7. Tap Save Key. The key is checked with Google immediately.
    8. +
    +

    What Save reports:

    + +

    If no browser is installed, the AI Studio link is copied to the clipboard so + you can open it on another device and type the key in by hand.

    + +
    + AI Studio isn't available in every country. If you can't create a key, the + Local backend needs no account at all — pick a .gguf chat + model and everything runs on the device. +
    + +

    Free tier and your data

    +

    Gemini has a free tier that is enough to use this plugin. Be aware that on + the free tier Google may use prompts and responses to improve its + products; the paid tier does not. Because the agent sends your prompts, project + context and the contents of files it reads, that applies to your code too. This + has always been true of the Gemini backend — it is written down here so it isn't + a surprise. The Local backend sends nothing anywhere.

    +

    Your Gemini API key

    The key is encrypted with AES/GCM under a hardware-backed Android Keystore secret, and only the ciphertext is written to the plugin's private storage — @@ -100,7 +145,11 @@

    Troubleshooting

    diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt index d34be773..6fa22e76 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/AiAssistantPlugin.kt @@ -49,6 +49,7 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { const val TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT = "ai_settings_simple_prompt" const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_settings_gemini_key" const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_settings_gemini_model" + const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_settings_get_free_key" @Volatile private var pluginContext: PluginContext? = null @@ -366,14 +367,24 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { tag = TOOLTIP_TAG_SETTINGS_GEMINI_KEY, summary = "Enter your Google Gemini API key. It is stored only on this device.", detail = """ -

    Paste a Gemini API key to enable the cloud backend. The key is - encrypted with a key held in this device's hardware-backed Android - Keystore before it is written to this plugin's private preferences, - and is sent only to Google's API over HTTPS. Requests (your prompts - and project context) leave the device when Gemini is selected.

    -

    Use the eye button to check what you typed, Save to store - it, Edit to change it later and Clear to remove it - from the device.

    +

    Paste a Gemini API key to enable the cloud backend. Keys are free + to create at aistudio.google.com/apikey — tap Get a free + key to go straight there. Google AI Studio sets up the + underlying Cloud project for you, so there is no Cloud console and + no billing setup involved.

    +

    The key is encrypted with a key held in this device's + hardware-backed Android Keystore before it is written to this + plugin's private preferences, and is sent only to Google's API over + HTTPS. Requests (your prompts and project context) leave the device + when Gemini is selected.

    +

    Save checks the key with Google before storing it, so a + key that doesn't work is reported straight away instead of failing + later mid-chat — a key Google rejects is not saved at all. If the + check can't be completed (no network, or the AI Core plugin is + disabled or out of date) you are asked whether to keep the key + anyway.

    +

    Use the eye button to check what you typed, Edit to change + the key later and Clear to remove it from the device.

    If the Keystore entry is ever lost — clearing the app's data, for instance — the stored key can no longer be decrypted and must be re-entered here.

    @@ -381,6 +392,29 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension { buttons = listOf( PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) ) + ), + PluginTooltipEntry( + tag = TOOLTIP_TAG_SETTINGS_GET_KEY, + summary = "Open Google AI Studio in your browser to create a free Gemini API key.", + detail = """ +

    Opens aistudio.google.com/apikey in your normal browser, + where you sign in with your Google account and tap Create API + key. AI Studio creates the Cloud project behind the scenes — the + Google Cloud console is not part of this.

    +

    Sign-in happens in the browser, so this plugin never sees your + Google password. Copy the key Google shows you, come back here and + paste it into the key field, then tap Save Key.

    +

    Gemini has a free tier. Note that on the free tier Google may use + prompts and responses to improve its products — and this plugin + sends your prompts and any file contents the agent reads. If that + matters for your project, use the on-device Local backend + instead: nothing leaves the device.

    +

    If no browser is installed the link is copied to the clipboard + so you can open it elsewhere.

    + """.trimIndent(), + buttons = listOf( + PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0) + ) ) ) diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt index c1c93d61..9c05faca 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt @@ -1,6 +1,8 @@ package com.itsaky.androidide.plugins.aiassistant.fragments import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri @@ -13,12 +15,16 @@ import android.view.ViewGroup import android.view.WindowManager import android.widget.* import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +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.base.PluginFragmentHelper import com.itsaky.androidide.plugins.services.IdeTooltipService import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend @@ -29,6 +35,7 @@ import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlin.math.roundToInt class AiSettingsFragment : DialogFragment() { @@ -44,6 +51,13 @@ class AiSettingsFragment : DialogFragment() { private lateinit var backendSpecificContainer: FrameLayout 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. + */ + private var onGeminiPaneResume: (() -> Unit)? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Disable Material transitions to avoid resource loading issues @@ -116,6 +130,17 @@ class AiSettingsFragment : DialogFragment() { setupBackendSelector() } + override fun onResume() { + super.onResume() + onGeminiPaneResume?.invoke() + } + + override fun onDestroyView() { + // Drops the captured Gemini pane views along with the callback. + onGeminiPaneResume = null + super.onDestroyView() + } + override fun onDismiss(dialog: android.content.DialogInterface) { super.onDismiss(dialog) // This is a dialog, so the chat screen behind it never gets onResume when we close. @@ -181,6 +206,8 @@ class AiSettingsFragment : DialogFragment() { 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 // Reuse the fragment's theme-aware inflater (routed through getPluginInflater) so these // sub-layouts follow the IDE day/night theme like the rest of the dialog. @@ -309,14 +336,41 @@ class AiSettingsFragment : DialogFragment() { val editButton = view.findViewById