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.
+
+ In Settings with the Gemini backend selected, tap
+ Get a free key . Your normal browser opens at AI Studio.
+ Sign in with your Google account in the browser and tap
+ Create API key . This plugin never sees your Google password.
+ 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 ).
+ Tap Save Key . The key is checked with Google immediately.
+
+ What Save reports:
+
+ Verified — your API key works — Google accepted the key and it has
+ been stored.
+ Key accepted, Google is rate-limiting — the key is valid and stored;
+ the quota is busy right now, not the credential.
+ Invalid API key — Google refused it, so nothing is saved .
+ Check what you pasted and try again. Keys are long, single-line, and contain
+ no spaces.
+ Couldn't reach Google / Couldn't check this key — the check
+ itself failed (no network, or the AI Core plugin is disabled or out of
+ date), so you are
+ asked whether to keep the key anyway. Kept this way it is stored but not
+ confirmed, and the status line says so.
+
+ 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
"No model configured" — select a .gguf file (Local)
or set an API key (Gemini) in Settings.
- Gemini errors — check the API key, network connection, and quota.
+ Gemini errors — check the API key, network connection, and quota.
+ Re-tapping Save on the key re-runs the live check and tells you which
+ of the three it is.
+ "Invalid API key" — the key was not stored. Make sure you copied
+ the key itself from AI Studio and not the page's URL.
Agent tab missing — confirm both AI Core and AI Assistant are
installed and the IDE was restarted.
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(R.id.btn_edit_api_key)
val clearButton = view.findViewById(R.id.btn_clear_api_key)
val statusTextView = view.findViewById(R.id.gemini_api_key_status_text)
+ val getKeyButton = view.findViewById(R.id.btn_get_free_key)
+ val verificationText = view.findViewById(R.id.gemini_key_verification_text)
// Not on apiKeyInput: long-press there is the paste menu, and a key is pasted.
- listOf(toggleVisibilityButton, saveButton, editButton, clearButton, statusTextView)
- .forEach { wireTooltip(it, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_KEY) }
+ listOf(
+ toggleVisibilityButton, saveButton, editButton, clearButton, statusTextView,
+ verificationText
+ ).forEach { wireTooltip(it, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_KEY) }
+ wireTooltip(getKeyButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GET_KEY)
// Create model selection container
val modelContainer = createModelSelectionUi(view)
+ /**
+ * Show the outcome of (or progress of) the live key check.
+ *
+ * @param message the user-facing line; carries no status glyph of its own
+ * @param icon leading status drawable, or 0 for the states that don't warrant one
+ * (in-progress, and the hints shown on returning from AI Studio)
+ */
+ fun showVerification(message: String, @DrawableRes icon: Int = 0) {
+ verificationText.text = message
+ // Relative (not left/right) so the icon follows the layout direction in RTL locales.
+ verificationText.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, 0, 0, 0)
+ verificationText.visibility = View.VISIBLE
+ }
+
+ /** Drop a verdict that no longer describes what is in the field. */
+ fun hideVerification() {
+ verificationText.visibility = View.GONE
+ verificationText.text = ""
+ verificationText.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
+ }
+
+ // "Get a free key" is absent here on purpose: it stays visible while Gemini is selected.
fun updateUiState(isEditing: Boolean) {
if (isEditing) {
statusTextView.visibility = View.GONE
@@ -380,34 +434,141 @@ class AiSettingsFragment : DialogFragment() {
applyKeyVisibility()
}
+ getKeyButton.setOnClickListener { openAiStudio() }
+
+ // Coming back from AI Studio, point at the next step; the clipboard is never read.
+ onGeminiPaneResume = {
+ // Kept on the ViewModel so a rotation while AI Studio is in front doesn't lose the hint.
+ if (viewModel.sentUserToAiStudio) {
+ viewModel.sentUserToAiStudio = false
+ // With a key already stored the field is hidden, so the next tap is Edit.
+ showVerification(
+ if (apiKeyLayout.visibility == View.VISIBLE) {
+ getString(R.string.msg_key_hint_paste_into_field)
+ } else {
+ getString(R.string.msg_key_hint_edit_first)
+ }
+ )
+ }
+ }
+
+ /** Enable or disable everything that would race the in-flight key check. */
+ fun setKeyEntryEnabled(enabled: Boolean) {
+ saveButton.isEnabled = enabled
+ getKeyButton.isEnabled = enabled
+ apiKeyInput.isEnabled = enabled
+ }
+
+ /**
+ * Encrypt and store [apiKey], then reflect the outcome. Only ever reached for a key Google
+ * confirmed, or one the user chose to keep after an inconclusive check.
+ */
+ suspend fun persistKey(
+ apiKey: String,
+ verified: Boolean,
+ resultText: String,
+ @DrawableRes resultIcon: Int
+ ) {
+ if (!viewModel.saveGeminiApiKey(apiKey, verified)) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.msg_api_key_save_failed),
+ Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.msg_api_key_saved),
+ Toast.LENGTH_SHORT
+ ).show()
+ updateUiState(isEditing = false)
+ statusTextView.text = savedApiKeyStatusText()
+ showVerification(resultText, resultIcon)
+ // A different key can reach a different set of models, so the picker is re-fetched.
+ viewModel.fetchGeminiModels()
+ }
+
+ /**
+ * Offer to keep a key that could not be checked. Distinct from a rejection: refusing a good
+ * key because the device is offline would leave the plugin unconfigurable, so this gets the
+ * muted "unchecked" icon and a key Google actually refused never reaches here.
+ */
+ fun confirmSaveUnverified(apiKey: String, reason: String) {
+ showVerification(reason, R.drawable.ic_key_unchecked)
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.title_save_unverified_key)
+ .setMessage(getString(R.string.msg_save_unverified_key, reason))
+ .setNegativeButton(R.string.action_cancel, null)
+ .setPositiveButton(R.string.action_save_anyway) { _, _ ->
+ viewLifecycleOwner.lifecycleScope.launch {
+ persistKey(
+ apiKey,
+ verified = false,
+ resultText = reason,
+ resultIcon = R.drawable.ic_key_unchecked
+ )
+ }
+ }
+ .show()
+ }
+
saveButton.setOnClickListener {
val apiKey = apiKeyInput.text.toString().trim()
+ // Blankness is the only shape rule: AI Studio keys need not match the AIza… form.
if (apiKey.isBlank()) {
Toast.makeText(requireContext(), getString(R.string.msg_api_key_empty), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
- saveButton.isEnabled = false
+ setKeyEntryEnabled(false)
+ showVerification(getString(R.string.msg_verifying_key))
viewLifecycleOwner.lifecycleScope.launch {
- val saved = try {
- viewModel.saveGeminiApiKey(apiKey)
+ val verdict = try {
+ viewModel.verifyGeminiKey(apiKey)
} finally {
- saveButton.isEnabled = true
+ setKeyEntryEnabled(true)
}
- if (!saved) {
- Toast.makeText(requireContext(), getString(R.string.msg_api_key_save_failed), Toast.LENGTH_LONG).show()
- return@launch
+ when (verdict) {
+ // Model count omitted: the user saved a key, not asked for a catalog.
+ is KeyVerification.Verified -> persistKey(
+ apiKey,
+ verified = true,
+ resultText = getString(R.string.msg_key_verified),
+ resultIcon = R.drawable.ic_key_verified
+ )
+
+ // A rate-limited key is a working key, so it gets the same icon as a clean pass.
+ KeyVerification.RateLimited -> persistKey(
+ apiKey,
+ verified = true,
+ resultText = getString(R.string.msg_key_verified_rate_limited),
+ resultIcon = R.drawable.ic_key_verified
+ )
+
+ // Nothing is written: a definitive refusal would only resurface mid-chat.
+ KeyVerification.Rejected -> {
+ showVerification(
+ getString(R.string.msg_key_rejected),
+ R.drawable.ic_key_rejected
+ )
+ apiKeyInput.requestFocus()
+ }
+
+ KeyVerification.Unreachable ->
+ confirmSaveUnverified(apiKey, getString(R.string.msg_key_unreachable))
+
+ KeyVerification.Unknown ->
+ confirmSaveUnverified(apiKey, getString(R.string.msg_key_uncheckable))
}
- Toast.makeText(requireContext(), getString(R.string.msg_api_key_saved), Toast.LENGTH_SHORT).show()
- updateUiState(isEditing = false)
- statusTextView.text = savedApiKeyStatusText()
}
}
- // Reveal the (already-fetched) key in an editable, focused field. Kept separate from
- // the click handler so the listener does one thing: fetch, then hand off.
+ // Reveal the (already-fetched) key in an editable, focused field.
fun revealEditMode(apiKey: String) {
apiKeyInput.setText(apiKey)
apiKeyInput.setSelection(apiKey.length)
+ // The old verdict described the stored key, which is about to change.
+ hideVerification()
updateUiState(isEditing = true)
isKeyVisible = false
applyKeyVisibility()
@@ -437,6 +598,7 @@ class AiSettingsFragment : DialogFragment() {
clearButton.setOnClickListener {
viewModel.clearGeminiApiKey()
Toast.makeText(requireContext(), getString(R.string.msg_api_key_cleared), Toast.LENGTH_SHORT).show()
+ hideVerification()
updateUiState(isEditing = true)
apiKeyInput.setText("")
}
@@ -448,11 +610,8 @@ class AiSettingsFragment : DialogFragment() {
/**
* Add or clear [WindowManager.LayoutParams.FLAG_SECURE] on this dialog's window.
*
- * Set while the API key is displayed in clear text: without it the key is captured by
- * screenshots, screen recordings and the recents-screen thumbnail, which would undo the
- * point of encrypting it at rest. The window may not exist yet on the first call (this runs
- * from view setup, before onStart), which is safe — the initial state is masked, so there is
- * no flag to apply until the user actually reveals the key.
+ * Set while the key is in clear text, or screenshots and the recents thumbnail would capture
+ * it. A null window on the first call is safe: the initial state is masked.
*
* @param secure true to block capture, false to allow it again
*/
@@ -468,34 +627,91 @@ class AiSettingsFragment : DialogFragment() {
}
}
- /** Status line for a stored key: dated when the save time is known, generic otherwise. */
+ /**
+ * Status line for a stored key: dated when the save time is known, generic otherwise, and
+ * saying "verified" only for a key Google actually confirmed — a key kept through the
+ * save-anyway path was never checked and must not claim otherwise.
+ */
private fun savedApiKeyStatusText(): String {
val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
+ val verified = viewModel.isGeminiKeyVerified()
if (timestamp <= 0) return getString(R.string.msg_api_key_is_saved)
val savedDate = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(Date(timestamp))
- return getString(R.string.msg_api_key_saved_on, savedDate)
+ return if (verified) {
+ getString(R.string.msg_api_key_verified_on, savedDate)
+ } else {
+ getString(R.string.msg_api_key_saved_on, savedDate)
+ }
+ }
+
+ /**
+ * Open Google AI Studio's key page in the *system* browser.
+ *
+ * A real browser, not a WebView: Google blocks sign-in in embedded WebViews, and the user
+ * should see Google's own URL bar. With no browser at all, the URL is copied instead.
+ */
+ private fun openAiStudio() {
+ val url = GeminiKeyOnboarding.AI_STUDIO_URL
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ runCatching { startActivity(intent) }
+ .onSuccess { viewModel.sentUserToAiStudio = true }
+ .onFailure { error ->
+ AiAssistantPlugin.getContext()?.logger
+ ?.warn("AiSettingsFragment: no browser could open AI Studio", error)
+ val message = if (copyToClipboard(url)) {
+ R.string.msg_no_browser_for_key
+ } else {
+ R.string.msg_key_link_copy_failed
+ }
+ Toast.makeText(requireContext(), getString(message, url), Toast.LENGTH_LONG).show()
+ }
}
+ /**
+ * Put [text] on the clipboard.
+ *
+ * Only ever used for the public AI Studio URL — never for a key, which would put the secret
+ * somewhere every app on the device can read it.
+ *
+ * @return true when the clipboard accepted the value
+ */
+ private fun copyToClipboard(text: String): Boolean {
+ val clipboard = requireContext()
+ .getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return false
+ return runCatching {
+ clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.app_name), text))
+ }.isSuccess
+ }
+
+ /**
+ * Density-independent [dp] as whole pixels, for the views this screen builds in code. The
+ * `setPadding` family takes raw pixels, so a literal shrinks as screen density rises.
+ *
+ * @param dp the density-independent size to convert
+ * @return the equivalent size in device pixels
+ */
+ private fun dp(dp: Int): Int = (dp * resources.displayMetrics.density).roundToInt()
+
private fun createModelSelectionUi(parent: View): LinearLayout {
val context = requireContext()
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
- setPadding(0, 32, 0, 0)
+ setPadding(0, dp(32), 0, 0)
}
// Add title
val titleText = TextView(context).apply {
- text = "Gemini Model"
+ text = getString(R.string.label_gemini_model)
textSize = 16f
- setPadding(0, 0, 0, 16)
+ setPadding(0, 0, 0, dp(16))
}
container.addView(titleText)
// Add current model display
val currentModelText = TextView(context).apply {
id = View.generateViewId()
- text = "Current: ${viewModel.getGeminiModel()}"
- setPadding(0, 0, 0, 8)
+ text = getString(R.string.current_model, viewModel.getGeminiModel())
+ setPadding(0, 0, 0, dp(8))
}
container.addView(currentModelText)
@@ -508,7 +724,7 @@ class AiSettingsFragment : DialogFragment() {
// Add refresh button
val refreshButton = Button(context).apply {
id = View.generateViewId()
- text = "Refresh Models"
+ text = getString(R.string.refresh_models)
}
container.addView(refreshButton)
@@ -559,7 +775,7 @@ class AiSettingsFragment : DialogFragment() {
modelSpinner.setSelection(0)
val migrated = models[0]
viewModel.saveGeminiModel(migrated)
- currentModelText?.text = "Current: $migrated"
+ currentModelText?.text = getString(R.string.current_model, migrated)
}
}
}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt
new file mode 100644
index 00000000..4fdfdcc6
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/CatalogResult.kt
@@ -0,0 +1,23 @@
+package com.itsaky.androidide.plugins.aiassistant.gemini
+
+/**
+ * Outcome of one model-catalog lookup against ai-core's Gemini backend.
+ *
+ * A closed hierarchy, so callers cannot treat "ai-core isn't installed" and "Google refused the
+ * key" alike — which the old `emptyList()`-on-every-failure bridge forced them to do.
+ */
+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. */
+ data object NoBackend : CatalogResult
+
+ /**
+ * The lookup failed. [cause] is the *unwrapped* failure — the API's [java.io.IOException] for
+ * an HTTP error, a [java.util.concurrent.TimeoutException], or a reflection failure when the
+ * cross-plugin contract has changed.
+ */
+ data class Failed(val cause: Throwable) : CatalogResult
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt
new file mode 100644
index 00000000..b03d6134
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt
@@ -0,0 +1,154 @@
+package com.itsaky.androidide.plugins.aiassistant.gemini
+
+import com.itsaky.androidide.plugins.PluginLogger
+import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+import java.lang.reflect.InvocationTargetException
+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 ai-assistant asks ai-core's Gemini backend for a model catalog.
+ *
+ * An abstraction the ViewModel can fake in tests, so the unchecked cross-classloader contract
+ * 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 ai-core's `GeminiBackend`, reached by reflection.
+ *
+ * `listModels` isn't on [LlmInferenceService.LlmBackend], so this is an unchecked contract: every
+ * break is a [CatalogResult.Failed], never an empty catalog that would read as "this key works".
+ *
+ * @param backendProvider resolves the "gemini" backend; injectable so tests need no SharedServices
+ */
+class ReflectiveGeminiCatalogGateway(
+ private val backendProvider: () -> Any? = ::resolveGeminiBackend
+) : GeminiCatalogGateway {
+
+ companion object {
+ private const val TAG = "GeminiCatalogGateway"
+
+ /** Backend id registered by ai-core's `GeminiBackend.getId()`. */
+ private const val BACKEND_ID = "gemini"
+
+ private const val METHOD_LIST_MODELS = "listModels"
+
+ /**
+ * Failsafe cap, well above ai-core'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 ai-core scope; not the expected wait.
+ */
+ private const val LIST_MODELS_TIMEOUT_SECONDS = 60L
+
+ /** Default [backendProvider]: the live lookup through the shared service registry. */
+ private fun resolveGeminiBackend(): Any? =
+ SharedServices.get(LlmInferenceService::class.java)?.getBackend(BACKEND_ID)
+ }
+
+ /**
+ * This plugin's IDE-surfaced log, so a broken cross-plugin contract 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() = AiAssistantPlugin.getContext()?.logger
+
+ override fun listModelsForSavedKey(): CatalogResult =
+ callListModels(paramTypes = emptyArray(), args = emptyArray())
+
+ /**
+ * No fallback to the no-arg `listModels()` when ai-core is too old to have this overload: that
+ * authenticates with the *saved* key, clearing a candidate on a different credential.
+ */
+ override fun listModels(apiKey: String): CatalogResult =
+ callListModels(paramTypes = arrayOf(String::class.java), args = arrayOf(apiKey))
+
+ /**
+ * Invoke `listModels` with the given signature and await its future.
+ *
+ * Blocks on [CompletableFuture.get], so call it from an IO dispatcher — never the main thread.
+ */
+ private fun callListModels(paramTypes: Array>, args: Array): CatalogResult {
+ val backend = try {
+ backendProvider()
+ } catch (e: Exception) {
+ logger?.error("$TAG: could not resolve the '$BACKEND_ID' backend", e)
+ return CatalogResult.Failed(e)
+ } ?: return CatalogResult.NoBackend
+
+ val method = try {
+ backend.javaClass.getMethod(METHOD_LIST_MODELS, *paramTypes)
+ } catch (e: NoSuchMethodException) {
+ val signature = paramTypes.joinToString { it.simpleName }
+ logger?.error(
+ "$TAG: ai-core's ${backend.javaClass.name} has no " +
+ "$METHOD_LIST_MODELS($signature): the cross-plugin contract changed. Expected " +
+ "`fun listModels($signature): CompletableFuture>`.",
+ e
+ )
+ return CatalogResult.Failed(e)
+ }
+
+ val raw = try {
+ method.invoke(backend, *args)
+ } catch (e: InvocationTargetException) {
+ // Unwrap: the interesting failure is the one listModels threw, not the wrapper.
+ val cause = e.cause ?: e
+ logger?.error("$TAG: $METHOD_LIST_MODELS threw", cause)
+ return CatalogResult.Failed(cause)
+ } catch (e: Exception) {
+ logger?.error("$TAG: could not invoke $METHOD_LIST_MODELS", e)
+ return CatalogResult.Failed(e)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ val future = raw as? CompletableFuture>
+ if (future == null) {
+ val message =
+ "$METHOD_LIST_MODELS returned ${raw?.javaClass?.name}, expected CompletableFuture"
+ logger?.error("$TAG: $message")
+ return CatalogResult.Failed(IllegalStateException(message))
+ }
+
+ return try {
+ CatalogResult.Success(future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty())
+ } catch (e: ExecutionException) {
+ // The API failure ai-core reported; its message carries the HTTP status.
+ CatalogResult.Failed(e.cause ?: e)
+ } catch (e: CancellationException) {
+ logger?.warn("$TAG: $METHOD_LIST_MODELS was cancelled by ai-core", e)
+ CatalogResult.Failed(e)
+ } catch (e: TimeoutException) {
+ future.cancel(true)
+ logger?.error(
+ "$TAG: $METHOD_LIST_MODELS did not complete within " +
+ "${LIST_MODELS_TIMEOUT_SECONDS}s; is ai-core still active?",
+ 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-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboarding.kt
new file mode 100644
index 00000000..ba9557a4
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboarding.kt
@@ -0,0 +1,16 @@
+package com.itsaky.androidide.plugins.aiassistant.gemini
+
+/**
+ * Where a Gemini API key comes from.
+ *
+ * Open [AI_STUDIO_URL] in a real browser, sign in with Google there, copy the key, paste it into
+ * the key field. This plugin never sees a Google password, and never reads the clipboard.
+ */
+object GeminiKeyOnboarding {
+
+ /**
+ * AI Studio, not `console.cloud.google.com`: AI Studio creates a default Cloud project on
+ * first use, which is why no console step is needed.
+ */
+ const val AI_STUDIO_URL = "https://aistudio.google.com/apikey"
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerification.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerification.kt
new file mode 100644
index 00000000..ed4f496a
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerification.kt
@@ -0,0 +1,101 @@
+package com.itsaky.androidide.plugins.aiassistant.gemini
+
+import java.io.IOException
+
+/**
+ * What a live check of a Gemini API key established.
+ *
+ * [Rejected] is a confirmed verdict from Google and blocks the save; [Unreachable] and [Unknown]
+ * establish nothing, so collapsing them together would save bad keys or block offline setup.
+ */
+sealed interface KeyVerification {
+
+ /**
+ * Google accepted the key and returned [modelCount] chat-capable models.
+ *
+ * [modelCount] proves the catalog was non-empty and is logged, but stays out of the UI: the
+ * user saved a key, not asked for a catalog.
+ */
+ data class Verified(val modelCount: Int) : KeyVerification
+
+ /**
+ * Google accepted the key but is rate-limiting (HTTP 429). The credential is valid; the quota
+ * is not. Treated as confirmed on purpose — calling this "rejected" would send users off to
+ * mint a second key that behaves identically.
+ */
+ data object RateLimited : KeyVerification
+
+ /** Google refused the request (any 4xx bar 429). The only state that blocks a save. */
+ data object Rejected : KeyVerification
+
+ /** The request never got an answer — no network, DNS failure, timeout, or a 5xx from Google. */
+ data object Unreachable : KeyVerification
+
+ /** Nothing could be checked: ai-core absent, or the cross-plugin contract broke. */
+ data object Unknown : KeyVerification
+
+ /**
+ * True when Google confirmed the key. This is the save rule in one place: a key is written to
+ * disk only when this is true, or when the user explicitly overrides an *inconclusive* check.
+ */
+ val isConfirmedValid: Boolean
+ get() = this is Verified || this is RateLimited
+}
+
+/**
+ * Interpret a catalog lookup as a verdict on the key that produced it.
+ *
+ * Pure: no Android framework state and no logging of its own — the failure itself is already
+ * reported by [ReflectiveGeminiCatalogGateway] — so every row of the mapping is unit-testable
+ * without a device or a live ai-core.
+ */
+fun CatalogResult.toKeyVerification(): KeyVerification = when (this) {
+ is CatalogResult.Success ->
+ // A valid key always lists something; zero models is unchecked, not a pass.
+ if (models.isEmpty()) KeyVerification.Unknown else KeyVerification.Verified(models.size)
+
+ CatalogResult.NoBackend -> KeyVerification.Unknown
+
+ is CatalogResult.Failed -> classifyFailure(cause)
+}
+
+/**
+ * Map a lookup failure onto a verdict, using the HTTP status ai-core embeds in its
+ * `ListModels HTTP : ` message. Any 4xx is the client's fault and rejects the key;
+ * with no status at all, an [IOException] is transport trouble and anything else is unchecked.
+ */
+private fun classifyFailure(cause: Throwable): KeyVerification =
+ when (failureStatusOf(cause)) {
+ null -> if (cause is IOException) KeyVerification.Unreachable else KeyVerification.Unknown
+ // Ordered before the 4xx range: a throttled key is valid, and must not read as refused.
+ 429 -> KeyVerification.RateLimited
+ in 400..499 -> KeyVerification.Rejected
+ // Google's fault, not the key's: a 5xx says nothing about the credential.
+ in 500..599 -> KeyVerification.Unreachable
+ // A status outside 4xx/5xx on a failure says nothing: unchecked, never rejected.
+ else -> KeyVerification.Unknown
+ }
+
+/**
+ * Matches the status in ai-core's `ListModels HTTP 403: {...}` failure message.
+ *
+ * Anchored on the whole prefix, not a bare `HTTP \d{3}`: Google's error body is appended to that
+ * message, and a looser pattern could read a verdict on the key out of server-supplied text.
+ */
+private val LIST_MODELS_FAILURE_STATUS = Regex("""ListModels HTTP (\d{3})""")
+
+/** Depth cap: a malformed cause chain can be self-referential, and this runs on user input. */
+private const val MAX_CAUSE_DEPTH = 5
+
+/** First failure status found walking [cause] and its causes, or null when there is none. */
+private fun failureStatusOf(cause: Throwable): Int? {
+ var current: Throwable? = cause
+ var depth = 0
+ while (current != null && depth < MAX_CAUSE_DEPTH) {
+ LIST_MODELS_FAILURE_STATUS.find(current.message.orEmpty())
+ ?.let { return it.groupValues[1].toIntOrNull() }
+ current = current.cause
+ depth++
+ }
+ return null
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
index 56f3a4b6..6f1eae6e 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
@@ -5,19 +5,21 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.itsaky.androidide.plugins.aiassistant.gemini.CatalogResult
+import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiCatalogGateway
+import com.itsaky.androidide.plugins.aiassistant.gemini.KeyVerification
+import com.itsaky.androidide.plugins.aiassistant.gemini.ReflectiveGeminiCatalogGateway
+import com.itsaky.androidide.plugins.aiassistant.gemini.toKeyVerification
import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.util.GgufFileInspector
-import com.itsaky.androidide.plugins.services.LlmInferenceService
-import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.itsaky.androidide.plugins.PluginContext
-import java.util.concurrent.CompletableFuture
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.TimeoutException
+import com.itsaky.androidide.plugins.PluginLogger
/**
* State for the model file loading.
@@ -58,7 +60,8 @@ data class GeminiModelOptions(val models: List, val isLive: Boolean)
class AiSettingsViewModel(
private val getContext: () -> PluginContext?,
- private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
+ private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+ private val catalogGateway: GeminiCatalogGateway = ReflectiveGeminiCatalogGateway()
) : ViewModel() {
companion object {
@@ -67,13 +70,6 @@ class AiSettingsViewModel(
/** Default selection; kept in sync with GeminiBackend.DEFAULT_MODEL. */
private const val DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
- /**
- * Failsafe cap on the cross-plugin model listing, above ai-core's own per-request budget
- * (15 s connect + 15 s read, paginated) so a slow-but-live fetch is never truncated.
- * Bounds a future that may never complete; it is not a network timeout.
- */
- private const val LIST_MODELS_TIMEOUT_SECONDS = 60L
-
/** Shown only when the live catalog can't be fetched — current models, no retired ones. */
private val FALLBACK_MODELS = listOf(
"gemini-2.5-flash",
@@ -82,6 +78,13 @@ class AiSettingsViewModel(
)
}
+ /**
+ * True between tapping *Get a free key* and the settings screen's next resume, so the UI can
+ * point at the next step once the user is back from AI Studio. Held here rather than on the
+ * fragment so a rotation while the browser is in front doesn't reset it and swallow the hint.
+ */
+ var sentUserToAiStudio: Boolean = false
+
private val _savedModelPath = MutableLiveData(null)
val savedModelPath: LiveData get() = _savedModelPath
@@ -111,8 +114,21 @@ class AiSettingsViewModel(
}
}
+ /**
+ * This plugin's settings store — and, for the Gemini keys, ai-core's too.
+ *
+ * ai-core resolves this plugin's `PluginContext` and asks for the same name, so both sides
+ * share one process-wide `SharedPreferencesImpl`: writes here need no flush to be visible.
+ */
private fun getPluginPrefs() = getContext()?.getPluginSharedPreferences("AgentSettings")
+ /**
+ * This plugin's IDE-surfaced log, so settings diagnostics land in the IDE's own log view rather
+ * than only in logcat. Null before `initialize()` and in JVM tests.
+ */
+ private val logger: PluginLogger?
+ get() = getContext()?.logger
+
/** Human-readable name persisted alongside the model path at load time, if any. */
fun getSavedModelName(): String? =
getPluginPrefs()?.getString("local_llm_model_name", null)?.takeIf { it.isNotBlank() }
@@ -144,7 +160,7 @@ class AiSettingsViewModel(
}
}
} catch (e: Exception) {
- android.util.Log.w(TAG, "Could not resolve display name for $uriString", e)
+ logger?.warn("$TAG: could not resolve display name for $uriString", e)
}
}
return fallbackDisplayName(uriString)
@@ -205,32 +221,78 @@ class AiSettingsViewModel(
}
/**
- * Encrypts [apiKey] via [SecureApiKeyStore] and persists only the ciphertext to private
- * prefs, off the main thread (Keystore IPC + AES/GCM). Nothing is written on failure.
+ * Check whether [apiKey] actually works, without storing it anywhere.
*
- * @param apiKey the plaintext key to store (trimmed before encryption)
- * @return true only if the key was both encrypted and persisted
+ * Asks ai-core to list the models the candidate key can reach; see [KeyVerification] for what
+ * each verdict establishes. Run this *before* [saveGeminiApiKey]. The key is never logged.
+ *
+ * @param apiKey the candidate key as typed, trimmed here
+ * @return the verdict; [KeyVerification.Unknown] when nothing could be established
*/
- suspend fun saveGeminiApiKey(apiKey: String): Boolean = withContext(ioDispatcher) {
- // Checked first: returning true here would have the UI claim an unwritten key was saved.
- val prefs = getPluginPrefs()
- if (prefs == null) {
- android.util.Log.e(TAG, "Cannot save Gemini API key: plugin preferences unavailable")
- return@withContext false
- }
- val encrypted = try {
- SecureApiKeyStore.encrypt(apiKey.trim())
+ suspend fun verifyGeminiKey(apiKey: String): KeyVerification = withContext(ioDispatcher) {
+ val candidate = apiKey.trim()
+ if (candidate.isEmpty()) return@withContext KeyVerification.Rejected
+ val result = try {
+ catalogGateway.listModels(candidate)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
- android.util.Log.e(TAG, "Failed to encrypt Gemini API key", e)
- return@withContext false
+ // Last-resort net: a verification crash must never be mistaken for a pass.
+ logger?.error("$TAG: Gemini key verification failed unexpectedly", e)
+ CatalogResult.Failed(e)
+ }
+ result.toKeyVerification().also { verification ->
+ // Diagnostic only: saving a key is not a request for a catalog, so the UI omits this.
+ if (verification is KeyVerification.Verified) {
+ logger?.debug(
+ "$TAG: Gemini key verified against ${verification.modelCount} " +
+ "chat-capable models"
+ )
+ }
}
- prefs.edit()
- .putString("gemini_api_key", encrypted)
- .putLong("gemini_api_key_timestamp", System.currentTimeMillis())
- .apply()
- true
}
+ /**
+ * Encrypts [apiKey] via [SecureApiKeyStore] and persists only the ciphertext to private prefs,
+ * off the main thread. Nothing is written on failure. Kept separate from [verifyGeminiKey]: a
+ * rejected key never reaches here, and an unverifiable one only after the user says so.
+ *
+ * @param apiKey the plaintext key to store (trimmed before encryption)
+ * @param verified true when [verifyGeminiKey] confirmed this key; recorded in the same write so
+ * the flag can never outlive or precede the key it describes
+ * @return true only if the key was both encrypted and persisted
+ */
+ suspend fun saveGeminiApiKey(apiKey: String, verified: Boolean = false): Boolean =
+ withContext(ioDispatcher) {
+ // Checked first: returning true here would have the UI claim an unwritten key was saved.
+ val prefs = getPluginPrefs()
+ if (prefs == null) {
+ logger?.error("$TAG: cannot save Gemini API key: plugin preferences unavailable")
+ return@withContext false
+ }
+ val encrypted = try {
+ SecureApiKeyStore.encrypt(apiKey.trim())
+ } catch (e: Exception) {
+ logger?.error("$TAG: failed to encrypt Gemini API key", e)
+ return@withContext false
+ }
+ // commit(), not apply(): only a synchronous write can honestly return "persisted".
+ prefs.edit()
+ .putString("gemini_api_key", encrypted)
+ .putLong("gemini_api_key_timestamp", System.currentTimeMillis())
+ .putBoolean("gemini_api_key_verified", verified)
+ .commit()
+ }
+
+ /**
+ * Whether the stored key was confirmed working by Google when it was saved.
+ *
+ * False for a key kept after an inconclusive check, so the status line can say "saved" without
+ * claiming "verified". Raw pref only, so safe on the main thread.
+ */
+ fun isGeminiKeyVerified(): Boolean =
+ getPluginPrefs()?.getBoolean("gemini_api_key_verified", false) ?: false
+
/**
* Decrypt the stored key off the main thread (Keystore IPC + AES/GCM), upgrading a
* pre-encryption plaintext key to ciphertext in passing so existing installs actually
@@ -256,6 +318,8 @@ class AiSettingsViewModel(
getPluginPrefs()?.edit()?.apply {
remove("gemini_api_key")
remove("gemini_api_key_timestamp")
+ // Removed with the key, or the next saved key would inherit this one's verdict.
+ remove("gemini_api_key_verified")
apply()
}
}
@@ -290,29 +354,33 @@ class AiSettingsViewModel(
try {
val apiKey = getGeminiApiKey()?.trim()
if (apiKey.isNullOrBlank()) {
- android.util.Log.w(TAG, "No Gemini API key saved; showing fallback models")
- _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
- return@launch
- }
-
- val llmService = SharedServices.get(LlmInferenceService::class.java)
- val geminiBackend = llmService?.getBackend("gemini")
- if (geminiBackend == null) {
- android.util.Log.e(TAG, "Gemini backend not available")
+ logger?.warn("$TAG: no Gemini API key saved; showing fallback models")
_geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
return@launch
}
- val models = listModelsViaBackend(geminiBackend)
- if (models.isEmpty()) {
- android.util.Log.w(TAG, "Live model list empty; showing fallback models")
- _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
- } else {
- android.util.Log.d(TAG, "Fetched ${models.size} Gemini models")
- _geminiModels.postValue(GeminiModelOptions(models, isLive = true))
+ when (val result = catalogGateway.listModelsForSavedKey()) {
+ is CatalogResult.Success -> {
+ if (result.models.isEmpty()) {
+ logger?.warn("$TAG: live model list empty; showing fallback models")
+ _geminiModels.postValue(
+ GeminiModelOptions(FALLBACK_MODELS, isLive = false)
+ )
+ } else {
+ logger?.debug("$TAG: fetched ${result.models.size} Gemini models")
+ _geminiModels.postValue(
+ GeminiModelOptions(result.models, isLive = true)
+ )
+ }
+ }
+ // Logged by the gateway; degrade to current-models-only, never a 404 model.
+ CatalogResult.NoBackend, is CatalogResult.Failed ->
+ _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
}
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
- android.util.Log.e(TAG, "Error fetching Gemini models", e)
+ logger?.error("$TAG: error fetching Gemini models", e)
_geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
} finally {
_geminiModelsLoading.postValue(false)
@@ -320,54 +388,6 @@ class AiSettingsViewModel(
}
}
- /**
- * Ask ai-core's Gemini backend for its live model catalog.
- *
- * `listModels()` isn't on the shared [LlmInferenceService.LlmBackend] interface, so reflection
- * is the only way across the plugin classloader boundary — an unchecked contract, hence the
- * loud log when it breaks rather than a silent fall back to [FALLBACK_MODELS].
- *
- * @param backend the resolved "gemini" backend instance from [SharedServices]
- * @return the live catalog, or an empty list when unavailable
- */
- private fun listModelsViaBackend(backend: Any): List {
- val method = try {
- backend.javaClass.getMethod("listModels")
- } catch (e: NoSuchMethodException) {
- android.util.Log.e(
- TAG,
- "ai-core's ${backend.javaClass.name} has no listModels(): the cross-plugin " +
- "contract changed. Expected `fun listModels(): CompletableFuture>`.",
- e
- )
- return emptyList()
- }
- val result = method.invoke(backend)
-
- @Suppress("UNCHECKED_CAST")
- val future = result as? CompletableFuture>
- if (future == null) {
- android.util.Log.e(
- TAG,
- "listModels() returned ${result?.javaClass?.name}, expected CompletableFuture"
- )
- return emptyList()
- }
- // Bounded: a future from ai-core's already-cancelled scope would never complete.
- return try {
- future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty()
- } catch (e: TimeoutException) {
- future.cancel(true)
- android.util.Log.e(
- TAG,
- "listModels() did not complete within ${LIST_MODELS_TIMEOUT_SECONDS}s; " +
- "is ai-core still active?",
- e
- )
- emptyList()
- }
- }
-
/**
* Load a model from URI.
* In the plugin context, we just save the path - the actual loading
@@ -399,9 +419,9 @@ class AiSettingsViewModel(
ModelLoadingState.Loaded(fileName)
)
- android.util.Log.d(TAG, "Model path saved: $uriString ($fileName)")
+ logger?.debug("$TAG: model path saved: $uriString ($fileName)")
} catch (e: Exception) {
- android.util.Log.e("AiSettingsViewModel", "Error saving model path", e)
+ logger?.error("$TAG: error saving model path", e)
_modelLoadingState.postValue(
ModelLoadingState.Error("Failed to save model path: ${e.message}")
)
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
index e0ebd8f6..526251cd 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
@@ -330,7 +330,7 @@ class ChatViewModel(
}
val prompt = """
- You are a senior Android developer integrated into AndroidIDE. Your goal is to build complete, working Android apps from user descriptions.
+ You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions.
AVAILABLE TOOLS:
$toolDescriptions
@@ -384,7 +384,7 @@ class ChatViewModel(
}
val prompt = """
- You are a coding assistant inside AndroidIDE.
+ You are a coding assistant inside CodeOnTheGo.
Rules:
- Reply with exactly ONE tool call, nothing else.
diff --git a/ai-assistant/src/main/res/drawable/ic_key_rejected.xml b/ai-assistant/src/main/res/drawable/ic_key_rejected.xml
new file mode 100644
index 00000000..ed0578cc
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_key_rejected.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_key_unchecked.xml b/ai-assistant/src/main/res/drawable/ic_key_unchecked.xml
new file mode 100644
index 00000000..a5a369a1
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_key_unchecked.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_key_verified.xml b/ai-assistant/src/main/res/drawable/ic_key_verified.xml
new file mode 100644
index 00000000..4011545b
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_key_verified.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml b/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
index 5a5819b7..32bf5d98 100644
--- a/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
+++ b/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
@@ -9,7 +9,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
- android:text="API Key saved on: %s"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
@@ -23,7 +22,7 @@
@@ -56,6 +55,17 @@
+
+
+ android:text="@string/btn_save_api_key" />
+
+
diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml
index fea862b9..f9cbf46e 100644
--- a/ai-assistant/src/main/res/values/strings.xml
+++ b/ai-assistant/src/main/res/values/strings.xml
@@ -95,6 +95,10 @@
Show API key
Hide API key
Enter your Gemini API key
+ Gemini API Key
+ Clear
+ Edit
+ Save Key
API Key saved
API Key saved on: %s
API Key is saved
@@ -102,6 +106,23 @@
API Key cleared
Couldn\'t save the API key on this device. Please try again.
The stored API key could not be read on this device. Please enter it again.
+
+ Get a free key
+ No browser available. The link was copied — open it on another device: %s
+ Couldn\'t open a browser. Get your key at %s
+ Copied your key? Paste it into the field above, then tap Save Key.
+ Copied a new key? Tap Edit, then paste it into the field.
+ Checking this key with Google…
+ Verified, your API key works
+ Key accepted — Google is rate-limiting right now
+ Invalid API key. It wasn\'t saved, check it and try again.
+ Couldn\'t reach Google to check this key.
+ Couldn\'t check this key — make sure the AI Core plugin is installed, enabled and up to date.
+ API Key saved and verified on: %s
+ Save this key anyway?
+ %s\n\nThe key looks fine but couldn\'t be confirmed, so it may not work until you\'re back online.
+ Save anyway
+ Cancel
Backend
Model
Temperature
@@ -143,6 +164,7 @@
Loading model…
+ Gemini Model
Current: %s
Loading…
Refresh Models
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt
new file mode 100644
index 00000000..73b193e7
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt
@@ -0,0 +1,20 @@
+package com.itsaky.androidide.plugins.aiassistant.gemini
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GeminiKeyOnboardingTest {
+
+ @Test
+ fun givenTheKeySourceUrl_whenInspected_thenItPointsAtAiStudioNotTheCloudConsole() {
+ // AI Studio provisions the Cloud project itself; pinned so nobody "fixes" it back.
+ assertEquals("https://aistudio.google.com/apikey", GeminiKeyOnboarding.AI_STUDIO_URL)
+ }
+
+ @Test
+ fun givenTheKeySourceUrl_whenInspected_thenItIsHttps() {
+ // A key is typed into whatever this opens; it must not be reachable over cleartext.
+ assertTrue(GeminiKeyOnboarding.AI_STUDIO_URL.startsWith("https://"))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt
new file mode 100644
index 00000000..be7a4746
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt
@@ -0,0 +1,171 @@
+package com.itsaky.androidide.plugins.aiassistant.gemini
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import java.util.concurrent.TimeoutException
+
+/**
+ * Covers every row of the catalog-result → verdict mapping.
+ *
+ * The rejection rows are load-bearing: [KeyVerification.Rejected] is the only state that blocks a
+ * save, so a wrong mapping there discards a working key or lets a broken one through.
+ */
+class KeyVerificationTest {
+
+ /** Mirrors the message ai-core's `GeminiBackend.fetchAvailableModels` throws. */
+ private fun listModelsHttpError(code: Int, body: String = """{"error":{}}""") =
+ IOException("ListModels HTTP $code: $body")
+
+ private fun verdictFor(cause: Throwable) =
+ CatalogResult.Failed(cause).toKeyVerification()
+
+ @Test
+ fun givenANonEmptyCatalog_whenInterpreted_thenTheKeyIsVerifiedWithItsModelCount() {
+ val result = CatalogResult.Success(listOf("gemini-2.5-flash", "gemini-2.5-pro"))
+
+ assertEquals(KeyVerification.Verified(2), result.toKeyVerification())
+ }
+
+ @Test
+ fun givenAnEmptyCatalog_whenInterpreted_thenReportsUnknownRatherThanAPass() {
+ // A valid key always lists something, so this says nothing — and must not read as success.
+ assertEquals(KeyVerification.Unknown, CatalogResult.Success(emptyList()).toKeyVerification())
+ }
+
+ @Test
+ fun givenNoBackend_whenInterpreted_thenReportsUnknownAndNeverARejection() {
+ assertEquals(KeyVerification.Unknown, CatalogResult.NoBackend.toKeyVerification())
+ }
+
+ @Test
+ fun givenHttp400ApiKeyInvalid_whenInterpreted_thenTheKeyIsRejected() {
+ val cause = listModelsHttpError(400, """{"error":{"status":"INVALID_ARGUMENT"}}""")
+
+ assertEquals(KeyVerification.Rejected, verdictFor(cause))
+ }
+
+ @Test
+ fun givenHttp401_whenInterpreted_thenTheKeyIsRejected() {
+ assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(401)))
+ }
+
+ @Test
+ fun givenHttp403PermissionDenied_whenInterpreted_thenTheKeyIsRejected() {
+ val cause = listModelsHttpError(403, """{"error":{"status":"PERMISSION_DENIED"}}""")
+
+ assertEquals(KeyVerification.Rejected, verdictFor(cause))
+ }
+
+ @Test
+ fun givenHttp429_whenInterpreted_thenTheKeyCountsAsValidBecauseItStillWorks() {
+ // Also pins branch order: 429 sits inside 400..499 and must be matched before it.
+ assertEquals(KeyVerification.RateLimited, verdictFor(listModelsHttpError(429)))
+ }
+
+ @Test
+ fun givenARateLimitedOrVerifiedKey_whenTheSaveRuleIsChecked_thenItIsConfirmed() {
+ assertEquals(true, KeyVerification.RateLimited.isConfirmedValid)
+ assertEquals(true, KeyVerification.Verified(1).isConfirmedValid)
+ }
+
+ @Test
+ fun givenAnyOtherVerdict_whenTheSaveRuleIsChecked_thenItIsNotConfirmed() {
+ assertEquals(false, KeyVerification.Rejected.isConfirmedValid)
+ assertEquals(false, KeyVerification.Unreachable.isConfirmedValid)
+ assertEquals(false, KeyVerification.Unknown.isConfirmedValid)
+ }
+
+ @Test
+ fun givenA5xx_whenInterpreted_thenReportsUnreachableBecauseItIsGooglesFaultNotTheKeys() {
+ assertEquals(KeyVerification.Unreachable, verdictFor(listModelsHttpError(500)))
+ assertEquals(KeyVerification.Unreachable, verdictFor(listModelsHttpError(503)))
+ }
+
+ @Test
+ fun givenAnIoExceptionWithNoStatus_whenInterpreted_thenReportsUnreachable() {
+ assertEquals(
+ KeyVerification.Unreachable,
+ verdictFor(UnknownHostException("generativelanguage.googleapis.com"))
+ )
+ assertEquals(
+ KeyVerification.Unreachable,
+ verdictFor(SocketTimeoutException("connect timed out"))
+ )
+ }
+
+ @Test
+ fun givenATimeout_whenInterpreted_thenReportsUnknownRatherThanATransportFailure() {
+ // A future ai-core will never complete means "couldn't check it", not "offline".
+ assertEquals(KeyVerification.Unknown, verdictFor(TimeoutException("gave up")))
+ }
+
+ @Test
+ fun givenABrokenCrossPluginContract_whenInterpreted_thenReportsUnknownSoNoKeyIsDiscarded() {
+ val cause = NoSuchMethodException(
+ "com.itsaky.androidide.plugins.aicore.GeminiBackend.listModels(java.lang.String)"
+ )
+
+ assertEquals(KeyVerification.Unknown, verdictFor(cause))
+ }
+
+ @Test
+ fun givenAWrappedCause_whenInterpreted_thenTheStatusIsStillFound() {
+ val wrapped = RuntimeException("catalog lookup failed", listModelsHttpError(403))
+
+ assertEquals(KeyVerification.Rejected, verdictFor(wrapped))
+ }
+
+ @Test
+ fun givenAStatusBuriedDeeperThanTheCap_whenInterpreted_thenTheCauseWalkStillTerminates() {
+ // Pins the depth bound so an unbounded walk (or a cycle) can't creep back in.
+ var deep: Throwable = listModelsHttpError(403)
+ repeat(6) { level -> deep = RuntimeException("wrapper $level", deep) }
+
+ assertEquals(KeyVerification.Unknown, verdictFor(deep))
+ }
+
+ @Test
+ fun givenHttp404_whenInterpreted_thenTheKeyIsRejected() {
+ assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(404)))
+ }
+
+ @Test
+ fun givenAnyOther4xx_whenInterpreted_thenTheKeyIsRejected() {
+ // Every 4xx bar 429 is a client-side refusal, so none of them may reach "Save anyway?".
+ assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(402)))
+ assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(418)))
+ assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(451)))
+ }
+
+ @Test
+ fun givenAStatusOutsideTheErrorRanges_whenInterpreted_thenReportsUnknown() {
+ assertEquals(KeyVerification.Unknown, verdictFor(listModelsHttpError(302)))
+ }
+
+ @Test
+ fun givenAWrapperMentioningAnotherStatus_whenInterpreted_thenOnlyTheContractMessageCounts() {
+ // Only ai-core's `ListModels HTTP ` is a status; prose in a wrapper is not.
+ val wrapped = RuntimeException("gateway saw HTTP 403", listModelsHttpError(500))
+
+ assertEquals(KeyVerification.Unreachable, verdictFor(wrapped))
+ }
+
+ @Test
+ fun givenAMessageWithNoContractPrefix_whenInterpreted_thenNoStatusIsInferred() {
+ // "HTTP 401" in unrelated prose is not ai-core reporting a status.
+ val cause = RuntimeException("proxy rewrote the request; see HTTP 401 in the spec")
+
+ assertEquals(KeyVerification.Unknown, verdictFor(cause))
+ }
+
+ @Test
+ fun givenAnAiCoreCancellation_whenInterpreted_thenNothingIsConcludedAboutTheKey() {
+ // The gateway turns a future ai-core cancelled into Failed rather than letting it escape.
+ val cause = java.util.concurrent.CancellationException("ai-core scope closed")
+
+ assertEquals(KeyVerification.Unknown, verdictFor(cause))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt
new file mode 100644
index 00000000..12412470
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt
@@ -0,0 +1,125 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.itsaky.androidide.plugins.aiassistant.gemini.CatalogResult
+import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiCatalogGateway
+import com.itsaky.androidide.plugins.aiassistant.gemini.KeyVerification
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import java.io.IOException
+
+/**
+ * Tests for the pre-save key check.
+ *
+ * The gateway is faked, so no ai-core, no network and no device are involved — which is the point
+ * of having extracted it out of the ViewModel's raw reflection.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class AiSettingsViewModelVerifyTest {
+
+ /** The ViewModel touches LiveData in its init block. */
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ private val candidateKey = "AIzaSyD-EXAMPLE_key_value_1234567890abc"
+
+ /** No PluginContext in a JVM test; verification never needs prefs, only the gateway. */
+ private fun viewModel(gateway: GeminiCatalogGateway) = AiSettingsViewModel(
+ getContext = { null },
+ ioDispatcher = UnconfinedTestDispatcher(),
+ catalogGateway = gateway
+ )
+
+ @Test
+ fun givenAWorkingKey_whenVerified_thenItIsVerifiedWithItsModelCount() = runTest {
+ val gateway = FakeGateway(CatalogResult.Success(listOf("gemini-2.5-flash", "gemini-2.5-pro")))
+
+ val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
+
+ assertEquals(KeyVerification.Verified(2), verdict)
+ }
+
+ @Test
+ fun givenATypedKey_whenVerified_thenThatKeyIsCheckedAndNotTheSavedOne() = runTest {
+ // Checking the *saved* key would clear a candidate on a different credential.
+ val gateway = FakeGateway(CatalogResult.Success(listOf("gemini-2.5-flash")))
+
+ viewModel(gateway).verifyGeminiKey(" $candidateKey ")
+
+ assertEquals(listOf(candidateKey), gateway.candidateKeys)
+ assertEquals(0, gateway.savedKeyCalls)
+ }
+
+ @Test
+ fun givenAKeyGoogleRefuses_whenVerified_thenItIsRejected() = runTest {
+ val gateway = FakeGateway(
+ CatalogResult.Failed(IOException("ListModels HTTP 400: {\"error\":{}}"))
+ )
+
+ val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
+
+ assertEquals(KeyVerification.Rejected, verdict)
+ }
+
+ @Test
+ fun givenNoNetwork_whenVerified_thenReportsUnreachableSoTheKeyIsNotCondemned() = runTest {
+ val gateway = FakeGateway(CatalogResult.Failed(IOException("Unable to resolve host")))
+
+ val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
+
+ assertEquals(KeyVerification.Unreachable, verdict)
+ }
+
+ @Test
+ fun givenAMissingAiCore_whenVerified_thenReportsUnknown() = runTest {
+ val verdict = viewModel(FakeGateway(CatalogResult.NoBackend)).verifyGeminiKey(candidateKey)
+
+ assertEquals(KeyVerification.Unknown, verdict)
+ }
+
+ @Test
+ fun givenAGatewayThatThrows_whenVerified_thenItCannotBeMistakenForAPass() = runTest {
+ val gateway = FakeGateway(error = IllegalStateException("classloader trouble"))
+
+ val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
+
+ assertEquals(KeyVerification.Unknown, verdict)
+ assertTrue(!verdict.isConfirmedValid)
+ }
+
+ @Test
+ fun givenABlankKey_whenVerified_thenItIsRejectedWithoutANetworkRoundTrip() = runTest {
+ val gateway = FakeGateway(CatalogResult.Success(listOf("gemini-2.5-flash")))
+
+ val verdict = viewModel(gateway).verifyGeminiKey(" ")
+
+ assertEquals(KeyVerification.Rejected, verdict)
+ assertTrue(gateway.candidateKeys.isEmpty())
+ }
+
+ /** Records what it was asked, so tests can assert *which* key got checked. */
+ private class FakeGateway(
+ private val response: CatalogResult? = null,
+ private val error: Throwable? = null
+ ) : GeminiCatalogGateway {
+
+ val candidateKeys = mutableListOf()
+ var savedKeyCalls = 0
+
+ override fun listModelsForSavedKey(): CatalogResult {
+ savedKeyCalls++
+ return response ?: CatalogResult.NoBackend
+ }
+
+ override fun listModels(apiKey: String): CatalogResult {
+ candidateKeys += apiKey
+ error?.let { throw it }
+ return response ?: CatalogResult.NoBackend
+ }
+ }
+}
diff --git a/ai-core/build.gradle.kts b/ai-core/build.gradle.kts
index 43089d40..fb356308 100644
--- a/ai-core/build.gradle.kts
+++ b/ai-core/build.gradle.kts
@@ -68,6 +68,7 @@ dependencies {
testImplementation(files("../libs/plugin-api.jar"))
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")
+ testImplementation("org.json:json:20231013")
}
/**
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
index efa1008c..2b5f07fb 100644
--- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
@@ -329,7 +329,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl
fun listModels(): CompletableFuture> {
val future = CompletableFuture>()
- scope.launch {
+ val job = scope.launch {
try {
val key = readGeminiApiKey()
if (key.isNullOrBlank()) {
@@ -348,14 +348,57 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend, Cancellabl
future.completeExceptionally(e)
}
}
+ future.cancelJobOnCancel(job)
return future
}
/**
- * Fetch and parse the ListModels catalog, following pagination, keeping only
- * models that support [METHOD_GENERATE_CONTENT] and stripping the `models/`
- * prefix from each name. Runs on the caller's (IO) coroutine.
+ * 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]
+ * reads the stored key. Nothing here touches the stored key or [keyCache].
+ *
+ * @param apiKey the candidate key to authenticate the request with; never logged
+ * @return the chat-capable catalog for [apiKey], or a future completed exceptionally with the
+ * `ListModels HTTP ` [IOException] from [fetchAvailableModels] — the caller reads the
+ * status code out of that message to tell a refused key from an unreachable network
+ */
+ fun listModels(apiKey: String): CompletableFuture> {
+ val future = CompletableFuture>()
+ val key = apiKey.trim()
+ if (key.isEmpty()) {
+ future.completeExceptionally(IllegalArgumentException("Gemini API key is blank"))
+ return future
+ }
+ // close() cancels the scope, making launch a silent no-op; fail loudly instead.
+ if (!scope.isActive) {
+ future.completeExceptionally(IllegalStateException("Gemini backend is closed"))
+ return future
+ }
+
+ val job = scope.launch {
+ try {
+ val models = fetchAvailableModels(key)
+ context.logger.info("GeminiBackend: candidate key lists ${models.size} chat models")
+ future.complete(models)
+ } catch (e: CancellationException) {
+ future.cancel(true)
+ throw e
+ } catch (e: Exception) {
+ context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}")
+ future.completeExceptionally(e)
+ }
+ }
+ future.cancelJobOnCancel(job)
+
+ return future
+ }
+
+ /**
+ * Fetch and parse the ListModels catalog, following pagination, keeping only models that
+ * support [METHOD_GENERATE_CONTENT]. Runs on the caller's (IO) coroutine. The
+ * `ListModels HTTP ` message is a cross-plugin contract — keep that shape if you reword.
*/
private fun fetchAvailableModels(apiKey: String): List {
val names = mutableListOf()
@@ -585,18 +628,67 @@ User: $userPrompt"""
}
/**
- * Format error message with user-friendly descriptions.
+ * Turn a failure into one user-facing sentence.
+ *
+ * [GeminiErrorFormatter] decides *what* went wrong; the wording comes from `strings.xml`. The
+ * raw HTTP error body stays on the logged exception and must never reach the transcript.
+ */
+ private fun formatErrorMessage(e: Exception): String =
+ userMessage(GeminiErrorFormatter.classify(e, getModelName()))
+
+ /**
+ * Resolve a [GeminiFailure] against the plugin's own resources.
+ *
+ * `context.androidContext` is plugin-scoped, so this plugin's string ids resolve here. A failed
+ * lookup degrades to the generic message rather than throwing out of an error handler.
*/
- private fun formatErrorMessage(e: Exception): String {
- return when {
- e.message?.contains("API key", ignoreCase = true) == true ->
- "Invalid API key. Please check your Gemini API key in settings."
- e.message?.contains("quota", ignoreCase = true) == true ||
- e.message?.contains("limit", ignoreCase = true) == true ->
- "API quota exceeded. Please check your Gemini API usage."
- e.message?.contains("network", ignoreCase = true) == true ->
- "Network error. Please check your internet connection."
- else -> "Gemini API error: ${e.message}"
+ private fun userMessage(failure: GeminiFailure): String = try {
+ val resources = context.androidContext
+ when (failure) {
+ is GeminiFailure.ModelUnavailable ->
+ resources.getString(R.string.gemini_error_model_unavailable, failure.modelName)
+
+ GeminiFailure.QuotaExceeded ->
+ resources.getString(R.string.gemini_error_quota)
+
+ GeminiFailure.KeyRefused ->
+ resources.getString(R.string.gemini_error_key_refused)
+
+ GeminiFailure.KeyInvalid ->
+ resources.getString(R.string.gemini_error_key_invalid)
+
+ is GeminiFailure.RequestRejected -> failure.reason?.let {
+ resources.getString(R.string.gemini_error_request_rejected_reason, it)
+ } ?: resources.getString(R.string.gemini_error_request_rejected)
+
+ is GeminiFailure.ServiceUnavailable ->
+ resources.getString(R.string.gemini_error_service_unavailable, failure.httpStatus)
+
+ is GeminiFailure.Unexpected -> failure.reason?.let {
+ resources.getString(R.string.gemini_error_unexpected_reason, failure.httpStatus, it)
+ } ?: resources.getString(R.string.gemini_error_unexpected, failure.httpStatus)
+
+ GeminiFailure.Unreachable ->
+ resources.getString(R.string.gemini_error_unreachable)
+
+ is GeminiFailure.Failed -> failure.reason?.let {
+ resources.getString(R.string.gemini_error_failed_reason, it)
+ } ?: resources.getString(R.string.gemini_error_failed)
}
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: could not resolve error string for $failure", e)
+ "The Gemini request failed."
}
}
+
+/**
+ * Cancel [job] when this future is cancelled by its caller.
+ *
+ * [CompletableFuture.cancel] only flips the future's own state, so without this a caller that
+ * gives up leaves the HTTP fetch running to completion for a result nobody will read.
+ *
+ * @param job the coroutine producing this future's value
+ */
+private fun CompletableFuture.cancelJobOnCancel(job: Job) {
+ whenComplete { _, _ -> if (isCancelled) job.cancel() }
+}
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt
new file mode 100644
index 00000000..3348f98a
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatter.kt
@@ -0,0 +1,155 @@
+package com.itsaky.androidide.plugins.aicore
+
+import org.json.JSONObject
+import java.io.IOException
+
+/**
+ * What the Generative Language API said went wrong, as far as it could be determined.
+ *
+ * Every field is nullable because the failure may not be an API response at all — a DNS failure or
+ * a proxy's HTML error page reaches the same code path.
+ */
+data class GeminiApiError(
+ /** HTTP status lifted from the `… HTTP : ` message, or null if there wasn't one. */
+ val httpStatus: Int?,
+ /** Google's machine-readable `error.status`, e.g. `NOT_FOUND`, or null. */
+ val apiStatus: String?,
+ /** Google's human-readable `error.message`, collapsed to one line, or null. */
+ val apiMessage: String?
+)
+
+/**
+ * A Gemini failure reduced to the thing the user needs to be told.
+ *
+ * Carries no text: the wording lives in `strings.xml`, which also lets every branch be unit-tested
+ * without a Context. Any reason is already single-lined, length-capped, and never a JSON body.
+ */
+sealed interface GeminiFailure {
+
+ /** The selected model is gone or was never available to this key (HTTP 404 / `NOT_FOUND`). */
+ data class ModelUnavailable(val modelName: String) : GeminiFailure
+
+ /** Rate limit or quota (HTTP 429 / `RESOURCE_EXHAUSTED`). The key itself is fine. */
+ data object QuotaExceeded : GeminiFailure
+
+ /** The credential was refused (HTTP 401/403, `UNAUTHENTICATED`, `PERMISSION_DENIED`). */
+ data object KeyRefused : GeminiFailure
+
+ /** The credential is malformed or wrong (HTTP 400 whose message names the API key). */
+ data object KeyInvalid : GeminiFailure
+
+ /** HTTP 400 about the request rather than the credential. */
+ data class RequestRejected(val reason: String?) : GeminiFailure
+
+ /** Google-side outage (HTTP 5xx). Says nothing about the key or the model. */
+ data class ServiceUnavailable(val httpStatus: Int) : GeminiFailure
+
+ /** An HTTP status with no specific handling. */
+ data class Unexpected(val httpStatus: Int, val reason: String?) : GeminiFailure
+
+ /** No response at all — no network, DNS failure, timeout. */
+ data object Unreachable : GeminiFailure
+
+ /** Everything else, including failures that never reached the network. */
+ data class Failed(val reason: String?) : GeminiFailure
+}
+
+/**
+ * Classifies a Gemini failure so it can be reported as one translated sentence.
+ *
+ * Replaces `"Gemini API error: ${e.message}"`, which put the entire HTTP error body in the chat.
+ * The log keeps the full body; **no [GeminiFailure] ever carries a JSON payload**.
+ */
+object GeminiErrorFormatter {
+
+ /**
+ * Matches the status in the `Gemini HTTP 404: {…}` and `ListModels HTTP 403: {…}` messages
+ * built by [GeminiBackend]. Kept loose (no prefix) so both forms are covered.
+ */
+ private val HTTP_STATUS = Regex("""HTTP (\d{3})""")
+
+ /** Longest slice of Google's own wording carried onward; keeps a stray body out of the UI. */
+ private const val MAX_ECHOED_REASON = 160
+
+ /**
+ * Pull the status code and, when the message carries a JSON error body, Google's own
+ * `status`/`message` out of it. A non-JSON, truncated or absent body yields nulls rather than
+ * throwing, because this runs while already handling a failure.
+ *
+ * @param rawMessage the throwable message, typically `Gemini HTTP : `
+ */
+ fun parse(rawMessage: String?): GeminiApiError {
+ val raw = rawMessage.orEmpty()
+ val error = runCatching {
+ val bodyStart = raw.indexOf('{')
+ if (bodyStart < 0) null else JSONObject(raw.substring(bodyStart)).optJSONObject("error")
+ }.getOrNull()
+
+ return GeminiApiError(
+ httpStatus = HTTP_STATUS.find(raw)?.groupValues?.get(1)?.toIntOrNull(),
+ apiStatus = error?.optString("status")?.takeIf { it.isNotBlank() },
+ apiMessage = error?.optString("message")?.takeIf { it.isNotBlank() }?.toSingleLine()
+ )
+ }
+
+ /**
+ * Decide what to tell the user about [error].
+ *
+ * @param error the failure as thrown; its message is parsed, and its type distinguishes a
+ * transport problem from an API refusal when there is no status to read
+ * @param modelName the model the request was for, so a retired-model failure can name it
+ */
+ fun classify(error: Throwable, modelName: String): GeminiFailure {
+ val parsed = parse(error.message)
+ val status = parsed.httpStatus
+
+ return when {
+ // ListModels still advertises the model, but generateContent refuses it on new keys.
+ status == 404 || parsed.apiStatus == "NOT_FOUND" ->
+ GeminiFailure.ModelUnavailable(modelName)
+
+ status == 429 || parsed.apiStatus == "RESOURCE_EXHAUSTED" ->
+ GeminiFailure.QuotaExceeded
+
+ status == 401 || status == 403 ||
+ parsed.apiStatus == "UNAUTHENTICATED" || parsed.apiStatus == "PERMISSION_DENIED" ->
+ GeminiFailure.KeyRefused
+
+ status == 400 && parsed.mentionsApiKey() -> GeminiFailure.KeyInvalid
+
+ status == 400 -> GeminiFailure.RequestRejected(safeReason(parsed, error))
+
+ status != null && status in 500..599 -> GeminiFailure.ServiceUnavailable(status)
+
+ status != null -> GeminiFailure.Unexpected(status, safeReason(parsed, error))
+
+ // No status at all: the request never got an answer.
+ error is IOException -> GeminiFailure.Unreachable
+
+ else -> GeminiFailure.Failed(safeReason(parsed, error))
+ }
+ }
+
+ /** True when Google's wording points at the credential rather than the request shape. */
+ private fun GeminiApiError.mentionsApiKey(): Boolean =
+ apiMessage?.contains("api key", ignoreCase = true) == true
+
+ /**
+ * Google's own explanation, but only when it is short and safe to show.
+ *
+ * Falls back to the throwable's message when there was no JSON body, and never when that
+ * message contains one — carrying a `{` onward is the bug this class exists to prevent.
+ *
+ * @return the reason, or null when there is nothing showable
+ */
+ private fun safeReason(parsed: GeminiApiError, error: Throwable): String? {
+ val reason = parsed.apiMessage
+ ?: error.message?.takeIf { !it.contains('{') }?.toSingleLine()
+ ?: return null
+ if (reason.isBlank() || reason.length > MAX_ECHOED_REASON) return null
+ return reason
+ }
+
+ /** Collapse whitespace runs so a pretty-printed JSON string can't span lines in the UI. */
+ private fun String.toSingleLine(): String = trim().replace(Regex("""\s+"""), " ")
+}
diff --git a/ai-core/src/main/res/values/strings.xml b/ai-core/src/main/res/values/strings.xml
index 9c894370..9ac0b181 100644
--- a/ai-core/src/main/res/values/strings.xml
+++ b/ai-core/src/main/res/values/strings.xml
@@ -1,5 +1,19 @@
+
+ The model \"%1$s\" is no longer available to your API key. Open AI Settings, tap Refresh Models and choose another model.
+ You have reached your Gemini rate limit or quota. Wait a moment and try again, or check your usage in Google AI Studio.
+ Google refused your Gemini API key. Re-enter it in AI Settings.
+ Your Gemini API key is not valid. Re-enter it in AI Settings.
+ Gemini rejected the request.
+ Gemini rejected the request. %1$s
+ Gemini is temporarily unavailable (HTTP %1$d). Try again in a moment.
+ Gemini returned an error (HTTP %1$d).
+ Gemini returned an error (HTTP %1$d). %2$s
+ Could not reach Gemini. Check your internet connection and try again.
+ The Gemini request failed.
+ The Gemini request failed. %1$s
+
The model file could not be found. Re-select the .gguf model in AI Settings.
The model file is empty — the download may have been interrupted. Re-download the .gguf model and select it again.
diff --git a/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatterTest.kt b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatterTest.kt
new file mode 100644
index 00000000..44be262e
--- /dev/null
+++ b/ai-core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/GeminiErrorFormatterTest.kt
@@ -0,0 +1,198 @@
+package com.itsaky.androidide.plugins.aicore
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+
+/**
+ * The invariant under test: **no [GeminiFailure] may carry a JSON body**, because whatever it
+ * carries is substituted into a translated string and shown in the chat transcript. Assertions are
+ * on the classification, not on English wording, which is free to change per locale.
+ */
+class GeminiErrorFormatterTest {
+
+ private val model = "gemini-2.5-flash"
+
+ /** Verbatim from the bug report: a retired model on a newly created key. */
+ private val retiredModelFailure = IOException(
+ """
+ Gemini HTTP 404: {
+ "error": {
+ "code": 404,
+ "message": "This model models/gemini-2.5-flash is no longer available to new users. Please update your code to use a newer model for the latest features and improvements.",
+ "status": "NOT_FOUND"
+ }
+ }
+ """.trimIndent()
+ )
+
+ private fun classify(error: Throwable) = GeminiErrorFormatter.classify(error, model)
+
+ /** The reason is the only free text that reaches the UI, so it carries the invariant. */
+ private fun assertReasonIsSafe(reason: String?) {
+ if (reason == null) return
+ assertFalse("leaked a JSON body: $reason", reason.contains('{') || reason.contains('}'))
+ assertFalse("spans multiple lines: $reason", reason.contains('\n'))
+ assertTrue("too long for a message: ${reason.length}", reason.length <= 160)
+ }
+
+ @Test
+ fun givenTheReported404_whenClassified_thenReportsModelUnavailableNamingTheModel() {
+ assertEquals(GeminiFailure.ModelUnavailable(model), classify(retiredModelFailure))
+ }
+
+ @Test
+ fun givenTheReported404_whenClassified_thenCarriesNoDeveloperFacingWording() {
+ // "Please update your code" is for a developer, not someone typing in a chat box.
+ val failure = classify(retiredModelFailure) as GeminiFailure.ModelUnavailable
+
+ assertEquals(model, failure.modelName)
+ }
+
+ @Test
+ fun givenTheReported404_whenParsed_thenTheBodyIsStillAvailableForDiagnostics() {
+ val parsed = GeminiErrorFormatter.parse(retiredModelFailure.message)
+
+ assertEquals(404, parsed.httpStatus)
+ assertEquals("NOT_FOUND", parsed.apiStatus)
+ val apiMessage = parsed.apiMessage
+ assertNotNull(apiMessage)
+ assertTrue(apiMessage!!.startsWith("This model models/gemini-2.5-flash"))
+ // Collapsed to one line so it can never break a layout if it is ever shown.
+ assertFalse(apiMessage.contains('\n'))
+ }
+
+ @Test
+ fun givenANotFoundStatusWithNoHttpPrefix_whenClassified_thenStillReportsModelUnavailable() {
+ val cause = IOException("""{"error":{"status":"NOT_FOUND","message":"nope"}}""")
+
+ assertEquals(GeminiFailure.ModelUnavailable(model), classify(cause))
+ }
+
+ @Test
+ fun givenHttp429_whenClassified_thenReportsQuotaExceededRatherThanAKeyProblem() {
+ val cause = IOException(
+ """Gemini HTTP 429: {"error":{"status":"RESOURCE_EXHAUSTED","message":"Quota exceeded"}}"""
+ )
+
+ assertEquals(GeminiFailure.QuotaExceeded, classify(cause))
+ }
+
+ @Test
+ fun givenHttp403_whenClassified_thenReportsKeyRefused() {
+ val cause = IOException(
+ """Gemini HTTP 403: {"error":{"status":"PERMISSION_DENIED","message":"denied"}}"""
+ )
+
+ assertEquals(GeminiFailure.KeyRefused, classify(cause))
+ }
+
+ @Test
+ fun givenHttp401_whenClassified_thenReportsKeyRefused() {
+ assertEquals(GeminiFailure.KeyRefused, classify(IOException("Gemini HTTP 401: {}")))
+ }
+
+ @Test
+ fun givenHttp400MentioningTheApiKey_whenClassified_thenReportsKeyInvalid() {
+ val cause = IOException(
+ """Gemini HTTP 400: {"error":{"status":"INVALID_ARGUMENT","message":"API key not valid. Please pass a valid API key."}}"""
+ )
+
+ assertEquals(GeminiFailure.KeyInvalid, classify(cause))
+ }
+
+ @Test
+ fun givenHttp400NotAboutTheKey_whenClassified_thenReportsRequestRejectedWithAShortReason() {
+ val cause = IOException(
+ """Gemini HTTP 400: {"error":{"status":"INVALID_ARGUMENT","message":"Request contains an invalid argument."}}"""
+ )
+
+ val failure = classify(cause) as GeminiFailure.RequestRejected
+ assertEquals("Request contains an invalid argument.", failure.reason)
+ assertReasonIsSafe(failure.reason)
+ }
+
+ @Test
+ fun givenAnOverlongApiReason_whenClassified_thenTheReasonIsDroppedInsteadOfShown() {
+ val cause = IOException("""Gemini HTTP 400: {"error":{"message":"${"x".repeat(500)}"}}""")
+
+ assertEquals(GeminiFailure.RequestRejected(null), classify(cause))
+ }
+
+ @Test
+ fun givenHttp503_whenClassified_thenReportsServiceUnavailableCarryingTheStatus() {
+ assertEquals(
+ GeminiFailure.ServiceUnavailable(503),
+ classify(IOException("""Gemini HTTP 503: {"error":{}}"""))
+ )
+ }
+
+ @Test
+ fun givenAnUnmappedHttpStatus_whenClassified_thenKeepsTheStatusAndASafeReason() {
+ val cause = IOException("""Gemini HTTP 418: {"error":{"message":"I am a teapot"}}""")
+
+ val failure = classify(cause) as GeminiFailure.Unexpected
+ assertEquals(418, failure.httpStatus)
+ assertEquals("I am a teapot", failure.reason)
+ assertReasonIsSafe(failure.reason)
+ }
+
+ @Test
+ fun givenAnIoFailureWithNoHttpStatus_whenClassified_thenReportsUnreachable() {
+ assertEquals(
+ GeminiFailure.Unreachable,
+ classify(UnknownHostException("generativelanguage.googleapis.com"))
+ )
+ assertEquals(GeminiFailure.Unreachable, classify(SocketTimeoutException("timeout")))
+ }
+
+ @Test
+ fun givenANonIoFailureWithAShortMessage_whenClassified_thenKeepsItAsTheReason() {
+ val failure = classify(IllegalStateException("backend was closed")) as GeminiFailure.Failed
+
+ assertEquals("backend was closed", failure.reason)
+ assertReasonIsSafe(failure.reason)
+ }
+
+ @Test
+ fun givenANonIoFailureWrappingAnErrorBody_whenClassified_thenKeepsOnlyTheParsedReason() {
+ val cause = RuntimeException("""weird {"error":{"message":"stream closed"}}""")
+
+ val failure = classify(cause) as GeminiFailure.Failed
+ assertEquals("stream closed", failure.reason)
+ assertReasonIsSafe(failure.reason)
+ }
+
+ @Test
+ fun givenAnUnparseableBody_whenClassified_thenNoReasonIsCarried() {
+ // The fallback branch must not become a new JSON leak: the raw text has a brace.
+ val cause = RuntimeException("""broke at {"unexpected": [1, 2""")
+
+ assertEquals(GeminiFailure.Failed(null), classify(cause))
+ }
+
+ @Test
+ fun givenAFailureWithNoMessage_whenParsedAndClassified_thenNothingIsInferred() {
+ val parsed = GeminiErrorFormatter.parse(null)
+
+ assertNull(parsed.httpStatus)
+ assertNull(parsed.apiStatus)
+ assertNull(parsed.apiMessage)
+ assertEquals(GeminiFailure.Failed(null), classify(RuntimeException()))
+ }
+
+ @Test
+ fun givenATruncatedOrNonJsonBody_whenClassified_thenTheHttpStatusStillDecides() {
+ val truncated = IOException("""Gemini HTTP 500: {"error":{"message":"cut off""")
+ val html = IOException("Gemini HTTP 502: Bad Gateway")
+
+ assertEquals(GeminiFailure.ServiceUnavailable(500), classify(truncated))
+ assertEquals(GeminiFailure.ServiceUnavailable(502), classify(html))
+ }
+}