From 845bb527c833f4e2c66c0fdc1645ebea1e5489d8 Mon Sep 17 00:00:00 2001
From: John Trujillo
Date: Mon, 20 Jul 2026 16:10:04 -0500
Subject: [PATCH 1/3] fix(ai-assistant): resolve file picker crashes and secure
API key
- Fix file picker empty state and `BadTokenException` by using the correct project context (`IdeProjectService`) and moving I/O off the main thread.
- Secure Gemini API key with hardware-backed Keystore encryption (including legacy migration) and add a show/hide toggle.
- Improve UI by replacing emoji markers with vector drawables and extracting hardcoded strings to `strings.xml`.
---
.../fragments/AiSettingsFragment.kt | 65 ++++-
.../aiassistant/fragments/ChatFragment.kt | 13 +-
.../fragments/FilePickerDialogFragment.kt | 267 ++++++++++++------
.../aiassistant/security/SecureApiKeyStore.kt | 114 ++++++++
.../viewmodel/AiSettingsViewModel.kt | 27 +-
.../src/main/res/drawable/ic_check.xml | 10 +
.../src/main/res/drawable/ic_file.xml | 10 +
.../src/main/res/drawable/ic_folder.xml | 10 +
.../src/main/res/drawable/ic_visibility.xml | 10 +
.../main/res/drawable/ic_visibility_off.xml | 10 +
.../src/main/res/layout/item_file_picker.xml | 34 +++
.../res/layout/layout_settings_gemini_api.xml | 30 +-
ai-assistant/src/main/res/values/strings.xml | 16 ++
.../plugins/aicore/GeminiBackend.kt | 5 +-
.../plugins/aicore/SecureApiKeyStore.kt | 114 ++++++++
15 files changed, 615 insertions(+), 120 deletions(-)
create mode 100644 ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
create mode 100644 ai-assistant/src/main/res/drawable/ic_check.xml
create mode 100644 ai-assistant/src/main/res/drawable/ic_file.xml
create mode 100644 ai-assistant/src/main/res/drawable/ic_folder.xml
create mode 100644 ai-assistant/src/main/res/drawable/ic_visibility.xml
create mode 100644 ai-assistant/src/main/res/drawable/ic_visibility_off.xml
create mode 100644 ai-assistant/src/main/res/layout/item_file_picker.xml
create mode 100644 ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
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 83c285c1..8c0686a4 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
@@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.text.method.HideReturnsTransformationMethod
+import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -290,6 +292,7 @@ class AiSettingsFragment : DialogFragment() {
private fun setupGeminiApiUi(view: View) {
val apiKeyLayout = view.findViewById(R.id.gemini_api_key_layout)
val apiKeyInput = view.findViewById(R.id.gemini_api_key_input)
+ val toggleVisibilityButton = view.findViewById(R.id.btn_toggle_api_key_visibility)
val saveButton = view.findViewByIdAI Settings
+ Show API key
+ Hide API key
+ Enter your Gemini API key
+ API Key saved
+ API Key saved on: %s
+ API Key is saved
+ API Key cannot be empty
+ API Key cleared
+ Couldn\'t save the API key on this device. Please try again.BackendModelTemperature
@@ -105,4 +114,11 @@
⚠️ Experimental AI. Use at your own risk.Current AI backendSend
+
+
+ Select Files
+ Select Files: %s
+ Add Selected
+ Toggle All
+ Project directory not found
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 081488b8..a15ec634 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
@@ -73,7 +73,8 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
context.logger.error("GeminiBackend: Error getting preferences", e)
null
}
- return prefs?.getString("gemini_api_key", null)?.trim()?.takeIf { it.isNotBlank() }
+ val stored = prefs?.getString("gemini_api_key", null)
+ return SecureApiKeyStore.decrypt(stored)?.trim()?.takeIf { it.isNotBlank() }
}
override fun getId(): String = "gemini"
@@ -81,7 +82,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
override fun getName(): String = "Gemini API"
override fun isAvailable(): Boolean {
- // Available once an API key is configured.
+ // Available once a (decryptable) API key is configured.
val apiKey = readGeminiApiKey()
context.logger.debug("GeminiBackend.isAvailable() - API key configured: ${!apiKey.isNullOrBlank()}")
return !apiKey.isNullOrBlank()
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
new file mode 100644
index 00000000..1e31a7fc
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
@@ -0,0 +1,114 @@
+package com.itsaky.androidide.plugins.aicore
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * AES/GCM encryption for sensitive settings (currently the Gemini API key),
+ * keyed by a hardware-backed Android Keystore secret. Only ciphertext is
+ * written to SharedPreferences, so a copied prefs file (root, `adb backup`,
+ * forensic dump) is useless without this device's Keystore.
+ *
+ * The alias and transform below are mirrored verbatim in ai-assistant's
+ * `SecureApiKeyStore` so a key written there can be decrypted here — both
+ * plugins run in the host app's process (same UID) and therefore share one
+ * Android Keystore. Keep the two copies in sync.
+ */
+object SecureApiKeyStore {
+ private const val TAG = "SecureApiKeyStore"
+ private const val KEYSTORE = "AndroidKeyStore"
+ private const val ALIAS = "cotg_ai_gemini_key_v1"
+ private const val TRANSFORM = "AES/GCM/NoPadding"
+ private const val IV_LEN = 12
+ private const val TAG_BITS = 128
+
+ /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */
+ const val ENC_PREFIX = "enc:v1:"
+
+ private fun getOrCreateKey(): SecretKey {
+ val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build()
+ )
+ return generator.generateKey()
+ }
+
+ private fun deleteKey() {
+ try {
+ KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e)
+ }
+ }
+
+ private fun encryptWith(key: SecretKey, plain: String): String {
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ val iv = cipher.iv
+ val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
+ val combined = ByteArray(iv.size + ciphertext.size)
+ System.arraycopy(iv, 0, combined, 0, iv.size)
+ System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
+ return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP)
+ }
+
+ /**
+ * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
+ *
+ * If the Keystore key has been permanently invalidated (e.g. the lock-screen credentials
+ * changed, or the entry is corrupt) the stale alias is dropped and a fresh key generated
+ * once before retrying. Any other Keystore/cipher failure is surfaced as a
+ * [GeneralSecurityException] so the caller can inform the user instead of crashing — the
+ * previous version let these propagate uncaught and take the IDE down on Save.
+ */
+ @Throws(GeneralSecurityException::class)
+ fun encrypt(plain: String): String {
+ return try {
+ encryptWith(getOrCreateKey(), plain)
+ } catch (e: KeyPermanentlyInvalidatedException) {
+ Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e)
+ deleteKey()
+ encryptWith(getOrCreateKey(), plain)
+ }
+ }
+
+ /**
+ * Return the plaintext for a stored value, handling both formats transparently:
+ * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as
+ * legacy plaintext (it gets migrated to ciphertext on the next save). Returns
+ * null if a ciphertext value can't be decrypted — e.g. the Keystore key was
+ * lost or invalidated — in which case the user must re-enter the key.
+ */
+ fun decrypt(stored: String?): String? {
+ if (stored == null) return null
+ if (!stored.startsWith(ENC_PREFIX)) return stored
+ return try {
+ val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP)
+ val iv = combined.copyOfRange(0, IV_LEN)
+ val ciphertext = combined.copyOfRange(IV_LEN, combined.size)
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
+ String(cipher.doFinal(ciphertext), Charsets.UTF_8)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to decrypt stored API key", e)
+ null
+ }
+ }
+}
From c25ee891bc3b7ce8f9a8a838038d837acc8a26ab Mon Sep 17 00:00:00 2001
From: John Trujillo
Date: Thu, 23 Jul 2026 15:23:33 -0500
Subject: [PATCH 2/3] fix(ai-assistant,ai-core): secure Gemini API key at rest
and confine the file picker
Encrypt the key with AES/GCM under an Android Keystore secret and run all crypto off the UI thread; confine the context-file picker to the open project via normalized canonical-path checks with off-thread listing and no fallback root.
---
ai-assistant/README.md | 41 +++-
ai-assistant/src/main/AndroidManifest.xml | 3 +-
ai-assistant/src/main/assets/docs/index.html | 32 +++
.../plugins/aiassistant/AiAssistantPlugin.kt | 195 +++++++++++++++++-
.../aiassistant/adapters/ChatAdapter.kt | 12 ++
.../fragments/AiSettingsFragment.kt | 123 ++++++++---
.../aiassistant/fragments/ChatFragment.kt | 58 +++++-
.../fragments/FilePickerDialogFragment.kt | 91 +++++---
.../aiassistant/security/SecureApiKeyStore.kt | 39 +++-
.../viewmodel/AiSettingsViewModel.kt | 95 ++++++---
ai-assistant/src/main/res/values/strings.xml | 2 +
.../plugins/aicore/GeminiBackend.kt | 88 ++++++--
.../plugins/aicore/LocalLlmBackend.kt | 36 +++-
.../plugins/aicore/SecureApiKeyStore.kt | 39 +++-
14 files changed, 717 insertions(+), 137 deletions(-)
diff --git a/ai-assistant/README.md b/ai-assistant/README.md
index 5a01ae6b..e3e09f7a 100644
--- a/ai-assistant/README.md
+++ b/ai-assistant/README.md
@@ -58,19 +58,40 @@ The build resolves `plugin-api.jar` from the repo-root `../libs/`.
## Security
-- **Gemini API key at rest.** When you use the Gemini backend, the API key is
- stored in this plugin's private `SharedPreferences` (`AgentSettings`). That
- store is **app-sandboxed** — other apps cannot read it — but it is **not
- encrypted at rest**, so it is recoverable on a rooted or otherwise compromised
- device. Remove it any time from **AI Settings** (or `clearGeminiApiKey()`).
- Encryption is deliberately not layered on here: the key is shared at runtime
- with the sibling `ai-core` plugin (which performs the Gemini calls), and
- `EncryptedSharedPreferences` would force both independent plugins to agree on a
- master-key alias and crypto library version — a fragile cross-plugin coupling.
- A host-provided secure-storage service is the correct long-term fix.
+- **Gemini API key at rest.** The key is encrypted with AES/GCM under a
+ hardware-backed Android Keystore secret and only the ciphertext is written to
+ this plugin's private `SharedPreferences` (`AgentSettings`), as
+ `enc:v1:` + base64(iv‖ciphertext). A copied prefs file — root, `adb backup`,
+ forensic dump — is useless without this device's Keystore. Remove the key any
+ time from **AI Settings** (or `clearGeminiApiKey()`).
+ - A key stored before this plugin encrypted them is still plaintext on disk;
+ it is re-encrypted in place the first time it's read
+ (`SecureApiKeyStore.readAndMigrate`), so no user action is needed.
+ - The key is **not** bound to user authentication
+ (`setUserAuthenticationRequired` is not set), so it stays usable for
+ background inference and a lock-screen credential change does not invalidate
+ it. If the Keystore entry is lost anyway — the app's data is cleared, or an
+ OEM Keystore drops the alias — the stored key can no longer be decrypted and
+ must be re-entered; **Edit** says so instead of presenting an empty field.
+ Saving surfaces a failure message rather than silently storing nothing.
+ - Encryption at rest defends against *offline* recovery of the prefs file. It
+ does not defend against code already running as the host IDE's UID, which can
+ simply call `decrypt` — that would need a host-provided secure-storage
+ service with per-plugin isolation.
+- **Shared crypto across two plugins.** `ai-core` performs the Gemini calls and
+ therefore has to read the same key. Both plugins run in the host IDE's process
+ (same UID) and so share one Android Keystore; each ships an identical copy of
+ `SecureApiKeyStore` pinned to the alias `cotg_ai_gemini_key_v1`. The two copies
+ must stay byte-identical apart from their package line — if `ALIAS`,
+ `TRANSFORM`, `IV_LEN` or `ENC_PREFIX` drift, `ai-core` silently fails to
+ decrypt and Gemini reports "backend not available". A host-provided
+ secure-storage service would remove the duplication.
- **Gemini API key in transit.** The key is sent to Google as an `x-goog-api-key`
request header (and via the SDK on the chat path), never in a URL query string.
- **File tools are confined to the project root** — see the in-IDE help page.
+- **The context-file picker is confined to the open project** and fails closed:
+ with no project open it refuses to open rather than falling back to a broader
+ root.
## License
diff --git a/ai-assistant/src/main/AndroidManifest.xml b/ai-assistant/src/main/AndroidManifest.xml
index f50acac3..7cdab370 100644
--- a/ai-assistant/src/main/AndroidManifest.xml
+++ b/ai-assistant/src/main/AndroidManifest.xml
@@ -40,9 +40,10 @@
android:name="plugin.editor_tabs"
android:value="1" />
+
+ android:value="filesystem.read,filesystem.write,system.commands,project.structure,ide.settings" />
What the agent can do
device.
+
Attaching context files
+
+
Tap the attach button beside the prompt to pick files whose
+ contents are sent with your next message.
+
The picker is rooted at the project you have open and cannot browse
+ above it. With no project open it declines to open at all.
+
Toggle All selects every file in the folder you are viewing;
+ Add Selected attaches them as chips above the prompt.
+
On the Gemini backend, attached file contents leave the device.
+
+
+
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 —
+ a copied preferences file is useless without this device's Keystore. Clear it
+ any time from Settings. If the Keystore entry is ever lost — clearing the
+ app's data, for example — the stored key can no longer be read and will need to
+ be entered again; tapping Edit tells you when that has happened.
+
+
Reading the conversation
+
+
Retry appears on a message whose reply failed, and re-sends the
+ same prompt with the same attached files rather than adding a new one.
+
Open AI Settings appears instead when the agent has not been
+ configured yet — no local model chosen, or no Gemini key saved.
+
System log rows are collapsed records of the agent's internal
+ steps: tools run, files touched, backend errors. Tap the header to expand
+ one. They are saved with the session but are not sent to the model.
+
Long-press a message to copy its text, or to edit and re-send one of
+ your own.
+
+
Troubleshooting
"No model configured" — select a .gguf file (Local)
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 7138429b..813c7518 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
@@ -28,10 +28,25 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
const val TOOLTIP_TAG_TAB = "agent_chat_tab"
+ // Tags for the interactive controls on the Agent chat screen (see ChatFragment).
+ const val TOOLTIP_TAG_CONTEXT_FILES = "agent_context_files"
+ const val TOOLTIP_TAG_CHAT_INPUT = "agent_chat_input"
+ const val TOOLTIP_TAG_CHAT_SEND = "agent_chat_send"
+ const val TOOLTIP_TAG_CHAT_MENU = "agent_chat_menu"
+
+ // Tags for the controls rendered inside chat messages (see ChatAdapter).
+ const val TOOLTIP_TAG_MESSAGE_RETRY = "agent_message_retry"
+ const val TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS = "agent_message_open_settings"
+ const val TOOLTIP_TAG_SYSTEM_LOG = "agent_system_log"
+
// Tags for the interactive controls on the AI Settings dialog (see AiSettingsFragment).
+ const val TOOLTIP_TAG_SETTINGS_BACK = "ai_settings_back"
const val TOOLTIP_TAG_SETTINGS_BACKEND = "ai_settings_backend"
const val TOOLTIP_TAG_SETTINGS_LOCAL_MODEL = "ai_settings_local_model"
+ const val TOOLTIP_TAG_SETTINGS_LOCAL_SHA = "ai_settings_local_model_sha"
+ 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"
@Volatile
private var pluginContext: PluginContext? = null
@@ -135,6 +150,126 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
)
)
),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CONTEXT_FILES,
+ summary = "Attach project files so the agent sees their contents with your next message.",
+ detail = """
+
Opens a picker rooted at the currently open project; you
+ can browse subfolders but not above the project root, and the
+ picker won't open at all when no project is open.
+
Tap files to select them, Toggle All to select every file
+ in the folder you're viewing, then Add Selected. Attached
+ files appear as chips above the prompt — remove a chip to drop the
+ file again.
+
Contents are sent with your message, so on the Gemini backend
+ they leave the device.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CHAT_INPUT,
+ summary = "Type your request here — questions, or instructions to change the project.",
+ detail = """
+
Ask a question ("what does this class do?") or give an
+ instruction ("add a Room dependency"). Plain read-only requests
+ such as open, read, list and search
+ are recognised directly and run without going through the model,
+ so they work on every backend.
+
Anything that writes to the project asks for your approval
+ first.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CHAT_SEND,
+ summary = "Send the prompt — turns into Stop while the agent is working.",
+ detail = """
+
Sends your message to the selected backend. It stays disabled
+ until you type something.
+
While the agent is thinking or running tools this same button
+ becomes Stop: tapping it cancels the current turn, ends any
+ in-progress reply and discards the remaining tool steps.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_CHAT_MENU,
+ summary = "Agent menu: open AI Settings or start a new chat session.",
+ detail = """
+
Opens the Agent's overflow menu:
+
+
Settings — choose the backend (Local or Gemini),
+ pick a model and manage your Gemini API key.
+
Clear chat — starts a fresh session. The previous
+ conversation stays on disk in the plugin's own storage.
+
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_MESSAGE_RETRY,
+ summary = "Send that message again after a failed reply.",
+ detail = """
+
Appears on a message whose reply failed — a dropped network
+ request, a model that wasn't loaded, or a turn you stopped.
+
Retry re-sends the same prompt with the same attached
+ context files; it does not add a new message to the conversation.
+ If it keeps failing, check the backend and model under
+ Settings.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS,
+ summary = "Jump to AI Settings to fix the problem this message reports.",
+ detail = """
+
Shown when the agent could not run because it is not configured
+ yet — no local model selected, or no Gemini API key saved.
+
Opens AI Settings so you can choose a backend, pick a
+ .gguf model or enter a key, then return and send your
+ message again.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SYSTEM_LOG,
+ summary = "Collapsed system log — tap to expand the agent's internal steps.",
+ detail = """
+
System entries record what the agent did behind the scenes: the
+ tools it ran, the files it touched and any errors the backend
+ reported.
+
They stay collapsed to keep the conversation readable — tap the
+ header to expand or collapse one. They are part of the saved
+ session, not messages sent to the model.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_BACK,
+ summary = "Close AI Settings and return to the Agent chat.",
+ detail = """
+
Closes this dialog. Every setting here is saved as you change
+ it, so there is nothing to confirm — the Agent picks up the new
+ backend and model as soon as you return.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_BACKEND,
summary = "Choose which model powers the Agent: on-device Local (llama.cpp) or cloud Gemini.",
@@ -166,14 +301,68 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_LOCAL_SHA,
+ summary = "Optional SHA-256 of your .gguf file, checked when the model is loaded.",
+ detail = """
+
Paste the expected SHA-256 hash of the model file. It is stored
+ with the model path and compared on load, so a truncated download
+ or a swapped file is reported instead of failing deep inside
+ llama.cpp.
+
Leave it empty to skip the check. The value is saved when the
+ field loses focus.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT,
+ summary = "Send small local models a plainer prompt with no tool instructions.",
+ detail = """
+
Small on-device models (roughly 1B parameters and under) tend to
+ ramble or echo the prompt when handed the full tool-calling system
+ prompt. With this on they get a short, plain instruction instead.
+
The trade-off: the model won't emit tool calls, so it answers
+ questions but won't edit your project. The direct
+ open/read/list/search commands still work either way. Turn
+ it off for a larger instruct model.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_SETTINGS_GEMINI_MODEL,
+ summary = "Pick which Gemini model to call; Refresh lists the ones your key can access.",
+ detail = """
+
Refresh Models asks Google which models your API key can
+ actually use and fills the list from the response. Until then the
+ list shows a small built-in set of known-good defaults.
+
Selecting a model saves it immediately. If a previously saved
+ model has since been retired, refreshing moves you to the first
+ model in the live list rather than leaving a name that returns
+ 404.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
PluginTooltipEntry(
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
- kept in this plugin's private preferences on-device and is sent
- only to Google's API over HTTPS. Requests (your prompts and
- project context) leave the device when Gemini is selected.
+ 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.
+
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.
""".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/adapters/ChatAdapter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt
index fd94569e..384220d5 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/adapters/ChatAdapter.kt
@@ -16,6 +16,7 @@ import android.widget.Toast
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
+import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
import com.itsaky.androidide.plugins.aiassistant.models.MessageStatus
@@ -26,8 +27,14 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
+/**
+ * @param wireTooltip attaches this plugin's long-press help for a tag to a view. Supplied by
+ * ChatFragment, which owns the [com.itsaky.androidide.plugins.services.IdeTooltipService]
+ * lookup, so the adapter stays free of service plumbing. Defaults to a no-op for tests.
+ */
class ChatAdapter(
private val markwon: Markwon,
+ private val wireTooltip: (View, String) -> Unit = { _, _ -> },
private val onMessageAction: (action: String, message: ChatMessage) -> Unit
) : ListAdapter(DiffCallback) {
@@ -207,11 +214,15 @@ class ChatAdapter(
holder.btnRetry.setOnClickListener {
onMessageAction(ACTION_OPEN_SETTINGS, message)
}
+ // Re-wired per bind: the same recycled button plays both roles, so the
+ // tag has to follow the role it currently has.
+ wireTooltip(holder.btnRetry, AiAssistantPlugin.TOOLTIP_TAG_MESSAGE_OPEN_SETTINGS)
} else {
holder.btnRetry.text = "Retry"
holder.btnRetry.setOnClickListener {
onMessageAction(ACTION_RETRY, message)
}
+ wireTooltip(holder.btnRetry, AiAssistantPlugin.TOOLTIP_TAG_MESSAGE_RETRY)
}
updateMessageMetadata(holder, message)
}
@@ -231,6 +242,7 @@ class ChatAdapter(
notifyItemChanged(pos)
}
}
+ wireTooltip(holder.messageHeader, AiAssistantPlugin.TOOLTIP_TAG_SYSTEM_LOG)
}
private fun updateSystemMessageExpansion(holder: SystemMessageViewHolder, message: ChatMessage) {
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 8c0686a4..4aaf09b1 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
@@ -10,10 +10,12 @@ import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.WindowManager
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
@@ -23,6 +25,7 @@ import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend
import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel
import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelLoadingState
+import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -54,6 +57,8 @@ class AiSettingsFragment : DialogFragment() {
?.get(IdeTooltipService::class.java)
} catch (e: Exception) {
// Tooltip help is optional; long-press simply shows nothing when it's unavailable.
+ AiAssistantPlugin.getContext()?.logger
+ ?.warn("AiSettingsFragment: tooltip service unavailable", e)
}
}
@@ -143,6 +148,7 @@ class AiSettingsFragment : DialogFragment() {
// Close the dialog
dismiss()
}
+ wireTooltip(backButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACK)
}
private fun setupBackendSelector() {
@@ -214,6 +220,8 @@ class AiSettingsFragment : DialogFragment() {
viewModel.loadModelFromUri(savedPath, requireContext())
}
}
+ // Same concept as Browse — choosing which local model to run.
+ wireTooltip(loadSavedButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_MODEL)
shaInput?.apply {
setText(viewModel.getLocalModelSha256().orEmpty())
@@ -223,12 +231,16 @@ class AiSettingsFragment : DialogFragment() {
}
}
}
+ // On the labelled wrapper, not the field: long-press there is the paste menu.
+ view.findViewById(R.id.local_model_sha_layout)
+ ?.let { wireTooltip(it, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_SHA) }
simplePromptCheckbox?.apply {
isChecked = viewModel.isUseSimpleLocalPromptEnabled()
setOnCheckedChangeListener { _, isChecked ->
viewModel.setUseSimpleLocalPrompt(isChecked)
}
+ wireTooltip(this, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_SIMPLE_PROMPT)
}
// Observe engine state
@@ -298,9 +310,9 @@ class AiSettingsFragment : DialogFragment() {
val clearButton = view.findViewById(R.id.btn_clear_api_key)
val statusTextView = view.findViewById(R.id.gemini_api_key_status_text)
- // Offer help on the API-key controls in both the editing and saved states.
- wireTooltip(apiKeyInput, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_KEY)
- wireTooltip(editButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_KEY)
+ // 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) }
// Create model selection container
val modelContainer = createModelSelectionUi(view)
@@ -321,19 +333,14 @@ class AiSettingsFragment : DialogFragment() {
}
}
- val savedApiKey = viewModel.getGeminiApiKey()
- if (savedApiKey.isNullOrBlank()) {
- updateUiState(isEditing = true)
- apiKeyInput.setText("")
- } else {
- updateUiState(isEditing = false)
- val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
- if (timestamp > 0) {
- val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
- val savedDate = sdf.format(Date(timestamp))
- statusTextView.text = getString(R.string.msg_api_key_saved_on, savedDate)
+ viewLifecycleOwner.lifecycleScope.launch {
+ val savedApiKey = viewModel.getGeminiApiKey()
+ val hasKey = !savedApiKey.isNullOrBlank()
+ updateUiState(isEditing = !hasKey)
+ if (hasKey) {
+ statusTextView.text = savedApiKeyStatusText()
} else {
- statusTextView.text = getString(R.string.msg_api_key_is_saved)
+ apiKeyInput.setText("")
}
}
@@ -355,6 +362,7 @@ class AiSettingsFragment : DialogFragment() {
)
toggleVisibilityButton.setColorFilter(apiKeyInput.currentHintTextColor)
apiKeyInput.setSelection(apiKeyInput.text?.length ?: 0)
+ setSecureWindow(isKeyVisible)
}
applyKeyVisibility()
@@ -370,27 +378,54 @@ class AiSettingsFragment : DialogFragment() {
Toast.makeText(requireContext(), getString(R.string.msg_api_key_empty), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
- if (!viewModel.saveGeminiApiKey(apiKey)) {
- Toast.makeText(requireContext(), getString(R.string.msg_api_key_save_failed), Toast.LENGTH_LONG).show()
- return@setOnClickListener
+ saveButton.isEnabled = false
+ viewLifecycleOwner.lifecycleScope.launch {
+ val saved = try {
+ viewModel.saveGeminiApiKey(apiKey)
+ } finally {
+ saveButton.isEnabled = true
+ }
+ if (!saved) {
+ Toast.makeText(requireContext(), getString(R.string.msg_api_key_save_failed), Toast.LENGTH_LONG).show()
+ return@launch
+ }
+ Toast.makeText(requireContext(), getString(R.string.msg_api_key_saved), Toast.LENGTH_SHORT).show()
+ updateUiState(isEditing = false)
+ statusTextView.text = savedApiKeyStatusText()
}
- Toast.makeText(requireContext(), getString(R.string.msg_api_key_saved), Toast.LENGTH_SHORT).show()
-
- updateUiState(isEditing = false)
- val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
- val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
- val savedDate = sdf.format(Date(timestamp))
- statusTextView.text = getString(R.string.msg_api_key_saved_on, savedDate)
}
- editButton.setOnClickListener {
+ // 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.
+ fun revealEditMode(apiKey: String) {
+ apiKeyInput.setText(apiKey)
+ apiKeyInput.setSelection(apiKey.length)
updateUiState(isEditing = true)
- apiKeyInput.setText(viewModel.getGeminiApiKey().orEmpty())
isKeyVisible = false
applyKeyVisibility()
apiKeyInput.requestFocus()
}
+ editButton.setOnClickListener {
+ editButton.isEnabled = false
+ viewLifecycleOwner.lifecycleScope.launch {
+ val apiKey = try {
+ viewModel.getGeminiApiKey()
+ } finally {
+ editButton.isEnabled = true
+ }
+ // null = a key IS stored but won't decrypt; an empty box alone looks like data loss.
+ if (apiKey == null) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.msg_api_key_unreadable),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ revealEditMode(apiKey.orEmpty())
+ }
+ }
+
clearButton.setOnClickListener {
viewModel.clearGeminiApiKey()
Toast.makeText(requireContext(), getString(R.string.msg_api_key_cleared), Toast.LENGTH_SHORT).show()
@@ -402,6 +437,37 @@ class AiSettingsFragment : DialogFragment() {
setupGeminiModelSelection(modelContainer)
}
+ /**
+ * 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.
+ *
+ * @param secure true to block capture, false to allow it again
+ */
+ private fun setSecureWindow(secure: Boolean) {
+ val window = dialog?.window ?: return
+ if (secure) {
+ window.setFlags(
+ WindowManager.LayoutParams.FLAG_SECURE,
+ WindowManager.LayoutParams.FLAG_SECURE
+ )
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+ }
+
+ /** Status line for a stored key: dated when the save time is known, generic otherwise. */
+ private fun savedApiKeyStatusText(): String {
+ val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
+ 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)
+ }
+
private fun createModelSelectionUi(parent: View): LinearLayout {
val context = requireContext()
val container = LinearLayout(context).apply {
@@ -460,6 +526,9 @@ class AiSettingsFragment : DialogFragment() {
if (modelSpinner == null || refreshButton == null) return
+ wireTooltip(modelSpinner, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL)
+ wireTooltip(refreshButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL)
+
// Track real user taps so programmatic selection changes never persist a model.
var userTouchedSpinner = false
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
index 881c8a69..9dc94021 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
@@ -13,13 +13,17 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.chip.Chip
+import com.google.android.material.snackbar.Snackbar
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.adapters.ChatAdapter
-import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding
import com.itsaky.androidide.plugins.aiassistant.models.AgentState
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.services.IdeProjectService
+import com.itsaky.androidide.plugins.services.IdeTooltipService
import io.noties.markwon.Markwon
import kotlinx.coroutines.launch
import java.io.File
@@ -37,6 +41,26 @@ class ChatFragment : Fragment() {
private lateinit var markwon: Markwon
private val contextFiles = mutableListOf()
+ private val tooltipService: IdeTooltipService? by lazy {
+ try {
+ PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID)
+ ?.get(IdeTooltipService::class.java)
+ } catch (e: Exception) {
+ AiAssistantPlugin.getContext()?.logger
+ ?.warn("ChatFragment: tooltip service unavailable; long-press help disabled", e)
+ null
+ }
+ }
+
+ /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
+ private fun wireTooltip(view: View, tag: String) {
+ view.setOnLongClickListener { anchor ->
+ val service = tooltipService ?: return@setOnLongClickListener false
+ service.showTooltip(anchor, AiAssistantPlugin.TOOLTIP_CATEGORY, tag)
+ true
+ }
+ }
+
companion object {
// Test prompt injection (for E2E testing via broadcast receiver)
@Volatile
@@ -179,7 +203,7 @@ class ChatFragment : Fragment() {
private fun setupRecyclerView() {
// The adapter inflates item views from parent.context (the RecyclerView's theme-aware
// Context), so it no longer needs a Context passed in.
- chatAdapter = ChatAdapter(markwon) { action, message ->
+ chatAdapter = ChatAdapter(markwon, ::wireTooltip) { action, message ->
onMessageAction(action, message)
}
binding.chatRecyclerView.apply {
@@ -212,6 +236,7 @@ class ChatFragment : Fragment() {
}
popup.show()
}
+ wireTooltip(binding.btnOverflowMenu, AiAssistantPlugin.TOOLTIP_TAG_CHAT_MENU)
}
private fun setupInputArea() {
@@ -239,6 +264,12 @@ class ChatFragment : Fragment() {
binding.btnAddContext.setOnClickListener {
showFilePicker()
}
+
+ // Anchored on the bar, not promptInputEdittext: long-press there is the paste menu.
+ wireTooltip(binding.btnAddContext, AiAssistantPlugin.TOOLTIP_TAG_CONTEXT_FILES)
+ wireTooltip(binding.inputBarCard, AiAssistantPlugin.TOOLTIP_TAG_CHAT_INPUT)
+ wireTooltip(binding.sendButton, AiAssistantPlugin.TOOLTIP_TAG_CHAT_SEND)
+ wireTooltip(binding.backendStatusText, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACKEND)
}
private fun setupStatusBar() {
@@ -337,15 +368,19 @@ class ChatFragment : Fragment() {
}
/**
- * Opens the file picker rooted at the open project. The host's
- * [IdeProjectService] is the source of truth for the project root; the
- * `System.getProperty` chain in [PathGuard] resolves to "/" at runtime (the
- * IDE process cwd), which lists nothing, so it is only a last-resort fallback.
+ * Opens the file picker rooted at the open project. The host's [IdeProjectService] is the
+ * only source of truth for that root — PathGuard's `System.getProperty` fallback resolves
+ * to "/" in the IDE process, which would root the picker at the device filesystem instead
+ * of the project. With no project open there is nothing to confine the picker to, so this
+ * fails closed and says so rather than opening an unconfined browser.
*/
private fun showFilePicker() {
val projectService = getPluginContext()?.services?.get(IdeProjectService::class.java)
val startPath = projectService?.getCurrentProject()?.rootDir?.absolutePath
- ?: PathGuard.projectRoot()
+ if (startPath.isNullOrBlank()) {
+ showInfoSnackbar(getString(R.string.file_picker_error_no_project))
+ return
+ }
val dialog = FilePickerDialogFragment.newInstance(startPath) { files ->
addContextFiles(files)
@@ -413,12 +448,17 @@ class ChatFragment : Fragment() {
*/
private fun showErrorSnackbar(message: String) {
val binding = _binding ?: return
- com.google.android.material.snackbar.Snackbar
- .make(binding.root, message, com.google.android.material.snackbar.Snackbar.LENGTH_LONG)
+ Snackbar
+ .make(binding.root, message, Snackbar.LENGTH_LONG)
.setAction("Settings") { openSettingsFragment() }
.show()
}
+ private fun showInfoSnackbar(message: String) {
+ val binding = _binding ?: return
+ Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
+ }
+
}
/**
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
index cba1cfbb..475fb366 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
@@ -16,8 +16,10 @@ import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
-import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.services.IdeTooltipService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -32,9 +34,11 @@ import java.io.File
* `requireContext()` throws `IllegalStateException: not attached to a context`
* (previously crashed on the second "Toggle All").
*
- * Navigation is confined to the start path supplied by the caller (the open
- * project root), so the picker can't be used to reach arbitrary files on the
- * device. All disk I/O runs off the main thread; see [computeListing].
+ * Navigation is confined to [startPath] — the open project root, which the
+ * caller must supply — so the picker can't be used to reach arbitrary files on
+ * the device. There is deliberately no fallback root: an unresolvable path
+ * shows the not-found message rather than defaulting to somewhere broader.
+ * All disk I/O runs off the main thread; see [computeListing].
*/
class FilePickerDialogFragment : DialogFragment() {
@@ -43,31 +47,58 @@ class FilePickerDialogFragment : DialogFragment() {
/** Root the picker is confined to; navigation can never go above this. */
private lateinit var rootDirectory: File
- private lateinit var currentDirectory: File
// Reused across in-place refreshes so we never rebuild the Dialog/Fragment.
private val rows = mutableListOf()
private lateinit var listAdapter: FileRowAdapter
private var alertDialog: AlertDialog? = null
+ private var tooltipService: IdeTooltipService? = null
companion object {
private const val ARG_START_PATH = "start_path"
private const val PARENT_NAME = ".."
+ /**
+ * @param startPath the project root to browse; navigation is confined to it.
+ */
fun newInstance(
- startPath: String? = null,
+ startPath: String,
onSelected: (List) -> Unit
): FilePickerDialogFragment {
return FilePickerDialogFragment().apply {
- // Default to the current project root when no path is given.
- arguments = Bundle().apply {
- putString(ARG_START_PATH, startPath ?: PathGuard.projectRoot())
- }
+ arguments = Bundle().apply { putString(ARG_START_PATH, startPath) }
onFilesSelected = onSelected
}
}
}
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ try {
+ tooltipService = PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID)
+ ?.get(IdeTooltipService::class.java)
+ } catch (e: Exception) {
+ // Tooltip help is optional; long-press simply shows nothing when it's unavailable.
+ AiAssistantPlugin.getContext()?.logger
+ ?.warn("FilePickerDialogFragment: tooltip service unavailable", e)
+ }
+ }
+
+ /** Shows this plugin's context-files tooltip when [view] is long-pressed (Tier 1/2 + guide). */
+ private fun wireTooltip(view: View) {
+ view.setOnLongClickListener { anchor -> showTooltip(anchor) }
+ }
+
+ private fun showTooltip(anchor: View): Boolean {
+ val service = tooltipService ?: return false
+ service.showTooltip(
+ anchor,
+ AiAssistantPlugin.TOOLTIP_CATEGORY,
+ AiAssistantPlugin.TOOLTIP_TAG_CONTEXT_FILES
+ )
+ return true
+ }
+
/** One navigable entry: a directory to descend into or a selectable file. */
private data class FileRow(
val file: File,
@@ -84,6 +115,7 @@ class FilePickerDialogFragment : DialogFragment() {
val listView = ListView(context).apply {
adapter = listAdapter
setOnItemClickListener { _, _, position, _ -> onItemClicked(position) }
+ setOnItemLongClickListener { _, itemView, _, _ -> showTooltip(itemView) }
}
val dialog = MaterialAlertDialogBuilder(context)
@@ -101,6 +133,11 @@ class FilePickerDialogFragment : DialogFragment() {
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
toggleAllInCurrentDirectory()
}
+ listOf(
+ AlertDialog.BUTTON_POSITIVE,
+ AlertDialog.BUTTON_NEGATIVE,
+ AlertDialog.BUTTON_NEUTRAL
+ ).mapNotNull { dialog.getButton(it) }.forEach(::wireTooltip)
}
alertDialog = dialog
loadInitial()
@@ -111,18 +148,20 @@ class FilePickerDialogFragment : DialogFragment() {
* Resolve the confined root from the start path and load its listing.
* The start path passed by the caller IS the confinement root — the picker
* confines navigation to the open project and starts there.
+ *
+ * Fails closed: a missing, blank, or non-directory path leaves [rootDirectory]
+ * unset and shows the not-found message, so the picker can never silently widen
+ * to a broader root (`/` in particular) when the project can't be resolved.
*/
private fun loadInitial() {
- val rootPath = arguments?.getString(ARG_START_PATH) ?: PathGuard.projectRoot()
+ val rootPath = arguments?.getString(ARG_START_PATH)
lifecycleScope.launch {
val listing = withContext(Dispatchers.IO) {
- rootDirectory = File(rootPath).canonicalOrAbsolute()
- if (!rootDirectory.exists()) {
- null
- } else {
- currentDirectory = rootDirectory
- computeListing(rootDirectory)
- }
+ if (rootPath.isNullOrBlank()) return@withContext null
+ val root = File(rootPath).canonicalOrAbsolute()
+ if (!root.isDirectory) return@withContext null
+ rootDirectory = root
+ computeListing(root)
}
if (listing == null) {
alertDialog?.setTitle(getString(R.string.file_picker_error_not_found))
@@ -135,10 +174,7 @@ class FilePickerDialogFragment : DialogFragment() {
/** Recompute the listing for [directory] off-thread and apply it in place. */
private fun populate(directory: File) {
lifecycleScope.launch {
- val listing = withContext(Dispatchers.IO) {
- currentDirectory = directory
- computeListing(directory)
- }
+ val listing = withContext(Dispatchers.IO) { computeListing(directory) }
applyListing(listing)
}
}
@@ -156,6 +192,7 @@ class FilePickerDialogFragment : DialogFragment() {
}
directory.listFiles()
+ ?.filter { isWithinRoot(it) }
?.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() })
?.forEach { file -> newRows.add(FileRow(file, file.name, file.isDirectory)) }
@@ -194,10 +231,12 @@ class FilePickerDialogFragment : DialogFragment() {
private fun titleFor(dir: File): String =
getString(R.string.file_picker_title_current, dir.name)
- private fun isWithinRoot(dir: File): Boolean {
- val root = canonical(rootDirectory)
- val path = canonical(dir)
- return path == root || path.startsWith(root + File.separator)
+ /** True if [file] resolves (symlinks included) to a path at or below [rootDirectory]. */
+ private fun isWithinRoot(file: File): Boolean = try {
+ val root = rootDirectory.canonicalFile.toPath().normalize()
+ file.canonicalFile.toPath().normalize().startsWith(root)
+ } catch (e: Exception) {
+ false
}
private fun canonical(f: File): String =
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
index fe42be23..21b42928 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
@@ -1,5 +1,6 @@
package com.itsaky.androidide.plugins.aiassistant.security
+import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
@@ -72,11 +73,12 @@ object SecureApiKeyStore {
/**
* Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
*
- * If the Keystore key has been permanently invalidated (e.g. the lock-screen credentials
- * changed, or the entry is corrupt) the stale alias is dropped and a fresh key generated
- * once before retrying. Any other Keystore/cipher failure is surfaced as a
- * [GeneralSecurityException] so the caller can inform the user instead of crashing — the
- * previous version let these propagate uncaught and take the IDE down on Save.
+ * The key is not auth-bound, so a credential change does not invalidate it; an alias an
+ * OEM Keystore drops anyway is regenerated once before retrying.
+ *
+ * @param plain the value to encrypt
+ * @throws GeneralSecurityException on any other Keystore/cipher failure, so the caller can
+ * inform the user instead of crashing the IDE on Save
*/
@Throws(GeneralSecurityException::class)
fun encrypt(plain: String): String {
@@ -92,7 +94,7 @@ object SecureApiKeyStore {
/**
* Return the plaintext for a stored value, handling both formats transparently:
* an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as
- * legacy plaintext (it gets migrated to ciphertext on the next save). Returns
+ * legacy plaintext (use [readAndMigrate] to upgrade it in place). Returns
* null if a ciphertext value can't be decrypted — e.g. the Keystore key was
* lost or invalidated — in which case the user must re-enter the key.
*/
@@ -111,4 +113,29 @@ object SecureApiKeyStore {
null
}
}
+
+ /**
+ * Read [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place.
+ *
+ * Keys written before this store existed are still plaintext on disk, and [decrypt] alone
+ * hands them back unchanged forever — so an install that configured its key earlier would
+ * never actually gain encryption. Re-encrypting on the first read closes that gap without
+ * making the user re-enter the key.
+ *
+ * Keystore IPC + AES/GCM, so call this off the main thread.
+ *
+ * @return the plaintext value, or null when nothing is stored or decryption failed.
+ */
+ fun readAndMigrate(prefs: SharedPreferences?, key: String): String? {
+ val stored = prefs?.getString(key, null) ?: return null
+ if (stored.startsWith(ENC_PREFIX)) return decrypt(stored)
+ if (stored.isBlank()) return stored
+ try {
+ prefs.edit().putString(key, encrypt(stored)).apply()
+ Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext")
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e)
+ }
+ return stored
+ }
}
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 22ea0c41..96d50ed0 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
@@ -8,8 +8,11 @@ import androidx.lifecycle.viewModelScope
import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import com.itsaky.androidide.plugins.PluginContext
/**
* State for the model file loading.
@@ -49,7 +52,8 @@ enum class AiBackend(val displayName: String) {
data class GeminiModelOptions(val models: List, val isLive: Boolean)
class AiSettingsViewModel(
- private val getContext: () -> com.itsaky.androidide.plugins.PluginContext?
+ private val getContext: () -> PluginContext?,
+ private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
companion object {
@@ -189,33 +193,39 @@ class AiSettingsViewModel(
}
/**
- * Persists the Gemini API key in this plugin's private SharedPreferences,
- * encrypted at rest with a hardware-backed Android Keystore secret (see
- * [SecureApiKeyStore]). Only ciphertext is written, so the prefs file alone
- * (root, `adb backup`, forensic dump) does not disclose the key. Use
- * [clearGeminiApiKey] to remove it.
+ * 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.
*
- * Returns false (persisting nothing) if encryption fails — e.g. a hardware Keystore
- * fault the automatic key-regeneration retry couldn't recover — so the caller can warn
- * the user instead of the whole IDE crashing on Save.
+ * @param apiKey the plaintext key to store (trimmed before encryption)
+ * @return true only if the key was both encrypted and persisted
*/
- fun saveGeminiApiKey(apiKey: String): Boolean {
+ 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())
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to encrypt Gemini API key", e)
- return false
- }
- getPluginPrefs()?.edit()?.apply {
- putString("gemini_api_key", encrypted)
- putLong("gemini_api_key_timestamp", System.currentTimeMillis())
- apply()
+ return@withContext false
}
- return true
+ prefs.edit()
+ .putString("gemini_api_key", encrypted)
+ .putLong("gemini_api_key_timestamp", System.currentTimeMillis())
+ .apply()
+ true
}
- fun getGeminiApiKey(): String? {
- return SecureApiKeyStore.decrypt(getPluginPrefs()?.getString("gemini_api_key", null))
+ /**
+ * 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
+ * end up encrypted rather than waiting for the user to re-enter the key.
+ */
+ suspend fun getGeminiApiKey(): String? = withContext(ioDispatcher) {
+ SecureApiKeyStore.readAndMigrate(getPluginPrefs(), "gemini_api_key")
}
fun getGeminiApiKeySaveTimestamp(): Long {
@@ -273,16 +283,7 @@ class AiSettingsViewModel(
return@launch
}
- // listModels() lives in the ai-core plugin and isn't part of the shared
- // LlmBackend interface, so reach it reflectively across the plugin
- // classloader boundary. It reads the saved key itself and returns the live
- // v1beta catalog (empty when unavailable).
- val method = geminiBackend.javaClass.getMethod("listModels")
- val futureResult = method.invoke(geminiBackend)
-
- @Suppress("UNCHECKED_CAST")
- val models: List =
- (futureResult as? java.util.concurrent.CompletableFuture>)?.get().orEmpty()
+ 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))
@@ -299,6 +300,42 @@ 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? java.util.concurrent.CompletableFuture>
+ if (future == null) {
+ android.util.Log.e(
+ TAG,
+ "listModels() returned ${result?.javaClass?.name}, expected CompletableFuture"
+ )
+ return emptyList()
+ }
+ return future.get().orEmpty()
+ }
+
/**
* Load a model from URI.
* In the plugin context, we just save the path - the actual loading
diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml
index c199a12a..a669d8c3 100644
--- a/ai-assistant/src/main/res/values/strings.xml
+++ b/ai-assistant/src/main/res/values/strings.xml
@@ -101,6 +101,7 @@
API Key cannot be emptyAPI Key clearedCouldn\'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.BackendModelTemperature
@@ -120,5 +121,6 @@
Select Files: %sAdd SelectedToggle All
+ Open a project first — context files are picked from the project you have open.Project directory not found
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 a15ec634..d0f15016 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
@@ -1,5 +1,7 @@
package com.itsaky.androidide.plugins.aicore
+import android.content.SharedPreferences
+import android.os.Looper
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.LlmInferenceService.*
@@ -10,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.coroutineContext
import org.json.JSONArray
@@ -35,10 +38,21 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
@Volatile
private var currentJob: Job? = null
+ /**
+ * Last decryption, as (value on disk -> plaintext). Decrypting costs a Keystore IPC round
+ * trip and [isAvailable] runs on every generate, so caching against the raw stored value
+ * pays that cost once and re-decrypts only when the stored key actually changes.
+ */
+ @Volatile
+ private var keyCache: Pair? = null
+
companion object {
/** Current default model. gemini-1.5-* is retired on v1beta and now 404s. */
const val DEFAULT_MODEL = "gemini-2.5-flash"
+ /** Pref key holding the (encrypted) Gemini API key, written by ai-assistant. */
+ private const val KEY_API_KEY = "gemini_api_key"
+
/** Base URL for the v1beta models API (ListModels, generateContent, streaming). */
private const val MODELS_BASE_URL =
"https://generativelanguage.googleapis.com/v1beta/models"
@@ -50,31 +64,61 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
private const val METHOD_STREAM_GENERATE_CONTENT = "streamGenerateContent"
}
+ /** ai-assistant's shared prefs, where the Gemini settings live, or null if unreachable. */
+ private fun agentPrefs(): SharedPreferences? = try {
+ SharedServices.get(PluginContext::class.java)
+ ?.getPluginSharedPreferences("AgentSettings")
+ } catch (e: Exception) {
+ context.logger.error("GeminiBackend: Error getting preferences", e)
+ null
+ }
+
/**
* Get the model name from preferences, or use the current default.
*/
- private fun getModelName(): String {
- val prefs = try {
- val aiAssistantContext = SharedServices.get(PluginContext::class.java)
- aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
- } catch (e: Exception) {
- context.logger.error("GeminiBackend: Error getting preferences", e)
- null
- }
- return prefs?.getString("gemini_model", DEFAULT_MODEL) ?: DEFAULT_MODEL
- }
+ private fun getModelName(): String =
+ agentPrefs()?.getString("gemini_model", DEFAULT_MODEL) ?: DEFAULT_MODEL
- /** Read the saved Gemini API key from ai-assistant's shared prefs, or null. */
+ /**
+ * Read the saved Gemini API key from ai-assistant's shared prefs, or null.
+ *
+ * Decryption is Keystore IPC + AES/GCM and must not run on the main thread. Every caller
+ * today reaches this from [Dispatchers.IO], but [LlmBackend.isAvailable] is a synchronous
+ * interface method a future caller could invoke from the UI thread — so instead of relying
+ * on that, a main-thread call answers from the cache and kicks off a background refresh
+ * rather than blocking. Worst case it reports "no key" once and is correct thereafter.
+ */
private fun readGeminiApiKey(): String? {
- val prefs = try {
- SharedServices.get(PluginContext::class.java)
- ?.getPluginSharedPreferences("AgentSettings")
- } catch (e: Exception) {
- context.logger.error("GeminiBackend: Error getting preferences", e)
- null
+ val stored = agentPrefs()?.getString(KEY_API_KEY, null)
+ if (stored.isNullOrBlank()) {
+ keyCache = null
+ return null
}
- val stored = prefs?.getString("gemini_api_key", null)
- return SecureApiKeyStore.decrypt(stored)?.trim()?.takeIf { it.isNotBlank() }
+ keyCache?.let { (raw, plain) -> if (raw == stored) return plain }
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ context.logger.warn("GeminiBackend: API key read on the main thread; refreshing off-thread")
+ // close() cancels scope, so without this guard the launch is a silent no-op.
+ if (scope.isActive) {
+ scope.launch { refreshKeyCache() }
+ } else {
+ context.logger.warn("GeminiBackend: backend already closed; not refreshing key cache")
+ }
+ return null
+ }
+ return refreshKeyCache()
+ }
+
+ /**
+ * Decrypt the stored key — upgrading a pre-encryption plaintext value in passing — and
+ * cache the result. Off-main-thread only; see [readGeminiApiKey].
+ */
+ private fun refreshKeyCache(): String? {
+ val prefs = agentPrefs()
+ val plain = SecureApiKeyStore.readAndMigrate(prefs, KEY_API_KEY)
+ ?.trim()?.takeIf { it.isNotBlank() }
+ val raw = prefs?.getString(KEY_API_KEY, null)
+ keyCache = raw?.let { it to plain }
+ return plain
}
override fun getId(): String = "gemini"
@@ -394,10 +438,16 @@ User: $userPrompt"""
/**
* Release all resources: cancel the backend scope and any in-flight
* request. Called from AiCorePlugin.dispose().
+ *
+ * [keyCache] holds the *decrypted* API key, so it is dropped here too — otherwise the
+ * plaintext stays reachable on the host process heap for as long as the IDE runs, long
+ * after the plugin was unloaded, which is exactly what encrypting at rest is meant to
+ * prevent.
*/
fun close() {
currentJob?.cancel()
scope.cancel()
+ keyCache = null
}
/**
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt
index 6ce44eb4..30b6a0bd 100644
--- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt
@@ -8,8 +8,11 @@ import com.itsaky.androidide.plugins.services.SharedServices
import com.itsaky.androidide.plugins.PluginContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.CompletableFuture
@@ -32,6 +35,15 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend {
private val llama by lazy { LLamaAndroid.instance() }
private val scope = CoroutineScope(Dispatchers.IO)
+ /**
+ * Separate from [scope] because [close] cancels [scope] and then has to run the unload —
+ * work submitted to a cancelled scope never starts.
+ */
+ private val teardownScope = CoroutineScope(Dispatchers.IO)
+
+ /** The [close] teardown, retained so [awaitClose] can join it. */
+ @Volatile private var teardownJob: Job? = null
+
@Volatile private var modelLoaded = false
@Volatile private var currentModelPath: String? = null
@@ -392,10 +404,15 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend {
* can block while inference is in flight, so it must never run via runBlocking
* on Main. Cancel generation, then unload on a background thread, then stop
* the Llm-RunLoop thread so it doesn't outlive the plugin.
+ *
+ * Teardown runs on [teardownScope] rather than a throwaway `CoroutineScope(...)` so the
+ * work has an owner: the returned [Job] is retained in [teardownJob], letting a caller
+ * observe or await it via [awaitClose] instead of dispose() returning while native work is
+ * still in flight with no handle to it.
*/
fun close() {
scope.cancel()
- CoroutineScope(Dispatchers.IO).launch {
+ teardownJob = teardownScope.launch {
try {
unloadModelInternal()
} catch (e: Exception) {
@@ -408,4 +425,21 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend {
}
}
}
+
+ /**
+ * Block until the [close] teardown finishes, at most [timeoutMs].
+ *
+ * Intended for tests and for a host that wants unload to have completed before it drops the
+ * plugin's classloader. Never call from the main thread — that is the deadlock [close] exists
+ * to avoid.
+ *
+ * @param timeoutMs how long to wait before giving up
+ * @return true if teardown finished (or never started), false if it was still running
+ */
+ fun awaitClose(timeoutMs: Long = 10_000): Boolean {
+ val job = teardownJob ?: return true
+ return runBlocking {
+ withTimeoutOrNull(timeoutMs) { job.join() } != null
+ }
+ }
}
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
index 1e31a7fc..c7c75c7d 100644
--- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
@@ -1,5 +1,6 @@
package com.itsaky.androidide.plugins.aicore
+import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
@@ -72,11 +73,12 @@ object SecureApiKeyStore {
/**
* Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
*
- * If the Keystore key has been permanently invalidated (e.g. the lock-screen credentials
- * changed, or the entry is corrupt) the stale alias is dropped and a fresh key generated
- * once before retrying. Any other Keystore/cipher failure is surfaced as a
- * [GeneralSecurityException] so the caller can inform the user instead of crashing — the
- * previous version let these propagate uncaught and take the IDE down on Save.
+ * The key is not auth-bound, so a credential change does not invalidate it; an alias an
+ * OEM Keystore drops anyway is regenerated once before retrying.
+ *
+ * @param plain the value to encrypt
+ * @throws GeneralSecurityException on any other Keystore/cipher failure, so the caller can
+ * inform the user instead of crashing the IDE on Save
*/
@Throws(GeneralSecurityException::class)
fun encrypt(plain: String): String {
@@ -92,7 +94,7 @@ object SecureApiKeyStore {
/**
* Return the plaintext for a stored value, handling both formats transparently:
* an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as
- * legacy plaintext (it gets migrated to ciphertext on the next save). Returns
+ * legacy plaintext (use [readAndMigrate] to upgrade it in place). Returns
* null if a ciphertext value can't be decrypted — e.g. the Keystore key was
* lost or invalidated — in which case the user must re-enter the key.
*/
@@ -111,4 +113,29 @@ object SecureApiKeyStore {
null
}
}
+
+ /**
+ * Read [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place.
+ *
+ * Keys written before this store existed are still plaintext on disk, and [decrypt] alone
+ * hands them back unchanged forever — so an install that configured its key earlier would
+ * never actually gain encryption. Re-encrypting on the first read closes that gap without
+ * making the user re-enter the key.
+ *
+ * Keystore IPC + AES/GCM, so call this off the main thread.
+ *
+ * @return the plaintext value, or null when nothing is stored or decryption failed.
+ */
+ fun readAndMigrate(prefs: SharedPreferences?, key: String): String? {
+ val stored = prefs?.getString(key, null) ?: return null
+ if (stored.startsWith(ENC_PREFIX)) return decrypt(stored)
+ if (stored.isBlank()) return stored
+ try {
+ prefs.edit().putString(key, encrypt(stored)).apply()
+ Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext")
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e)
+ }
+ return stored
+ }
}
From 50908ea2dfbf03c10d3b8899f2b5516bf76cc2d7 Mon Sep 17 00:00:00 2001
From: John Trujillo
Date: Wed, 29 Jul 2026 08:54:00 -0500
Subject: [PATCH 3/3] fix: secure Gemini API key at rest and confine the file
picker
Encrypt the key with AES/GCM under an Android Keystore secret, migrating existing plaintext on first read; send it as a header, never a query string. Confine the context-file picker to the open project, failing closed on an unresolvable root, and fix the crash on a second "Toggle All".
---
ai-assistant/src/main/AndroidManifest.xml | 6 +-
.../aiassistant/adapters/ChatAdapter.kt | 65 +++++++++++++++----
.../fragments/AiSettingsFragment.kt | 8 +++
.../aiassistant/security/SecureApiKeyStore.kt | 12 ++--
.../viewmodel/AiSettingsViewModel.kt | 34 +++++++++-
ai-core/build.gradle.kts | 65 +++++++++++++++++++
ai-core/src/main/AndroidManifest.xml | 2 +-
.../androidide/plugins/aicore/AiCorePlugin.kt | 2 +
.../plugins/aicore/GeminiBackend.kt | 21 +++++-
.../plugins/aicore/LocalLlmBackend.kt | 15 +++--
.../plugins/aicore/SecureApiKeyStore.kt | 12 ++--
11 files changed, 209 insertions(+), 33 deletions(-)
diff --git a/ai-assistant/src/main/AndroidManifest.xml b/ai-assistant/src/main/AndroidManifest.xml
index 7cdab370..0f0e60ea 100644
--- a/ai-assistant/src/main/AndroidManifest.xml
+++ b/ai-assistant/src/main/AndroidManifest.xml
@@ -33,17 +33,17 @@
+ android:value="26.31" />
-
+
+ android:value="filesystem.read,filesystem.write,system.commands,project.structure" />
{
holder.loadingIndicator.visibility = View.VISIBLE
holder.messageContent.visibility = View.GONE
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
}
MessageStatus.SENT -> {
holder.loadingIndicator.visibility = View.GONE
@@ -137,19 +144,19 @@ class ChatAdapter(
if (message.sender == Sender.AGENT && message.durationMs == null) {
animateGeneratingDots(holder)
} else {
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
}
}
MessageStatus.COMPLETED -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
markwon.setMarkdown(holder.messageContent, payload.text)
}
MessageStatus.ERROR -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
holder.messageContent.text = payload.text
}
}
@@ -180,6 +187,8 @@ class ChatAdapter(
holder.messageContent.visibility = View.GONE
holder.btnRetry.visibility = View.GONE
holder.messageMetadataContainer.visibility = View.GONE
+ // A row that goes back to LOADING after SENT still had a live dots loop.
+ hideGeneratingDots(holder)
}
MessageStatus.SENT -> {
holder.loadingIndicator.visibility = View.GONE
@@ -192,14 +201,14 @@ class ChatAdapter(
if (message.sender == Sender.AGENT && message.durationMs == null) {
animateGeneratingDots(holder)
} else {
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
}
}
MessageStatus.COMPLETED -> {
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
holder.btnRetry.visibility = View.GONE
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
markwon.setMarkdown(holder.messageContent, message.text)
updateMessageMetadata(holder, message)
}
@@ -207,7 +216,7 @@ class ChatAdapter(
holder.loadingIndicator.visibility = View.GONE
holder.messageContent.visibility = View.VISIBLE
holder.btnRetry.visibility = View.VISIBLE
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
holder.messageContent.text = message.text
if (message.sender == Sender.SYSTEM) {
holder.btnRetry.text = "Open AI Settings"
@@ -258,22 +267,50 @@ class ChatAdapter(
}
}
+ /**
+ * Starts — or restarts — the "..." animation, cancelling any step already queued for [holder]
+ * so repeated binds of one recycled row cannot stack loops. The step is posted on the dots
+ * view, not a bare main-looper Handler, so [hideGeneratingDots] can cancel it.
+ *
+ * @param holder the row whose dots should animate
+ */
private fun animateGeneratingDots(holder: DefaultMessageViewHolder) {
+ hideGeneratingDots(holder)
holder.generatingDots.visibility = View.VISIBLE
val dotStates = arrayOf(".", "..", "...")
var currentIndex = 0
- val handler = android.os.Handler(android.os.Looper.getMainLooper())
- val runnable = object : Runnable {
+ val step = object : Runnable {
override fun run() {
- if (holder.generatingDots.visibility == View.VISIBLE) {
- holder.generatingDots.text = dotStates[currentIndex]
- currentIndex = (currentIndex + 1) % dotStates.size
- handler.postDelayed(this, 500)
+ if (holder.generatingDots.visibility != View.VISIBLE) {
+ holder.generatingDotsStep = null
+ return
}
+ holder.generatingDots.text = dotStates[currentIndex]
+ currentIndex = (currentIndex + 1) % dotStates.size
+ holder.generatingDots.postDelayed(this, 500)
}
}
- handler.post(runnable)
+ holder.generatingDotsStep = step
+ holder.generatingDots.post(step)
+ }
+
+ /**
+ * Hides the dots and cancels the animation. Visibility alone is not enough: the running step
+ * only notices it on its next tick, and never at all once the view is detached.
+ *
+ * @param holder the row whose dots should stop
+ */
+ private fun hideGeneratingDots(holder: DefaultMessageViewHolder) {
+ holder.generatingDotsStep?.let { holder.generatingDots.removeCallbacks(it) }
+ holder.generatingDotsStep = null
+ holder.generatingDots.visibility = View.GONE
+ }
+
+ /** Stops the dots animation of a row leaving the screen, so its step can't outlive the view. */
+ override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
+ super.onViewRecycled(holder)
+ if (holder is DefaultMessageViewHolder) hideGeneratingDots(holder)
}
private fun createPreview(rawText: String): String {
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 4aaf09b1..145deb02 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
@@ -341,6 +341,14 @@ class AiSettingsFragment : DialogFragment() {
statusTextView.text = savedApiKeyStatusText()
} else {
apiKeyInput.setText("")
+ // A stored-but-undecryptable key also reads as null; warn as the Edit path does.
+ if (viewModel.hasStoredGeminiApiKey()) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.msg_api_key_unreadable),
+ Toast.LENGTH_LONG
+ ).show()
+ }
}
}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
index 21b42928..91bbc0dc 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
@@ -25,6 +25,7 @@ import javax.crypto.spec.GCMParameterSpec
* Android Keystore. Keep the two copies in sync.
*/
object SecureApiKeyStore {
+ // Drift in the constants below fails ai-core's verifySecureApiKeyStoreParity build task.
private const val TAG = "SecureApiKeyStore"
private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS = "cotg_ai_gemini_key_v1"
@@ -122,20 +123,23 @@ object SecureApiKeyStore {
* never actually gain encryption. Re-encrypting on the first read closes that gap without
* making the user re-enter the key.
*
+ * The value is trimmed on migration, so the stored, displayed and sent forms all agree.
+ *
* Keystore IPC + AES/GCM, so call this off the main thread.
*
- * @return the plaintext value, or null when nothing is stored or decryption failed.
+ * @return the trimmed plaintext value, or null when nothing is stored or decryption failed.
*/
fun readAndMigrate(prefs: SharedPreferences?, key: String): String? {
val stored = prefs?.getString(key, null) ?: return null
if (stored.startsWith(ENC_PREFIX)) return decrypt(stored)
- if (stored.isBlank()) return stored
+ val plain = stored.trim()
+ if (plain.isEmpty()) return plain
try {
- prefs.edit().putString(key, encrypt(stored)).apply()
+ prefs.edit().putString(key, encrypt(plain)).apply()
Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext")
} catch (e: Exception) {
Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e)
}
- return stored
+ return plain
}
}
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 96d50ed0..be4e2b1c 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
@@ -13,6 +13,9 @@ 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
/**
* State for the model file loading.
@@ -62,6 +65,13 @@ 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",
@@ -228,6 +238,14 @@ class AiSettingsViewModel(
SecureApiKeyStore.readAndMigrate(getPluginPrefs(), "gemini_api_key")
}
+ /**
+ * True when a Gemini key is present on disk, whether or not it can still be decrypted. Lets
+ * the UI tell "nothing was saved" from "the Keystore entry is gone" — [getGeminiApiKey] is
+ * null for both. Raw pref only, so no Keystore IPC and safe on the main thread.
+ */
+ fun hasStoredGeminiApiKey(): Boolean =
+ !getPluginPrefs()?.getString("gemini_api_key", null).isNullOrBlank()
+
fun getGeminiApiKeySaveTimestamp(): Long {
return getPluginPrefs()?.getLong("gemini_api_key_timestamp", 0L) ?: 0L
}
@@ -325,7 +343,7 @@ class AiSettingsViewModel(
val result = method.invoke(backend)
@Suppress("UNCHECKED_CAST")
- val future = result as? java.util.concurrent.CompletableFuture>
+ val future = result as? CompletableFuture>
if (future == null) {
android.util.Log.e(
TAG,
@@ -333,7 +351,19 @@ class AiSettingsViewModel(
)
return emptyList()
}
- return future.get().orEmpty()
+ // 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()
+ }
}
/**
diff --git a/ai-core/build.gradle.kts b/ai-core/build.gradle.kts
index ff3a4772..43089d40 100644
--- a/ai-core/build.gradle.kts
+++ b/ai-core/build.gradle.kts
@@ -70,6 +70,71 @@ dependencies {
testImplementation("io.mockk:mockk:1.13.8")
}
+/**
+ * Fails the build when the crypto constants of ai-assistant's and ai-core's duplicated
+ * SecureApiKeyStore drift, which would otherwise surface only on a device as "backend not
+ * available". On preBuild, not `test`: CI runs assemblePlugin and never the unit tests.
+ */
+val verifySecureApiKeyStoreParity by tasks.registering {
+ group = "verification"
+ description = "Fails if ai-core and ai-assistant's SecureApiKeyStore crypto constants differ."
+
+ val ours = file("src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt")
+ val theirs = file(
+ "../ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt"
+ )
+ // inputs.files (not inputs.file) so a missing sibling is an absent input, not a failure.
+ inputs.files(ours, theirs)
+
+ doLast {
+ if (!theirs.exists()) {
+ logger.warn(
+ "SecureApiKeyStore parity check skipped: ${theirs.path} not found. " +
+ "Build ai-core from the plugin-examples repo to verify it."
+ )
+ return@doLast
+ }
+
+ val required = listOf("KEYSTORE", "ALIAS", "TRANSFORM", "IV_LEN", "TAG_BITS", "ENC_PREFIX")
+ val constant = Regex("""const\s+val\s+(\w+)\s*=\s*(.+)""")
+
+ fun constantsOf(source: File): Map = source.readLines()
+ .mapNotNull { constant.find(it) }
+ .associate { it.groupValues[1] to it.groupValues[2].substringBefore("//").trim() }
+ .filterKeys { it in required }
+
+ val ourConstants = constantsOf(ours)
+ val theirConstants = constantsOf(theirs)
+
+ val missing = required.filter { it !in ourConstants || it !in theirConstants }
+ val drifted = required.filter {
+ it in ourConstants && it in theirConstants && ourConstants[it] != theirConstants[it]
+ }
+
+ if (missing.isNotEmpty() || drifted.isNotEmpty()) {
+ val details = buildString {
+ if (missing.isNotEmpty()) {
+ appendLine(" missing from one or both copies: ${missing.joinToString()}")
+ }
+ drifted.forEach {
+ appendLine(" $it: ai-core=${ourConstants[it]} ai-assistant=${theirConstants[it]}")
+ }
+ }
+ throw GradleException(
+ "SecureApiKeyStore crypto constants differ between ai-core and ai-assistant.\n" +
+ details +
+ "A key encrypted by one plugin would not decrypt in the other. " +
+ "Keep both copies in sync:\n" +
+ " ${ours.path}\n ${theirs.path}"
+ )
+ }
+ }
+}
+
+tasks.named("preBuild") {
+ dependsOn(verifySecureApiKeyStoreParity)
+}
+
// AAR metadata checks are disabled by convention for these application-as-library
// plugins. The prebuilt llama .aar carries a "core library desugaring required"
// flag, but this module's minSdk (33) makes desugaring unnecessary at runtime,
diff --git a/ai-core/src/main/AndroidManifest.xml b/ai-core/src/main/AndroidManifest.xml
index d85f5827..4fdb1037 100644
--- a/ai-core/src/main/AndroidManifest.xml
+++ b/ai-core/src/main/AndroidManifest.xml
@@ -35,7 +35,7 @@
+ android:value="26.31" />