Copy both .cgp files to the device and install via the CoGo
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 604eb87e..e3809c24 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
@@ -59,16 +59,6 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension, Settings
// Tags for the interactive controls on the AI Settings screen (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"
- const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_settings_get_free_key"
-
- // Tags for the memory pre-flight warning (see MemoryWarningDialogFragment).
- const val TOOLTIP_TAG_MEMORY_PROCEED = "agent_memory_warning_proceed"
- const val TOOLTIP_TAG_MEMORY_CANCEL = "agent_memory_warning_cancel"
@Volatile
private var pluginContext: PluginContext? = null
@@ -80,7 +70,7 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension, Settings
this.context = context
pluginContext = context // Store for ChatFragment access
- // Also store in SharedServices so ai-core can access preferences
+ // Also store in SharedServices so the backend plugins can access preferences
SharedServices.register(PluginContext::class.java, context)
context.logger.info("AI Assistant Plugin initializing...")
@@ -92,8 +82,8 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension, Settings
llmService = SharedServices.get(LlmInferenceService::class.java)
if (llmService == null) {
- context.logger.warn("LlmInferenceService not available - LOCAL_LLM backend disabled")
- context.logger.warn("Install AI Core plugin to enable local LLM support")
+ context.logger.warn("LlmInferenceService not available - no backend can be reached")
+ context.logger.warn("Install the AI Core plugin, plus at least one AI backend plugin")
} else {
context.logger.info("LlmInferenceService available from SharedServices")
}
@@ -437,168 +427,20 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension, Settings
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_BACKEND,
- summary = "Choose which model powers the Agent: on-device Local (llama.cpp) or cloud Gemini.",
- detail = """
- Selects the active inference backend:
-
- Local — runs a .gguf model entirely on
- the device; nothing leaves the phone.
- Gemini — calls Google's cloud API over HTTPS; needs
- an API key.
-
- The choice below changes which settings appear.
- """.trimIndent(),
- buttons = listOf(
- PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
- )
- ),
- PluginTooltipEntry(
- tag = TOOLTIP_TAG_SETTINGS_LOCAL_MODEL,
- summary = "Pick a local .gguf chat model to run on-device.",
- detail = """
- Browse for a .gguf model file to load with
- llama.cpp. Use a chat/instruct model — embedding-only
- models can't generate replies. Larger models are slower and use
- more memory; the file is copied into the app's private storage on
- first use.
- The model is measured against this device's free memory before it
- is accepted. If it looks too large you get the figures and a choice
- to cancel or continue.
- """.trimIndent(),
- buttons = listOf(
- PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
- )
- ),
- PluginTooltipEntry(
- tag = TOOLTIP_TAG_MEMORY_PROCEED,
- summary = "Load this model anyway, accepting that it may fail or slow the device.",
+ summary = "Choose which installed backend powers the Agent.",
detail = """
- The model's weights plus its working memory look larger than the
- RAM free right now. Weights are memory-mapped, so a load can still
- succeed by paging — which is why the outcome is a risk rather than a
- certainty: it may work, fail quickly, or stall for minutes first.
- Use this when you know the numbers are wrong for your situation,
- for example because you are about to close other apps.
+ Lists every AI backend plugin installed and registered with
+ AI Core — for example AI Local Backend for
+ on-device .gguf models, or AI Gemini Backend
+ for Google's cloud API.
+ Each backend supplies its own settings, so the panel below
+ changes with the choice. If the list is empty, no backend is
+ installed: add one from the Plugin Manager.
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
- PluginTooltipEntry(
- tag = TOOLTIP_TAG_MEMORY_CANCEL,
- summary = "Abandon this model; the previously selected one is left untouched.",
- detail = """
- Nothing is saved and nothing is loaded, so the model you had
- selected before stays in use. This is the safe choice, and also what
- happens if you dismiss the warning with Back.
- To fit a large model, close other apps and pick it again, or
- choose a smaller or more heavily quantized build — a Q4_K_M
- quantization of a 1–3B model is the most likely to run.
- """.trimIndent(),
- buttons = listOf(
- 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. Keys are
- created at aistudio.google.com/apikey — tap Get API
- Key to go straight there. Google AI Studio sets up the
- underlying Cloud project for you, so there is no Cloud console and
- no billing setup involved.
- The key is encrypted with a key held in this device's
- hardware-backed Android Keystore before it is written to this
- plugin's private preferences, and is sent only to Google's API over
- HTTPS. Requests (your prompts and project context) leave the device
- when Gemini is selected.
- Save checks the key with Google before storing it, so a
- key that doesn't work is reported straight away instead of failing
- later mid-chat — a key Google rejects is not saved at all. If the
- check can't be completed (no network, or the AI Core plugin is
- disabled or out of date) you are asked whether to keep the key
- anyway.
- Use the eye button to check what you typed, Edit to change
- the key later and Clear to remove it from the device.
- If the Keystore entry is ever lost — clearing the app's data,
- for instance — the stored key can no longer be decrypted and must
- be re-entered here.
- """.trimIndent(),
- buttons = listOf(
- PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
- )
- ),
- PluginTooltipEntry(
- tag = TOOLTIP_TAG_SETTINGS_GET_KEY,
- summary = "Open Google AI Studio in your browser to create a Gemini API key.",
- detail = """
- Opens aistudio.google.com/apikey in your normal browser,
- where you sign in with your Google account and tap Create API
- key . AI Studio creates the Cloud project behind the scenes — the
- Google Cloud console is not part of this.
- Sign-in happens in the browser, so this plugin never sees your
- Google password. Copy the key Google shows you, come back here and
- paste it into the key field, then tap Save Key .
- Gemini has a free tier. Note that on the free tier Google may use
- prompts and responses to improve its products — and this plugin
- sends your prompts and any file contents the agent reads. If that
- matters for your project, use the on-device Local backend
- instead: nothing leaves the device.
- If no browser is installed the link is copied to the clipboard
- so you can open it elsewhere.
- """.trimIndent(),
- buttons = listOf(
- PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
- )
- )
)
/** Subdirectory under src/main/assets/ holding the Tier 3 offline docs. */
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/backends/BackendFragmentFactory.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/backends/BackendFragmentFactory.kt
new file mode 100644
index 00000000..8a9eade6
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/backends/BackendFragmentFactory.kt
@@ -0,0 +1,82 @@
+package com.itsaky.androidide.plugins.aiassistant.backends
+
+import android.os.Bundle
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentFactory
+import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
+import com.itsaky.androidide.plugins.aiassistant.R
+import kotlin.math.roundToInt
+
+/**
+ * Instantiates a backend's settings pane with the loader that can actually see it.
+ *
+ * Each plugin gets its own `DexClassLoader`, so a class packaged in a backend's `.cgp` is invisible
+ * to this plugin's loader — including to the FragmentManager, which rebuilds a restored pane from
+ * its class name alone. Installing this on the child FragmentManager is what makes the pane survive
+ * rotation and process death.
+ *
+ * @param delegate the factory to fall back on for this plugin's own fragments
+ * @param resolve maps a fragment class name to the backend plugin's loader; null if none claims it
+ */
+class BackendFragmentFactory(
+ private val delegate: FragmentFactory,
+ private val resolve: (String) -> ClassLoader?,
+) : FragmentFactory() {
+
+ override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
+ val backendLoader = resolve(className)
+ ?: return instantiateOwn(classLoader, className)
+
+ return try {
+ backendLoader.loadClass(className)
+ .getDeclaredConstructor()
+ .newInstance() as Fragment
+ } catch (e: Throwable) {
+ // A pane that will not construct is the backend's bug, but it must not take the
+ // settings screen down with it.
+ logError("backend settings pane '$className' could not be constructed", e)
+ MissingBackendFragment()
+ }
+ }
+
+ /**
+ * Falls back to the default factory, standing in for a pane whose backend is gone rather than
+ * throwing: an uninstalled backend is exactly the state a restored selection lands in.
+ */
+ private fun instantiateOwn(classLoader: ClassLoader, className: String): Fragment = try {
+ delegate.instantiate(classLoader, className)
+ } catch (e: Throwable) {
+ logError("no installed backend provides '$className'", e)
+ MissingBackendFragment()
+ }
+
+ private fun logError(message: String, error: Throwable) {
+ AiAssistantPlugin.getContext()?.logger?.error("BackendFragmentFactory: $message", error)
+ }
+}
+
+/**
+ * Stands in for a settings pane that could not be built — most often a backend uninstalled while
+ * its pane was in the saved state. Says so on screen rather than leaving a blank area that reads as
+ * a broken settings screen.
+ */
+class MissingBackendFragment : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val padding = (16 * resources.displayMetrics.density).roundToInt()
+ return TextView(inflater.context).apply {
+ setText(R.string.backend_pane_unavailable)
+ gravity = Gravity.CENTER
+ setPadding(padding, padding, padding, padding)
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/backends/BackendRegistry.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/backends/BackendRegistry.kt
new file mode 100644
index 00000000..cff511b9
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/backends/BackendRegistry.kt
@@ -0,0 +1,121 @@
+package com.itsaky.androidide.plugins.aiassistant.backends
+
+import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
+import com.itsaky.androidide.plugins.services.LlmInferenceService
+import com.itsaky.androidide.plugins.services.SharedServices
+
+/**
+ * A backend the settings selector can offer.
+ *
+ * @param id the backend's own id; this is what gets persisted as the selection
+ * @param displayName the backend's own label, shown in the selector
+ * @param settingsFragmentClassName the settings pane the backend contributes, or null if it has none
+ * @param classLoader the loader that can see [settingsFragmentClassName] — the backend plugin's own,
+ * since this plugin's loader cannot see classes packaged in another `.cgp`
+ */
+data class BackendOption(
+ val id: String,
+ val displayName: String,
+ val settingsFragmentClassName: String?,
+ val classLoader: ClassLoader?,
+)
+
+/**
+ * The backends currently installed, as this plugin sees them.
+ *
+ * Everything here comes from AI Core's live registry, so no provider is named anywhere in this
+ * plugin: a backend that is not installed is simply absent from [options], and one that ships in a
+ * `.cgp` written by someone else appears without a line of code changing here.
+ */
+object BackendRegistry {
+
+ /** SharedPreferences file holding the selection; AI Core reads the same file and key. */
+ const val PREFERENCE_FILE = "AgentSettings"
+
+ /** Key under which the selected backend id is stored in [PREFERENCE_FILE]. */
+ const val PREFERENCE_KEY = "ai_backend_preference"
+
+ private const val TAG = "BackendRegistry"
+
+ /**
+ * Selections written before the stored value *was* the backend id. Additive-only: dropping an
+ * entry strands every device that stored it. Mirrors AI Core's own legacy map, which cannot be
+ * shared across the plugin classloader boundary.
+ */
+ private val LEGACY_IDS = mapOf(
+ "LOCAL_LLM" to "local",
+ "GEMINI" to "gemini",
+ )
+
+ /**
+ * Every registered backend, sorted by label so the selector's order is stable across restarts
+ * (the underlying registry is a hash map).
+ *
+ * @return the installed backends; empty when AI Core is absent or no backend registered
+ */
+ fun options(): List {
+ val backends = try {
+ service()?.availableBackends.orEmpty()
+ } catch (e: Exception) {
+ logError("could not list the registered backends", e)
+ emptyList()
+ }
+ return backends.mapNotNull(::describe).sortedBy { it.displayName }
+ }
+
+ /**
+ * The loader that can instantiate [fragmentClassName], found by asking each registered backend
+ * which pane it contributes. Used by [BackendFragmentFactory] to rebuild a restored pane.
+ *
+ * @return the owning backend plugin's loader, or null if no installed backend claims the class
+ */
+ fun classLoaderFor(fragmentClassName: String): ClassLoader? =
+ options().firstOrNull { it.settingsFragmentClassName == fragmentClassName }?.classLoader
+
+ /**
+ * The backend the user selected, as a backend id.
+ *
+ * @return the stored id, migrating a legacy value in passing; null when nothing is stored
+ */
+ fun selectedId(): String? {
+ val stored = prefs()?.getString(PREFERENCE_KEY, null)?.trim()
+ if (stored.isNullOrEmpty()) return null
+ return LEGACY_IDS[stored] ?: stored
+ }
+
+ /**
+ * Persists [backendId] as the selection. Stores the backend's own id, so a backend AI Core has
+ * never heard of routes correctly without anything mapping it.
+ */
+ fun select(backendId: String) {
+ prefs()?.edit()?.putString(PREFERENCE_KEY, backendId)?.apply()
+ }
+
+ /**
+ * Describes one backend, tolerating a backend that throws from its own accessors: one bad
+ * `.cgp` must cost the user that entry in the list, not the whole settings screen.
+ */
+ private fun describe(backend: LlmInferenceService.LlmBackend): BackendOption? = try {
+ BackendOption(
+ id = backend.id,
+ displayName = backend.name,
+ settingsFragmentClassName = backend.settingsFragmentClassName?.takeIf { it.isNotBlank() },
+ // The backend object is constructed by its own plugin, so its loader is by construction
+ // the one that can see the pane it names.
+ classLoader = backend.javaClass.classLoader,
+ )
+ } catch (e: Throwable) {
+ logError("a registered backend could not describe itself; omitting it", e)
+ null
+ }
+
+ private fun service(): LlmInferenceService? =
+ SharedServices.get(LlmInferenceService::class.java)
+
+ private fun prefs() =
+ AiAssistantPlugin.getContext()?.getPluginSharedPreferences(PREFERENCE_FILE)
+
+ private fun logError(message: String, error: Throwable) {
+ AiAssistantPlugin.getContext()?.logger?.error("$TAG: $message", error)
+ }
+}
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 a6b5c064..d6e35043 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt
@@ -1,72 +1,64 @@
package com.itsaky.androidide.plugins.aiassistant.fragments
-import android.annotation.SuppressLint
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
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.annotation.DrawableRes
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.FrameLayout
+import android.widget.ImageButton
+import android.widget.LinearLayout
+import android.widget.Spinner
+import android.widget.TextView
import androidx.fragment.app.Fragment
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.itsaky.androidide.plugins.PluginContext
+import androidx.fragment.app.commit
import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
-import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiKeyOnboarding
-import com.itsaky.androidide.plugins.aiassistant.gemini.KeyVerification
+import com.itsaky.androidide.plugins.aiassistant.backends.BackendFragmentFactory
+import com.itsaky.androidide.plugins.aiassistant.backends.BackendOption
+import com.itsaky.androidide.plugins.aiassistant.backends.BackendRegistry
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.services.IdeTooltipService
-import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend
-import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel
-import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState
-import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelLoadingState
-import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelMemoryWarning
-import kotlinx.coroutines.launch
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
import kotlin.math.roundToInt
/**
* The Agent settings screen, reached from Preferences → Configuration → Agent and from the Agent
* chat's own shortcuts. The host mounts it full-screen in PluginScreenActivity, which provides no
* toolbar, so this fragment brings its own app bar and closes by finishing that activity.
+ *
+ * Deliberately knows no backend. It offers whichever backends registered themselves with AI Core
+ * and mounts, below the selector, the settings pane the selected backend contributes — so adding a
+ * provider means shipping a `.cgp`, not editing this screen.
*/
-class AiSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host {
+class AiSettingsFragment : Fragment() {
- private lateinit var viewModel: AiSettingsViewModel
private lateinit var settingsToolbar: LinearLayout
private lateinit var backButton: ImageButton
private lateinit var backendSpinner: Spinner
private lateinit var backendSpecificContainer: FrameLayout
private var tooltipService: IdeTooltipService? = null
- /**
- * Set while the Gemini pane is on screen, so [onResume] can nudge the user towards **Paste
- * key** after they come back from AI Studio. Cleared when the pane is replaced or the view is
- * destroyed — it captures views, so holding it any longer would leak them.
- */
- private var onGeminiPaneResume: (() -> Unit)? = null
+ /** Backends as of [onViewCreated], so the spinner's positions stay valid while it is on screen. */
+ private var backends: List = emptyList()
+
+ /** Backend whose pane is currently mounted, so re-selecting it does not rebuild it. */
+ private var shownBackendId: String? = null
+
+ companion object {
+ /** Tag for the mounted backend pane, so it can be found across a configuration change. */
+ private const val TAG_BACKEND_PANE = "backend_settings_pane"
+ }
override fun onCreate(savedInstanceState: Bundle?) {
+ // Installed before super.onCreate, which is where a saved child-fragment state is restored:
+ // the backend pane lives in another plugin's classloader and the default factory cannot see
+ // it, so without this a rotation would silently replace the pane with a blank fragment.
+ childFragmentManager.fragmentFactory = BackendFragmentFactory(
+ childFragmentManager.fragmentFactory,
+ BackendRegistry::classLoaderFor,
+ )
super.onCreate(savedInstanceState)
- // Disable Material transitions to avoid resource loading issues
- // Plugin uses compileOnly dependencies, so Material transition resources aren't bundled
- enterTransition = null
- exitTransition = null
// Resolve the IDE tooltip service so the settings controls can offer in-app help.
try {
@@ -88,30 +80,13 @@ class AiSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host {
}
}
- private val filePickerLauncher =
- registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
- uri?.let {
- try {
- val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
- requireContext().contentResolver.takePersistableUriPermission(it, takeFlags)
-
- val uriString = it.toString()
- viewModel.loadModelFromUri(uriString, requireContext())
- Toast.makeText(requireContext(), getString(R.string.model_loading_toast), Toast.LENGTH_SHORT).show()
- } catch (e: Exception) {
- Toast.makeText(requireContext(), getString(R.string.state_error, e.message), Toast.LENGTH_LONG).show()
- }
- }
- }
-
/**
* Route inflation through the host so this screen's views resolve against a Context whose
* Configuration tracks the IDE's day/night setting (DayNight PluginTheme + values-night/
* colors); the raw fragment inflater pins the screen to light mode.
*
- * Overridden here rather than applied inside [onCreateView] so that `layoutInflater` itself is
- * the themed one — the backend panes swapped into [backendSpecificContainer] and anything else
- * reaching for it get the theme for free. Same shape as ChatFragment.
+ * Only this screen's own views: a backend pane inflates against *its* plugin's resources and
+ * overrides this for itself.
*/
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
val inflater = super.onGetLayoutInflater(savedInstanceState)
@@ -129,84 +104,9 @@ class AiSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- initializeViewModel()
initializeViews(view)
setupToolbar()
- setupBackendSelector()
- observeMemoryWarnings()
- }
-
- /**
- * Puts a "this model may not fit" question to the user. Collected under STARTED so the dialog is
- * never shown to a stopped fragment; the event waits in the ViewModel until then.
- */
- private fun observeMemoryWarnings() {
- dropStaleMemoryWarning()
- viewLifecycleOwner.lifecycleScope.launch {
- viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.modelMemoryWarnings.collect(::showMemoryWarning)
- }
- }
- }
-
- /**
- * Dismiss a warning dialog the framework restored around a question that no longer exists.
- * After process death the load that raised it is gone, so every button on it would be a silent
- * no-op — better to take it away than to leave the user pressing a dialog that decides nothing.
- */
- private fun dropStaleMemoryWarning() {
- if (viewModel.hasPendingMemoryWarning) return
- val restored = childFragmentManager.findFragmentByTag(MemoryWarningDialogFragment.TAG)
- (restored as? MemoryWarningDialogFragment)?.dismissAllowingStateLoss()
- }
-
- /**
- * Shown as a child fragment, so it survives rotation and can still reach this host. Must stay
- * idempotent: an unanswered question is re-published to every new collector by
- * [com.itsaky.androidide.plugins.aiassistant.viewmodel.UserConfirmation].
- *
- * @param warning the model and the figures to put to the user
- */
- private fun showMemoryWarning(warning: ModelMemoryWarning) {
- if (childFragmentManager.findFragmentByTag(MemoryWarningDialogFragment.TAG) != null) return
- MemoryWarningDialogFragment.newInstance(warning)
- .show(childFragmentManager, MemoryWarningDialogFragment.TAG)
- }
-
- override fun onModelMemoryDecision(proceed: Boolean) {
- viewModel.onMemoryWarningDecision(proceed)
- // Not requireContext(): onCancel can reach us as the fragment is going away.
- val ctx = context ?: return
- if (!proceed) {
- Toast.makeText(
- ctx,
- getString(R.string.llm_memory_warning_declined),
- Toast.LENGTH_LONG,
- ).show()
- }
- }
-
- override fun onResume() {
- super.onResume()
- onGeminiPaneResume?.invoke()
- }
-
- override fun onDestroyView() {
- // Drops the captured Gemini pane views along with the callback.
- onGeminiPaneResume = null
- setSecureWindow(false)
- super.onDestroyView()
- }
-
- private fun initializeViewModel() {
- viewModel = ViewModelProvider(
- this,
- AiSettingsViewModelFactory { getPluginContext() }
- )[AiSettingsViewModel::class.java]
- }
-
- private fun getPluginContext(): PluginContext? {
- return com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin.getContext()
+ setupBackendSelector(restoring = savedInstanceState != null)
}
private fun initializeViews(view: View) {
@@ -224,667 +124,139 @@ class AiSettingsFragment : Fragment(), MemoryWarningDialogFragment.Host {
wireTooltip(backButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACK)
}
- private fun setupBackendSelector() {
- val backends = viewModel.getAvailableBackends()
- val backendNames = backends.map { it.displayName }
+ /**
+ * Fills the selector from the live backend registry and mounts the selected backend's pane.
+ *
+ * @param restoring true when the framework already rebuilt the pane from saved state, in which
+ * case the initial selection must not replace it and lose whatever the user had typed
+ */
+ private fun setupBackendSelector(restoring: Boolean) {
+ backends = BackendRegistry.options()
+
+ if (backends.isEmpty()) {
+ backendSpinner.visibility = View.GONE
+ showPlaceholder(getString(R.string.backend_none_installed))
+ return
+ }
+
+ backendSpinner.visibility = View.VISIBLE
val adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_item,
- backendNames
+ backends.map { it.displayName }
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
backendSpinner.adapter = adapter
wireTooltip(backendSpinner, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_BACKEND)
- val currentBackend = viewModel.getCurrentBackend()
- backendSpinner.setSelection(backends.indexOf(currentBackend))
- updateBackendSpecificUi(currentBackend)
-
- backendSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
- val selectedBackend = backends[position]
- viewModel.saveBackend(selectedBackend)
- updateBackendSpecificUi(selectedBackend)
- }
-
- override fun onNothingSelected(parent: AdapterView<*>?) {}
- }
- }
-
- private fun updateBackendSpecificUi(backend: AiBackend) {
- backendSpecificContainer.removeAllViews()
- // The Gemini pane's views are about to go; its resume callback must not outlive them.
- onGeminiPaneResume = null
-
- // layoutInflater is the theme-aware one (see onGetLayoutInflater), so these sub-layouts
- // follow the IDE day/night theme like the rest of the screen.
- when (backend) {
- AiBackend.LOCAL_LLM -> {
- val localLlmView = layoutInflater
- .inflate(R.layout.layout_settings_local_llm, backendSpecificContainer, false)
- backendSpecificContainer.addView(localLlmView)
- setupLocalLlmUi(localLlmView)
- }
- AiBackend.GEMINI -> {
- val geminiApiView = layoutInflater
- .inflate(R.layout.layout_settings_gemini_api, backendSpecificContainer, false)
- backendSpecificContainer.addView(geminiApiView)
- setupGeminiApiUi(geminiApiView)
- }
- }
- }
-
- private fun setupLocalLlmUi(view: View) {
- val modelPathTextView = view.findViewById(R.id.selected_model_path)
- val browseButton = view.findViewById(R.id.btn_browse_model)
- val loadSavedButton = view.findViewById(R.id.loadSavedButton)
- val modelStatusTextView = view.findViewById(R.id.model_status_text_view)
- val engineStatusTextView = view.findViewById(R.id.engine_status_text)
- val simplePromptCheckbox = view.findViewById(R.id.switch_simple_local_prompt)
- val shaInput = view.findViewById(R.id.local_model_sha_input)
-
- browseButton.setOnClickListener {
- filePickerLauncher.launch(arrayOf("*/*"))
- }
- wireTooltip(browseButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_LOCAL_MODEL)
-
- loadSavedButton.setOnClickListener {
- val savedPath = viewModel.savedModelPath.value
- if (savedPath != null) {
- 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())
- setOnFocusChangeListener { _, hasFocus ->
- if (!hasFocus) {
- viewModel.saveLocalModelSha256(text?.toString())
- }
- }
- }
- // 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)
+ // A stored selection whose backend was uninstalled falls back to the first one offered,
+ // rather than leaving the screen describing a backend that cannot run.
+ val storedId = BackendRegistry.selectedId()
+ val selected = backends.firstOrNull { it.id == storedId } ?: backends.first()
+ if (selected.id != storedId) {
+ BackendRegistry.select(selected.id)
}
- // Observe engine state
- viewModel.engineState.observe(viewLifecycleOwner) { state ->
- when (state) {
- is EngineState.Initializing, EngineState.Uninitialized -> {
- engineStatusTextView.text = getString(R.string.engine_initializing)
- browseButton.isEnabled = false
- loadSavedButton.isEnabled = false
- }
- is EngineState.Initialized -> {
- engineStatusTextView.text = getString(R.string.engine_ready)
- browseButton.isEnabled = true
- loadSavedButton.isEnabled = viewModel.savedModelPath.value != null
- }
- is EngineState.Error -> {
- engineStatusTextView.text = state.message
- browseButton.isEnabled = false
- loadSavedButton.isEnabled = false
- }
- }
+ if (restoring && childFragmentManager.findFragmentByTag(TAG_BACKEND_PANE) != null) {
+ shownBackendId = selected.id
}
- // Observe saved model path
- viewModel.savedModelPath.observe(viewLifecycleOwner) { path ->
- loadSavedButton.isEnabled = path != null && viewModel.engineState.value is EngineState.Initialized
+ backendSpinner.setSelection(backends.indexOf(selected))
+ showBackendPane(selected)
- if (path != null) {
- modelPathTextView.visibility = View.VISIBLE
- val fileName = viewModel.getSavedModelName() ?: viewModel.fallbackDisplayName(path)
- modelPathTextView.text = getString(R.string.model_saved_path, fileName)
- } else {
- modelPathTextView.visibility = View.GONE
+ backendSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ val backend = backends.getOrNull(position) ?: return
+ BackendRegistry.select(backend.id)
+ showBackendPane(backend)
}
- }
- // Observe model loading state
- viewModel.modelLoadingState.observe(viewLifecycleOwner) { state ->
- when (state) {
- is ModelLoadingState.Idle -> {
- modelStatusTextView.visibility = View.VISIBLE
- modelStatusTextView.text = getString(R.string.model_none_loaded)
- }
- is ModelLoadingState.Loading -> {
- modelStatusTextView.visibility = View.VISIBLE
- modelStatusTextView.text = getString(R.string.model_loading_wait)
- }
- is ModelLoadingState.Loaded -> {
- modelStatusTextView.visibility = View.VISIBLE
- modelStatusTextView.text = getString(R.string.model_loaded, state.modelName)
- }
- is ModelLoadingState.Error -> {
- modelStatusTextView.visibility = View.VISIBLE
- modelStatusTextView.text = getString(R.string.model_load_error, state.message)
- }
- }
+ override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
- @SuppressLint("SetTextI18n")
- 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.findViewById(R.id.btn_save_api_key)
- val editButton = view.findViewById(R.id.btn_edit_api_key)
- val clearButton = view.findViewById(R.id.btn_clear_api_key)
- val statusTextView = view.findViewById(R.id.gemini_api_key_status_text)
- val getKeyButton = view.findViewById(R.id.btn_get_free_key)
- val verificationText = view.findViewById(R.id.gemini_key_verification_text)
-
- // Not on apiKeyInput: long-press there is the paste menu, and a key is pasted.
- listOf(
- toggleVisibilityButton, saveButton, editButton, clearButton, statusTextView,
- verificationText
- ).forEach { wireTooltip(it, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_KEY) }
- wireTooltip(getKeyButton, AiAssistantPlugin.TOOLTIP_TAG_SETTINGS_GET_KEY)
-
- // Create model selection container
- val modelContainer = createModelSelectionUi(view)
-
- /**
- * Show the outcome of (or progress of) the live key check.
- *
- * @param message the user-facing line; carries no status glyph of its own
- * @param icon leading status drawable, or 0 for the states that don't warrant one
- * (in-progress, and the hints shown on returning from AI Studio)
- */
- fun showVerification(message: String, @DrawableRes icon: Int = 0) {
- verificationText.text = message
- // Relative (not left/right) so the icon follows the layout direction in RTL locales.
- verificationText.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, 0, 0, 0)
- verificationText.visibility = View.VISIBLE
- }
-
- /** Drop a verdict that no longer describes what is in the field. */
- fun hideVerification() {
- verificationText.visibility = View.GONE
- verificationText.text = ""
- verificationText.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
- }
-
- // "Get API Key" is absent here on purpose: it stays visible while Gemini is selected.
- fun updateUiState(isEditing: Boolean) {
- if (isEditing) {
- statusTextView.visibility = View.GONE
- apiKeyLayout.visibility = View.VISIBLE
- saveButton.visibility = View.VISIBLE
- editButton.visibility = View.GONE
- clearButton.visibility = View.GONE
- } else {
- statusTextView.visibility = View.VISIBLE
- apiKeyLayout.visibility = View.GONE
- saveButton.visibility = View.GONE
- editButton.visibility = View.VISIBLE
- clearButton.visibility = View.VISIBLE
- }
- }
-
- viewLifecycleOwner.lifecycleScope.launch {
- val savedApiKey = viewModel.getGeminiApiKey()
- val hasKey = !savedApiKey.isNullOrBlank()
- updateUiState(isEditing = !hasKey)
- if (hasKey) {
- 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()
- }
- }
- }
-
- toggleVisibilityButton.setColorFilter(apiKeyInput.currentHintTextColor)
-
- var isKeyVisible = false
-
- fun applyKeyVisibility() {
- apiKeyInput.transformationMethod = if (isKeyVisible) {
- HideReturnsTransformationMethod.getInstance()
- } else {
- PasswordTransformationMethod.getInstance()
- }
- toggleVisibilityButton.setImageResource(
- if (isKeyVisible) R.drawable.ic_visibility_off else R.drawable.ic_visibility
- )
- toggleVisibilityButton.contentDescription = getString(
- if (isKeyVisible) R.string.cd_hide_api_key else R.string.cd_show_api_key
- )
- toggleVisibilityButton.setColorFilter(apiKeyInput.currentHintTextColor)
- apiKeyInput.setSelection(apiKeyInput.text?.length ?: 0)
- setSecureWindow(isKeyVisible)
- }
-
- applyKeyVisibility()
-
- toggleVisibilityButton.setOnClickListener {
- isKeyVisible = !isKeyVisible
- applyKeyVisibility()
- }
-
- getKeyButton.setOnClickListener { openAiStudio() }
-
- // Coming back from AI Studio, point at the next step; the clipboard is never read.
- onGeminiPaneResume = {
- // Kept on the ViewModel so a rotation while AI Studio is in front doesn't lose the hint.
- if (viewModel.sentUserToAiStudio) {
- viewModel.sentUserToAiStudio = false
- // With a key already stored the field is hidden, so the next tap is Edit.
- showVerification(
- if (apiKeyLayout.visibility == View.VISIBLE) {
- getString(R.string.msg_key_hint_paste_into_field)
- } else {
- getString(R.string.msg_key_hint_edit_first)
- }
- )
- }
- }
-
- /** Enable or disable everything that would race the in-flight key check. */
- fun setKeyEntryEnabled(enabled: Boolean) {
- saveButton.isEnabled = enabled
- getKeyButton.isEnabled = enabled
- apiKeyInput.isEnabled = enabled
- }
-
- /**
- * Encrypt and store [apiKey], then reflect the outcome. Only ever reached for a key Google
- * confirmed, or one the user chose to keep after an inconclusive check.
- */
- suspend fun persistKey(
- apiKey: String,
- verified: Boolean,
- resultText: String,
- @DrawableRes resultIcon: Int
- ) {
- if (!viewModel.saveGeminiApiKey(apiKey, verified)) {
- Toast.makeText(
- requireContext(),
- getString(R.string.msg_api_key_save_failed),
- Toast.LENGTH_LONG
- ).show()
- return
- }
- Toast.makeText(
- requireContext(),
- getString(R.string.msg_api_key_saved),
- Toast.LENGTH_SHORT
- ).show()
- updateUiState(isEditing = false)
- statusTextView.text = savedApiKeyStatusText()
- showVerification(resultText, resultIcon)
- // A different key can reach a different set of models, so the picker is re-fetched.
- viewModel.fetchGeminiModels()
- }
-
- /**
- * Offer to keep a key that could not be checked. Distinct from a rejection: refusing a good
- * key because the device is offline would leave the plugin unconfigurable, so this gets the
- * muted "unchecked" icon and a key Google actually refused never reaches here.
- */
- fun confirmSaveUnverified(apiKey: String, reason: String) {
- showVerification(reason, R.drawable.ic_key_unchecked)
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.title_save_unverified_key)
- .setMessage(getString(R.string.msg_save_unverified_key, reason))
- .setNegativeButton(R.string.action_cancel, null)
- .setPositiveButton(R.string.action_save_anyway) { _, _ ->
- viewLifecycleOwner.lifecycleScope.launch {
- persistKey(
- apiKey,
- verified = false,
- resultText = reason,
- resultIcon = R.drawable.ic_key_unchecked
- )
- }
- }
- .show()
- }
-
- saveButton.setOnClickListener {
- val apiKey = apiKeyInput.text.toString().trim()
- // Blankness is the only shape rule: AI Studio keys need not match the AIza… form.
- if (apiKey.isBlank()) {
- Toast.makeText(requireContext(), getString(R.string.msg_api_key_empty), Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- setKeyEntryEnabled(false)
- showVerification(getString(R.string.msg_verifying_key))
- viewLifecycleOwner.lifecycleScope.launch {
- val verdict = try {
- viewModel.verifyGeminiKey(apiKey)
- } finally {
- setKeyEntryEnabled(true)
- }
- when (verdict) {
- // Model count omitted: the user saved a key, not asked for a catalog.
- is KeyVerification.Verified -> persistKey(
- apiKey,
- verified = true,
- resultText = getString(R.string.msg_key_verified),
- resultIcon = R.drawable.ic_key_verified
- )
-
- // A rate-limited key is a working key, so it gets the same icon as a clean pass.
- KeyVerification.RateLimited -> persistKey(
- apiKey,
- verified = true,
- resultText = getString(R.string.msg_key_verified_rate_limited),
- resultIcon = R.drawable.ic_key_verified
- )
-
- // Nothing is written: a definitive refusal would only resurface mid-chat.
- KeyVerification.Rejected -> {
- showVerification(
- getString(R.string.msg_key_rejected),
- R.drawable.ic_key_rejected
- )
- apiKeyInput.requestFocus()
- }
-
- KeyVerification.Unreachable ->
- confirmSaveUnverified(apiKey, getString(R.string.msg_key_unreachable))
-
- KeyVerification.Unknown ->
- confirmSaveUnverified(apiKey, getString(R.string.msg_key_uncheckable))
- }
- }
- }
+ /**
+ * Mounts [backend]'s own settings pane in the container below the selector.
+ *
+ * The pane is a Fragment packaged in the backend's `.cgp`; it is loaded by that plugin's
+ * classloader (see [BackendFragmentFactory]) and inflates against that plugin's resources, so
+ * nothing about the provider is known here beyond the class name it declared.
+ */
+ private fun showBackendPane(backend: BackendOption) {
+ if (shownBackendId == backend.id) return
+ shownBackendId = backend.id
- // Reveal the (already-fetched) key in an editable, focused field.
- fun revealEditMode(apiKey: String) {
- apiKeyInput.setText(apiKey)
- apiKeyInput.setSelection(apiKey.length)
- // The old verdict described the stored key, which is about to change.
- hideVerification()
- updateUiState(isEditing = true)
- isKeyVisible = false
- applyKeyVisibility()
- apiKeyInput.requestFocus()
+ val className = backend.settingsFragmentClassName
+ if (className == null) {
+ // A backend configured entirely from its own defaults is legitimate, so this is a
+ // statement about that backend rather than an error.
+ clearPane()
+ showPlaceholder(getString(R.string.backend_no_settings, backend.displayName))
+ return
}
- 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())
- }
+ val paneClass = loadPaneClass(backend, className)
+ if (paneClass == null) {
+ clearPane()
+ showPlaceholder(getString(R.string.backend_pane_unavailable))
+ return
}
- clearButton.setOnClickListener {
- viewModel.clearGeminiApiKey()
- Toast.makeText(requireContext(), getString(R.string.msg_api_key_cleared), Toast.LENGTH_SHORT).show()
- hideVerification()
- updateUiState(isEditing = true)
- apiKeyInput.setText("")
+ backendSpecificContainer.removeAllViews()
+ childFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.backend_specific_settings_container, paneClass, null, TAG_BACKEND_PANE)
}
-
- // Setup model selection
- setupGeminiModelSelection(modelContainer)
}
/**
- * Add or clear [WindowManager.LayoutParams.FLAG_SECURE] on the host activity's window.
- *
- * Set while the key is in clear text, or screenshots and the recents thumbnail would capture
- * it. Cleared in [onDestroyView], since the window outlives this fragment's view.
+ * Loads the pane class with the backend plugin's own loader.
*
- * @param secure true to block capture, false to allow it again
+ * @return the class, or null when the backend named a class its own plugin does not contain or
+ * that is not a Fragment — a broken backend, reported rather than crashing this screen
*/
- private fun setSecureWindow(secure: Boolean) {
- val window = activity?.window ?: return
- if (secure) {
- window.setFlags(
- WindowManager.LayoutParams.FLAG_SECURE,
- WindowManager.LayoutParams.FLAG_SECURE
+ @Suppress("UNCHECKED_CAST")
+ private fun loadPaneClass(backend: BackendOption, className: String): Class? {
+ val loader = backend.classLoader ?: return null
+ return try {
+ val loaded = loader.loadClass(className)
+ if (!Fragment::class.java.isAssignableFrom(loaded)) {
+ AiAssistantPlugin.getContext()?.logger?.error(
+ "AiSettingsFragment: backend '${backend.id}' declared '$className', " +
+ "which is not a Fragment"
+ )
+ return null
+ }
+ loaded as Class
+ } catch (e: Throwable) {
+ AiAssistantPlugin.getContext()?.logger?.error(
+ "AiSettingsFragment: backend '${backend.id}' declared '$className', " +
+ "which its plugin does not contain",
+ e
)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ null
}
}
- /**
- * Status line for a stored key: dated when the save time is known, generic otherwise, and
- * saying "verified" only for a key Google actually confirmed — a key kept through the
- * save-anyway path was never checked and must not claim otherwise.
- */
- private fun savedApiKeyStatusText(): String {
- val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
- val verified = viewModel.isGeminiKeyVerified()
- if (timestamp <= 0) return getString(R.string.msg_api_key_is_saved)
- val savedDate = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(Date(timestamp))
- return if (verified) {
- getString(R.string.msg_api_key_verified_on, savedDate)
- } else {
- getString(R.string.msg_api_key_saved_on, savedDate)
- }
- }
-
- /**
- * Open Google AI Studio's key page in the *system* browser.
- *
- * A real browser, not a WebView: Google blocks sign-in in embedded WebViews, and the user
- * should see Google's own URL bar. With no browser at all, the URL is copied instead.
- */
- private fun openAiStudio() {
- val url = GeminiKeyOnboarding.AI_STUDIO_URL
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
- runCatching { startActivity(intent) }
- .onSuccess { viewModel.sentUserToAiStudio = true }
- .onFailure { error ->
- AiAssistantPlugin.getContext()?.logger
- ?.warn("AiSettingsFragment: no browser could open AI Studio", error)
- val message = if (copyToClipboard(url)) {
- R.string.msg_no_browser_for_key
- } else {
- R.string.msg_key_link_copy_failed
- }
- Toast.makeText(requireContext(), getString(message, url), Toast.LENGTH_LONG).show()
- }
- }
-
- /**
- * Put [text] on the clipboard.
- *
- * Only ever used for the public AI Studio URL — never for a key, which would put the secret
- * somewhere every app on the device can read it.
- *
- * @return true when the clipboard accepted the value
- */
- private fun copyToClipboard(text: String): Boolean {
- val clipboard = requireContext()
- .getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return false
- return runCatching {
- clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.app_name), text))
- }.isSuccess
- }
-
- /**
- * Density-independent [dp] as whole pixels, for the views this screen builds in code. The
- * `setPadding` family takes raw pixels, so a literal shrinks as screen density rises.
- *
- * @param dp the density-independent size to convert
- * @return the equivalent size in device pixels
- */
- private fun dp(dp: Int): Int = (dp * resources.displayMetrics.density).roundToInt()
-
- private fun createModelSelectionUi(parent: View): LinearLayout {
- val context = requireContext()
- val container = LinearLayout(context).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(0, dp(32), 0, 0)
- }
-
- // Add title
- val titleText = TextView(context).apply {
- text = getString(R.string.label_gemini_model)
- textSize = 16f
- setPadding(0, 0, 0, dp(16))
- }
- container.addView(titleText)
-
- // Add current model display
- val currentModelText = TextView(context).apply {
- id = View.generateViewId()
- text = getString(R.string.current_model, viewModel.getGeminiModel())
- setPadding(0, 0, 0, dp(8))
- }
- container.addView(currentModelText)
-
- // Add model spinner
- val modelSpinner = Spinner(context).apply {
- id = View.generateViewId()
- }
- container.addView(modelSpinner)
-
- // Add refresh button
- val refreshButton = Button(context).apply {
- id = View.generateViewId()
- text = getString(R.string.refresh_models)
- }
- container.addView(refreshButton)
-
- // Find the parent container and add this
- if (parent is ViewGroup) {
- parent.addView(container)
+ /** Removes a mounted pane, so a placeholder is not drawn behind the previous backend's UI. */
+ private fun clearPane() {
+ val pane = childFragmentManager.findFragmentByTag(TAG_BACKEND_PANE) ?: return
+ childFragmentManager.commit {
+ setReorderingAllowed(true)
+ remove(pane)
}
-
- // Tag the views for later reference
- container.tag = "model_container"
- currentModelText.tag = "current_model_text"
- modelSpinner.tag = "model_spinner"
- refreshButton.tag = "refresh_button"
-
- return container
}
- @SuppressLint("ClickableViewAccessibility")
- private fun setupGeminiModelSelection(container: View) {
- val currentModelText = container.findViewWithTag("current_model_text")
- val modelSpinner = container.findViewWithTag("model_spinner")
- val refreshButton = container.findViewWithTag("refresh_button")
-
- 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
-
- // Setup spinner
- fun updateModelSpinner(models: List, isLive: Boolean) {
- val adapter = ArrayAdapter(
- requireContext(),
- android.R.layout.simple_spinner_item,
- models
- )
- adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
- modelSpinner.adapter = adapter
-
- // Migrate off a retired saved model only for a live catalog, never for the fallback.
- val currentModel = viewModel.getGeminiModel()
- val currentIndex = models.indexOf(currentModel)
- when {
- currentIndex >= 0 -> modelSpinner.setSelection(currentIndex)
- isLive && models.isNotEmpty() -> {
- modelSpinner.setSelection(0)
- val migrated = models[0]
- viewModel.saveGeminiModel(migrated)
- currentModelText?.text = getString(R.string.current_model, migrated)
- }
- }
- }
-
- // Observe models
- viewModel.geminiModels.observe(viewLifecycleOwner) { options ->
- if (options.models.isNotEmpty()) {
- updateModelSpinner(options.models, options.isLive)
- }
- }
-
- // Observe loading state
- viewModel.geminiModelsLoading.observe(viewLifecycleOwner) { isLoading ->
- refreshButton.isEnabled = !isLoading
- refreshButton.text = if (isLoading) getString(R.string.loading) else getString(R.string.refresh_models)
- }
-
- modelSpinner.setOnTouchListener { _, _ ->
- userTouchedSpinner = true
- false
- }
-
- // Handle model selection
- modelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
- // Ignore programmatic selections; only a real user pick persists the model.
- if (!userTouchedSpinner) return
- val selectedModel = parent?.getItemAtPosition(position) as? String
- if (selectedModel != null && selectedModel != viewModel.getGeminiModel()) {
- viewModel.saveGeminiModel(selectedModel)
- currentModelText?.text = getString(R.string.current_model, selectedModel)
- Toast.makeText(requireContext(), getString(R.string.model_changed, selectedModel), Toast.LENGTH_SHORT).show()
- }
+ /** Puts [message] in the pane container, for the states where there is no pane to show. */
+ private fun showPlaceholder(message: String) {
+ val padding = (16 * resources.displayMetrics.density).roundToInt()
+ backendSpecificContainer.removeAllViews()
+ backendSpecificContainer.addView(
+ TextView(requireContext()).apply {
+ text = message
+ setPadding(padding, padding, padding, padding)
}
-
- override fun onNothingSelected(parent: AdapterView<*>?) {}
- }
-
- // Handle refresh button
- refreshButton.setOnClickListener {
- viewModel.fetchGeminiModels()
- }
-
- // Initial fetch
- if (viewModel.geminiModels.value?.models.isNullOrEmpty()) {
- viewModel.fetchGeminiModels()
- }
- }
-}
-
-/**
- * Factory for creating AiSettingsViewModel with PluginContext dependency.
- */
-class AiSettingsViewModelFactory(
- private val getContext: () -> PluginContext?
-) : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun create(modelClass: Class): T {
- if (modelClass.isAssignableFrom(AiSettingsViewModel::class.java)) {
- return AiSettingsViewModel(getContext) as T
- }
- throw IllegalArgumentException("Unknown ViewModel class")
+ )
}
}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt
deleted file mode 100644
index b03d6134..00000000
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiCatalogGateway.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.gemini
-
-import com.itsaky.androidide.plugins.PluginLogger
-import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
-import com.itsaky.androidide.plugins.services.LlmInferenceService
-import com.itsaky.androidide.plugins.services.SharedServices
-import java.lang.reflect.InvocationTargetException
-import java.util.concurrent.CancellationException
-import java.util.concurrent.CompletableFuture
-import java.util.concurrent.ExecutionException
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.TimeoutException
-
-/**
- * The one place ai-assistant asks ai-core's Gemini backend for a model catalog.
- *
- * An abstraction the ViewModel can fake in tests, so the unchecked cross-classloader contract
- * lives behind a single seam that fails in one recognisable way.
- */
-interface GeminiCatalogGateway {
-
- /**
- * Models available to the key currently saved on disk. Used to populate the model picker,
- * where "which key" is never in question.
- */
- fun listModelsForSavedKey(): CatalogResult
-
- /**
- * Models available to [apiKey], which need not be — and during key entry is not — the saved
- * one. This is what makes checking a key before persisting it possible.
- */
- fun listModels(apiKey: String): CatalogResult
-}
-
-/**
- * [GeminiCatalogGateway] over ai-core's `GeminiBackend`, reached by reflection.
- *
- * `listModels` isn't on [LlmInferenceService.LlmBackend], so this is an unchecked contract: every
- * break is a [CatalogResult.Failed], never an empty catalog that would read as "this key works".
- *
- * @param backendProvider resolves the "gemini" backend; injectable so tests need no SharedServices
- */
-class ReflectiveGeminiCatalogGateway(
- private val backendProvider: () -> Any? = ::resolveGeminiBackend
-) : GeminiCatalogGateway {
-
- companion object {
- private const val TAG = "GeminiCatalogGateway"
-
- /** Backend id registered by ai-core's `GeminiBackend.getId()`. */
- private const val BACKEND_ID = "gemini"
-
- private const val METHOD_LIST_MODELS = "listModels"
-
- /**
- * Failsafe cap, well above ai-core's own budget (15 s connect + 15 s read, paginated) so a
- * slow-but-live fetch is never truncated. Bounds a future that may never complete, such as
- * one from an already-cancelled ai-core scope; not the expected wait.
- */
- private const val LIST_MODELS_TIMEOUT_SECONDS = 60L
-
- /** Default [backendProvider]: the live lookup through the shared service registry. */
- private fun resolveGeminiBackend(): Any? =
- SharedServices.get(LlmInferenceService::class.java)?.getBackend(BACKEND_ID)
- }
-
- /**
- * This plugin's IDE-surfaced log, so a broken cross-plugin contract shows up in the IDE's own
- * log view rather than only in logcat. Null before `initialize()` and in JVM tests.
- */
- private val logger: PluginLogger?
- get() = AiAssistantPlugin.getContext()?.logger
-
- override fun listModelsForSavedKey(): CatalogResult =
- callListModels(paramTypes = emptyArray(), args = emptyArray())
-
- /**
- * No fallback to the no-arg `listModels()` when ai-core is too old to have this overload: that
- * authenticates with the *saved* key, clearing a candidate on a different credential.
- */
- override fun listModels(apiKey: String): CatalogResult =
- callListModels(paramTypes = arrayOf(String::class.java), args = arrayOf(apiKey))
-
- /**
- * Invoke `listModels` with the given signature and await its future.
- *
- * Blocks on [CompletableFuture.get], so call it from an IO dispatcher — never the main thread.
- */
- private fun callListModels(paramTypes: Array>, args: Array): CatalogResult {
- val backend = try {
- backendProvider()
- } catch (e: Exception) {
- logger?.error("$TAG: could not resolve the '$BACKEND_ID' backend", e)
- return CatalogResult.Failed(e)
- } ?: return CatalogResult.NoBackend
-
- val method = try {
- backend.javaClass.getMethod(METHOD_LIST_MODELS, *paramTypes)
- } catch (e: NoSuchMethodException) {
- val signature = paramTypes.joinToString { it.simpleName }
- logger?.error(
- "$TAG: ai-core's ${backend.javaClass.name} has no " +
- "$METHOD_LIST_MODELS($signature): the cross-plugin contract changed. Expected " +
- "`fun listModels($signature): CompletableFuture>`.",
- e
- )
- return CatalogResult.Failed(e)
- }
-
- val raw = try {
- method.invoke(backend, *args)
- } catch (e: InvocationTargetException) {
- // Unwrap: the interesting failure is the one listModels threw, not the wrapper.
- val cause = e.cause ?: e
- logger?.error("$TAG: $METHOD_LIST_MODELS threw", cause)
- return CatalogResult.Failed(cause)
- } catch (e: Exception) {
- logger?.error("$TAG: could not invoke $METHOD_LIST_MODELS", e)
- return CatalogResult.Failed(e)
- }
-
- @Suppress("UNCHECKED_CAST")
- val future = raw as? CompletableFuture>
- if (future == null) {
- val message =
- "$METHOD_LIST_MODELS returned ${raw?.javaClass?.name}, expected CompletableFuture"
- logger?.error("$TAG: $message")
- return CatalogResult.Failed(IllegalStateException(message))
- }
-
- return try {
- CatalogResult.Success(future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS).orEmpty())
- } catch (e: ExecutionException) {
- // The API failure ai-core reported; its message carries the HTTP status.
- CatalogResult.Failed(e.cause ?: e)
- } catch (e: CancellationException) {
- logger?.warn("$TAG: $METHOD_LIST_MODELS was cancelled by ai-core", e)
- CatalogResult.Failed(e)
- } catch (e: TimeoutException) {
- future.cancel(true)
- logger?.error(
- "$TAG: $METHOD_LIST_MODELS did not complete within " +
- "${LIST_MODELS_TIMEOUT_SECONDS}s; is ai-core still active?",
- e
- )
- CatalogResult.Failed(e)
- } catch (e: InterruptedException) {
- // Restore the flag so the cancelled coroutine's thread still sees it.
- Thread.currentThread().interrupt()
- future.cancel(true)
- CatalogResult.Failed(e)
- }
- }
-}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
deleted file mode 100644
index 91bbc0dc..00000000
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
+++ /dev/null
@@ -1,145 +0,0 @@
-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
-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-core's
- * `SecureApiKeyStore` so a key written here can be decrypted there — 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
- }
-}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/util/ByteSize.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/util/ByteSize.kt
deleted file mode 100644
index cf89ba5a..00000000
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/util/ByteSize.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.util
-
-import java.util.Locale
-
-/**
- * Formats byte counts for display, in binary units and the US locale. Mirrors ai-core's `ByteSize`,
- * unreachable from here across isolated plugin classloaders — keep the units in step, since both
- * describe the same memory and mixing binary with decimal would read as a contradiction.
- */
-internal object ByteSize {
-
- private const val BYTES_PER_MB = 1024.0 * 1024.0
- private const val BYTES_PER_GB = BYTES_PER_MB * 1024.0
-
- /**
- * Formats [bytes] with the largest unit that keeps the figure meaningful; sub-gigabyte values
- * stay in MB, since "0.3 GB free" reads as a broken string rather than as a shortage.
- *
- * @param bytes a byte count
- * @return the size as a one-decimal "X.X GB" or "X.X MB" string
- */
- fun format(bytes: Long): String =
- if (bytes >= BYTES_PER_GB) String.format(Locale.US, "%.1f GB", bytes / BYTES_PER_GB)
- else String.format(Locale.US, "%.1f MB", bytes / BYTES_PER_MB)
-}
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
deleted file mode 100644
index 0f5149b2..00000000
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
+++ /dev/null
@@ -1,552 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.viewmodel
-
-import android.content.Context
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.itsaky.androidide.plugins.aiassistant.gemini.CatalogResult
-import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiCatalogGateway
-import com.itsaky.androidide.plugins.aiassistant.gemini.KeyVerification
-import com.itsaky.androidide.plugins.aiassistant.gemini.ReflectiveGeminiCatalogGateway
-import com.itsaky.androidide.plugins.aiassistant.gemini.toKeyVerification
-import com.itsaky.androidide.plugins.aiassistant.memory.DeviceMemory
-import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryEstimator
-import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryGate
-import com.itsaky.androidide.plugins.aiassistant.memory.SystemDeviceMemory
-import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
-import com.itsaky.androidide.plugins.aiassistant.R
-import com.itsaky.androidide.plugins.aiassistant.util.ByteSize
-import com.itsaky.androidide.plugins.aiassistant.util.ContentModelFileSource
-import com.itsaky.androidide.plugins.aiassistant.util.GgufFileInspector
-import com.itsaky.androidide.plugins.aiassistant.util.GgufHeaderReader
-import com.itsaky.androidide.plugins.aiassistant.util.ModelFileInfo
-import com.itsaky.androidide.plugins.aiassistant.util.ModelFileSource
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import com.itsaky.androidide.plugins.PluginContext
-import com.itsaky.androidide.plugins.PluginLogger
-
-/**
- * State for the model file loading.
- */
-sealed class ModelLoadingState {
- object Idle : ModelLoadingState()
- object Loading : ModelLoadingState()
- data class Loaded(val modelName: String) : ModelLoadingState()
- data class Error(val message: String) : ModelLoadingState()
-}
-
-/**
- * State for the inference engine initialization.
- */
-sealed class EngineState {
- object Uninitialized : EngineState()
- object Initializing : EngineState()
- object Initialized : EngineState()
- data class Error(val message: String) : EngineState()
-}
-
-/**
- * Available AI backends.
- */
-enum class AiBackend(val displayName: String) {
- LOCAL_LLM("Local LLM"),
- GEMINI("Gemini API")
-}
-
-/**
- * Gemini models to offer, plus whether they came from a live catalog fetch (vs the fallback list).
- * Migrate a saved-but-missing model off the list only when [isLive] is true.
- *
- * @param models model ids to display in the picker
- * @param isLive true if [models] is a confirmed live catalog, false for the offline fallback
- */
-data class GeminiModelOptions(val models: List, val isLive: Boolean)
-
-/**
- * A selected model that may not fit in this device's memory, with the figures to show the user.
- *
- * @param modelName the model's display name
- * @param loadBytes memory the weights need
- * @param runBytes memory the KV cache and compute buffers need on top of the weights
- * @param availableBytes free RAM when the check ran
- * @param severity whether the shortfall makes failure likely or merely possible
- */
-data class ModelMemoryWarning(
- val modelName: String,
- val loadBytes: Long,
- val runBytes: Long,
- val availableBytes: Long,
- val severity: ModelMemoryGate.Severity,
-)
-
-/**
- * @param deviceMemory free-RAM reading for the pre-flight; null builds the live one, which cannot
- * be a default argument because it needs [logger], and a default cannot reach an instance member
- * @param modelFiles reads a selected model's name, size and bytes; null builds the live one
- */
-class AiSettingsViewModel(
- private val getContext: () -> PluginContext?,
- private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
- private val catalogGateway: GeminiCatalogGateway = ReflectiveGeminiCatalogGateway(),
- deviceMemory: DeviceMemory? = null,
- modelFiles: ModelFileSource? = null,
-) : ViewModel() {
-
- private val deviceMemory: DeviceMemory = deviceMemory ?: SystemDeviceMemory(
- contextProvider = { getContext()?.androidContext },
- onReadError = { e -> logger?.warn("$TAG: could not read free memory", e) },
- )
-
- private val modelFiles: ModelFileSource = modelFiles ?: ContentModelFileSource { what, e ->
- logger?.warn("$TAG: $what", e)
- }
-
- companion object {
- private const val TAG = "AiSettingsViewModel"
-
- /** Default selection; kept in sync with GeminiBackend.DEFAULT_MODEL. */
- private const val DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
-
- /** Shown only when the live catalog can't be fetched — current models, no retired ones. */
- private val FALLBACK_MODELS = listOf(
- "gemini-2.5-flash",
- "gemini-2.5-pro",
- "gemini-2.0-flash",
- )
- }
-
- /**
- * True between tapping *Get API Key* and the settings screen's next resume, so the UI can
- * point at the next step once the user is back from AI Studio. Held here rather than on the
- * fragment so a rotation while the browser is in front doesn't reset it and swallow the hint.
- */
- var sentUserToAiStudio: Boolean = false
-
- private val _savedModelPath = MutableLiveData(null)
- val savedModelPath: LiveData get() = _savedModelPath
-
- private val _modelLoadingState = MutableLiveData(ModelLoadingState.Idle)
- val modelLoadingState: LiveData get() = _modelLoadingState
-
- private val _engineState = MutableLiveData(EngineState.Initialized)
- val engineState: LiveData get() = _engineState
-
- /** The memory pre-flight's consent gate; see [loadModelFromUri]. */
- private val memoryConfirmation = UserConfirmation()
-
- /**
- * Models that may not fit in memory, to be put to the user as a warning. One-shot events: each
- * is delivered once, and the answer comes back through [onMemoryWarningDecision].
- */
- val modelMemoryWarnings: Flow get() = memoryConfirmation.requests
-
- /**
- * Whether a memory warning is actually waiting on an answer. False after process death, where
- * the dialog is restored by the framework but the load that raised it is long gone — the UI
- * uses this to drop a dialog whose answer nobody would receive.
- */
- val hasPendingMemoryWarning: Boolean get() = memoryConfirmation.hasOutstandingRequest
-
- init {
- checkInitialState()
- }
-
- private fun checkInitialState() {
- val prefs = getPluginPrefs()
- val savedPath = prefs?.getString("local_llm_model_path", null)
- _savedModelPath.value = savedPath
-
- // For plugin, engine is always "ready" since it's managed by ai-core plugin
- _engineState.value = EngineState.Initialized
- _modelLoadingState.value = modelStateFor(savedPath)
- }
-
- /**
- * The state describing the model that is actually configured. Built from the name persisted at
- * load time, so it needs no provider query.
- *
- * @param savedPath the stored model path, or null when none is configured
- */
- private fun modelStateFor(savedPath: String?): ModelLoadingState =
- if (savedPath != null) {
- ModelLoadingState.Loaded(getSavedModelName() ?: fallbackDisplayName(savedPath))
- } else {
- ModelLoadingState.Idle
- }
-
- /**
- * This plugin's settings store — and, for the Gemini keys, ai-core's too.
- *
- * ai-core resolves this plugin's `PluginContext` and asks for the same name, so both sides
- * share one process-wide `SharedPreferencesImpl`: writes here need no flush to be visible.
- */
- private fun getPluginPrefs() = getContext()?.getPluginSharedPreferences("AgentSettings")
-
- /**
- * This plugin's IDE-surfaced log, so settings diagnostics land in the IDE's own log view rather
- * than only in logcat. Null before `initialize()` and in JVM tests.
- */
- private val logger: PluginLogger?
- get() = getContext()?.logger
-
- /** Human-readable name persisted alongside the model path at load time, if any. */
- fun getSavedModelName(): String? =
- getPluginPrefs()?.getString("local_llm_model_name", null)?.takeIf { it.isNotBlank() }
-
- private fun saveLocalModelName(name: String?) {
- getPluginPrefs()?.edit()?.putString("local_llm_model_name", name)?.apply()
- }
-
- /** Decoded last path segment — a cheap fallback that at least avoids raw %3A escapes. */
- fun fallbackDisplayName(uriOrPath: String): String = modelFiles.fallbackDisplayName(uriOrPath)
-
- fun getAvailableBackends(): List = AiBackend.entries
-
- fun saveBackend(backend: AiBackend) {
- getPluginPrefs()?.edit()?.apply {
- putString("ai_backend_preference", backend.name)
- apply()
- }
- }
-
- fun getCurrentBackend(): AiBackend {
- val backendName = getPluginPrefs()?.getString("ai_backend_preference", "LOCAL_LLM")
- return try {
- AiBackend.valueOf(backendName ?: "LOCAL_LLM")
- } catch (e: Exception) {
- AiBackend.LOCAL_LLM
- }
- }
-
- fun saveLocalModelPath(path: String) {
- getPluginPrefs()?.edit()?.apply {
- putString("local_llm_model_path", path)
- apply()
- }
- // Use postValue instead of value since this can be called from background threads
- _savedModelPath.postValue(path)
- }
-
- fun getLocalModelPath(): String? {
- return getPluginPrefs()?.getString("local_llm_model_path", null)
- }
-
- fun saveLocalModelSha256(hash: String?) {
- getPluginPrefs()?.edit()?.apply {
- putString("local_llm_model_sha256", hash?.trim() ?: "")
- apply()
- }
- }
-
- fun getLocalModelSha256(): String? {
- return getPluginPrefs()?.getString("local_llm_model_sha256", null)
- ?.takeIf { it.isNotBlank() }
- }
-
- fun setUseSimpleLocalPrompt(enabled: Boolean) {
- getPluginPrefs()?.edit()?.apply {
- putBoolean("use_simple_local_prompt", enabled)
- apply()
- }
- }
-
- fun isUseSimpleLocalPromptEnabled(): Boolean {
- return getPluginPrefs()?.getBoolean("use_simple_local_prompt", true) ?: true
- }
-
- /**
- * Check whether [apiKey] actually works, without storing it anywhere.
- *
- * Asks ai-core to list the models the candidate key can reach; see [KeyVerification] for what
- * each verdict establishes. Run this *before* [saveGeminiApiKey]. The key is never logged.
- *
- * @param apiKey the candidate key as typed, trimmed here
- * @return the verdict; [KeyVerification.Unknown] when nothing could be established
- */
- suspend fun verifyGeminiKey(apiKey: String): KeyVerification = withContext(ioDispatcher) {
- val candidate = apiKey.trim()
- if (candidate.isEmpty()) return@withContext KeyVerification.Rejected
- val result = try {
- catalogGateway.listModels(candidate)
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- // Last-resort net: a verification crash must never be mistaken for a pass.
- logger?.error("$TAG: Gemini key verification failed unexpectedly", e)
- CatalogResult.Failed(e)
- }
- result.toKeyVerification().also { verification ->
- // Diagnostic only: saving a key is not a request for a catalog, so the UI omits this.
- if (verification is KeyVerification.Verified) {
- logger?.debug(
- "$TAG: Gemini key verified against ${verification.modelCount} " +
- "chat-capable models"
- )
- }
- }
- }
-
- /**
- * Encrypts [apiKey] via [SecureApiKeyStore] and persists only the ciphertext to private prefs,
- * off the main thread. Nothing is written on failure. Kept separate from [verifyGeminiKey]: a
- * rejected key never reaches here, and an unverifiable one only after the user says so.
- *
- * @param apiKey the plaintext key to store (trimmed before encryption)
- * @param verified true when [verifyGeminiKey] confirmed this key; recorded in the same write so
- * the flag can never outlive or precede the key it describes
- * @return true only if the key was both encrypted and persisted
- */
- suspend fun saveGeminiApiKey(apiKey: String, verified: Boolean = false): Boolean =
- withContext(ioDispatcher) {
- // Checked first, or the UI would claim an unwritten key was saved.
- val prefs = getPluginPrefs()
- if (prefs == null) {
- logger?.error("$TAG: cannot save Gemini API key: plugin preferences unavailable")
- return@withContext false
- }
- val encrypted = try {
- SecureApiKeyStore.encrypt(apiKey.trim())
- } catch (e: Exception) {
- logger?.error("$TAG: failed to encrypt Gemini API key", e)
- return@withContext false
- }
- // commit(), not apply(): only a synchronous write can honestly return "persisted".
- prefs.edit()
- .putString("gemini_api_key", encrypted)
- .putLong("gemini_api_key_timestamp", System.currentTimeMillis())
- .putBoolean("gemini_api_key_verified", verified)
- .commit()
- }
-
- /**
- * Whether the stored key was confirmed working by Google when it was saved.
- *
- * False for a key kept after an inconclusive check, so the status line can say "saved" without
- * claiming "verified". Raw pref only, so safe on the main thread.
- */
- fun isGeminiKeyVerified(): Boolean =
- getPluginPrefs()?.getBoolean("gemini_api_key_verified", false) ?: false
-
- /**
- * Decrypt the stored key off the main thread (Keystore IPC + AES/GCM), upgrading a
- * pre-encryption plaintext key to ciphertext in passing so existing installs actually
- * 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")
- }
-
- /**
- * 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
- }
-
- fun clearGeminiApiKey() {
- getPluginPrefs()?.edit()?.apply {
- remove("gemini_api_key")
- remove("gemini_api_key_timestamp")
- // Removed with the key, or the next saved key would inherit this one's verdict.
- remove("gemini_api_key_verified")
- apply()
- }
- }
-
- fun saveGeminiModel(model: String) {
- getPluginPrefs()?.edit()?.apply {
- putString("gemini_model", model)
- apply()
- }
- }
-
- fun getGeminiModel(): String {
- return getPluginPrefs()?.getString("gemini_model", DEFAULT_GEMINI_MODEL) ?: DEFAULT_GEMINI_MODEL
- }
-
- private val _geminiModels = MutableLiveData(GeminiModelOptions(emptyList(), isLive = false))
- val geminiModels: LiveData get() = _geminiModels
-
- private val _geminiModelsLoading = MutableLiveData(false)
- val geminiModelsLoading: LiveData get() = _geminiModelsLoading
-
- /**
- * Ask ai-core's Gemini backend for the models the current API key can actually
- * use, and publish them to [geminiModels]. Falls back to [FALLBACK_MODELS] (current
- * models only — never a retired one) when there is no key, no backend, or the live
- * lookup fails, so the picker is never populated with a model that would 404.
- */
- fun fetchGeminiModels() {
- viewModelScope.launch(Dispatchers.IO) {
- _geminiModelsLoading.postValue(true)
-
- try {
- val apiKey = getGeminiApiKey()?.trim()
- if (apiKey.isNullOrBlank()) {
- logger?.warn("$TAG: no Gemini API key saved; showing fallback models")
- _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
- return@launch
- }
-
- when (val result = catalogGateway.listModelsForSavedKey()) {
- is CatalogResult.Success -> {
- if (result.models.isEmpty()) {
- logger?.warn("$TAG: live model list empty; showing fallback models")
- _geminiModels.postValue(
- GeminiModelOptions(FALLBACK_MODELS, isLive = false)
- )
- } else {
- logger?.debug("$TAG: fetched ${result.models.size} Gemini models")
- _geminiModels.postValue(
- GeminiModelOptions(result.models, isLive = true)
- )
- }
- }
- // Logged by the gateway; degrade to current-models-only, never a 404 model.
- CatalogResult.NoBackend, is CatalogResult.Failed ->
- _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- logger?.error("$TAG: error fetching Gemini models", e)
- _geminiModels.postValue(GeminiModelOptions(FALLBACK_MODELS, isLive = false))
- } finally {
- _geminiModelsLoading.postValue(false)
- }
- }
- }
-
- /**
- * Saves the selected model's path; ai-core's `LocalLlmBackend` does the loading itself. That
- * write is what makes it load, so the memory pre-flight gates it: a model the user declines is
- * never stored, and therefore never loaded (ADFA-1798).
- *
- * @param uriString the selected model, as a `content://` URI or a filesystem path
- * @param context resolves the model's display name, size and header
- */
- fun loadModelFromUri(uriString: String, context: Context) {
- viewModelScope.launch(ioDispatcher) {
- _modelLoadingState.postValue(ModelLoadingState.Loading)
-
- try {
- // One lookup for both: the real file name to show, and the size to estimate from.
- val fileInfo = modelFiles.info(context, uriString)
- val fileName = fileInfo.displayName
-
- // Rejected up front, so no bad path is persisted or shown as "Loaded".
- if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) {
- _modelLoadingState.postValue(
- ModelLoadingState.Error(context.getString(R.string.error_model_not_gguf, fileName))
- )
- return@launch
- }
-
- if (!confirmMemoryHeadroom(uriString, fileInfo, context)) {
- logger?.info("$TAG: model declined at the memory warning: $fileName")
- // Never the configured model: re-checking it and declining must not revoke it.
- if (uriString != getLocalModelPath()) {
- modelFiles.releaseAccess(context, uriString)
- }
- restoreSavedModelState()
- return@launch
- }
-
- // Persist the name before the path so the savedModelPath observer can read it.
- saveLocalModelName(fileName)
- saveLocalModelPath(uriString)
-
- // Nothing is loaded here; ai-core reads this path when it needs the model.
- _modelLoadingState.postValue(
- ModelLoadingState.Loaded(fileName)
- )
-
- logger?.debug("$TAG: model path saved: $uriString ($fileName)")
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- logger?.error("$TAG: error saving model path", e)
- _modelLoadingState.postValue(
- ModelLoadingState.Error("Failed to save model path: ${e.message}")
- )
- }
- }
- }
-
- /**
- * Answers an outstanding [modelMemoryWarnings] question. Safe to call from the main thread, and
- * a no-op when nothing is waiting.
- *
- * @param proceed true to load the model anyway, false to abandon the selection
- */
- fun onMemoryWarningDecision(proceed: Boolean) {
- memoryConfirmation.answer(proceed)
- }
-
- /**
- * Checks the model against free RAM and, when it looks too large, asks the user whether to go
- * ahead. Fails OPEN: an unreadable size or header means no warning rather than a wrong one.
- *
- * @return true to continue with this model
- */
- private suspend fun confirmMemoryHeadroom(
- uriString: String,
- fileInfo: ModelFileInfo,
- context: Context
- ): Boolean {
- val modelName = fileInfo.displayName
- val estimate = ModelMemoryEstimator.estimate(
- fileSizeBytes = fileInfo.sizeBytes,
- header = GgufHeaderReader.read { modelFiles.openStream(context, uriString) },
- )
- // Read last and never cached: the user may have just closed apps to make room.
- val availableBytes = deviceMemory.availableBytes()
-
- return when (val verdict = ModelMemoryGate.evaluate(estimate, availableBytes)) {
- ModelMemoryGate.Verdict.Safe -> true
-
- ModelMemoryGate.Verdict.Unknown -> {
- val missing = if (estimate == null) "the model's size" else "free memory"
- logger?.warn("$TAG: could not read $missing; skipping the pre-flight for $modelName")
- true
- }
-
- is ModelMemoryGate.Verdict.Risky -> {
- logger?.warn(
- "$TAG: $modelName may not fit: needs ${ByteSize.format(verdict.estimate.loadBytes)}" +
- " + ${ByteSize.format(verdict.estimate.runBytes)} to run," +
- " ${ByteSize.format(verdict.availableBytes)} free (${verdict.severity})"
- )
- memoryConfirmation.ask(
- ModelMemoryWarning(
- modelName = modelName,
- loadBytes = verdict.estimate.loadBytes,
- runBytes = verdict.estimate.runBytes,
- availableBytes = verdict.availableBytes,
- severity = verdict.severity,
- )
- )
- }
- }
- }
-
- /**
- * Republishes the model that is actually configured, so abandoning a selection leaves the
- * screen describing the previous model rather than the one that was never stored.
- */
- private fun restoreSavedModelState() {
- _modelLoadingState.postValue(modelStateFor(getLocalModelPath()))
- }
-
-}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
index a742fde8..13310254 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiassistant.R
+import com.itsaky.androidide.plugins.aiassistant.backends.BackendRegistry
import com.itsaky.androidide.plugins.aiassistant.models.AgentState
import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage
import com.itsaky.androidide.plugins.aiassistant.models.ChatSession
@@ -66,7 +67,7 @@ class ChatViewModel(
const val RESPOND_TOOL = "respond"
/**
- * [LlmConfig.extraParams] key for the local-backend GBNF; must match ai-core's
+ * [LlmConfig.extraParams] key for the local-backend GBNF; must match ai-backend-local's
* `LocalLlmBackend.EXTRA_PARAM_GRAMMAR`.
*/
private const val EXTRA_PARAM_GRAMMAR = "grammar"
@@ -74,8 +75,16 @@ class ChatViewModel(
/** Per-argument cap in the tool badge shown in the transcript. */
private const val TOOL_BADGE_ARG_LIMIT = 80
- /** Near-greedy sampling for local models, whose tool arguments must be copied, not invented. */
- private const val LOCAL_TEMPERATURE = 0.15f
+ /**
+ * The call envelope this side parses back (see [ToolCallExtractor]) and constrains local
+ * sampling to (see [buildLocalToolCallGrammar]). Handed to every backend composing a system
+ * prompt, so all three can never drift apart.
+ */
+ const val TOOL_CALL_SYNTAX =
+ """{"tool":"TOOL_NAME","args":{"arg":"value"}} """
+
+ /** Sampling temperature for a backend that declares no preference of its own. */
+ private const val DEFAULT_TEMPERATURE = 0.2f
/** Max open files named in the prompt's IDE-context block. */
private const val MAX_CONTEXT_OPEN_FILES = 8
@@ -156,12 +165,14 @@ class ChatViewModel(
val activeBackendLabel: StateFlow = _activeBackendLabel.asStateFlow()
private fun selectedBackendLabel(): String {
- val pref = getContext()?.getPluginSharedPreferences("AgentSettings")
- ?.getString("ai_backend_preference", "LOCAL_LLM")
- return when (pref) {
- "GEMINI" -> "Gemini API"
- else -> "Local LLM"
- }
+ val backends = BackendRegistry.options()
+ val selectedId = BackendRegistry.selectedId()
+ // Falls back to the first installed backend, matching how the settings screen resolves a
+ // selection whose backend was uninstalled.
+ val selected = backends.firstOrNull { it.id == selectedId } ?: backends.firstOrNull()
+ return selected?.displayName
+ ?: getContext()?.androidContext?.getString(R.string.backend_none_installed_short)
+ ?: ""
}
/** Re-read the selected backend and update [activeBackendLabel]; call when returning to chat. */
@@ -336,20 +347,108 @@ class ChatViewModel(
}
/**
- * Build appropriate system prompt based on LLM backend.
+ * Builds the system prompt for the active backend.
+ *
+ * The wording comes from the backend, which knows its own model; this side supplies the tool
+ * contract and appends the IDE context. A backend with no prompt of its own gets
+ * [buildDefaultSystemPrompt], so a third-party `.cgp` works without shipping prompt text.
*/
private suspend fun buildSystemPrompt(): String {
// One editor read serves both the IDE CONTEXT block and the paths in the examples.
val ide = readIdeSnapshot()
val examplePath = ide.exampleFilePath()
- val base = if (currentBackendId == "gemini") {
- buildSystemPromptGemini(examplePath)
- } else {
- buildSystemPromptLocal(examplePath)
- }
+ val base = backendSystemPrompt(examplePath) ?: buildDefaultSystemPrompt(examplePath)
return base + ide.contextBlock()
}
+ /**
+ * Asks the active backend for its system prompt.
+ *
+ * @return the backend's prompt, or null when it has none, is unreachable, or throws — one bad
+ * backend must degrade to the default prompt, not break every message
+ */
+ private fun backendSystemPrompt(examplePath: String): String? {
+ val backend = try {
+ getLlmService()?.getBackend(currentBackendId)
+ } catch (e: Throwable) {
+ android.util.Log.w("ChatViewModel", "Could not resolve backend '$currentBackendId'", e)
+ null
+ } ?: return null
+
+ return try {
+ backend.getSystemPrompt(
+ LlmInferenceService.SystemPromptRequest(
+ promptToolDefinitions(),
+ TOOL_CALL_SYNTAX,
+ examplePath,
+ )
+ )?.takeIf { it.isNotBlank() }
+ } catch (e: Throwable) {
+ android.util.Log.w(
+ "ChatViewModel",
+ "Backend '$currentBackendId' failed to supply a system prompt; using the default",
+ e
+ )
+ null
+ }
+ }
+
+ /**
+ * The sampling temperature the active backend asks for.
+ *
+ * @return the backend's preference, or null when it declares none or cannot be reached
+ */
+ private fun backendTemperature(): Float? = try {
+ getLlmService()?.getBackend(currentBackendId)?.defaultTemperature
+ } catch (e: Throwable) {
+ android.util.Log.w("ChatViewModel", "Backend '$currentBackendId' failed to supply a temperature", e)
+ null
+ }
+
+ /**
+ * The tools to present in the system prompt: every registered handler, plus [RESPOND_TOOL],
+ * which is not a handler but is how the model addresses the user.
+ */
+ private fun promptToolDefinitions(): List =
+ toolRouter.getAllHandlers().map { handler ->
+ LlmInferenceService.ToolDefinition(handler.toolName, handler.description, emptyMap())
+ } + LlmInferenceService.ToolDefinition(
+ RESPOND_TOOL,
+ "Send the user your reply or final answer. It MUST carry a \"message\" holding the " +
+ "text itself — a respond call with no \"message\" shows the user nothing.",
+ emptyMap(),
+ )
+
+ /**
+ * Prompt used for a backend that supplies none of its own.
+ *
+ * Deliberately short: it states the protocol this side parses and nothing about model
+ * behaviour, which is the part only the backend can know. A backend that needs more should
+ * override `getSystemPrompt`.
+ */
+ private fun buildDefaultSystemPrompt(examplePath: String): String {
+ val toolDescriptions = promptToolDefinitions()
+ .joinToString("\n") { "- ${it.name}: ${it.description}" }
+
+ return """
+ You are a coding assistant inside CodeOnTheGo.
+
+ Reply with exactly ONE tool call and nothing else. After a tool call, stop and wait — the
+ real result arrives next turn. Never invent tool output, and never claim an action you did
+ not perform through a tool. For a greeting or a question you can answer directly, use
+ "$RESPOND_TOOL".
+
+ Tools:
+ $toolDescriptions
+
+ TOOL CALL FORMAT — emit a single line in EXACTLY this format and nothing after it:
+ $TOOL_CALL_SYNTAX
+
+ Example:
+ {"tool":"open_file","args":{"file_path":"$examplePath"}}
+ """.trimIndent()
+ }
+
/**
* What the IDE has open, project-relative, read once per prompt.
* @property currentFile the focused file, or null when nothing is open.
@@ -415,131 +514,6 @@ class ChatViewModel(
}
}
- /**
- * System prompt for Gemini (high autonomy, structured tool calling via native functions).
- * @param examplePath path shown in the tool-call examples — this project's own open file when
- * there is one, so the examples never imply a language or layout the project doesn't have.
- */
- private fun buildSystemPromptGemini(examplePath: String): String {
- val toolDescriptions = toolRouter.getAllHandlers().joinToString("\n") { handler ->
- "- ${handler.toolName}: ${handler.description}"
- }
-
- val prompt = """
- You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions.
-
- AVAILABLE TOOLS:
- $toolDescriptions
- - respond: Send the user your reply or final answer. It MUST carry a "message" holding the
- text itself — a respond call with no "message" shows the user nothing.
-
- BEHAVIOR:
- - Create complete, production-ready code
- - Call tools proactively to build, test, and verify your work
- - Read files to understand project structure before making changes
- - After each file modification, verify the build compiles
- - Generate apps that actually run and work as described
-
- RULES:
- - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet.
- - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one.
- - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line.
- - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk.
- - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists.
- - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected.
- - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing.
- - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored.
- - Paths are relative to the project root and must be complete. If you don't know a file's exact path, find it with search_project or list_files first, then act on the real path — don't guess.
- - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond.
-
- TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it:
- {"tool":"TOOL_NAME","args":{"arg":"value"}}
- Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing.
- The tool only runs when you emit the line itself.
-
- FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path
- only when it is the file you actually mean):
- Report the finished task (the summary goes in "message"):
- {"tool":"respond","args":{"message":"Renamed count to itemCount."}}
- Open a file once you know its path:
- {"tool":"open_file","args":{"file_path":"$examplePath"}}
- Find a file by name:
- {"tool":"search_project","args":{"query":"${exampleFileStem(examplePath)}"}}
- List the project's top-level files (an empty directory means the project root):
- {"tool":"list_files","args":{"directory":""}}
- Change part of a file (line breaks inside a value MUST be written as \n):
- {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}}
-
- WORKFLOW:
- 1. Understand the user's request
- 2. List files to understand the project structure
- 3. Create/modify files with complete implementations
- 4. Add dependencies if needed
- 5. Sync gradle and verify compilation
- 6. Run the app to confirm it works
- 7. Report success and what was built
- """.trimIndent()
-
- android.util.Log.d("ChatViewModel", "Using Gemini system prompt (high autonomy mode) with ${toolRouter.getAllHandlers().size} tools")
- return prompt
- }
-
- /**
- * File name without its extension, for a `search_project` example that matches [examplePath].
- * @param examplePath the example path.
- * @return the bare stem (e.g. "MainActivity").
- */
- private fun exampleFileStem(examplePath: String): String =
- examplePath.substringAfterLast('/').substringBeforeLast('.')
-
- /**
- * System prompt for local LLMs (guided step-by-step with text-based tool calling).
- * @param examplePath path shown in the tool-call examples — this project's own open file when
- * there is one, so the examples never imply a language or layout the project doesn't have.
- */
- private fun buildSystemPromptLocal(examplePath: String): String {
- val toolDescriptions = toolRouter.getAllHandlers().joinToString("\n") { handler ->
- "- ${handler.toolName}: ${handler.description}"
- }
-
- val prompt = """
- You are a coding assistant inside CodeOnTheGo.
-
- Rules:
- - Reply with exactly ONE tool call, nothing else.
- - Use a file/project tool only when the user asks about files, code, or the project; for a greeting, small talk, or a question you can answer, use "respond".
- - Never invent tool output or claim an action you didn't perform via a tool. After a tool call, stop; the real result returns next turn.
- - "respond" must carry a "message" — your reply or final answer.
- - read_file and open_file accept a bare file name (the project is searched for it). Never invent deep paths.
- - To change a file, use edit_file, not update_file. Call read_file FIRST, then copy the text to replace into "old_string" EXACTLY as it appears in that output (same spelling, same indentation). It must appear only once — include the line above or below if it doesn't.
- - "old_string" is the text that is in the file NOW; "new_string" is what it should become. They must differ. To rename x to y: old_string has x, new_string has y.
- - Never put a real line break inside an argument value: write it as \n. Keep old_string/new_string to a few lines; make several small edits rather than one big one.
- - edit_file needs a real path, not a bare name, and never a path you invented. If you don't know it, call search_project with the file name FIRST and use the path it returns — don't guess the folders, and don't guess the extension (.kt vs .java).
- - To rename something everywhere in a file, make ONE edit_file call with old_string set to just the old name and "replace_all":"true".
-
- Tools:
- $toolDescriptions
- - respond: Send the user a message or your final answer.
-
- Examples (pick the tool that matches; copy the FORMAT, not the values):
- Greeting / question you can answer -> respond:
- {"tool":"respond","args":{"message":"Hi! What would you like to build?"}}
- Open a file (a bare name is fine here) -> open_file:
- {"tool":"open_file","args":{"file_path":"${examplePath.substringAfterLast('/')}"}}
- Change one line of a file -> edit_file:
- {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"setTitle(\"Old\")","new_string":"setTitle(\"New\")"}}
- Change two lines (note the \n, never a real line break) -> edit_file:
- {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"a = 1\nb = 2","new_string":"a = 10\nb = 20"}}
- Rename every use of one name in a file -> ONE edit_file with replace_all (NOT one call per line):
- {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"oldName","new_string":"newName","replace_all":"true"}}
- Find where a file actually lives before editing it -> search_project:
- {"tool":"search_project","args":{"query":"${exampleFileStem(examplePath)}"}}
- """.trimIndent()
-
- android.util.Log.d("ChatViewModel", "Using Local LLM system prompt (guided mode) with ${toolRouter.getAllHandlers().size} tools")
- return prompt
- }
-
/**
* Executes a batch of tool calls, renders each result as a TOOL message, and
* returns the results for the [agentLoop] to feed back; leaves [AgentState.Idle]
@@ -608,15 +582,11 @@ class ChatViewModel(
val backends = llmService.availableBackends
android.util.Log.d("ChatViewModel", "checkBackendAvailability: Found ${backends.size} backends")
- // Read backend preference from settings
- val prefs = getContext()?.getPluginSharedPreferences("AgentSettings")
- val preferredBackendName = prefs?.getString("ai_backend_preference", "LOCAL_LLM")
- android.util.Log.d("ChatViewModel", "checkBackendAvailability: Preferred backend = $preferredBackendName")
- val preferredBackendId = when (preferredBackendName) {
- "GEMINI" -> "gemini"
- "LOCAL_LLM" -> "local"
- else -> "local"
- }
+ // The selection is stored as the backend's own id, so it needs no mapping:
+ // a backend this plugin has never heard of resolves like any other.
+ val preferredBackendId = BackendRegistry.selectedId()
+ ?: backends.firstOrNull()?.id
+ android.util.Log.d("ChatViewModel", "checkBackendAvailability: Preferred backend = $preferredBackendId")
// First try to use the preferred backend
var foundAvailable = false
@@ -676,8 +646,8 @@ class ChatViewModel(
if (!_isBackendAvailable.value) {
android.util.Log.d("ChatViewModel", "sendMessage: Backend not available")
emitSystemError(
- "No LLM backend is set up yet. Open Settings to select a local .gguf model, " +
- "or add a Gemini API key."
+ "No LLM backend is set up yet. Open Settings to choose an installed " +
+ "backend and finish configuring it."
)
return
}
@@ -715,7 +685,7 @@ class ChatViewModel(
val config = LlmInferenceService.LlmConfig(currentBackendId).apply {
// The grammar shapes a local tool call but not its values, so paths get sampled.
- temperature = if (currentBackendId == "gemini") 0.7f else LOCAL_TEMPERATURE
+ temperature = backendTemperature() ?: DEFAULT_TEMPERATURE
maxTokens = 4096 // headroom for complete tool calls
systemPrompt = buildSystemPrompt()
// Local backend constrains generation to this grammar; cloud ignores it.
@@ -943,23 +913,22 @@ class ChatViewModel(
}
try {
- if (currentBackendId == "gemini") {
- llmService.generateStreaming(agentLoop.renderTranscript(turns), config, streamCallback)
- } else {
- llmService.generateStreamingWithTools(
- turns.lastOrNull()?.content.orEmpty(),
- turns.dropLast(1),
- config,
- emptyList(),
- object : LlmInferenceService.ToolStreamCallback {
- override fun onToken(token: String) = streamCallback.onToken(token)
- override fun onToolCall(request: LlmInferenceService.ToolCallRequest) = Unit
- override fun onComplete(response: LlmInferenceService.LlmResponse) =
- streamCallback.onComplete(response)
- override fun onError(error: String) = streamCallback.onError(error)
- }
- )
- }
+ // Every backend takes the structured form: the last turn as the prompt, the rest as
+ // history. Tools are empty because tool calls travel in the reply text (TOOL_CALL_SYNTAX),
+ // not through native function calling.
+ llmService.generateStreamingWithTools(
+ turns.lastOrNull()?.content.orEmpty(),
+ turns.dropLast(1),
+ config,
+ emptyList(),
+ object : LlmInferenceService.ToolStreamCallback {
+ override fun onToken(token: String) = streamCallback.onToken(token)
+ override fun onToolCall(request: LlmInferenceService.ToolCallRequest) = Unit
+ override fun onComplete(response: LlmInferenceService.LlmResponse) =
+ streamCallback.onComplete(response)
+ override fun onError(error: String) = streamCallback.onError(error)
+ }
+ )
} catch (e: Exception) {
// A synchronous throw fires no callback; complete deferred so await() doesn't hang.
android.util.Log.e("ChatViewModel", "generateStreaming threw synchronously", e)
diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml
index 523465af..149cdad3 100644
--- a/ai-assistant/src/main/res/values/strings.xml
+++ b/ai-assistant/src/main/res/values/strings.xml
@@ -29,12 +29,9 @@
Processing…
Executing step %1$d of %2$d: %3$s
Cancelling…
- Error: %s
No LLM backend available. Configure AI Core plugin.
- Local LLM
- Gemini API
Model: %s
@@ -116,37 +113,7 @@
AI Settings
Close settings
AI Backend
- Show API key
- Hide API key
- Enter your Gemini API key
- Gemini API Key
- Clear
- Edit
- Save Key
- API Key saved
- API Key saved on: %s
- API Key is saved
- 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.
-
- Get API Key
- No browser available. The link was copied — open it on another device: %s
- Couldn\'t open a browser. Get your key at %s
- Copied your key? Paste it into the field above, then tap Save Key.
- Copied a new key? Tap Edit, then paste it into the field.
- Checking this key with Google…
- Verified, your API key works
- Key accepted — Google is rate-limiting right now
- Invalid API key. It wasn\'t saved, check it and try again.
- Couldn\'t reach Google to check this key.
- Couldn\'t check this key — make sure the AI Core plugin is installed, enabled and up to date.
- API Key saved and verified on: %s
- Save this key anyway?
- %s\n\nThe key looks fine but couldn\'t be confirmed, so it may not work until you\'re back online.
- Save anyway
- Cancel
+
Backend
Model
Temperature
@@ -177,38 +144,19 @@
System Log
- Initializing engine…
- Engine ready
- Saved: %s
- No model is currently loaded
- Loading model, please wait…
- ✅ Model loaded: %s
- ❌ Error: %s
- Loading model…
- No model selected
- Browse for Model File
- Load from saved
- Optional SHA-256 (for verification)
- SHA-256 hash
- Use simple local prompt
- Gemini Model
- Current: %s
- Loading…
- Refresh Models
- Model changed to %s
- \"%1$s\" isn\'t a valid .gguf model (it may be the wrong file or a corrupt or partial download). Select a .gguf chat model.
- This model may be too large
- \"%1$s\" needs about %2$s of memory to load, plus about %3$s more to run. This device has about %4$s available right now, which may not be enough. It might work, or fail quickly, or fail after several minutes.\n\nClose other apps to free memory, or choose a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model).
- \"%1$s\" needs about %2$s of memory to load, plus about %3$s more to run. This device has about %4$s available right now, which is not enough. Loading it will most likely fail, and may make the IDE unresponsive first.\n\nClose other apps to free memory, or choose a smaller or more heavily quantized model (for example a Q4_K_M build of a 1–3B model).
- Proceed anyway
- Cancel
- Model not selected. The previously selected model, if any, is unchanged.
+
+
+ No AI backend is installed. Install a backend plugin, for example AI Local Backend for on-device models, or AI Gemini Backend for Google\'s API, from the Plugin Manager.
+ No backend
+ %1$s has no settings to configure.
+ This backend\'s settings are unavailable. It may have been uninstalled or disabled.
+ Error: %s
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt
deleted file mode 100644
index 73b193e7..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/GeminiKeyOnboardingTest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.gemini
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class GeminiKeyOnboardingTest {
-
- @Test
- fun givenTheKeySourceUrl_whenInspected_thenItPointsAtAiStudioNotTheCloudConsole() {
- // AI Studio provisions the Cloud project itself; pinned so nobody "fixes" it back.
- assertEquals("https://aistudio.google.com/apikey", GeminiKeyOnboarding.AI_STUDIO_URL)
- }
-
- @Test
- fun givenTheKeySourceUrl_whenInspected_thenItIsHttps() {
- // A key is typed into whatever this opens; it must not be reachable over cleartext.
- assertTrue(GeminiKeyOnboarding.AI_STUDIO_URL.startsWith("https://"))
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt
deleted file mode 100644
index be7a4746..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/gemini/KeyVerificationTest.kt
+++ /dev/null
@@ -1,171 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.gemini
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import java.io.IOException
-import java.net.SocketTimeoutException
-import java.net.UnknownHostException
-import java.util.concurrent.TimeoutException
-
-/**
- * Covers every row of the catalog-result → verdict mapping.
- *
- * The rejection rows are load-bearing: [KeyVerification.Rejected] is the only state that blocks a
- * save, so a wrong mapping there discards a working key or lets a broken one through.
- */
-class KeyVerificationTest {
-
- /** Mirrors the message ai-core's `GeminiBackend.fetchAvailableModels` throws. */
- private fun listModelsHttpError(code: Int, body: String = """{"error":{}}""") =
- IOException("ListModels HTTP $code: $body")
-
- private fun verdictFor(cause: Throwable) =
- CatalogResult.Failed(cause).toKeyVerification()
-
- @Test
- fun givenANonEmptyCatalog_whenInterpreted_thenTheKeyIsVerifiedWithItsModelCount() {
- val result = CatalogResult.Success(listOf("gemini-2.5-flash", "gemini-2.5-pro"))
-
- assertEquals(KeyVerification.Verified(2), result.toKeyVerification())
- }
-
- @Test
- fun givenAnEmptyCatalog_whenInterpreted_thenReportsUnknownRatherThanAPass() {
- // A valid key always lists something, so this says nothing — and must not read as success.
- assertEquals(KeyVerification.Unknown, CatalogResult.Success(emptyList()).toKeyVerification())
- }
-
- @Test
- fun givenNoBackend_whenInterpreted_thenReportsUnknownAndNeverARejection() {
- assertEquals(KeyVerification.Unknown, CatalogResult.NoBackend.toKeyVerification())
- }
-
- @Test
- fun givenHttp400ApiKeyInvalid_whenInterpreted_thenTheKeyIsRejected() {
- val cause = listModelsHttpError(400, """{"error":{"status":"INVALID_ARGUMENT"}}""")
-
- assertEquals(KeyVerification.Rejected, verdictFor(cause))
- }
-
- @Test
- fun givenHttp401_whenInterpreted_thenTheKeyIsRejected() {
- assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(401)))
- }
-
- @Test
- fun givenHttp403PermissionDenied_whenInterpreted_thenTheKeyIsRejected() {
- val cause = listModelsHttpError(403, """{"error":{"status":"PERMISSION_DENIED"}}""")
-
- assertEquals(KeyVerification.Rejected, verdictFor(cause))
- }
-
- @Test
- fun givenHttp429_whenInterpreted_thenTheKeyCountsAsValidBecauseItStillWorks() {
- // Also pins branch order: 429 sits inside 400..499 and must be matched before it.
- assertEquals(KeyVerification.RateLimited, verdictFor(listModelsHttpError(429)))
- }
-
- @Test
- fun givenARateLimitedOrVerifiedKey_whenTheSaveRuleIsChecked_thenItIsConfirmed() {
- assertEquals(true, KeyVerification.RateLimited.isConfirmedValid)
- assertEquals(true, KeyVerification.Verified(1).isConfirmedValid)
- }
-
- @Test
- fun givenAnyOtherVerdict_whenTheSaveRuleIsChecked_thenItIsNotConfirmed() {
- assertEquals(false, KeyVerification.Rejected.isConfirmedValid)
- assertEquals(false, KeyVerification.Unreachable.isConfirmedValid)
- assertEquals(false, KeyVerification.Unknown.isConfirmedValid)
- }
-
- @Test
- fun givenA5xx_whenInterpreted_thenReportsUnreachableBecauseItIsGooglesFaultNotTheKeys() {
- assertEquals(KeyVerification.Unreachable, verdictFor(listModelsHttpError(500)))
- assertEquals(KeyVerification.Unreachable, verdictFor(listModelsHttpError(503)))
- }
-
- @Test
- fun givenAnIoExceptionWithNoStatus_whenInterpreted_thenReportsUnreachable() {
- assertEquals(
- KeyVerification.Unreachable,
- verdictFor(UnknownHostException("generativelanguage.googleapis.com"))
- )
- assertEquals(
- KeyVerification.Unreachable,
- verdictFor(SocketTimeoutException("connect timed out"))
- )
- }
-
- @Test
- fun givenATimeout_whenInterpreted_thenReportsUnknownRatherThanATransportFailure() {
- // A future ai-core will never complete means "couldn't check it", not "offline".
- assertEquals(KeyVerification.Unknown, verdictFor(TimeoutException("gave up")))
- }
-
- @Test
- fun givenABrokenCrossPluginContract_whenInterpreted_thenReportsUnknownSoNoKeyIsDiscarded() {
- val cause = NoSuchMethodException(
- "com.itsaky.androidide.plugins.aicore.GeminiBackend.listModels(java.lang.String)"
- )
-
- assertEquals(KeyVerification.Unknown, verdictFor(cause))
- }
-
- @Test
- fun givenAWrappedCause_whenInterpreted_thenTheStatusIsStillFound() {
- val wrapped = RuntimeException("catalog lookup failed", listModelsHttpError(403))
-
- assertEquals(KeyVerification.Rejected, verdictFor(wrapped))
- }
-
- @Test
- fun givenAStatusBuriedDeeperThanTheCap_whenInterpreted_thenTheCauseWalkStillTerminates() {
- // Pins the depth bound so an unbounded walk (or a cycle) can't creep back in.
- var deep: Throwable = listModelsHttpError(403)
- repeat(6) { level -> deep = RuntimeException("wrapper $level", deep) }
-
- assertEquals(KeyVerification.Unknown, verdictFor(deep))
- }
-
- @Test
- fun givenHttp404_whenInterpreted_thenTheKeyIsRejected() {
- assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(404)))
- }
-
- @Test
- fun givenAnyOther4xx_whenInterpreted_thenTheKeyIsRejected() {
- // Every 4xx bar 429 is a client-side refusal, so none of them may reach "Save anyway?".
- assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(402)))
- assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(418)))
- assertEquals(KeyVerification.Rejected, verdictFor(listModelsHttpError(451)))
- }
-
- @Test
- fun givenAStatusOutsideTheErrorRanges_whenInterpreted_thenReportsUnknown() {
- assertEquals(KeyVerification.Unknown, verdictFor(listModelsHttpError(302)))
- }
-
- @Test
- fun givenAWrapperMentioningAnotherStatus_whenInterpreted_thenOnlyTheContractMessageCounts() {
- // Only ai-core's `ListModels HTTP ` is a status; prose in a wrapper is not.
- val wrapped = RuntimeException("gateway saw HTTP 403", listModelsHttpError(500))
-
- assertEquals(KeyVerification.Unreachable, verdictFor(wrapped))
- }
-
- @Test
- fun givenAMessageWithNoContractPrefix_whenInterpreted_thenNoStatusIsInferred() {
- // "HTTP 401" in unrelated prose is not ai-core reporting a status.
- val cause = RuntimeException("proxy rewrote the request; see HTTP 401 in the spec")
-
- assertEquals(KeyVerification.Unknown, verdictFor(cause))
- }
-
- @Test
- fun givenAnAiCoreCancellation_whenInterpreted_thenNothingIsConcludedAboutTheKey() {
- // The gateway turns a future ai-core cancelled into Failed rather than letting it escape.
- val cause = java.util.concurrent.CancellationException("ai-core scope closed")
-
- assertEquals(KeyVerification.Unknown, verdictFor(cause))
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/memory/ModelMemoryEstimatorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/memory/ModelMemoryEstimatorTest.kt
deleted file mode 100644
index a1c4ac61..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/memory/ModelMemoryEstimatorTest.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.memory
-
-import com.itsaky.androidide.plugins.aiassistant.util.GgufHeader
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-/**
- * Tests for the memory arithmetic behind the pre-flight warning.
- *
- * The figures reach the user, so they are asserted exactly rather than as ranges: a wrong KV-cache
- * formula would still produce a plausible-looking dialog.
- */
-class ModelMemoryEstimatorTest {
-
- private val megabyte = 1024L * 1024
- private val computeBuffer = 256 * megabyte
-
- /** gemma-3-1b: 26 layers, 1152 wide, 4 heads, 1 kv head — so a 288-wide kv projection. */
- private val gemma = GgufHeader(
- architecture = "gemma3",
- blockCount = 26,
- embeddingLength = 1152,
- headCount = 4,
- headCountKv = 1,
- )
-
- @Test
- fun givenAModelShape_whenEstimated_thenTheKvCacheIsSizedForTheFullContext() {
- val fileSize = 800 * megabyte
- // A literal: restating the formula asserts only that the code agrees with itself.
- val expectedKvCache = 122_683_392L
-
- val estimate = ModelMemoryEstimator.estimate(fileSize, gemma)
-
- assertEquals(fileSize, estimate?.loadBytes)
- assertEquals(expectedKvCache + computeBuffer, estimate?.runBytes)
- assertTrue(estimate?.fromHeader == true)
- }
-
- @Test
- fun givenAModelWithoutGroupedQueryAttention_whenEstimated_thenEveryHeadIsCached() {
- val mha = gemma.copy(headCountKv = null)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, mha)
-
- // One kv head per attention head: four times gemma's 122,683,392-byte cache.
- assertEquals(490_733_568L + computeBuffer, estimate?.runBytes)
- }
-
- @Test
- fun givenADeclaredKeyAndValueWidth_whenEstimated_thenTheyAreUsedOverTheHeadQuotient() {
- // gemma-3 declares 256, not the 288 embedding / heads implies — a 12% overstatement.
- val declared = gemma.copy(keyLength = 256, valueLength = 256)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, declared)
-
- // 2 x 26 x 4096 x 1 x (256 + 256).
- assertEquals(109_051_904L + computeBuffer, estimate?.runBytes)
- }
-
- @Test
- fun givenOnlyOneOfTheTwoWidthsDeclared_whenEstimated_thenTheOtherStillFallsBack() {
- val halfDeclared = gemma.copy(keyLength = 256)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, halfDeclared)
-
- // 2 x 26 x 4096 x 1 x (256 declared key + 288 derived value).
- assertEquals(115_867_648L + computeBuffer, estimate?.runBytes)
- }
-
- @Test
- fun givenAZeroedKeyWidth_whenEstimated_thenTheDerivedWidthIsUsedInstead() {
- val corrupt = gemma.copy(keyLength = 0, valueLength = 0)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, corrupt)
-
- assertEquals(122_683_392L + computeBuffer, estimate?.runBytes)
- }
-
- @Test
- fun givenNoHeader_whenEstimated_thenItFallsBackToAShareOfTheFileSize() {
- val fileSize = 4096L * megabyte
-
- val estimate = ModelMemoryEstimator.estimate(fileSize, header = null)
-
- assertEquals(fileSize, estimate?.loadBytes)
- assertEquals(fileSize / 4, estimate?.runBytes)
- assertFalse(estimate?.fromHeader == true)
- }
-
- @Test
- fun givenASmallModelAndNoHeader_whenEstimated_thenTheRuntimeFloorStillApplies() {
- // A quarter of a 300 MB file is nowhere near enough for a KV cache at full context.
- val estimate = ModelMemoryEstimator.estimate(300 * megabyte, header = null)
-
- assertEquals(256 * megabyte, estimate?.runBytes)
- }
-
- @Test
- fun givenAnIncompleteHeader_whenEstimated_thenItFallsBackRatherThanGuessing() {
- val partial = gemma.copy(embeddingLength = null)
-
- val estimate = ModelMemoryEstimator.estimate(4096L * megabyte, partial)
-
- assertEquals(1024 * megabyte, estimate?.runBytes)
- assertFalse(estimate?.fromHeader == true)
- }
-
- @Test
- fun givenAZeroedHeaderValue_whenEstimated_thenItFallsBackInsteadOfDividingByZero() {
- val corrupt = gemma.copy(headCount = 0)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, corrupt)
-
- assertEquals(256 * megabyte, estimate?.runBytes)
- assertFalse(estimate?.fromHeader == true)
- }
-
- @Test
- fun givenALayerCountThatWouldWrapTheProduct_whenEstimated_thenItFallsBackInsteadOfUnderstating() {
- // 2^61 layers with 288-wide projections wraps the KV term to exactly zero.
- val crafted = gemma.copy(blockCount = 1L shl 61, keyLength = 288, valueLength = 288)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, crafted)
-
- assertEquals(256 * megabyte, estimate?.runBytes)
- assertFalse(estimate?.fromHeader == true)
- }
-
- @Test
- fun givenALayerCountThatWouldMakeTheProductNegative_whenEstimated_thenTheTotalStaysPositive() {
- // 2^49 layers with 1-wide projections lands on Long.MIN_VALUE, which reads as "it fits".
- val crafted = gemma.copy(blockCount = 1L shl 49, keyLength = 1, valueLength = 1)
-
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, crafted)!!
-
- assertTrue(estimate.totalBytes > 0L)
- assertFalse(estimate.fromHeader)
- }
-
- @Test
- fun givenTheLargestRealisticShape_whenEstimated_thenTheCeilingsStillAcceptIt() {
- // llama-3.1-405B: 126 layers, 16384 wide, 128 heads, 8 kv heads.
- val large = GgufHeader(
- architecture = "llama",
- blockCount = 126,
- embeddingLength = 16384,
- headCount = 128,
- headCountKv = 8,
- )
-
- val estimate = ModelMemoryEstimator.estimate(200L * 1024 * megabyte, large)
-
- assertTrue(estimate?.fromHeader == true)
- }
-
- @Test
- fun givenAFileSizeThatWouldWrapTheTotal_whenTotalled_thenItSaturatesInsteadOfGoingNegative() {
- // A queried SIZE column is not trustworthy, and a negative total reads as "it fits".
- val estimate = ModelMemoryEstimator.estimate(Long.MAX_VALUE, gemma)!!
-
- assertEquals(Long.MAX_VALUE, estimate.totalBytes)
- }
-
- @Test
- fun givenAnUnknownFileSize_whenEstimated_thenThereIsNoEstimate() {
- assertNull(ModelMemoryEstimator.estimate(null, gemma))
- assertNull(ModelMemoryEstimator.estimate(0L, gemma))
- }
-
- @Test
- fun givenAnEstimate_whenTotalled_thenItIsTheWeightsPlusTheRuntime() {
- val estimate = ModelMemoryEstimator.estimate(800 * megabyte, gemma)!!
-
- assertEquals(estimate.loadBytes + estimate.runBytes, estimate.totalBytes)
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/memory/ModelMemoryGateTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/memory/ModelMemoryGateTest.kt
deleted file mode 100644
index 02c1d60b..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/memory/ModelMemoryGateTest.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.memory
-
-import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryGate.Severity
-import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryGate.Verdict
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Tests for the decision to warn, including its boundaries — one byte either side of them is the
- * difference between an interrupted user and a crash.
- */
-class ModelMemoryGateTest {
-
- private val megabyte = 1024L * 1024
-
- /** 800 MB of weights that need 400 MB of working memory: 1200 MB in total. */
- private val estimate = MemoryEstimate(
- loadBytes = 800 * megabyte,
- runBytes = 400 * megabyte,
- fromHeader = true,
- )
-
- private fun severityAt(availableBytes: Long): Severity? =
- (ModelMemoryGate.evaluate(estimate, availableBytes) as? Verdict.Risky)?.severity
-
- @Test
- fun givenRoomForEverything_whenEvaluated_thenTheUserIsNotInterrupted() {
- assertEquals(Verdict.Safe, ModelMemoryGate.evaluate(estimate, 2048 * megabyte))
- }
-
- @Test
- fun givenExactlyEnough_whenEvaluated_thenItIsSafe() {
- assertEquals(Verdict.Safe, ModelMemoryGate.evaluate(estimate, estimate.totalBytes))
- }
-
- @Test
- fun givenOneByteTooLittle_whenEvaluated_thenThrashingIsTheRisk() {
- assertEquals(Severity.TIGHT, severityAt(estimate.totalBytes - 1))
- }
-
- @Test
- fun givenRoomForTheRuntimeButNotTheWeights_whenEvaluated_thenThrashingIsTheRisk() {
- assertEquals(Severity.TIGHT, severityAt(600 * megabyte))
- }
-
- @Test
- fun givenNotEvenRoomForTheRuntime_whenEvaluated_thenFailureIsExpected() {
- assertEquals(Severity.INSUFFICIENT, severityAt(estimate.runBytes - 1))
- }
-
- @Test
- fun givenExactlyEnoughForTheRuntime_whenEvaluated_thenItIsOnlyTight() {
- assertEquals(Severity.TIGHT, severityAt(estimate.runBytes))
- }
-
- @Test
- fun givenNoEstimate_whenEvaluated_thenNothingIsClaimed() {
- assertEquals(Verdict.Unknown, ModelMemoryGate.evaluate(null, 2048 * megabyte))
- }
-
- @Test
- fun givenUnreadableMemory_whenEvaluated_thenItFailsOpenRatherThanWarning() {
- // A device we can't measure must not be told its model won't fit.
- assertEquals(Verdict.Unknown, ModelMemoryGate.evaluate(estimate, null))
- }
-
- @Test
- fun givenNoFreeMemoryAtAll_whenEvaluated_thenThatIsARealReadingAndNotUnknown() {
- assertEquals(Severity.INSUFFICIENT, severityAt(0L))
- }
-
- @Test
- fun givenARiskyModel_whenEvaluated_thenTheVerdictCarriesTheFiguresToShow() {
- val verdict = ModelMemoryGate.evaluate(estimate, 600 * megabyte) as Verdict.Risky
-
- assertEquals(estimate, verdict.estimate)
- assertEquals(600 * megabyte, verdict.availableBytes)
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspectorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspectorTest.kt
deleted file mode 100644
index 099e1bf2..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufFileInspectorTest.kt
+++ /dev/null
@@ -1,93 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.util
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import java.io.ByteArrayInputStream
-import java.io.IOException
-import java.io.InputStream
-
-class GgufFileInspectorTest {
-
- /** Captures whatever [GgufFileInspector.looksLikeGguf] reports, standing in for Log.w. */
- private var reportedError: Exception? = null
-
- private fun looksLikeGguf(openStream: () -> InputStream?): Boolean =
- GgufFileInspector.looksLikeGguf(openStream) { reportedError = it }
-
- @Test
- fun givenGgufMagic_whenChecked_thenAccepted() {
- val header = byteArrayOf(0x47, 0x47, 0x55, 0x46) // "GGUF"
- assertTrue(GgufFileInspector.bytesAreGgufMagic(header))
- }
-
- @Test
- fun givenMagicWithTrailingBytes_whenChecked_thenAccepted() {
- val header = byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03, 0x00)
- assertTrue(GgufFileInspector.bytesAreGgufMagic(header))
- }
-
- @Test
- fun givenZipMagic_whenChecked_thenRejected() {
- val header = byteArrayOf(0x50, 0x4B, 0x03, 0x04) // "PK.." — a .zip renamed to .gguf
- assertFalse(GgufFileInspector.bytesAreGgufMagic(header))
- }
-
- @Test
- fun givenFewerThanFourBytes_whenChecked_thenRejected() {
- assertFalse(GgufFileInspector.bytesAreGgufMagic(byteArrayOf(0x47, 0x47, 0x55)))
- }
-
- @Test
- fun givenGgufStream_whenInspected_thenAccepted() {
- assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03)) })
- assertNull(reportedError)
- }
-
- @Test
- fun givenNonGgufStream_whenInspected_thenRejected() {
- assertFalse(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x50, 0x4B, 0x03, 0x04)) })
- assertNull(reportedError)
- }
-
- @Test
- fun givenStreamThatThrowsMidRead_whenInspected_thenFailsOpenAndReports() {
- // SAF can drop a content:// stream mid-transfer; a real model must not be rejected for it.
- val truncating = object : InputStream() {
- private var served = 0
- override fun read(): Int = throw IOException("stream died")
- override fun read(b: ByteArray, off: Int, len: Int): Int {
- if (served > 0) throw IOException("stream died")
- served = 2
- b[off] = 0x47
- b[off + 1] = 0x47
- return 2
- }
- }
-
- assertTrue(looksLikeGguf { truncating })
- assertEquals("stream died", reportedError?.message)
- }
-
- @Test
- fun givenUnopenableDocument_whenInspected_thenFailsOpenAndReports() {
- assertTrue(looksLikeGguf { throw SecurityException("permission revoked") })
- assertEquals("permission revoked", reportedError?.message)
- }
-
- @Test
- fun givenNullStream_whenInspected_thenFailsOpenWithoutError() {
- // openInputStream() returns null for a provider that can't produce the document.
- assertTrue(looksLikeGguf { null })
- assertNull(reportedError)
- }
-
- @Test
- fun givenTruncatedStream_whenInspected_thenFailsOpen() {
- // Fewer than four bytes available: not enough to judge, so don't reject.
- assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47)) })
- assertNull(reportedError)
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufHeaderReaderTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufHeaderReaderTest.kt
deleted file mode 100644
index 26088ed7..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufHeaderReaderTest.kt
+++ /dev/null
@@ -1,283 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.util
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Test
-import java.io.ByteArrayInputStream
-import java.io.IOException
-import java.io.InputStream
-
-/**
- * Tests for the GGUF metadata read that the memory estimate is based on.
- *
- * Every case runs against real bytes from [GgufWriter], since the point of the reader is that it
- * agrees with the on-disk format — and that it gives up cleanly when it doesn't.
- */
-class GgufHeaderReaderTest {
-
- private fun read(bytes: ByteArray): GgufHeader? =
- GgufHeaderReader.read { ByteArrayInputStream(bytes) }
-
- /** The shape of gemma-3-1b, as a representative grouped-query-attention model. */
- private fun gemmaHeader(version: Int = 3) = GgufWriter(version)
- .string("general.architecture", "gemma3")
- .string("general.name", "gemma-3-1b-it")
- .uint32("gemma3.block_count", 26)
- .uint32("gemma3.embedding_length", 1152)
- .uint32("gemma3.attention.head_count", 4)
- .uint32("gemma3.attention.head_count_kv", 1)
- .build()
-
- @Test
- fun givenAGgufHeader_whenRead_thenEveryShapeValueIsReturned() {
- val header = read(gemmaHeader())
-
- assertEquals("gemma3", header?.architecture)
- assertEquals(26L, header?.blockCount)
- assertEquals(1152L, header?.embeddingLength)
- assertEquals(4L, header?.headCount)
- assertEquals(1L, header?.headCountKv)
- }
-
- @Test
- fun givenAVersionOneHeader_whenRead_thenItsNarrowerLengthsAreUnderstood() {
- // v1 wrote 32-bit counts and string lengths; misreading them desynchronizes every field.
- val header = read(gemmaHeader(version = 1))
-
- assertEquals("gemma3", header?.architecture)
- assertEquals(26L, header?.blockCount)
- assertEquals(1L, header?.headCountKv)
- }
-
- @Test
- fun givenAModelWithoutGroupedQueryAttention_whenRead_thenTheKvHeadCountIsAbsent() {
- val bytes = GgufWriter()
- .string("general.architecture", "llama")
- .uint32("llama.block_count", 32)
- .uint32("llama.embedding_length", 4096)
- .uint32("llama.attention.head_count", 32)
- .build()
-
- val header = read(bytes)
-
- assertEquals(32L, header?.headCount)
- assertNull(header?.headCountKv)
- }
-
- @Test
- fun givenShapeKeysBehindATokenizerArray_whenRead_thenTheArrayIsSkippedAndTheKeysAreFound() {
- val bytes = GgufWriter()
- .stringArray("tokenizer.ggml.tokens", List(500) { "token$it" })
- .string("general.architecture", "qwen2")
- .uint32("qwen2.block_count", 28)
- .uint32("qwen2.embedding_length", 1536)
- .uint32("qwen2.attention.head_count", 12)
- .uint32("qwen2.attention.head_count_kv", 2)
- .build()
-
- val header = read(bytes)
-
- assertEquals(28L, header?.blockCount)
- assertEquals(2L, header?.headCountKv)
- }
-
- @Test
- fun givenSixtyFourBitShapeValues_whenRead_thenTheyAreReadAtTheRightWidth() {
- val bytes = GgufWriter()
- .string("general.architecture", "llama")
- .uint64("llama.block_count", 32)
- .uint64("llama.embedding_length", 4096)
- .uint64("llama.attention.head_count", 32)
- .build()
-
- val header = read(bytes)
-
- assertEquals(32L, header?.blockCount)
- assertEquals(4096L, header?.embeddingLength)
- }
-
- @Test
- fun givenAShapeValueOfAnUnexpectedType_whenRead_thenOnlyThatValueIsLostAndTheRestSurvive() {
- // The value still has to be consumed, or every later key would be read from its middle.
- val bytes = GgufWriter()
- .string("general.architecture", "llama")
- .float32("llama.block_count", 32f)
- .uint32("llama.embedding_length", 4096)
- .uint32("llama.attention.head_count", 32)
- .build()
-
- val header = read(bytes)
-
- assertNull(header?.blockCount)
- assertEquals(4096L, header?.embeddingLength)
- assertEquals(32L, header?.headCount)
- }
-
- @Test
- fun givenAShapeValueOfAnAbsurdMagnitude_whenRead_thenItIsReturnedUnchangedForTheEstimatorToReject() {
- // The reader reports what the file declares; the plausibility ceiling lives in the estimator.
- val bytes = GgufWriter()
- .string("general.architecture", "llama")
- .uint64("llama.block_count", 1L shl 61)
- .uint32("llama.attention.head_count", 32)
- .build()
-
- val header = read(bytes)
-
- assertEquals(1L shl 61, header?.blockCount)
- assertEquals(32L, header?.headCount)
- }
-
- @Test
- fun givenAFileThatIsNotGguf_whenRead_thenThereIsNoHeader() {
- assertNull(read("This is a text file, not a model".toByteArray()))
- }
-
- @Test
- fun givenATruncatedHeader_whenRead_thenThereIsNoHeader() {
- val truncated = gemmaHeader().copyOfRange(0, 40)
-
- assertNull(read(truncated))
- }
-
- @Test
- fun givenAnUnopenableFile_whenRead_thenThereIsNoHeader() {
- assertNull(GgufHeaderReader.read { null })
- }
-
- @Test
- fun givenAStreamThatFailsMidRead_whenRead_thenThereIsNoHeader() {
- val failing = object : InputStream() {
- override fun read(): Int = throw IOException("device detached")
- }
-
- assertNull(GgufHeaderReader.read { failing })
- }
-
- @Test
- fun givenAHeaderClaimingAnAbsurdEntryCount_whenRead_thenThereIsNoHeader() {
- // Truncating is worse than giving up: a head_count_kv never reached reads as plain MHA.
- val bytes = GgufWriter()
- .string("general.architecture", "llama")
- .build()
- .let { corruptEntryCount(it) }
-
- assertNull(read(bytes))
- }
-
- @Test(timeout = 30_000)
- fun givenAnArrayCountThatWouldRunOnPastTheMetadata_whenRead_thenTheParseIsAbandoned() {
- // An array count is unbounded, so without a cap this walks the whole multi-GB file.
- val bytes = GgufWriter()
- .lyingStringArray("tokenizer.ggml.tokens", declaredCount = Long.MAX_VALUE / 2, values = listOf("a"))
- .string("general.architecture", "llama")
- .build()
-
- assertNull(GgufHeaderReader.read { EndlessStream(bytes) })
- }
-
- @Test(timeout = 30_000)
- fun givenOneStringValueClaimingAnEnormousLength_whenRead_thenTheByteBudgetStillStopsIt() {
- // One huge skip, not many small ones, so charging on completion never gets the chance.
- val bytes = GgufWriter()
- .lyingString("general.description", declaredLength = Long.MAX_VALUE / 2)
- .string("general.architecture", "llama")
- .build()
-
- assertNull(GgufHeaderReader.read { EndlessStream(bytes) })
- }
-
- @Test(timeout = 30_000)
- fun givenAnArrayNestedInsideAnArray_whenRead_thenItIsRejectedRatherThanFollowed() {
- // Recursing per nesting level ends in a StackOverflowError, not an Exception.
- val bytes = GgufWriter()
- .string("general.architecture", "llama")
- .nestedArray("tokenizer.ggml.merges")
- .build()
-
- assertNull(read(bytes))
- }
-
- @Test
- fun givenShapeKeysForAnotherArchitecture_whenRead_thenOnlyThisModelsAreReturned() {
- // A multimodal file carries the vision tower's shape alongside the language model's.
- val bytes = GgufWriter()
- .string("general.architecture", "qwen2")
- .uint32("clip.vision.block_count", 27)
- .uint32("clip.vision.embedding_length", 1152)
- .uint32("clip.vision.attention.head_count", 16)
- .uint32("qwen2.block_count", 28)
- .uint32("qwen2.embedding_length", 1536)
- .uint32("qwen2.attention.head_count", 12)
- .build()
-
- val header = read(bytes)
-
- assertEquals(28L, header?.blockCount)
- assertEquals(1536L, header?.embeddingLength)
- assertEquals(12L, header?.headCount)
- }
-
- @Test
- fun givenTheArchitectureDeclaredAfterTheShapeKeys_whenRead_thenTheyAreStillAttributed() {
- // general.architecture is conventionally first, but the format does not require it.
- val bytes = GgufWriter()
- .uint32("llama.block_count", 32)
- .uint32("llama.attention.head_count", 32)
- .string("general.architecture", "llama")
- .build()
-
- val header = read(bytes)
-
- assertEquals(32L, header?.blockCount)
- assertEquals(32L, header?.headCount)
- }
-
- @Test
- fun givenDeclaredKeyAndValueWidths_whenRead_thenTheyAreReturned() {
- val bytes = GgufWriter()
- .string("general.architecture", "gemma3")
- .uint32("gemma3.attention.key_length", 256)
- .uint32("gemma3.attention.value_length", 256)
- .build()
-
- val header = read(bytes)
-
- assertEquals(256L, header?.keyLength)
- assertEquals(256L, header?.valueLength)
- }
-
- /** Overwrites the entry count (bytes 16..23 of a v3 header) with a huge value. */
- private fun corruptEntryCount(bytes: ByteArray): ByteArray = bytes.copyOf().also {
- for (i in 16 until 24) it[i] = 0xFF.toByte()
- it[23] = 0x00 // keep it positive
- }
-
- /**
- * [prefix], then zeros forever — a stand-in for a multi-gigabyte model whose metadata lies, so
- * the reader has to stop itself rather than be stopped by end-of-file. The offset is a Long
- * deliberately: as an Int it wrapped past 2 GB and threw, ending the parse for the wrong reason.
- */
- private class EndlessStream(private val prefix: ByteArray) : InputStream() {
-
- private var offset = 0L
-
- override fun read(): Int =
- (if (offset < prefix.size) prefix[offset.toInt()].toInt() and 0xFF else 0)
- .also { offset++ }
-
- override fun read(b: ByteArray, off: Int, len: Int): Int {
- var written = 0
- while (written < len && offset < prefix.size) {
- b[off + written] = prefix[offset.toInt()]
- offset++
- written++
- }
- if (written < len) {
- b.fill(0, off + written, off + len)
- offset += len - written
- }
- return len
- }
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufWriter.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufWriter.kt
deleted file mode 100644
index 36998648..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/util/GgufWriter.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.util
-
-import java.io.ByteArrayOutputStream
-
-/**
- * Writes GGUF headers for tests, so the reader is exercised against real bytes rather than a stub.
- *
- * @param version the GGUF version to declare; 1 uses 32-bit lengths and counts, 2+ use 64-bit
- */
-internal class GgufWriter(private val version: Int = 3) {
-
- private companion object {
- const val T_UINT32 = 4
- const val T_FLOAT32 = 6
- const val T_STRING = 8
- const val T_ARRAY = 9
- const val T_UINT64 = 10
- }
-
- private val wide = version >= 2
- private val entries = ByteArrayOutputStream()
- private var entryCount = 0L
-
- fun string(key: String, value: String): GgufWriter = entry(key, T_STRING) { writeString(value) }
-
- fun uint32(key: String, value: Long): GgufWriter = entry(key, T_UINT32) { writeU32(value) }
-
- fun uint64(key: String, value: Long): GgufWriter = entry(key, T_UINT64) { writeU64(value) }
-
- fun float32(key: String, value: Float): GgufWriter =
- entry(key, T_FLOAT32) { writeU32(java.lang.Float.floatToIntBits(value).toLong() and 0xFFFFFFFFL) }
-
- /** A tokenizer-sized value, so tests can check that the reader skips past one correctly. */
- fun stringArray(key: String, values: List): GgufWriter = entry(key, T_ARRAY) {
- writeU32(T_STRING.toLong())
- writeCount(values.size.toLong())
- values.forEach { writeString(it) }
- }
-
- /**
- * An array whose declared length lies about how much data follows, for testing the reader's
- * bounds. A real file corrupted mid-download looks like this.
- *
- * @param declaredCount the element count written to the file, however untrue
- * @param values the elements actually written
- */
- fun lyingStringArray(key: String, declaredCount: Long, values: List): GgufWriter =
- entry(key, T_ARRAY) {
- writeU32(T_STRING.toLong())
- writeCount(declaredCount)
- values.forEach { writeString(it) }
- }
-
- /**
- * A string value whose declared length lies about how much data follows. Distinct from
- * [lyingStringArray]: this is one enormous skip rather than many small ones, which is what a
- * byte budget charged only on completion fails to catch.
- */
- fun lyingString(key: String, declaredLength: Long): GgufWriter =
- entry(key, T_STRING) { writeCount(declaredLength) }
-
- /**
- * An array whose elements are themselves arrays. The format forbids this, and following one
- * recurses a level per nesting — the shape that used to end in a StackOverflowError.
- */
- fun nestedArray(key: String, declaredCount: Long = 1L): GgufWriter = entry(key, T_ARRAY) {
- writeU32(T_ARRAY.toLong())
- writeCount(declaredCount)
- }
-
- /** @return the complete header: magic, version, tensor count, entry count, then the entries. */
- fun build(): ByteArray {
- val out = ByteArrayOutputStream()
- out.write("GGUF".toByteArray(Charsets.US_ASCII))
- out.writeU32(version.toLong())
- out.writeCount(0L) // tensor count
- out.writeCount(entryCount)
- out.write(entries.toByteArray())
- return out.toByteArray()
- }
-
- private fun entry(key: String, type: Int, writeValue: ByteArrayOutputStream.() -> Unit): GgufWriter {
- entries.writeString(key)
- entries.writeU32(type.toLong())
- entries.writeValue()
- entryCount++
- return this
- }
-
- private fun ByteArrayOutputStream.writeString(value: String) {
- val bytes = value.toByteArray(Charsets.UTF_8)
- writeCount(bytes.size.toLong())
- write(bytes)
- }
-
- private fun ByteArrayOutputStream.writeCount(value: Long) {
- if (wide) writeU64(value) else writeU32(value)
- }
-
- private fun ByteArrayOutputStream.writeU32(value: Long) {
- for (shift in 0 until 4) write(((value shr (8 * shift)) and 0xFF).toInt())
- }
-
- private fun ByteArrayOutputStream.writeU64(value: Long) {
- for (shift in 0 until 8) write(((value shr (8 * shift)) and 0xFF).toInt())
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelMemoryTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelMemoryTest.kt
deleted file mode 100644
index 265c7503..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelMemoryTest.kt
+++ /dev/null
@@ -1,290 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.viewmodel
-
-import android.content.ContentResolver
-import android.content.Context
-import android.net.Uri
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiCatalogGateway
-import com.itsaky.androidide.plugins.aiassistant.memory.DeviceMemory
-import com.itsaky.androidide.plugins.aiassistant.memory.ModelMemoryGate
-import com.itsaky.androidide.plugins.aiassistant.util.GgufWriter
-import com.itsaky.androidide.plugins.aiassistant.util.ModelFileInfo
-import com.itsaky.androidide.plugins.aiassistant.util.ModelFileSource
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkAll
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.runCurrent
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TemporaryFolder
-import java.io.File
-import java.io.InputStream
-
-/**
- * Tests the memory pre-flight end to end through the ViewModel: what the user is asked and what is
- * persisted — persisting the path is what makes ai-core load, so "not persisted" asserts no load.
- * Free RAM and the file lookup are faked; the model is a real file with a real GGUF header.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class AiSettingsViewModelMemoryTest {
-
- /** The ViewModel touches LiveData in its init block and posts to it from the load. */
- @get:Rule
- val instantTaskExecutorRule = InstantTaskExecutorRule()
-
- @get:Rule
- val tempFolder = TemporaryFolder()
-
- private val megabyte = 1024L * 1024
- private val gigabyte = 1024 * megabyte
-
- /**
- * gemma-3-1b's shape: a 122,683,392-byte KV cache at full context, plus the 256 MB compute
- * buffer. A literal, so this asserts the arithmetic rather than restating it.
- */
- private val expectedRunBytes = 122_683_392L + 256 * megabyte
-
- private val dispatcher = UnconfinedTestDispatcher()
- private lateinit var modelFile: File
- private lateinit var fileSource: FakeModelFileSource
-
- @Before
- fun setUp() {
- Dispatchers.setMain(dispatcher)
- modelFile = tempModel("model.gguf", gguf())
- fileSource = FakeModelFileSource()
- // Only for GgufFileInspector's pre-check; everything else goes through the file source.
- mockkStatic(Uri::class)
- every { Uri.parse(any()) } returns mockk(relaxed = true)
- }
-
- @After
- fun tearDown() {
- Dispatchers.resetMain()
- unmockkAll()
- }
-
- @Test
- fun givenPlentyOfFreeMemory_whenAModelIsSelected_thenItIsAcceptedWithoutAWarning() = runTest {
- val viewModel = viewModel(availableBytes = 8 * gigabyte)
- val asked = collectWarnings(viewModel)
-
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- assertTrue(asked.isEmpty())
- assertEquals(modelFile.absolutePath, viewModel.savedModelPath.value)
- assertTrue(viewModel.modelLoadingState.value is ModelLoadingState.Loaded)
- }
-
- @Test
- fun givenTooLittleFreeMemory_whenAModelIsSelected_thenTheUserIsAskedWithTheFigures() = runTest {
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- val asked = collectWarnings(viewModel)
-
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- assertEquals(1, asked.size)
- val warning = asked.single()
- assertEquals(modelFile.name, warning.modelName)
- assertEquals(modelFile.length(), warning.loadBytes)
- assertEquals(expectedRunBytes, warning.runBytes)
- assertEquals(64 * megabyte, warning.availableBytes)
- assertEquals(ModelMemoryGate.Severity.INSUFFICIENT, warning.severity)
- }
-
- @Test
- fun givenTheMemoryWarning_whenTheUserCancels_thenTheModelIsNeverPersisted() = runTest {
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- collectWarnings(viewModel)
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- viewModel.onMemoryWarningDecision(proceed = false)
- runCurrent()
-
- assertNull(viewModel.savedModelPath.value)
- assertEquals(ModelLoadingState.Idle, viewModel.modelLoadingState.value)
- }
-
- @Test
- fun givenTheMemoryWarning_whenTheUserCancels_thenTheDocumentGrantIsGivenBack() = runTest {
- // The grant is taken before the check runs, and its table has a hard per-app limit.
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- collectWarnings(viewModel)
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- viewModel.onMemoryWarningDecision(proceed = false)
- runCurrent()
-
- assertEquals(listOf(modelFile.absolutePath), fileSource.released)
- }
-
- @Test
- fun givenTheMemoryWarning_whenTheUserProceeds_thenTheGrantIsKept() = runTest {
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- collectWarnings(viewModel)
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- viewModel.onMemoryWarningDecision(proceed = true)
- runCurrent()
-
- assertEquals(emptyList(), fileSource.released)
- }
-
- @Test
- fun givenTheMemoryWarning_whenTheUserProceeds_thenTheModelIsPersistedAnyway() = runTest {
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- collectWarnings(viewModel)
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- viewModel.onMemoryWarningDecision(proceed = true)
- runCurrent()
-
- assertEquals(modelFile.absolutePath, viewModel.savedModelPath.value)
- assertTrue(viewModel.modelLoadingState.value is ModelLoadingState.Loaded)
- }
-
- @Test
- fun givenUnreadableFreeMemory_whenAModelIsSelected_thenItIsAcceptedRatherThanQuestioned() = runTest {
- // Failing open: a device we cannot measure must not be told its model won't fit.
- val viewModel = viewModel(availableBytes = null)
- val asked = collectWarnings(viewModel)
-
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- assertTrue(asked.isEmpty())
- assertEquals(modelFile.absolutePath, viewModel.savedModelPath.value)
- }
-
- @Test
- fun givenAnUnknownFileSize_whenAModelIsSelected_thenItIsAcceptedRatherThanQuestioned() = runTest {
- fileSource.sizeOverride = null
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- val asked = collectWarnings(viewModel)
-
- viewModel.loadModelFromUri(modelFile.absolutePath, androidContext(modelFile))
- runCurrent()
-
- assertTrue(asked.isEmpty())
- assertEquals(modelFile.absolutePath, viewModel.savedModelPath.value)
- }
-
- @Test
- fun givenAModelWithoutAReadableHeader_whenSelected_thenTheSizeBasedEstimateIsUsed() = runTest {
- // Valid magic, nothing usable behind it: the estimate falls back instead of vanishing.
- val headerless = tempModel("headerless.gguf", "GGUF".toByteArray() + ByteArray(8))
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- val asked = collectWarnings(viewModel)
-
- viewModel.loadModelFromUri(headerless.absolutePath, androidContext(headerless))
- runCurrent()
-
- assertEquals(256 * megabyte, asked.single().runBytes)
- }
-
- @Test
- fun givenAFileThatIsNotAModel_whenSelected_thenItIsRejectedBeforeTheMemoryCheck() = runTest {
- val notAModel = tempModel("notes.txt", "nowhere near a model file".toByteArray())
- val viewModel = viewModel(availableBytes = 64 * megabyte)
- val asked = collectWarnings(viewModel)
-
- viewModel.loadModelFromUri(notAModel.absolutePath, androidContext(notAModel))
- runCurrent()
-
- assertTrue(asked.isEmpty())
- assertNull(viewModel.savedModelPath.value)
- assertTrue(viewModel.modelLoadingState.value is ModelLoadingState.Error)
- }
-
- private fun viewModel(availableBytes: Long?) = AiSettingsViewModel(
- getContext = { null },
- ioDispatcher = dispatcher,
- catalogGateway = mockk(relaxed = true),
- deviceMemory = DeviceMemory { availableBytes },
- modelFiles = fileSource,
- )
-
- /** Collects the warnings the ViewModel raises, so tests can assert on what the user is shown. */
- private fun kotlinx.coroutines.test.TestScope.collectWarnings(
- viewModel: AiSettingsViewModel
- ): List {
- val asked = mutableListOf()
- backgroundScope.launch { viewModel.modelMemoryWarnings.collect { asked += it } }
- return asked
- }
-
- /**
- * Serves the model file over a mocked resolver, which is all GgufFileInspector's magic-byte
- * pre-check needs. Name, size and the header read go through [fileSource] instead.
- */
- private fun androidContext(file: File): Context {
- val resolver = mockk()
- every { resolver.openInputStream(any()) } answers { file.inputStream() }
- return mockk(relaxed = true) {
- every { contentResolver } returns resolver
- }
- }
-
- private fun tempModel(name: String, bytes: ByteArray): File =
- tempFolder.newFile(name).apply { writeBytes(bytes) }
-
- private fun gguf(): ByteArray = GgufWriter()
- .string("general.architecture", "gemma3")
- .uint32("gemma3.block_count", 26)
- .uint32("gemma3.embedding_length", 1152)
- .uint32("gemma3.attention.head_count", 4)
- .uint32("gemma3.attention.head_count_kv", 1)
- .build()
-
- /**
- * Reads the real files these tests write, with no Android framework on the path — which is what
- * the ViewModel taking a [ModelFileSource] instead of a ContentResolver buys.
- */
- private class FakeModelFileSource : ModelFileSource {
-
- /** Set to null to simulate a provider that will not report a size. */
- var sizeOverride: Long? = UNSET
-
- val released = mutableListOf()
-
- override fun info(context: Context, uriString: String): ModelFileInfo {
- val file = File(uriString)
- val size = if (sizeOverride == UNSET) file.length().takeIf { it > 0L } else sizeOverride
- return ModelFileInfo(file.name, size)
- }
-
- override fun openStream(context: Context, uriString: String): InputStream? =
- File(uriString).takeIf { it.isFile }?.inputStream()
-
- override fun fallbackDisplayName(uriOrPath: String): String =
- uriOrPath.substringAfterLast('/')
-
- override fun releaseAccess(context: Context, uriString: String) {
- released += uriString
- }
-
- private companion object {
- /** Distinguishes "use the real file length" from a deliberate null. */
- const val UNSET = Long.MIN_VALUE
- }
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt
deleted file mode 100644
index 12412470..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModelVerifyTest.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.viewmodel
-
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import com.itsaky.androidide.plugins.aiassistant.gemini.CatalogResult
-import com.itsaky.androidide.plugins.aiassistant.gemini.GeminiCatalogGateway
-import com.itsaky.androidide.plugins.aiassistant.gemini.KeyVerification
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Rule
-import org.junit.Test
-import java.io.IOException
-
-/**
- * Tests for the pre-save key check.
- *
- * The gateway is faked, so no ai-core, no network and no device are involved — which is the point
- * of having extracted it out of the ViewModel's raw reflection.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class AiSettingsViewModelVerifyTest {
-
- /** The ViewModel touches LiveData in its init block. */
- @get:Rule
- val instantTaskExecutorRule = InstantTaskExecutorRule()
-
- private val candidateKey = "AIzaSyD-EXAMPLE_key_value_1234567890abc"
-
- /** No PluginContext in a JVM test; verification never needs prefs, only the gateway. */
- private fun viewModel(gateway: GeminiCatalogGateway) = AiSettingsViewModel(
- getContext = { null },
- ioDispatcher = UnconfinedTestDispatcher(),
- catalogGateway = gateway
- )
-
- @Test
- fun givenAWorkingKey_whenVerified_thenItIsVerifiedWithItsModelCount() = runTest {
- val gateway = FakeGateway(CatalogResult.Success(listOf("gemini-2.5-flash", "gemini-2.5-pro")))
-
- val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
-
- assertEquals(KeyVerification.Verified(2), verdict)
- }
-
- @Test
- fun givenATypedKey_whenVerified_thenThatKeyIsCheckedAndNotTheSavedOne() = runTest {
- // Checking the *saved* key would clear a candidate on a different credential.
- val gateway = FakeGateway(CatalogResult.Success(listOf("gemini-2.5-flash")))
-
- viewModel(gateway).verifyGeminiKey(" $candidateKey ")
-
- assertEquals(listOf(candidateKey), gateway.candidateKeys)
- assertEquals(0, gateway.savedKeyCalls)
- }
-
- @Test
- fun givenAKeyGoogleRefuses_whenVerified_thenItIsRejected() = runTest {
- val gateway = FakeGateway(
- CatalogResult.Failed(IOException("ListModels HTTP 400: {\"error\":{}}"))
- )
-
- val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
-
- assertEquals(KeyVerification.Rejected, verdict)
- }
-
- @Test
- fun givenNoNetwork_whenVerified_thenReportsUnreachableSoTheKeyIsNotCondemned() = runTest {
- val gateway = FakeGateway(CatalogResult.Failed(IOException("Unable to resolve host")))
-
- val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
-
- assertEquals(KeyVerification.Unreachable, verdict)
- }
-
- @Test
- fun givenAMissingAiCore_whenVerified_thenReportsUnknown() = runTest {
- val verdict = viewModel(FakeGateway(CatalogResult.NoBackend)).verifyGeminiKey(candidateKey)
-
- assertEquals(KeyVerification.Unknown, verdict)
- }
-
- @Test
- fun givenAGatewayThatThrows_whenVerified_thenItCannotBeMistakenForAPass() = runTest {
- val gateway = FakeGateway(error = IllegalStateException("classloader trouble"))
-
- val verdict = viewModel(gateway).verifyGeminiKey(candidateKey)
-
- assertEquals(KeyVerification.Unknown, verdict)
- assertTrue(!verdict.isConfirmedValid)
- }
-
- @Test
- fun givenABlankKey_whenVerified_thenItIsRejectedWithoutANetworkRoundTrip() = runTest {
- val gateway = FakeGateway(CatalogResult.Success(listOf("gemini-2.5-flash")))
-
- val verdict = viewModel(gateway).verifyGeminiKey(" ")
-
- assertEquals(KeyVerification.Rejected, verdict)
- assertTrue(gateway.candidateKeys.isEmpty())
- }
-
- /** Records what it was asked, so tests can assert *which* key got checked. */
- private class FakeGateway(
- private val response: CatalogResult? = null,
- private val error: Throwable? = null
- ) : GeminiCatalogGateway {
-
- val candidateKeys = mutableListOf()
- var savedKeyCalls = 0
-
- override fun listModelsForSavedKey(): CatalogResult {
- savedKeyCalls++
- return response ?: CatalogResult.NoBackend
- }
-
- override fun listModels(apiKey: String): CatalogResult {
- candidateKeys += apiKey
- error?.let { throw it }
- return response ?: CatalogResult.NoBackend
- }
- }
-}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/UserConfirmationTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/UserConfirmationTest.kt
deleted file mode 100644
index 113c61af..00000000
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/UserConfirmationTest.kt
+++ /dev/null
@@ -1,177 +0,0 @@
-package com.itsaky.androidide.plugins.aiassistant.viewmodel
-
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.async
-import kotlinx.coroutines.cancelAndJoin
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.runCurrent
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-/**
- * Tests for the await-the-user primitive behind the memory warning.
- *
- * This is where the feature's concurrency lives, so it is tested directly: whether a decision is
- * delivered, what happens to a question nobody answers, and that two selections cannot interleave.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class UserConfirmationTest {
-
- @Test
- fun givenAQuestion_whenTheUserProceeds_thenAskReturnsTrue() = runTest {
- val confirmation = UserConfirmation()
-
- val answer = async { confirmation.ask("model.gguf") }
- assertEquals("model.gguf", confirmation.requests.first())
- confirmation.answer(true)
-
- assertTrue(answer.await())
- }
-
- @Test
- fun givenAQuestion_whenTheUserCancels_thenAskReturnsFalse() = runTest {
- val confirmation = UserConfirmation()
-
- val answer = async { confirmation.ask("model.gguf") }
- confirmation.requests.first()
- confirmation.answer(false)
-
- assertFalse(answer.await())
- }
-
- @Test
- fun givenAQuestionOnTheWay_whenNobodyHasAnsweredYet_thenAskIsStillWaiting() = runTest {
- val confirmation = UserConfirmation()
-
- val answer = async { confirmation.ask("model.gguf") }
- runCurrent()
-
- assertTrue(answer.isActive)
- confirmation.answer(false)
- assertFalse(answer.await())
- }
-
- @Test
- fun givenAnAnswerWithNothingPending_whenTheNextQuestionIsAsked_thenItIsNotPreAnswered() = runTest {
- // A stray reply from a dismissed dialog must not decide the following selection.
- val confirmation = UserConfirmation()
- confirmation.answer(true)
-
- val answer = async { confirmation.ask("model.gguf") }
- runCurrent()
-
- assertTrue(answer.isActive)
- confirmation.answer(false)
- assertFalse(answer.await())
- }
-
- @Test
- fun givenAnAnsweredQuestion_whenAnsweredAgain_thenTheDuplicateIsIgnored() = runTest {
- // A recreated dialog reporting the same decision twice is harmless.
- val confirmation = UserConfirmation()
-
- val answer = async { confirmation.ask("model.gguf") }
- confirmation.requests.first()
- confirmation.answer(true)
- confirmation.answer(false)
-
- assertTrue(answer.await())
- }
-
- @Test
- fun givenAQuestionAwaitingAnAnswer_whenAnotherIsAsked_thenItWaitsItsTurn() = runTest {
- val confirmation = UserConfirmation()
- val asked = mutableListOf()
- backgroundScope.launch { confirmation.requests.collect { asked += it } }
-
- val first = async { confirmation.ask("first.gguf") }
- val second = async { confirmation.ask("second.gguf") }
- runCurrent()
-
- // Only one question is outstanding, so an answer can never be attributed to the wrong one.
- assertEquals(listOf("first.gguf"), asked)
-
- confirmation.answer(true)
- runCurrent()
- assertEquals(listOf("first.gguf", "second.gguf"), asked)
-
- confirmation.answer(false)
- assertTrue(first.await())
- assertFalse(second.await())
- }
-
- @Test
- fun givenAQuestionRaised_whenTheCollectorIsReplaced_thenTheNewOneIsStillAsked() = runTest {
- // A rotation replaces the collector; the question must not vanish with the old view.
- val confirmation = UserConfirmation()
- val firstCollector = launch { confirmation.requests.collect { } }
-
- val answer = async { confirmation.ask("model.gguf") }
- runCurrent()
- firstCollector.cancelAndJoin()
-
- assertEquals("model.gguf", confirmation.requests.first())
- confirmation.answer(true)
- assertTrue(answer.await())
- }
-
- @Test
- fun givenAnAnsweredQuestion_whenACollectorSubscribesAfterwards_thenItIsNotAskedAgain() = runTest {
- // The withdrawn question must not reappear and re-show the dialog on the next resume.
- val confirmation = UserConfirmation()
- val asked = mutableListOf()
-
- val answer = async { confirmation.ask("model.gguf") }
- confirmation.requests.first()
- confirmation.answer(true)
- assertTrue(answer.await())
-
- backgroundScope.launch { confirmation.requests.collect { asked += it } }
- runCurrent()
-
- assertEquals(emptyList(), asked)
- }
-
- @Test
- fun givenNoQuestionEverAsked_whenInspected_thenNothingIsOutstanding() {
- // After process death the UI may hold a dialog for a question this object never saw.
- assertFalse(UserConfirmation().hasOutstandingRequest)
- }
-
- @Test
- fun givenAQuestionOnTheWay_whenInspected_thenItIsOutstandingUntilAnswered() = runTest {
- val confirmation = UserConfirmation()
-
- val answer = async { confirmation.ask("model.gguf") }
- runCurrent()
- assertTrue(confirmation.hasOutstandingRequest)
-
- confirmation.answer(true)
- answer.await()
-
- assertFalse(confirmation.hasOutstandingRequest)
- }
-
- @Test
- fun givenAnAbandonedQuestion_whenTheCallerIsCancelled_thenTheNextQuestionStillGetsThrough() = runTest {
- // The ViewModel being cleared mid-dialog must not wedge the next selection.
- val confirmation = UserConfirmation()
- val asked = mutableListOf()
- backgroundScope.launch { confirmation.requests.collect { asked += it } }
-
- val abandoned = launch { confirmation.ask("abandoned.gguf") }
- runCurrent()
- abandoned.cancelAndJoin()
-
- val answer = async { confirmation.ask("next.gguf") }
- runCurrent()
- confirmation.answer(true)
-
- assertTrue(answer.await())
- assertEquals(listOf("abandoned.gguf", "next.gguf"), asked)
- }
-}
diff --git a/ai-backend-gemini/.gitignore b/ai-backend-gemini/.gitignore
new file mode 100644
index 00000000..5380c6d5
--- /dev/null
+++ b/ai-backend-gemini/.gitignore
@@ -0,0 +1,3 @@
+**/.cxx/
+build-output.log
+**/.kotlin/
diff --git a/ai-backend-gemini/README.md b/ai-backend-gemini/README.md
new file mode 100644
index 00000000..38f922e2
--- /dev/null
+++ b/ai-backend-gemini/README.md
@@ -0,0 +1,60 @@
+# AI Gemini Backend plugin for CodeOnTheGo
+
+Google Gemini API inference for CodeOnTheGo's AI plugins. Registers itself as the
+`gemini` backend with [`ai-core`](../ai-core/)'s `LlmInferenceService`, which is
+what `ai-assistant`, `code-suggestions-plugin`, `speech-to-text-plugin` and
+`vector-search-plugin` actually talk to.
+
+Calls the Generative Language REST API directly over `HttpURLConnection` rather
+than the google-genai SDK: the SDK bundles OkHttp 4.x, but plugins run in the
+host IDE's classloader where `okhttp3` resolves to the host's older OkHttp, and
+that mismatch crashed generation with a `NoSuchMethodError`.
+
+## Building
+
+Prerequisites: Android SDK (API 33+), JDK 17. Create `local.properties` with
+`sdk.dir=...`. This plugin uses the shared wrapper at the repo root:
+
+```bash
+cd ai-backend-gemini
+../gradlew assemblePlugin # release -> build/plugin/ai-backend-gemini.cgp
+../gradlew assemblePluginDebug # debug variant
+```
+
+## API key handling
+
+The key is entered in **AI Assistant → AI Settings**, not here. It is stored
+encrypted (AES/GCM under a hardware-backed Android Keystore secret) and sent as
+an `x-goog-api-key` **header**, never in a URL query string.
+
+`SecureApiKeyStore.kt` is duplicated verbatim from `ai-assistant` so a key written
+there decrypts here — both plugins share one Keystore because they run in the host
+app's process. The `verifySecureApiKeyStoreParity` task in `build.gradle.kts` fails
+the build if the crypto constants drift, because that failure would otherwise only
+surface on a device as "backend not available".
+
+## Installation
+
+Install **`ai-core` as well** — without the router this plugin has nothing to
+register with. Order does not matter: this plugin re-registers when it sees
+ai-core activate. Copy `build/plugin/ai-backend-gemini.cgp` to the device, install
+via CodeOnTheGo's Plugin Manager, then restart the IDE.
+
+## Cross-plugin contract
+
+`ai-assistant` reaches `GeminiBackend.listModels()` and `listModels(String)`
+reflectively (see its `ReflectiveGeminiCatalogGateway`) to populate the model
+picker and to verify a key before it is saved. Those two signatures, and the
+`ListModels HTTP ` message shape thrown by `fetchAvailableModels`, are a
+contract — `proguard-rules.pro` pins the methods.
+
+## Key classes
+
+- `GeminiPlugin.kt` — plugin entry point; registers the backend with ai-core
+- `GeminiBackend.kt` — the REST transport, streaming (SSE) and model catalog
+- `GeminiErrorFormatter.kt` — turns an API failure into one translated sentence
+- `SecureApiKeyStore.kt` — AES/GCM at rest, mirrored from ai-assistant
+
+## License
+
+GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
diff --git a/ai-backend-gemini/ai-backend-gemini.html b/ai-backend-gemini/ai-backend-gemini.html
new file mode 100644
index 00000000..694b1c95
--- /dev/null
+++ b/ai-backend-gemini/ai-backend-gemini.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+AI Gemini Backend Plugin
+
+
+
+ AI Gemini Backend Plugin
+
+ Executive overview
+ AI Gemini Backend adds Google's Gemini models to CodeOnTheGo's
+ AI features. It is a headless plugin with no screens of its own: it
+ registers itself as the gemini inference backend with AI
+ Core , which routes requests from AI Assistant , Code
+ Suggestions , Speech to Text and Vector Search .
+ Install AI Core alongside it — without the router this plugin has
+ nothing to register with. Install order does not matter.
+ Using this backend sends prompts, and any file contents a plugin includes in
+ them, to Google over HTTPS. Choose AI Local Backend instead if inference
+ must stay on the device.
+
+ Core functionality
+
+ Cloud inference via direct calls to the Gemini REST API, with text
+ completion, chat with history, and server-sent-events streaming.
+ Live model catalog — lists the models the saved key can actually
+ use, so a retired model name is never offered.
+ Key verification before saving — a newly entered key is checked
+ against the catalog, distinguishing a refused key from an unreachable
+ network.
+ Encrypted at rest — the API key is stored as AES/GCM ciphertext
+ under a hardware-backed Android Keystore secret, and a pre-existing
+ plaintext key is upgraded in place on first read.
+ Translated, safe error messages — an API failure becomes one
+ user-facing sentence; the raw HTTP error body stays in the log and never
+ reaches the chat transcript.
+
+
+ Technical architecture
+
+ Component Role
+ GeminiPluginPlugin entry point. Registers the
+ backend with AI Core on activation, re-registering if AI Core activates
+ later; cancels in-flight requests and drops the decrypted key on
+ dispose.
+ GeminiBackendThe transport. Calls the
+ Generative Language REST API over HttpURLConnection, parses the
+ streaming response, and fetches the model catalog.
+ GeminiErrorFormatterClassifies a failure
+ (retired model, quota, refused key, outage, unreachable) so it can be
+ reported as one translated sentence.
+ SecureApiKeyStoreAES/GCM encryption of the API
+ key, keyed by an Android Keystore secret shared with AI Assistant.
+
+ No third-party HTTP SDK. The google-genai SDK bundles OkHttp 4.x, but
+ plugins run in the host IDE's classloader where okhttp3 resolves to
+ the host's older OkHttp — a mismatch that crashed generation with a
+ NoSuchMethodError. HttpURLConnection has no
+ third-party dependency and works regardless of the host's OkHttp version.
+
+ Usage
+
+ Obtain a Gemini API key from Google AI Studio.
+ Install AI Core and AI Gemini Backend via the Plugin
+ Manager, then restart the IDE.
+ Install a consumer plugin (e.g. AI Assistant ).
+ Open AI Assistant → AI Settings , enter the key (it is verified
+ before it is saved), and pick a model from the live list.
+
+
+ The key is sent as an x-goog-api-key request header, never in a
+ URL query string — query strings leak into logs, proxies and crash reports.
+
+
+ Key benefits
+
+ High capability — frontier-class models on a device that could not
+ run them locally.
+ Small footprint — no bundled model or native library.
+ Credential hygiene — encrypted at rest, header-only in transit,
+ dropped from memory when the plugin unloads.
+ No dead model names — the picker reflects the live catalog for your
+ specific key.
+ Coexists with the local backend — install both and switch in AI
+ Settings.
+
+
+
diff --git a/ai-backend-gemini/build.gradle.kts b/ai-backend-gemini/build.gradle.kts
new file mode 100644
index 00000000..047abd0f
--- /dev/null
+++ b/ai-backend-gemini/build.gradle.kts
@@ -0,0 +1,143 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "ai-backend-gemini"
+}
+
+android {
+ namespace = "com.itsaky.androidide.plugins.aigemini"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.itsaky.androidide.plugins.aigemini"
+ minSdk = 33
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ isShrinkResources = false
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt",
+ "META-INF/INDEX.LIST"
+ )
+ }
+ }
+}
+
+dependencies {
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ // 'implementation' (not 'compileOnly') for the androidx/Material libraries: AAPT2 needs them
+ // at compile time to process the settings pane's layout, as in every CoGo plugin with XML.
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.fragment:fragment-ktx:1.8.8")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ testImplementation(files("../libs/plugin-api.jar"))
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("io.mockk:mockk:1.13.8")
+ testImplementation("org.json:json:20231013")
+}
+
+/**
+ * Fails the build when the crypto constants of ai-assistant's and this plugin'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-backend-gemini and ai-assistant's SecureApiKeyStore crypto constants differ."
+
+ val ours = file("src/main/kotlin/com/itsaky/androidide/plugins/aigemini/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-backend-gemini 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-backend-gemini=${ourConstants[it]} ai-assistant=${theirConstants[it]}")
+ }
+ }
+ throw GradleException(
+ "SecureApiKeyStore crypto constants differ between ai-backend-gemini 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.
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") ||
+ it.name.contains("checkReleaseAarMetadata")
+}.configureEach { enabled = false }
diff --git a/ai-backend-gemini/gradle.properties b/ai-backend-gemini/gradle.properties
new file mode 100644
index 00000000..fcd58cda
--- /dev/null
+++ b/ai-backend-gemini/gradle.properties
@@ -0,0 +1,10 @@
+android.enableJetifier=false
+android.jetifier.ignorelist=common-30.2.2.jar
+android.nonTransitiveRClass=false
+android.useAndroidX=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
+org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
+org.gradle.parallel=true
+
+kotlin.code.style=official
diff --git a/ai-backend-gemini/proguard-rules.pro b/ai-backend-gemini/proguard-rules.pro
new file mode 100644
index 00000000..2cfa0f9c
--- /dev/null
+++ b/ai-backend-gemini/proguard-rules.pro
@@ -0,0 +1,16 @@
+# AI Gemini Backend Plugin ProGuard Rules
+
+# Keep plugin entry point
+-keep public class com.itsaky.androidide.plugins.aigemini.GeminiPlugin {
+ public ;
+}
+
+# Keep the backend: ai-assistant resolves listModels reflectively across the
+# plugin classloader boundary, because listModels is not on LlmBackend. Renaming
+# or stripping it breaks the model picker and key verification silently.
+-keep public class com.itsaky.androidide.plugins.aigemini.GeminiBackend {
+ public ;
+}
+
+# Keep plugin-api interfaces
+-keep interface com.itsaky.androidide.plugins.** { *; }
diff --git a/ai-backend-gemini/settings.gradle.kts b/ai-backend-gemini/settings.gradle.kts
new file mode 100644
index 00000000..626c8d17
--- /dev/null
+++ b/ai-backend-gemini/settings.gradle.kts
@@ -0,0 +1,35 @@
+@file:Suppress("UnstableApiUsage")
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.13.2")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "ai-backend-gemini"
diff --git a/ai-backend-gemini/src/main/AndroidManifest.xml b/ai-backend-gemini/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a69d9b07
--- /dev/null
+++ b/ai-backend-gemini/src/main/AndroidManifest.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ai-backend-gemini/src/main/assets/docs/index.html b/ai-backend-gemini/src/main/assets/docs/index.html
new file mode 100644
index 00000000..b5300d80
--- /dev/null
+++ b/ai-backend-gemini/src/main/assets/docs/index.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+AI Gemini Backend — Guide
+
+
+
+ AI Gemini Backend — Cloud Inference
+
+ AI Gemini Backend is a headless plugin: it has no screens of its own.
+ It adds the gemini backend to AI Core , which routes requests
+ from AI Assistant , Code Suggestions , Speech to Text and
+ Vector Search . Install AI Core as well — without it this plugin
+ has nothing to register with.
+
+ Setup
+
+ Get a Gemini API key from Google AI Studio.
+ Enter it in AI Assistant → AI Settings . The key is verified against
+ Google's model catalog before it is saved.
+ Pick a model in the same screen. The list is fetched live, so it never
+ offers a retired model.
+
+
+
+ The key is stored encrypted with AES/GCM under a hardware-backed Android
+ Keystore secret, and is sent as an x-goog-api-key header — never
+ in a URL, where it would leak into logs and proxies.
+
+
+ Privacy
+ Prompts, and any file contents a plugin includes in them, are transmitted to
+ Google over HTTPS. If you need inference that stays on the device, install
+ AI Local Backend instead and select it in AI Settings.
+
+ Troubleshooting
+
+ "Google refused your Gemini API key" — the key is wrong or lacks
+ access; re-enter it in AI Settings.
+ "The model … is no longer available" — tap Refresh Models in
+ AI Settings and pick another one.
+ "Could not reach Gemini" — no network route; check the connection.
+ Rate limit or quota — wait, or check usage in Google AI Studio.
+ The gemini backend never appears — AI Core isn't
+ installed or activated; install it and restart the IDE.
+
+
+
diff --git a/ai-backend-gemini/src/main/assets/icon_day.png b/ai-backend-gemini/src/main/assets/icon_day.png
new file mode 100644
index 0000000000000000000000000000000000000000..0102f74b648b313c917e27cf7ef5f8a3bd5e9b36
GIT binary patch
literal 15407
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
Z^#47HYtmp4HTVDk002ovPDHLkV1iZm)ZYLA
literal 0
HcmV?d00001
diff --git a/ai-backend-gemini/src/main/assets/icon_night.png b/ai-backend-gemini/src/main/assets/icon_night.png
new file mode 100644
index 0000000000000000000000000000000000000000..070800ae5daddbbf1b32467f76125c5da7ca03fa
GIT binary patch
literal 16365
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