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..0f0e60ea 100644
--- a/ai-assistant/src/main/AndroidManifest.xml
+++ b/ai-assistant/src/main/AndroidManifest.xml
@@ -33,13 +33,14 @@
+ android:value="26.31" />
+
diff --git a/ai-assistant/src/main/assets/docs/index.html b/ai-assistant/src/main/assets/docs/index.html
index 6bef0c06..c17a60a8 100644
--- a/ai-assistant/src/main/assets/docs/index.html
+++ b/ai-assistant/src/main/assets/docs/index.html
@@ -64,6 +64,38 @@
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..4aa61e8a 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) {
@@ -55,6 +62,13 @@ class ChatAdapter(
val generatingDots: TextView = view.findViewById(R.id.generating_dots)
val messageDuration: TextView = view.findViewById(R.id.message_duration)
val btnRetry: Button = view.findViewById(R.id.btn_retry)
+
+ /**
+ * Queued next step of the "..." animation, or null when it isn't running. Retained so
+ * [ChatAdapter.hideGeneratingDots] can cancel it: a Runnable left on the main looper
+ * would keep this holder, its views and their Context reachable after the row is gone.
+ */
+ var generatingDotsStep: Runnable? = null
}
class SystemMessageViewHolder(view: View) : MessageViewHolder(view) {
@@ -119,7 +133,7 @@ class ChatAdapter(
MessageStatus.LOADING -> {
holder.loadingIndicator.visibility = View.VISIBLE
holder.messageContent.visibility = View.GONE
- holder.generatingDots.visibility = View.GONE
+ hideGeneratingDots(holder)
}
MessageStatus.SENT -> {
holder.loadingIndicator.visibility = View.GONE
@@ -130,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
}
}
@@ -173,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
@@ -185,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)
}
@@ -200,18 +216,22 @@ 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"
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 +251,7 @@ class ChatAdapter(
notifyItemChanged(pos)
}
}
+ wireTooltip(holder.messageHeader, AiAssistantPlugin.TOOLTIP_TAG_SYSTEM_LOG)
}
private fun updateSystemMessageExpansion(holder: SystemMessageViewHolder, message: ChatMessage) {
@@ -246,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 83c285c1..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
@@ -5,13 +5,17 @@ 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
+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
@@ -21,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
@@ -52,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)
}
}
@@ -141,6 +148,7 @@ class AiSettingsFragment : DialogFragment() {
// Close the dialog
dismiss()
}
+ wireTooltip(backButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACK)
}
private fun setupBackendSelector() {
@@ -212,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())
@@ -221,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
@@ -290,14 +304,15 @@ 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.
+ The stored API key could not be read on this device. Please enter it again.BackendModelTemperature
@@ -105,4 +115,12 @@
⚠️ Experimental AI. Use at your own risk.Current AI backendSend
+
+
+ Select Files
+ Select Files: %s
+ Add Selected
+ Toggle All
+ Open a project first — context files are picked from the project you have open.
+ Project directory not found
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" />
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,30 +64,80 @@ 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
+ 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.
+ *
+ * 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; [warmKeyCache] fills the cache first so that never reports "no key".
+ */
+ private fun readGeminiApiKey(): String? {
+ val stored = agentPrefs()?.getString(KEY_API_KEY, null)
+ if (stored.isNullOrBlank()) {
+ keyCache = null
+ return null
+ }
+ 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 prefs?.getString("gemini_model", DEFAULT_MODEL) ?: DEFAULT_MODEL
+ return refreshKeyCache()
}
- /** Read the saved Gemini API key from ai-assistant's shared prefs, or null. */
- 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
+ /**
+ * 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
+ }
+
+ /**
+ * Warm [keyCache] off-thread, so the synchronous [isAvailable] never reports "no key" for a
+ * stored, decryptable key just because it was first called from the main thread. Invoked from
+ * AiCorePlugin.activate().
+ */
+ fun warmKeyCache() {
+ if (!scope.isActive) return
+ scope.launch {
+ try {
+ val warmed = refreshKeyCache() != null
+ context.logger.debug("GeminiBackend: key cache warmed (key present: $warmed)")
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ context.logger.warn("GeminiBackend: could not warm key cache: ${e.message}")
+ }
}
- return prefs?.getString("gemini_api_key", null)?.trim()?.takeIf { it.isNotBlank() }
}
override fun getId(): String = "gemini"
@@ -81,7 +145,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()
@@ -393,10 +457,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..3e83461f 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,12 @@ 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.isActive
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 +36,16 @@ 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. Cancelled once its teardown job finishes;
+ * a `var` because that is terminal and a second [close] must re-create it to do any work.
+ */
+ @Volatile private var 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 +406,18 @@ 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, and is cancelled once that job completes.
*/
fun close() {
scope.cancel()
- CoroutineScope(Dispatchers.IO).launch {
+ // A prior close() cancelled the scope on completion, and a dead scope never starts work.
+ val teardown = teardownScope.takeIf { it.isActive }
+ ?: CoroutineScope(Dispatchers.IO).also { teardownScope = it }
+ teardownJob = teardown.launch {
try {
unloadModelInternal()
} catch (e: Exception) {
@@ -407,5 +429,24 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend {
llama.shutdown()
}
}
+ // Cancel the captured scope, not the field: a later close() may have replaced it.
+ teardownJob?.invokeOnCompletion { teardown.cancel() }
+ }
+
+ /**
+ * 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
new file mode 100644
index 00000000..229f17a3
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
@@ -0,0 +1,145 @@
+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
+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 {
+ // 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"
+ 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).
+ *
+ * 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 {
+ 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 (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.
+ */
+ 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
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * 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 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)
+ val plain = stored.trim()
+ if (plain.isEmpty()) return plain
+ try {
+ 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 plain
+ }
+}