Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego
| [`ai-core/`](ai-core/) | Shared on-device LLM inference backend (bundled llama.cpp AAR) plus a Gemini API backend, exposed to other plugins as a runtime service. |
| [`ai-assistant/`](ai-assistant/) | In-IDE AI chat assistant with tool calling; talks to `ai-core` for inference over local or Gemini models. |
| [`flutter-template/`](flutter-template/) | Adds Flutter starter project templates (Basic, BLoC, Provider, GetX, Riverpod) to the New Project screen. |
| [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. |
| [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. |
| [`vector-search-plugin/`](vector-search-plugin/) | Semantic code search using embeddings and vector similarity. |

## Building a plugin

Expand Down
16 changes: 16 additions & 0 deletions ai-assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ The build resolves `plugin-api.jar` from the repo-root `../libs/`.
- `fragments/AiSettingsFragment.kt`, `viewmodel/AiSettingsViewModel.kt` — model/backend config
- `tool/` — the agent tool-loop (executor, router, per-tool handlers, approval)

## Security

- **Gemini API key at rest.** When you use the Gemini backend, the API key is
stored in this plugin's private `SharedPreferences` (`AgentSettings`). That
store is **app-sandboxed** — other apps cannot read it — but it is **not
encrypted at rest**, so it is recoverable on a rooted or otherwise compromised
device. Remove it any time from **AI Settings** (or `clearGeminiApiKey()`).
Encryption is deliberately not layered on here: the key is shared at runtime
with the sibling `ai-core` plugin (which performs the Gemini calls), and
`EncryptedSharedPreferences` would force both independent plugins to agree on a
master-key alias and crypto library version — a fragile cross-plugin coupling.
A host-provided secure-storage service is the correct long-term fix.
- **Gemini API key in transit.** The key is sent to Google as an `x-goog-api-key`
request header (and via the SDK on the chat path), never in a URL query string.
- **File tools are confined to the project root** — see the in-IDE help page.

## License

GPL-3.0 — same as AndroidIDE / CodeOnTheGo.
Binary file modified ai-assistant/src/main/assets/icon_day.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ai-assistant/src/main/assets/icon_night.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,6 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
}
}

// Note: Encrypted Gemini API key migration handled by EncryptedPrefs

if (migratedCount > 0) {
context.logger.info("Migrated $migratedCount settings from app to plugin")
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import java.util.Locale

class AiSettingsFragment : DialogFragment() {

companion object {
/** FragmentResult key signalling the chat screen that settings were closed. */
const val RESULT_SETTINGS_CLOSED = "ai_settings_closed"
}

private lateinit var viewModel: AiSettingsViewModel
private lateinit var settingsToolbar: LinearLayout
private lateinit var backButton: ImageButton
Expand Down Expand Up @@ -77,6 +82,15 @@ class AiSettingsFragment : DialogFragment() {
setupBackendSelector()
}

override fun onDismiss(dialog: android.content.DialogInterface) {
super.onDismiss(dialog)
// This is a dialog, so the chat screen behind it never gets onResume when we close.
// Signal it to re-resolve the selected backend (routing + availability + label).
if (isAdded) {
parentFragmentManager.setFragmentResult(RESULT_SETTINGS_CLOSED, Bundle.EMPTY)
}
}

private fun initializeViewModel() {
viewModel = ViewModelProvider(
this,
Expand Down Expand Up @@ -213,7 +227,7 @@ class AiSettingsFragment : DialogFragment() {

if (path != null) {
modelPathTextView.visibility = View.VISIBLE
val fileName = path.substringAfterLast("/")
val fileName = viewModel.getSavedModelName() ?: viewModel.fallbackDisplayName(path)
modelPathTextView.text = "Saved: $fileName"
} else {
modelPathTextView.visibility = View.GONE
Expand Down Expand Up @@ -387,11 +401,18 @@ class AiSettingsFragment : DialogFragment() {
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
modelSpinner.adapter = adapter

// Set current selection
// Set current selection. If the saved model is no longer offered
// migrate to the first available one so the next chat request
// doesn't 404 on a dead model name.
val currentModel = viewModel.getGeminiModel()
val currentIndex = models.indexOf(currentModel)
if (currentIndex >= 0) {
modelSpinner.setSelection(currentIndex)
} else if (models.isNotEmpty()) {
modelSpinner.setSelection(0)
val migrated = models[0]
viewModel.saveGeminiModel(migrated)
currentModelText?.text = "Current: $migrated"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ class ChatFragment : Fragment() {
setupBackendIndicator()
observeViewModel()

// The settings screen is a DialogFragment, so this fragment's onResume does not fire
// when it closes. Listen for its dismissal to re-resolve the selected backend (routing +
// availability) and refresh the indicator label.
parentFragmentManager.setFragmentResultListener(
AiSettingsFragment.RESULT_SETTINGS_CLOSED, viewLifecycleOwner
) { _, _ ->
viewModel.checkBackendAvailability()
viewModel.refreshBackendLabel()
}

// Check for test prompt from broadcast receiver (E2E testing)
injectPendingTestPrompt()
}
Expand Down Expand Up @@ -139,6 +149,8 @@ class ChatFragment : Fragment() {
// Check backend availability when fragment becomes visible
// This ensures we check after all plugins have loaded
viewModel.checkBackendAvailability()
// Reflect the currently selected backend (updates after returning from settings).
viewModel.refreshBackendLabel()
}

private fun initializeViewModel() {
Expand Down Expand Up @@ -225,7 +237,13 @@ class ChatFragment : Fragment() {
}

private fun setupBackendIndicator() {
binding.backendStatusText.text = "Gemini"
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.activeBackendLabel.collect { label ->
binding.backendStatusText.text = label
}
}
}
}

private fun observeViewModel() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ class AiSettingsViewModel(
private val getContext: () -> com.itsaky.androidide.plugins.PluginContext?
) : ViewModel() {

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",
)
}

private val _savedModelPath = MutableLiveData<String?>(null)
val savedModelPath: LiveData<String?> get() = _savedModelPath

Expand All @@ -57,15 +71,59 @@ class AiSettingsViewModel(

private fun checkInitialState() {
val prefs = getPluginPrefs()
_savedModelPath.value = prefs?.getString("local_llm_model_path", null)
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 = ModelLoadingState.Idle
// Reflect a previously selected model so it survives closing/reopening settings,
// using the display name persisted at load time (no content-provider query here).
_modelLoadingState.value = if (savedPath != null) {
ModelLoadingState.Loaded(getSavedModelName() ?: fallbackDisplayName(savedPath))
} else {
ModelLoadingState.Idle
}
}

private fun getPluginPrefs() = getContext()?.getPluginSharedPreferences("AgentSettings")

/** 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 =
(try { android.net.Uri.decode(uriOrPath) } catch (e: Exception) { uriOrPath }).substringAfterLast('/')

/**
* Resolve the real file name for a selected model. For a `content://` URI this queries the
* document provider's [OpenableColumns.DISPLAY_NAME] (e.g. "Llama-3.2-1B.gguf"); otherwise,
* and on any failure, it falls back to the decoded last path segment. Do NOT call on the main
* thread — the provider query can block.
*/
private fun resolveDisplayName(uriString: String): String {
if (uriString.startsWith("content://")) {
try {
val uri = android.net.Uri.parse(uriString)
getContext()?.androidContext?.contentResolver
?.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c ->
val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && c.moveToFirst() && !c.isNull(idx)) {
c.getString(idx)?.takeIf { it.isNotBlank() }?.let { return it }
}
}
} catch (e: Exception) {
android.util.Log.w(TAG, "Could not resolve display name for $uriString", e)
}
}
return fallbackDisplayName(uriString)
}

fun getAvailableBackends(): List<AiBackend> = AiBackend.entries

fun saveBackend(backend: AiBackend) {
Expand Down Expand Up @@ -120,6 +178,12 @@ class AiSettingsViewModel(
return getPluginPrefs()?.getBoolean("use_simple_local_prompt", true) ?: true
}

/**
* Persists the Gemini API key in this plugin's private SharedPreferences.
* The store is app-sandboxed (not world-readable) but NOT encrypted at rest;
* it is recoverable on a rooted/compromised device. Use [clearGeminiApiKey]
* to remove it. See the plugin README "Security" section for the tradeoff.
*/
fun saveGeminiApiKey(apiKey: String) {
getPluginPrefs()?.edit()?.apply {
putString("gemini_api_key", apiKey)
Expand Down Expand Up @@ -152,7 +216,7 @@ class AiSettingsViewModel(
}

fun getGeminiModel(): String {
return getPluginPrefs()?.getString("gemini_model", "gemini-1.5-flash") ?: "gemini-1.5-flash"
return getPluginPrefs()?.getString("gemini_model", DEFAULT_GEMINI_MODEL) ?: DEFAULT_GEMINI_MODEL
}

private val _geminiModels = MutableLiveData<List<String>>(emptyList())
Expand All @@ -161,77 +225,52 @@ class AiSettingsViewModel(
private val _geminiModelsLoading = MutableLiveData<Boolean>(false)
val geminiModelsLoading: LiveData<Boolean> 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 {
// Get the LlmInferenceService from SharedServices
val llmService = SharedServices.get(LlmInferenceService::class.java)
if (llmService == null) {
android.util.Log.e("AiSettingsViewModel", "LlmInferenceService not available")
_geminiModels.postValue(listOf(
"gemini-1.5-flash",
"gemini-1.5-pro",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-flash",
"gemini-3.5-flash"
))
_geminiModelsLoading.postValue(false)
val apiKey = getGeminiApiKey()?.trim()
if (apiKey.isNullOrBlank()) {
android.util.Log.w(TAG, "No Gemini API key saved; showing fallback models")
_geminiModels.postValue(FALLBACK_MODELS)
return@launch
}

// Get the Gemini backend
val geminiBackend = llmService.getBackend("gemini")
val llmService = SharedServices.get(LlmInferenceService::class.java)
val geminiBackend = llmService?.getBackend("gemini")
if (geminiBackend == null) {
android.util.Log.e("AiSettingsViewModel", "Gemini backend not available")
_geminiModels.postValue(listOf(
"gemini-1.5-flash",
"gemini-1.5-pro",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-flash",
"gemini-3.5-flash"
))
_geminiModelsLoading.postValue(false)
android.util.Log.e(TAG, "Gemini backend not available")
_geminiModels.postValue(FALLBACK_MODELS)
return@launch
}

// Try to get list models method via reflection
// (since GeminiBackend is not in the interface)
try {
val listModelsMethod = geminiBackend.javaClass.getMethod("listModels")
val futureResult = listModelsMethod.invoke(geminiBackend)

if (futureResult is java.util.concurrent.CompletableFuture<*>) {
@Suppress("UNCHECKED_CAST")
val models = (futureResult as java.util.concurrent.CompletableFuture<List<String>>).get()
android.util.Log.d("AiSettingsViewModel", "Fetched ${models.size} Gemini models")
_geminiModels.postValue(models)
}
} catch (e: Exception) {
android.util.Log.e("AiSettingsViewModel", "Error fetching models", e)
// Fallback to default list
_geminiModels.postValue(listOf(
"gemini-1.5-flash",
"gemini-1.5-pro",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-flash",
"gemini-3.5-flash"
))
// listModels() lives in the ai-core plugin and isn't part of the shared
// LlmBackend interface, so reach it reflectively across the plugin
// classloader boundary. It reads the saved key itself and returns the live
// v1beta catalog (empty when unavailable).
val method = geminiBackend.javaClass.getMethod("listModels")
val futureResult = method.invoke(geminiBackend)

@Suppress("UNCHECKED_CAST")
val models: List<String> =
(futureResult as? java.util.concurrent.CompletableFuture<List<String>>)?.get().orEmpty()
if (models.isEmpty()) {
android.util.Log.w(TAG, "Live model list empty; showing fallback models")
_geminiModels.postValue(FALLBACK_MODELS)
} else {
android.util.Log.d(TAG, "Fetched ${models.size} Gemini models")
_geminiModels.postValue(models)
}
} catch (e: Exception) {
android.util.Log.e("AiSettingsViewModel", "Error in fetchGeminiModels", e)
_geminiModels.postValue(listOf(
"gemini-1.5-flash",
"gemini-1.5-pro",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-flash",
"gemini-3.5-flash"
))
android.util.Log.e(TAG, "Error fetching Gemini models", e)
_geminiModels.postValue(FALLBACK_MODELS)
} finally {
_geminiModelsLoading.postValue(false)
}
Expand All @@ -248,10 +287,11 @@ class AiSettingsViewModel(
_modelLoadingState.postValue(ModelLoadingState.Loading)

try {
// Extract filename from URI for display
val fileName = uriString.substringAfterLast("/")
// Resolve the real file name (not the raw content-URI doc id) for display.
val fileName = resolveDisplayName(uriString)

// Save the path
// Persist the name before the path so the savedModelPath observer can read it.
saveLocalModelName(fileName)
saveLocalModelPath(uriString)

// In plugin context, we don't directly load the model
Expand All @@ -260,7 +300,7 @@ class AiSettingsViewModel(
ModelLoadingState.Loaded(fileName)
)

android.util.Log.d("AiSettingsViewModel", "Model path saved: $uriString")
android.util.Log.d(TAG, "Model path saved: $uriString ($fileName)")
} catch (e: Exception) {
android.util.Log.e("AiSettingsViewModel", "Error saving model path", e)
_modelLoadingState.postValue(
Expand Down
Loading