diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt
index 83c285c1..76ba11a8 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt
@@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.text.method.HideReturnsTransformationMethod
+import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -12,6 +14,7 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
@@ -21,6 +24,7 @@ import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiBackend
import com.itsaky.androidide.plugins.aiassistant.viewmodel.AiSettingsViewModel
import com.itsaky.androidide.plugins.aiassistant.viewmodel.EngineState
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ModelLoadingState
+import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -290,6 +294,7 @@ class AiSettingsFragment : DialogFragment() {
private fun setupGeminiApiUi(view: View) {
val apiKeyLayout = view.findViewById(R.id.gemini_api_key_layout)
val apiKeyInput = view.findViewById(R.id.gemini_api_key_input)
+ val toggleVisibilityButton = view.findViewById(R.id.btn_toggle_api_key_visibility)
val saveButton = view.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)
@@ -318,47 +323,93 @@ class AiSettingsFragment : DialogFragment() {
}
}
- val savedApiKey = viewModel.getGeminiApiKey()
- if (savedApiKey.isNullOrBlank()) {
- updateUiState(isEditing = true)
- apiKeyInput.setText("")
- } else {
- updateUiState(isEditing = false)
- val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
- if (timestamp > 0) {
- val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
- val savedDate = sdf.format(Date(timestamp))
- statusTextView.text = "API Key saved on: $savedDate"
+ viewLifecycleOwner.lifecycleScope.launch {
+ val savedApiKey = viewModel.getGeminiApiKey()
+ val hasKey = !savedApiKey.isNullOrBlank()
+ updateUiState(isEditing = !hasKey)
+ if (hasKey) {
+ statusTextView.text = savedApiKeyStatusText()
} else {
- statusTextView.text = "API Key is saved"
+ apiKeyInput.setText("")
}
}
- saveButton.setOnClickListener {
- val apiKey = apiKeyInput.text.toString()
- if (apiKey.isNotBlank()) {
- viewModel.saveGeminiApiKey(apiKey)
- Toast.makeText(requireContext(), "API Key saved", Toast.LENGTH_SHORT).show()
+ toggleVisibilityButton.setColorFilter(apiKeyInput.currentHintTextColor)
- updateUiState(isEditing = false)
- val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
- val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
- val savedDate = sdf.format(Date(timestamp))
- statusTextView.text = "API Key saved on: $savedDate"
+ var isKeyVisible = false
+
+ fun applyKeyVisibility() {
+ apiKeyInput.transformationMethod = if (isKeyVisible) {
+ HideReturnsTransformationMethod.getInstance()
} else {
- Toast.makeText(requireContext(), "API Key cannot be empty", Toast.LENGTH_SHORT).show()
+ 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)
}
- editButton.setOnClickListener {
+ applyKeyVisibility()
+
+ toggleVisibilityButton.setOnClickListener {
+ isKeyVisible = !isKeyVisible
+ applyKeyVisibility()
+ }
+
+ saveButton.setOnClickListener {
+ val apiKey = apiKeyInput.text.toString().trim()
+ if (apiKey.isBlank()) {
+ Toast.makeText(requireContext(), getString(R.string.msg_api_key_empty), Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ saveButton.isEnabled = false
+ viewLifecycleOwner.lifecycleScope.launch {
+ val saved = try {
+ viewModel.saveGeminiApiKey(apiKey)
+ } finally {
+ saveButton.isEnabled = true
+ }
+ if (!saved) {
+ Toast.makeText(requireContext(), getString(R.string.msg_api_key_save_failed), Toast.LENGTH_LONG).show()
+ return@launch
+ }
+ Toast.makeText(requireContext(), getString(R.string.msg_api_key_saved), Toast.LENGTH_SHORT).show()
+ updateUiState(isEditing = false)
+ statusTextView.text = savedApiKeyStatusText()
+ }
+ }
+
+ // Reveal the (already-fetched) key in an editable, focused field. Kept separate from
+ // the click handler so the listener does one thing: fetch, then hand off.
+ fun revealEditMode(apiKey: String) {
+ apiKeyInput.setText(apiKey)
+ apiKeyInput.setSelection(apiKey.length)
updateUiState(isEditing = true)
- apiKeyInput.setText("••••••••••••••••")
+ isKeyVisible = false
+ applyKeyVisibility()
apiKeyInput.requestFocus()
}
+ editButton.setOnClickListener {
+ editButton.isEnabled = false
+ viewLifecycleOwner.lifecycleScope.launch {
+ val apiKey = try {
+ viewModel.getGeminiApiKey().orEmpty()
+ } finally {
+ editButton.isEnabled = true
+ }
+ revealEditMode(apiKey)
+ }
+ }
+
clearButton.setOnClickListener {
viewModel.clearGeminiApiKey()
- Toast.makeText(requireContext(), "API Key cleared", Toast.LENGTH_SHORT).show()
+ Toast.makeText(requireContext(), getString(R.string.msg_api_key_cleared), Toast.LENGTH_SHORT).show()
updateUiState(isEditing = true)
apiKeyInput.setText("")
}
@@ -367,6 +418,14 @@ class AiSettingsFragment : DialogFragment() {
setupGeminiModelSelection(modelContainer)
}
+ /** Status line for a stored key: dated when the save time is known, generic otherwise. */
+ private fun savedApiKeyStatusText(): String {
+ val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
+ if (timestamp <= 0) return getString(R.string.msg_api_key_is_saved)
+ val savedDate = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(Date(timestamp))
+ return getString(R.string.msg_api_key_saved_on, savedDate)
+ }
+
private fun createModelSelectionUi(parent: View): LinearLayout {
val context = requireContext()
val container = LinearLayout(context).apply {
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
index a293646f..881c8a69 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
@@ -15,9 +15,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.chip.Chip
import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding
import com.itsaky.androidide.plugins.aiassistant.models.AgentState
import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel
+import com.itsaky.androidide.plugins.services.IdeProjectService
import io.noties.markwon.Markwon
import kotlinx.coroutines.launch
import java.io.File
@@ -334,9 +336,16 @@ class ChatFragment : Fragment() {
dialog.show(parentFragmentManager, "approval_dialog")
}
+ /**
+ * Opens the file picker rooted at the open project. The host's
+ * [IdeProjectService] is the source of truth for the project root; the
+ * `System.getProperty` chain in [PathGuard] resolves to "/" at runtime (the
+ * IDE process cwd), which lists nothing, so it is only a last-resort fallback.
+ */
private fun showFilePicker() {
- // Start from AndroidIDE projects directory by default
- val startPath = "/storage/emulated/0/AndroidIDEProjects"
+ val projectService = getPluginContext()?.services?.get(IdeProjectService::class.java)
+ val startPath = projectService?.getCurrentProject()?.rootDir?.absolutePath
+ ?: PathGuard.projectRoot()
val dialog = FilePickerDialogFragment.newInstance(startPath) { files ->
addContextFiles(files)
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
index bc7e2dfa..39eb0e2f 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/FilePickerDialogFragment.kt
@@ -1,143 +1,235 @@
package com.itsaky.androidide.plugins.aiassistant.fragments
import android.app.Dialog
+import android.content.Context
import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
import android.widget.ArrayAdapter
+import android.widget.ImageView
import android.widget.ListView
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.view.ContextThemeWrapper
+import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.itsaky.androidide.plugins.aiassistant.R
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import java.io.File
/**
- * Dialog for picking files to add as context.
+ * Dialog for picking project files to add as chat context.
+ *
+ * The dialog is created once and refreshed **in place** as the user navigates
+ * or toggles selection. It never dismisses-and-recreates itself: doing so
+ * detaches the fragment from its context, after which a follow-up
+ * `requireContext()` throws `IllegalStateException: not attached to a context`
+ * (previously crashed on the second "Toggle All").
+ *
+ * Navigation is confined to the start path supplied by the caller (the open
+ * project root), so the picker can't be used to reach arbitrary files on the
+ * device. All disk I/O runs off the main thread; see [computeListing].
*/
class FilePickerDialogFragment : DialogFragment() {
private var onFilesSelected: ((List) -> Unit)? = null
- private var currentDirectory: File? = null
private val selectedFiles = mutableSetOf()
+ /** Root the picker is confined to; navigation can never go above this. */
+ private lateinit var rootDirectory: File
+ private lateinit var currentDirectory: File
+
+ // Reused across in-place refreshes so we never rebuild the Dialog/Fragment.
+ private val rows = mutableListOf()
+ private lateinit var listAdapter: FileRowAdapter
+ private var alertDialog: AlertDialog? = null
+
companion object {
private const val ARG_START_PATH = "start_path"
+ private const val PARENT_NAME = ".."
fun newInstance(
startPath: String? = null,
onSelected: (List) -> Unit
): FilePickerDialogFragment {
return FilePickerDialogFragment().apply {
+ // Default to the current project root when no path is given.
arguments = Bundle().apply {
- // Use provided path or default to storage root
- val path = startPath ?: "/storage/emulated/0"
- putString(ARG_START_PATH, path)
+ putString(ARG_START_PATH, startPath ?: PathGuard.projectRoot())
}
onFilesSelected = onSelected
}
}
}
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val startPath = arguments?.getString(ARG_START_PATH) ?: "/storage/emulated/0"
- currentDirectory = File(startPath)
+ /** One navigable entry: a directory to descend into or a selectable file. */
+ private data class FileRow(
+ val file: File,
+ val displayName: String,
+ val isDirectory: Boolean,
+ )
- if (!currentDirectory!!.exists()) {
- // Fallback to root if start path doesn't exist
- currentDirectory = File("/storage/emulated/0")
+ /** Result of a directory scan, computed off the main thread. */
+ private data class Listing(val rows: List, val directory: File)
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val context = ContextThemeWrapper(requireContext(), R.style.PluginTheme)
+ listAdapter = FileRowAdapter(context)
+ val listView = ListView(context).apply {
+ adapter = listAdapter
+ setOnItemClickListener { _, _, position, _ -> onItemClicked(position) }
}
- if (!currentDirectory!!.exists()) {
- return MaterialAlertDialogBuilder(requireContext())
- .setTitle("Error")
- .setMessage("Storage directory not found")
- .setPositiveButton("OK") { _, _ -> dismiss() }
- .create()
+ val dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.file_picker_title)
+ .setView(listView)
+ .setPositiveButton(R.string.file_picker_add_selected) { _, _ ->
+ onFilesSelected?.invoke(selectedFiles.toList())
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ // Wired in setOnShowListener so the click doesn't auto-dismiss the dialog.
+ .setNeutralButton(R.string.file_picker_toggle_all, null)
+ .create()
+
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
+ toggleAllInCurrentDirectory()
+ }
}
+ alertDialog = dialog
+ loadInitial()
+ return dialog
+ }
- return showDirectoryPicker(currentDirectory!!)
+ /**
+ * Resolve the confined root from the start path and load its listing.
+ * The start path passed by the caller IS the confinement root — the picker
+ * confines navigation to the open project and starts there.
+ */
+ private fun loadInitial() {
+ val rootPath = arguments?.getString(ARG_START_PATH) ?: PathGuard.projectRoot()
+ lifecycleScope.launch {
+ val listing = withContext(Dispatchers.IO) {
+ rootDirectory = File(rootPath).canonicalOrAbsolute()
+ if (!rootDirectory.exists()) {
+ null
+ } else {
+ currentDirectory = rootDirectory
+ computeListing(rootDirectory)
+ }
+ }
+ if (listing == null) {
+ alertDialog?.setTitle(getString(R.string.file_picker_error_not_found))
+ } else {
+ applyListing(listing)
+ }
+ }
}
- private fun showDirectoryPicker(directory: File): Dialog {
- val files = directory.listFiles()?.sortedWith(
- compareBy { !it.isDirectory }
- .thenBy { it.name }
- ) ?: emptyList()
+ /** Recompute the listing for [directory] off-thread and apply it in place. */
+ private fun populate(directory: File) {
+ lifecycleScope.launch {
+ val listing = withContext(Dispatchers.IO) {
+ currentDirectory = directory
+ computeListing(directory)
+ }
+ applyListing(listing)
+ }
+ }
- val items = mutableListOf()
- val fileList = mutableListOf()
+ /** Pure disk I/O — must run on a background dispatcher. */
+ private fun computeListing(directory: File): Listing {
+ val newRows = mutableListOf()
- // Add parent directory option if not at root
- if (directory.parentFile != null && directory.parentFile!!.exists()) {
- items.add("📁 ..")
- fileList.add(directory.parentFile!!)
+ // Offer ".." only while the parent is still inside the confined root.
+ val parent = directory.parentFile
+ if (parent != null && parent.exists() && isWithinRoot(parent) &&
+ canonical(directory) != canonical(rootDirectory)
+ ) {
+ newRows.add(FileRow(parent, PARENT_NAME, isDirectory = true))
}
- // Add directories and files
- files.forEach { file ->
- val prefix = if (file.isDirectory) "📁 " else "📄 "
- val suffix = if (selectedFiles.contains(file)) " ✓" else ""
- items.add("$prefix${file.name}$suffix")
- fileList.add(file)
- }
+ directory.listFiles()
+ ?.filter { isWithinRoot(it) }
+ ?.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() })
+ ?.forEach { file -> newRows.add(FileRow(file, file.name, file.isDirectory)) }
+
+ return Listing(newRows, directory)
+ }
- val adapter = ArrayAdapter(
- requireContext(),
- android.R.layout.simple_list_item_1,
- items
- )
+ /** Swap in a freshly computed [listing] — main thread only. */
+ private fun applyListing(listing: Listing) {
+ rows.clear()
+ rows.addAll(listing.rows)
+ listAdapter.notifyDataSetChanged()
+ alertDialog?.setTitle(titleFor(listing.directory))
+ }
- val listView = ListView(requireContext()).apply {
- this.adapter = adapter
- choiceMode = ListView.CHOICE_MODE_MULTIPLE
+ private fun onItemClicked(position: Int) {
+ val row = rows[position]
+ if (row.isDirectory) {
+ populate(row.file)
+ } else {
+ if (!selectedFiles.remove(row.file)) selectedFiles.add(row.file)
+ listAdapter.notifyDataSetChanged()
}
+ }
- return MaterialAlertDialogBuilder(requireContext())
- .setTitle("Select Files: ${directory.name}")
- .setView(listView)
- .setPositiveButton("Add Selected") { _, _ ->
- onFilesSelected?.invoke(selectedFiles.toList())
- dismiss()
- }
- .setNegativeButton("Cancel") { _, _ -> dismiss() }
- .setNeutralButton("Toggle All") { _, _ ->
- // Toggle all files in current directory
- val currentFiles = files.filter { it.isFile }
- if (selectedFiles.containsAll(currentFiles)) {
- selectedFiles.removeAll(currentFiles.toSet())
- } else {
- selectedFiles.addAll(currentFiles)
- }
- // Refresh dialog
- dismiss()
- showDirectoryPicker(directory).show()
- }
- .create().apply {
- listView.setOnItemClickListener { _, _, position, _ ->
- val file = fileList[position]
-
- if (file.isDirectory) {
- // Navigate to directory
- dismiss()
- showDirectoryPicker(file).show()
- } else {
- // Toggle file selection
- if (selectedFiles.contains(file)) {
- selectedFiles.remove(file)
- } else {
- selectedFiles.add(file)
- }
- // Refresh display
- items[position] = if (selectedFiles.contains(file)) {
- "📄 ${file.name} ✓"
- } else {
- "📄 ${file.name}"
- }
- adapter.notifyDataSetChanged()
- }
- }
- }
+ private fun toggleAllInCurrentDirectory() {
+ val currentFiles = rows.filter { !it.isDirectory }.map { it.file }
+ if (currentFiles.isEmpty()) return
+ if (selectedFiles.containsAll(currentFiles)) {
+ selectedFiles.removeAll(currentFiles.toSet())
+ } else {
+ selectedFiles.addAll(currentFiles)
+ }
+ listAdapter.notifyDataSetChanged()
+ }
+
+ private fun titleFor(dir: File): String =
+ getString(R.string.file_picker_title_current, dir.name)
+
+ /** True if [file] resolves (symlinks included) to a path at or below [rootDirectory]. */
+ private fun isWithinRoot(file: File): Boolean = try {
+ val root = rootDirectory.canonicalFile.toPath().normalize()
+ file.canonicalFile.toPath().normalize().startsWith(root)
+ } catch (e: Exception) {
+ false
}
+ private fun canonical(f: File): String =
+ try { f.canonicalPath } catch (e: Exception) { f.absolutePath }
+
+ private fun File.canonicalOrAbsolute(): File =
+ try { canonicalFile } catch (e: Exception) { absoluteFile }
+
override fun onDestroyView() {
super.onDestroyView()
onFilesSelected = null
+ alertDialog = null
+ }
+
+ /** Binds each [FileRow] to [R.layout.item_file_picker]; the check reflects selection. */
+ private inner class FileRowAdapter(context: Context) :
+ ArrayAdapter(context, 0, rows) {
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView
+ ?: LayoutInflater.from(parent.context).inflate(R.layout.item_file_picker, parent, false)
+ val row = getItem(position) ?: return view
+
+ view.findViewById(R.id.file_picker_row_icon)
+ .setImageResource(if (row.isDirectory) R.drawable.ic_folder else R.drawable.ic_file)
+ view.findViewById(R.id.file_picker_row_name).text = row.displayName
+ view.findViewById(R.id.file_picker_row_check).isVisible =
+ selectedFiles.contains(row.file)
+ return view
+ }
}
}
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
new file mode 100644
index 00000000..fe42be23
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/security/SecureApiKeyStore.kt
@@ -0,0 +1,114 @@
+package com.itsaky.androidide.plugins.aiassistant.security
+
+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 {
+ private const val TAG = "SecureApiKeyStore"
+ private const val KEYSTORE = "AndroidKeyStore"
+ private const val ALIAS = "cotg_ai_gemini_key_v1"
+ private const val TRANSFORM = "AES/GCM/NoPadding"
+ private const val IV_LEN = 12
+ private const val TAG_BITS = 128
+
+ /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */
+ const val ENC_PREFIX = "enc:v1:"
+
+ private fun getOrCreateKey(): SecretKey {
+ val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build()
+ )
+ return generator.generateKey()
+ }
+
+ private fun deleteKey() {
+ try {
+ KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e)
+ }
+ }
+
+ private fun encryptWith(key: SecretKey, plain: String): String {
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ val iv = cipher.iv
+ val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
+ val combined = ByteArray(iv.size + ciphertext.size)
+ System.arraycopy(iv, 0, combined, 0, iv.size)
+ System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
+ return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP)
+ }
+
+ /**
+ * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
+ *
+ * If the Keystore key has been permanently invalidated (e.g. the lock-screen credentials
+ * changed, or the entry is corrupt) the stale alias is dropped and a fresh key generated
+ * once before retrying. Any other Keystore/cipher failure is surfaced as a
+ * [GeneralSecurityException] so the caller can inform the user instead of crashing — the
+ * previous version let these propagate uncaught and take the IDE down on Save.
+ */
+ @Throws(GeneralSecurityException::class)
+ fun encrypt(plain: String): String {
+ return try {
+ encryptWith(getOrCreateKey(), plain)
+ } catch (e: KeyPermanentlyInvalidatedException) {
+ Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e)
+ deleteKey()
+ encryptWith(getOrCreateKey(), plain)
+ }
+ }
+
+ /**
+ * Return the plaintext for a stored value, handling both formats transparently:
+ * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as
+ * legacy plaintext (it gets migrated to ciphertext on the next save). Returns
+ * null if a ciphertext value can't be decrypted — e.g. the Keystore key was
+ * lost or invalidated — in which case the user must re-enter the key.
+ */
+ fun decrypt(stored: String?): String? {
+ if (stored == null) return null
+ if (!stored.startsWith(ENC_PREFIX)) return stored
+ return try {
+ val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP)
+ val iv = combined.copyOfRange(0, IV_LEN)
+ val ciphertext = combined.copyOfRange(IV_LEN, combined.size)
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
+ String(cipher.doFinal(ciphertext), Charsets.UTF_8)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to decrypt stored API key", e)
+ null
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
index 0ca9b3c1..eb2440e4 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AiSettingsViewModel.kt
@@ -5,10 +5,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.SharedServices
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import com.itsaky.androidide.plugins.PluginContext
/**
* State for the model file loading.
@@ -48,7 +52,8 @@ enum class AiBackend(val displayName: String) {
data class GeminiModelOptions(val models: List, val isLive: Boolean)
class AiSettingsViewModel(
- private val getContext: () -> com.itsaky.androidide.plugins.PluginContext?
+ private val getContext: () -> PluginContext?,
+ private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
companion object {
@@ -188,21 +193,30 @@ class AiSettingsViewModel(
}
/**
- * 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.
+ * Encrypts [apiKey] via [SecureApiKeyStore] and persists only the ciphertext to private
+ * prefs, off the main thread (Keystore IPC + AES/GCM). Nothing is written on failure.
+ *
+ * @param apiKey the plaintext key to store (trimmed before encryption)
+ * @return true if the key was encrypted and persisted, false if encryption failed
*/
- fun saveGeminiApiKey(apiKey: String) {
+ suspend fun saveGeminiApiKey(apiKey: String): Boolean = withContext(ioDispatcher) {
+ val encrypted = try {
+ SecureApiKeyStore.encrypt(apiKey.trim())
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "Failed to encrypt Gemini API key", e)
+ return@withContext false
+ }
getPluginPrefs()?.edit()?.apply {
- putString("gemini_api_key", apiKey)
+ putString("gemini_api_key", encrypted)
putLong("gemini_api_key_timestamp", System.currentTimeMillis())
apply()
}
+ true
}
- fun getGeminiApiKey(): String? {
- return getPluginPrefs()?.getString("gemini_api_key", null)
+ /** Decrypt the stored key off the main thread (Keystore IPC + AES/GCM). */
+ suspend fun getGeminiApiKey(): String? = withContext(ioDispatcher) {
+ SecureApiKeyStore.decrypt(getPluginPrefs()?.getString("gemini_api_key", null))
}
fun getGeminiApiKeySaveTimestamp(): Long {
diff --git a/ai-assistant/src/main/res/drawable/ic_check.xml b/ai-assistant/src/main/res/drawable/ic_check.xml
new file mode 100644
index 00000000..9368bf87
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_file.xml b/ai-assistant/src/main/res/drawable/ic_file.xml
new file mode 100644
index 00000000..f239f312
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_file.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_folder.xml b/ai-assistant/src/main/res/drawable/ic_folder.xml
new file mode 100644
index 00000000..dc6b0802
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_folder.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_visibility.xml b/ai-assistant/src/main/res/drawable/ic_visibility.xml
new file mode 100644
index 00000000..a3e222a2
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_visibility.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/drawable/ic_visibility_off.xml b/ai-assistant/src/main/res/drawable/ic_visibility_off.xml
new file mode 100644
index 00000000..92c48569
--- /dev/null
+++ b/ai-assistant/src/main/res/drawable/ic_visibility_off.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/ai-assistant/src/main/res/layout/item_file_picker.xml b/ai-assistant/src/main/res/layout/item_file_picker.xml
new file mode 100644
index 00000000..20efc497
--- /dev/null
+++ b/ai-assistant/src/main/res/layout/item_file_picker.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml b/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
index 179cbb56..5a5819b7 100644
--- a/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
+++ b/ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
@@ -27,13 +27,33 @@
android:textAppearance="?android:attr/textAppearanceSmall"
android:paddingBottom="4dp"/>
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+
+
+
+
AI Settings
+ Show API key
+ Hide API key
+ Enter your Gemini API key
+ API Key saved
+ API Key saved on: %s
+ API Key is saved
+ API Key cannot be empty
+ API Key cleared
+ Couldn\'t save the API key on this device. Please try again.
Backend
Model
Temperature
@@ -105,4 +114,11 @@
⚠️ Experimental AI. Use at your own risk.
Current AI backend
Send
+
+
+ Select Files
+ Select Files: %s
+ Add Selected
+ Toggle All
+ Project directory not found
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
index 081488b8..a15ec634 100644
--- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt
@@ -73,7 +73,8 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
context.logger.error("GeminiBackend: Error getting preferences", e)
null
}
- return prefs?.getString("gemini_api_key", null)?.trim()?.takeIf { it.isNotBlank() }
+ val stored = prefs?.getString("gemini_api_key", null)
+ return SecureApiKeyStore.decrypt(stored)?.trim()?.takeIf { it.isNotBlank() }
}
override fun getId(): String = "gemini"
@@ -81,7 +82,7 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
override fun getName(): String = "Gemini API"
override fun isAvailable(): Boolean {
- // Available once an API key is configured.
+ // Available once a (decryptable) API key is configured.
val apiKey = readGeminiApiKey()
context.logger.debug("GeminiBackend.isAvailable() - API key configured: ${!apiKey.isNullOrBlank()}")
return !apiKey.isNullOrBlank()
diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
new file mode 100644
index 00000000..1e31a7fc
--- /dev/null
+++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/SecureApiKeyStore.kt
@@ -0,0 +1,114 @@
+package com.itsaky.androidide.plugins.aicore
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * AES/GCM encryption for sensitive settings (currently the Gemini API key),
+ * keyed by a hardware-backed Android Keystore secret. Only ciphertext is
+ * written to SharedPreferences, so a copied prefs file (root, `adb backup`,
+ * forensic dump) is useless without this device's Keystore.
+ *
+ * The alias and transform below are mirrored verbatim in ai-assistant's
+ * `SecureApiKeyStore` so a key written there can be decrypted here — both
+ * plugins run in the host app's process (same UID) and therefore share one
+ * Android Keystore. Keep the two copies in sync.
+ */
+object SecureApiKeyStore {
+ private const val TAG = "SecureApiKeyStore"
+ private const val KEYSTORE = "AndroidKeyStore"
+ private const val ALIAS = "cotg_ai_gemini_key_v1"
+ private const val TRANSFORM = "AES/GCM/NoPadding"
+ private const val IV_LEN = 12
+ private const val TAG_BITS = 128
+
+ /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */
+ const val ENC_PREFIX = "enc:v1:"
+
+ private fun getOrCreateKey(): SecretKey {
+ val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build()
+ )
+ return generator.generateKey()
+ }
+
+ private fun deleteKey() {
+ try {
+ KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e)
+ }
+ }
+
+ private fun encryptWith(key: SecretKey, plain: String): String {
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ val iv = cipher.iv
+ val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
+ val combined = ByteArray(iv.size + ciphertext.size)
+ System.arraycopy(iv, 0, combined, 0, iv.size)
+ System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
+ return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP)
+ }
+
+ /**
+ * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
+ *
+ * If the Keystore key has been permanently invalidated (e.g. the lock-screen credentials
+ * changed, or the entry is corrupt) the stale alias is dropped and a fresh key generated
+ * once before retrying. Any other Keystore/cipher failure is surfaced as a
+ * [GeneralSecurityException] so the caller can inform the user instead of crashing — the
+ * previous version let these propagate uncaught and take the IDE down on Save.
+ */
+ @Throws(GeneralSecurityException::class)
+ fun encrypt(plain: String): String {
+ return try {
+ encryptWith(getOrCreateKey(), plain)
+ } catch (e: KeyPermanentlyInvalidatedException) {
+ Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e)
+ deleteKey()
+ encryptWith(getOrCreateKey(), plain)
+ }
+ }
+
+ /**
+ * Return the plaintext for a stored value, handling both formats transparently:
+ * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as
+ * legacy plaintext (it gets migrated to ciphertext on the next save). Returns
+ * null if a ciphertext value can't be decrypted — e.g. the Keystore key was
+ * lost or invalidated — in which case the user must re-enter the key.
+ */
+ fun decrypt(stored: String?): String? {
+ if (stored == null) return null
+ if (!stored.startsWith(ENC_PREFIX)) return stored
+ return try {
+ val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP)
+ val iv = combined.copyOfRange(0, IV_LEN)
+ val ciphertext = combined.copyOfRange(IV_LEN, combined.size)
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
+ String(cipher.doFinal(ciphertext), Charsets.UTF_8)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to decrypt stored API key", e)
+ null
+ }
+ }
+}