ai-chat-android-auto / commit
Add multi-provider support with user-selectable models
README.md | 104 +++++++++++ .../java/net/fuzzyworld/claudeauto/AiProvider.kt | 39 ++++ .../net/fuzzyworld/claudeauto/AnthropicProvider.kt | 107 +++++++++++ .../net/fuzzyworld/claudeauto/ClaudeApiClient.kt | 75 -------- .../fuzzyworld/claudeauto/ClaudeMediaService.kt | 30 ++-- .../net/fuzzyworld/claudeauto/GeminiProvider.kt | 109 +++++++++++ .../claudeauto/OpenAiCompatibleProvider.kt | 150 ++++++++++++++++ .../main/java/net/fuzzyworld/claudeauto/Prefs.kt | 88 ++++++++- .../net/fuzzyworld/claudeauto/ProviderRegistry.kt | 21 +++ .../net/fuzzyworld/claudeauto/SetupActivity.kt | 200 +++++++++++++++++++-- app/src/main/res/layout/activity_setup.xml | 185 ++++++++++++++----- 11 files changed, 955 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1a8e27 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# AI Auto + +An Android Auto voice assistant that sends your spoken questions to an AI provider and reads the responses back through the car's speakers. + +You choose the provider — Anthropic, OpenAI, Groq, Mistral, Google Gemini, or a local Ollama instance. API keys are stored on-device in `EncryptedSharedPreferences` and never leave the phone. + +--- + +## How it works + +1. Open the **AI Auto** app on your phone and configure a provider + API key. +2. Connect your phone to the car. The app appears in the Android Auto launcher. +3. Tap **Ask AI** (or press Play on the steering wheel) and speak your question. +4. The app sends your question to the provider's API and reads the answer aloud. +5. Past exchanges appear in the browse list so you can re-ask any previous question. + +--- + +## Setup + +### Requirements +- Android 6.0+ (API 23) +- Android Auto on the phone + +### Phone setup +1. Install the APK (sideload or Play Store release). +2. Open **AI Auto** on your phone. +3. Select a provider, enter your API key, and tap **Save Key**. +4. Tap **↻** to fetch available models and select one. +5. Grant microphone permission when prompted. + +### Android Auto +The app registers as a media browser service, so it requires no special permissions beyond a standard media app. If using a sideloaded build, enable **Unknown sources** for Android Auto via ADB first: + +``` +adb shell settings put global android_auto_unknown_sources 1 +``` + +--- + +## Supported providers + +| Provider | API key required | Web search | Notes | +|---|---|---|---| +| [Anthropic](https://console.anthropic.com) | ✅ | ✅ (built-in) | Default provider | +| [OpenAI](https://platform.openai.com) | ✅ | — | GPT-4o and newer | +| [Groq](https://console.groq.com) | ✅ | — | Very fast inference | +| [Mistral](https://console.mistral.ai) | ✅ | — | | +| [Google Gemini](https://aistudio.google.com) | ✅ | — | | +| Ollama (local) | — | — | Requires local server; configure base URL | +| Custom (OpenAI-compatible) | Optional | — | Any endpoint that speaks `/v1/chat/completions` | + +--- + +## Adding a new provider + +### OpenAI-compatible API (5 lines) + +Add an object to [OpenAiCompatibleProvider.kt](app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt): + +```kotlin +object MyProvider : OpenAiCompatibleProvider() { + override val id = "myprovider" // stable, never change + override val displayName = "My Provider" + override val defaultBaseUrl = "https://api.myprovider.com" + override val defaultModel = "my-model-name" + override val requiresApiKey = true +} +``` + +Then add `MyProvider` to the list in [ProviderRegistry.kt](app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt). + +### Unique API format + +Implement the `AiProvider` interface directly (see [GeminiProvider.kt](app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt) as an example), then register it in `ProviderRegistry`. + +The interface requires two methods: +- `ask(apiKey, baseUrl, model, question, onResult)` — call the API and return the text response +- `listModels(apiKey, baseUrl, onResult)` — return a list of `ModelInfo(id, displayName)` + +--- + +## Building from source + +```bash +# Clone and open in Android Studio, or build from the command line: +./gradlew assembleRelease +``` + +Signing credentials go in `local.properties` (gitignored). See `local.properties.example`. + +--- + +## Upcoming work + +- **App rename / rebrand** — the app is currently labelled "Claude Auto" in places; needs a provider-neutral name for the open-source release +- **Persist conversation history across sessions** — current history is in-memory only and lost when the service stops +- **Per-provider system prompt customization** — let users adjust the assistant's persona and instructions +- **Streaming response support** — start speaking the first sentence while the rest of the response is still arriving, reducing perceived latency +- **Web search for non-Anthropic providers** — integrate an external search API (e.g. Brave Search, Tavily) so all providers can answer current-events questions +- **Gemini grounding / web search** — wire up Google's native grounding tool for Gemini responses +- **Hands-free activation** — start listening without tapping, triggered by a wake phrase or steering-wheel button mapping +- **Export / import configuration** — back up and restore provider settings and API keys +- **Android Automotive OS (AAOS) support** — native in-car OS support alongside the current Android Auto projection mode diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/AiProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/AiProvider.kt new file mode 100644 index 0000000..33befd8 --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/claudeauto/AiProvider.kt @@ -0,0 +1,39 @@ +package net.fuzzyworld.claudeauto + +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +data class ModelInfo(val id: String, val displayName: String) + +interface AiProvider { + /** Stable key used in SharedPreferences. Never change after release. */ + val id: String + val displayName: String + val defaultBaseUrl: String + val defaultModel: String + val requiresApiKey: Boolean + /** Whether the user can override the base URL (Ollama, Custom). */ + val allowsBaseUrlOverride: Boolean + val supportsWebSearch: Boolean + + fun ask( + apiKey: String, + baseUrl: String, + model: String, + question: String, + onResult: (String?) -> Unit, + ) + + fun listModels( + apiKey: String, + baseUrl: String, + onResult: (List<ModelInfo>?) -> Unit, + ) +} + +internal object Http { + val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .build() +} diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/AnthropicProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/AnthropicProvider.kt new file mode 100644 index 0000000..9ce2be2 --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/claudeauto/AnthropicProvider.kt @@ -0,0 +1,107 @@ +package net.fuzzyworld.claudeauto + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException + +object AnthropicProvider : AiProvider { + override val id = "anthropic" + override val displayName = "Anthropic" + override val defaultBaseUrl = "https://api.anthropic.com" + override val defaultModel = "claude-sonnet-4-6" + override val requiresApiKey = true + override val allowsBaseUrlOverride = false + override val supportsWebSearch = true + + override fun ask( + apiKey: String, + baseUrl: String, + model: String, + question: String, + onResult: (String?) -> Unit, + ) { + val payload = JSONObject().apply { + put("model", model) + put("max_tokens", 1024) + put("system", SYSTEM_PROMPT) + put("messages", JSONArray().apply { + put(JSONObject().apply { + put("role", "user") + put("content", question) + }) + }) + put("tools", JSONArray().apply { + put(JSONObject().apply { + put("type", "web_search_20250305") + put("name", "web_search") + }) + }) + }.toString().toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$baseUrl/v1/messages") + .post(payload) + .addHeader("x-api-key", apiKey) + .addHeader("anthropic-version", "2023-06-01") + .addHeader("anthropic-beta", "web-search-2025-03-05") + .build() + + Http.client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = onResult(null) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { onResult(null); return } + try { + val content = JSONObject(it.body!!.string()).getJSONArray("content") + val text = buildString { + for (i in 0 until content.length()) { + val block = content.getJSONObject(i) + if (block.getString("type") == "text") append(block.getString("text")) + } + }.trim() + onResult(text.ifEmpty { null }) + } catch (_: Exception) { onResult(null) } + } + } + }) + } + + override fun listModels( + apiKey: String, + baseUrl: String, + onResult: (List<ModelInfo>?) -> Unit, + ) { + val request = Request.Builder() + .url("$baseUrl/v1/models") + .get() + .addHeader("x-api-key", apiKey) + .addHeader("anthropic-version", "2023-06-01") + .build() + + Http.client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = onResult(null) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { onResult(null); return } + try { + val data = JSONObject(it.body!!.string()).getJSONArray("data") + val models = (0 until data.length()).map { i -> + val obj = data.getJSONObject(i) + ModelInfo( + id = obj.getString("id"), + displayName = obj.optString("display_name", obj.getString("id")), + ) + } + onResult(models) + } catch (_: Exception) { onResult(null) } + } + } + }) + } +} diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt deleted file mode 100644 index 22d3719..0000000 --- a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt +++ /dev/null @@ -1,75 +0,0 @@ -package net.fuzzyworld.claudeauto - -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray -import org.json.JSONObject -import java.io.IOException -import java.util.concurrent.TimeUnit - -class ClaudeApiClient { - - private val http = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(90, TimeUnit.SECONDS) - .build() - - fun ask(apiKey: String, question: String, onResult: (String?) -> Unit) { - val payload = JSONObject().apply { - put("model", "claude-sonnet-4-6") - put("max_tokens", 1024) - put( - "system", - "You are a voice assistant in a car. Be concise and conversational — " + - "keep responses to 1-3 sentences unless genuinely more is needed. " + - "Never use markdown, bullet points, code blocks, or lists. " + - "Speak naturally, as if talking to a driver." - ) - put("messages", JSONArray().apply { - put(JSONObject().apply { - put("role", "user") - put("content", question) - }) - }) - put("tools", JSONArray().apply { - put(JSONObject().apply { - put("type", "web_search_20250305") - put("name", "web_search") - }) - }) - }.toString().toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("https://api.anthropic.com/v1/messages") - .post(payload) - .addHeader("x-api-key", apiKey) - .addHeader("anthropic-version", "2023-06-01") - .addHeader("anthropic-beta", "web-search-2025-03-05") - .build() - - http.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) = onResult(null) - - override fun onResponse(call: Call, response: Response) { - response.use { - if (!it.isSuccessful) { onResult(null); return } - try { - val content = JSONObject(it.body!!.string()).getJSONArray("content") - val text = buildString { - for (i in 0 until content.length()) { - val block = content.getJSONObject(i) - if (block.getString("type") == "text") { - append(block.getString("text")) - } - } - }.trim() - onResult(text.ifEmpty { null }) - } catch (_: Exception) { - onResult(null) - } - } - } - }) - } -} diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt index 155e394..12d82dc 100644 --- a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt +++ b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt @@ -38,7 +38,6 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe private var focusRequest: AudioFocusRequest? = null private val mainHandler = Handler(Looper.getMainLooper()) - private val api = ClaudeApiClient() companion object { private const val ROOT_ID = "__ROOT__" @@ -101,7 +100,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe items += MediaItem( MediaDescriptionCompat.Builder() .setMediaId(ASK_ID) - .setTitle("Ask Claude") + .setTitle("Ask AI") .setSubtitle("Tap ▶ to speak") .build(), MediaItem.FLAG_PLAYABLE @@ -144,7 +143,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe id == CLEAR_ID -> clearHistory() id?.startsWith(HISTORY_PREFIX) == true -> { val idx = id.removePrefix(HISTORY_PREFIX).toIntOrNull() - if (idx != null && idx in history.indices) sendToClaude(history[idx].question) + if (idx != null && idx in history.indices) sendToProvider(history[idx].question) } } } @@ -201,8 +200,8 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe // ── Voice input ──────────────────────────────────────────────────────────── private fun startListening() { - if (!Prefs.hasApiKey(this)) { - speak("No API key configured. Open the Claude Auto app on your phone to add it.") + if (!Prefs.isReady(this)) { + speak("Please finish setting up the app on your phone before using it.") return } if (!SpeechRecognizer.isRecognitionAvailable(this)) { @@ -227,7 +226,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe private val recognitionListener = object : RecognitionListener { override fun onResults(r: Bundle?) { val text = r?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull() - if (text != null) sendToClaude(text) + if (text != null) sendToProvider(text) else mainHandler.post { releaseAudioFocus(); speak("Couldn't understand — please try again.") } } override fun onError(error: Int) { @@ -253,11 +252,18 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe // ── API + TTS ────────────────────────────────────────────────────────────── - private fun sendToClaude(question: String) { + private fun sendToProvider(question: String) { pushState(State.PROCESSING, question) - api.ask(Prefs.apiKey(this), question) { response -> + val providerId = Prefs.selectedProviderId(this) + val provider = ProviderRegistry.byId(providerId) + provider.ask( + apiKey = Prefs.apiKey(this, providerId), + baseUrl = Prefs.baseUrl(this, providerId), + model = Prefs.model(this, providerId), + question = question, + ) { response -> mainHandler.post { - val answer = response ?: "Couldn't reach Claude. Check your internet connection." + val answer = response ?: "Couldn't reach the AI. Check your internet connection." history.add(HistoryEntry(question, answer)) notifyChildrenChanged(ROOT_ID) speak(answer) @@ -277,11 +283,11 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe state = newState val (title, subtitle) = when (newState) { - State.IDLE -> "Ask Claude" to + State.IDLE -> "Ask AI" to (history.lastOrNull()?.answer ?: "Tap ▶ to speak") State.LISTENING -> "Listening…" to "Speak your question" State.PROCESSING -> "Thinking…" to (if (text.isNotEmpty()) "\"$text\"" else "") - State.SPEAKING -> "Claude" to text + State.SPEAKING -> "AI" to text } val pbState = when (newState) { @@ -301,7 +307,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Claude Auto") + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "AI Auto") .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, subtitle) .build() ) diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt new file mode 100644 index 0000000..b33c0a1 --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt @@ -0,0 +1,109 @@ +package net.fuzzyworld.claudeauto + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException + +object GeminiProvider : AiProvider { + override val id = "gemini" + override val displayName = "Google Gemini" + override val defaultBaseUrl = "https://generativelanguage.googleapis.com" + override val defaultModel = "gemini-2.0-flash" + override val requiresApiKey = true + override val allowsBaseUrlOverride = false + override val supportsWebSearch = false + + override fun ask( + apiKey: String, + baseUrl: String, + model: String, + question: String, + onResult: (String?) -> Unit, + ) { + val payload = JSONObject().apply { + put("system_instruction", JSONObject().apply { + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", SYSTEM_PROMPT) }) + }) + }) + put("contents", JSONArray().apply { + put(JSONObject().apply { + put("role", "user") + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", question) }) + }) + }) + }) + }.toString().toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$baseUrl/v1beta/models/$model:generateContent?key=$apiKey") + .post(payload) + .build() + + Http.client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = onResult(null) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { onResult(null); return } + try { + val text = JSONObject(it.body!!.string()) + .getJSONArray("candidates") + .getJSONObject(0) + .getJSONObject("content") + .getJSONArray("parts") + .getJSONObject(0) + .getString("text") + onResult(text.trim().ifEmpty { null }) + } catch (_: Exception) { onResult(null) } + } + } + }) + } + + override fun listModels( + apiKey: String, + baseUrl: String, + onResult: (List<ModelInfo>?) -> Unit, + ) { + val request = Request.Builder() + .url("$baseUrl/v1beta/models?key=$apiKey") + .get() + .build() + + Http.client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = onResult(null) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { onResult(null); return } + try { + val arr = JSONObject(it.body!!.string()).getJSONArray("models") + val models = (0 until arr.length()) + .map { i -> arr.getJSONObject(i) } + .filter { obj -> + val methods = obj.optJSONArray("supportedGenerationMethods") + methods != null && (0 until methods.length()).any { j -> + methods.getString(j) == "generateContent" + } + } + .map { obj -> + // "name" is "models/gemini-2.0-flash" — strip the prefix + val mid = obj.getString("name").removePrefix("models/") + ModelInfo( + id = mid, + displayName = obj.optString("displayName", mid), + ) + } + onResult(models) + } catch (_: Exception) { onResult(null) } + } + } + }) + } +} diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt new file mode 100644 index 0000000..2ee0eb5 --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt @@ -0,0 +1,150 @@ +package net.fuzzyworld.claudeauto + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException + +abstract class OpenAiCompatibleProvider : AiProvider { + override val supportsWebSearch = false + override val allowsBaseUrlOverride = false + + override fun ask( + apiKey: String, + baseUrl: String, + model: String, + question: String, + onResult: (String?) -> Unit, + ) { + val payload = JSONObject().apply { + put("model", model) + put("max_tokens", 1024) + put("messages", JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", SYSTEM_PROMPT) + }) + put(JSONObject().apply { + put("role", "user") + put("content", question) + }) + }) + }.toString().toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$baseUrl/v1/chat/completions") + .post(payload) + .addHeader("Authorization", "Bearer $apiKey") + .build() + + Http.client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = onResult(null) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { onResult(null); return } + try { + val text = JSONObject(it.body!!.string()) + .getJSONArray("choices") + .getJSONObject(0) + .getJSONObject("message") + .getString("content") + onResult(text.trim().ifEmpty { null }) + } catch (_: Exception) { onResult(null) } + } + } + }) + } + + override fun listModels( + apiKey: String, + baseUrl: String, + onResult: (List<ModelInfo>?) -> Unit, + ) { + val request = Request.Builder() + .url("$baseUrl/v1/models") + .get() + .addHeader("Authorization", "Bearer $apiKey") + .build() + + Http.client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = onResult(null) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { onResult(null); return } + try { + val data = JSONObject(it.body!!.string()).getJSONArray("data") + val models = (0 until data.length()) + .map { i -> data.getJSONObject(i) } + .filter { obj -> filterModel(obj) } + .map { obj -> + val mid = obj.getString("id") + ModelInfo( + id = mid, + displayName = obj.optString("name", mid), + ) + } + .sortedBy { it.id } + onResult(models) + } catch (_: Exception) { onResult(null) } + } + } + }) + } + + /** Override to restrict which models appear in the list. */ + open fun filterModel(obj: JSONObject): Boolean = true +} + +// ── Concrete providers ──────────────────────────────────────────────────────── + +object OpenAiProvider : OpenAiCompatibleProvider() { + override val id = "openai" + override val displayName = "OpenAI" + override val defaultBaseUrl = "https://api.openai.com" + override val defaultModel = "gpt-4o" + override val requiresApiKey = true + + override fun filterModel(obj: JSONObject): Boolean { + val id = obj.optString("id", "") + return id.startsWith("gpt-") || id.startsWith("o1") || id.startsWith("o3") || id.startsWith("o4") + } +} + +object GroqProvider : OpenAiCompatibleProvider() { + override val id = "groq" + override val displayName = "Groq" + override val defaultBaseUrl = "https://api.groq.com/openai" + override val defaultModel = "llama-3.3-70b-versatile" + override val requiresApiKey = true +} + +object MistralProvider : OpenAiCompatibleProvider() { + override val id = "mistral" + override val displayName = "Mistral" + override val defaultBaseUrl = "https://api.mistral.ai" + override val defaultModel = "mistral-large-latest" + override val requiresApiKey = true +} + +object OllamaProvider : OpenAiCompatibleProvider() { + override val id = "ollama" + override val displayName = "Ollama (local)" + override val defaultBaseUrl = "http://localhost:11434" + override val defaultModel = "llama3" + override val requiresApiKey = false + override val allowsBaseUrlOverride = true +} + +object CustomOpenAiProvider : OpenAiCompatibleProvider() { + override val id = "custom" + override val displayName = "Custom (OpenAI-compatible)" + override val defaultBaseUrl = "" + override val defaultModel = "" + override val requiresApiKey = false + override val allowsBaseUrlOverride = true +} diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt b/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt index ab34d61..8b9fc09 100644 --- a/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt +++ b/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt @@ -4,10 +4,12 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import org.json.JSONArray +import org.json.JSONObject object Prefs { - private const val FILE = "claude_prefs" - private const val KEY = "api_key" + private const val FILE = "ai_auto_prefs" + private const val KEY_PROVIDER = "provider_id" private fun prefs(context: Context): SharedPreferences { val master = MasterKey.Builder(context) @@ -16,12 +18,84 @@ object Prefs { return EncryptedSharedPreferences.create( context, FILE, master, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) } - fun apiKey(context: Context): String = prefs(context).getString(KEY, "") ?: "" - fun hasApiKey(context: Context): Boolean = apiKey(context).isNotBlank() - fun saveApiKey(context: Context, key: String) = prefs(context).edit().putString(KEY, key).apply() - fun clearApiKey(context: Context) = prefs(context).edit().remove(KEY).apply() + // ── Provider selection ───────────────────────────────────────────────────── + + fun selectedProviderId(context: Context): String = + prefs(context).getString(KEY_PROVIDER, AnthropicProvider.id) ?: AnthropicProvider.id + + fun saveProviderId(context: Context, id: String) = + prefs(context).edit().putString(KEY_PROVIDER, id).apply() + + // ── Per-provider API key ─────────────────────────────────────────────────── + + fun apiKey(context: Context, providerId: String = selectedProviderId(context)): String = + prefs(context).getString("key_$providerId", "") ?: "" + + fun hasApiKey(context: Context, providerId: String = selectedProviderId(context)): Boolean = + apiKey(context, providerId).isNotBlank() + + fun saveApiKey(context: Context, providerId: String, key: String) = + prefs(context).edit().putString("key_$providerId", key).apply() + + fun clearApiKey(context: Context, providerId: String) = + prefs(context).edit().remove("key_$providerId").apply() + + // ── Per-provider base URL (with fallback to provider default) ────────────── + + fun baseUrl(context: Context, providerId: String = selectedProviderId(context)): String { + val default = ProviderRegistry.byId(providerId).defaultBaseUrl + return prefs(context).getString("url_$providerId", default)?.ifBlank { default } ?: default + } + + fun saveBaseUrl(context: Context, providerId: String, url: String) = + prefs(context).edit().putString("url_$providerId", url).apply() + + // ── Per-provider selected model (with fallback to provider default) ───────── + + fun model(context: Context, providerId: String = selectedProviderId(context)): String { + val default = ProviderRegistry.byId(providerId).defaultModel + return prefs(context).getString("model_$providerId", default)?.ifBlank { default } ?: default + } + + fun saveModel(context: Context, providerId: String, modelId: String) = + prefs(context).edit().putString("model_$providerId", modelId).apply() + + // ── Cached model list ────────────────────────────────────────────────────── + + fun cachedModels(context: Context, providerId: String): List<ModelInfo>? { + val json = prefs(context).getString("models_$providerId", null) ?: return null + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val obj = arr.getJSONObject(i) + ModelInfo(obj.getString("id"), obj.getString("displayName")) + } + } catch (_: Exception) { null } + } + + fun saveModelCache(context: Context, providerId: String, models: List<ModelInfo>) { + val arr = JSONArray().apply { + models.forEach { m -> + put(JSONObject().apply { + put("id", m.id) + put("displayName", m.displayName) + }) + } + } + prefs(context).edit().putString("models_$providerId", arr.toString()).apply() + } + + // ── Ready check (used by the media service) ──────────────────────────────── + + fun isReady(context: Context): Boolean { + val providerId = selectedProviderId(context) + val provider = ProviderRegistry.byId(providerId) + if (provider.requiresApiKey && !hasApiKey(context, providerId)) return false + if (provider.allowsBaseUrlOverride && baseUrl(context, providerId).isBlank()) return false + return model(context, providerId).isNotBlank() + } } diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt new file mode 100644 index 0000000..25cb31e --- /dev/null +++ b/app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt @@ -0,0 +1,21 @@ +package net.fuzzyworld.claudeauto + +internal const val SYSTEM_PROMPT = + "You are a voice assistant in a car. Be concise and conversational — " + + "keep responses to 1–3 sentences unless genuinely more detail is needed. " + + "Never use markdown, bullet points, code blocks, or lists. " + + "Speak naturally, as if talking to a driver." + +object ProviderRegistry { + val all: List<AiProvider> = listOf( + AnthropicProvider, + OpenAiProvider, + GroqProvider, + MistralProvider, + GeminiProvider, + OllamaProvider, + CustomOpenAiProvider, + ) + + fun byId(id: String): AiProvider = all.find { it.id == id } ?: AnthropicProvider +} diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt b/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt index 145109d..eadcc8b 100644 --- a/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt +++ b/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt @@ -3,10 +3,15 @@ package net.fuzzyworld.claudeauto import android.Manifest import android.content.pm.PackageManager import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter import android.widget.Button import android.widget.EditText import android.widget.LinearLayout +import android.widget.Spinner import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -15,59 +20,220 @@ import androidx.core.content.ContextCompat class SetupActivity : AppCompatActivity() { - private lateinit var enterKeyGroup: LinearLayout + private lateinit var providerSpinner: Spinner + private lateinit var apiKeyGroup: LinearLayout private lateinit var keyInput: EditText - private lateinit var clearBtn: Button + private lateinit var saveKeyBtn: Button + private lateinit var clearKeyBtn: Button + private lateinit var baseUrlGroup: LinearLayout + private lateinit var baseUrlInput: EditText + private lateinit var modelSpinner: Spinner private lateinit var statusView: TextView + private var selectedProvider: AiProvider = AnthropicProvider + private var modelList: List<ModelInfo> = emptyList() + private var initialized = false + + private val mainHandler = Handler(Looper.getMainLooper()) + private val requestMic = registerForActivityResult( ActivityResultContracts.RequestPermission() - ) { _ -> refreshUI() } + ) { _ -> updateStatus() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_setup) - enterKeyGroup = findViewById(R.id.enterKeyGroup) - keyInput = findViewById(R.id.keyInput) - clearBtn = findViewById(R.id.clearBtn) - statusView = findViewById(R.id.status) + providerSpinner = findViewById(R.id.providerSpinner) + apiKeyGroup = findViewById(R.id.apiKeyGroup) + keyInput = findViewById(R.id.keyInput) + saveKeyBtn = findViewById(R.id.saveKeyBtn) + clearKeyBtn = findViewById(R.id.clearKeyBtn) + baseUrlGroup = findViewById(R.id.baseUrlGroup) + baseUrlInput = findViewById(R.id.baseUrlInput) + modelSpinner = findViewById(R.id.modelSpinner) + statusView = findViewById(R.id.status) + + setupProviderSpinner() + setupModelSpinner() - findViewById<Button>(R.id.saveBtn).setOnClickListener { + saveKeyBtn.setOnClickListener { val key = keyInput.text.toString().trim() if (key.isBlank()) { Toast.makeText(this, "Paste your API key first.", Toast.LENGTH_SHORT).show() return@setOnClickListener } - Prefs.saveApiKey(this, key) + Prefs.saveApiKey(this, selectedProvider.id, key) keyInput.setText("") Toast.makeText(this, "API key saved.", Toast.LENGTH_SHORT).show() refreshUI() + maybeAutoFetch() } - clearBtn.setOnClickListener { - Prefs.clearApiKey(this) + clearKeyBtn.setOnClickListener { + Prefs.clearApiKey(this, selectedProvider.id) Toast.makeText(this, "API key cleared.", Toast.LENGTH_SHORT).show() refreshUI() } + findViewById<Button>(R.id.saveUrlBtn).setOnClickListener { + val url = baseUrlInput.text.toString().trim() + if (url.isBlank()) { + Toast.makeText(this, "Enter a base URL first.", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + Prefs.saveBaseUrl(this, selectedProvider.id, url) + Toast.makeText(this, "URL saved.", Toast.LENGTH_SHORT).show() + refreshUI() + maybeAutoFetch() + } + + findViewById<Button>(R.id.fetchModelsBtn).setOnClickListener { fetchModels() } + if (!hasMic()) requestMic.launch(Manifest.permission.RECORD_AUDIO) + + // defer so all setSelection() calls during init fire before listeners start saving + mainHandler.post { initialized = true } + } + + // ── Provider spinner ─────────────────────────────────────────────────────── + + private fun setupProviderSpinner() { + val names = ProviderRegistry.all.map { it.displayName } + providerSpinner.adapter = ArrayAdapter( + this, android.R.layout.simple_spinner_item, names + ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + val savedId = Prefs.selectedProviderId(this) + val savedIdx = ProviderRegistry.all.indexOfFirst { it.id == savedId }.coerceAtLeast(0) + selectedProvider = ProviderRegistry.all[savedIdx] + providerSpinner.setSelection(savedIdx) + + providerSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { + if (!initialized) return + selectedProvider = ProviderRegistry.all[pos] + Prefs.saveProviderId(this@SetupActivity, selectedProvider.id) + refreshUI() + maybeAutoFetch() + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + refreshUI() } + // ── Model spinner ────────────────────────────────────────────────────────── + + private fun setupModelSpinner() { + modelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { + if (!initialized) return + val model = modelList.getOrNull(pos) ?: return + Prefs.saveModel(this@SetupActivity, selectedProvider.id, model.id) + updateStatus() + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + + val cached = Prefs.cachedModels(this, selectedProvider.id) + if (cached != null) populateModelSpinner(cached) + else populateModelSpinner(listOf(ModelInfo(selectedProvider.defaultModel, selectedProvider.defaultModel))) + + maybeAutoFetch() + } + + private fun populateModelSpinner(models: List<ModelInfo>) { + modelList = models + val savedModel = Prefs.model(this, selectedProvider.id) + val adapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + models.map { it.displayName }, + ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } + + val prev = initialized + initialized = false + modelSpinner.adapter = adapter + val idx = models.indexOfFirst { it.id == savedModel }.coerceAtLeast(0) + modelSpinner.setSelection(idx) + initialized = prev + } + + // ── Model fetch ──────────────────────────────────────────────────────────── + + private fun maybeAutoFetch() { + val provider = selectedProvider + val canFetch = if (provider.requiresApiKey) { + Prefs.hasApiKey(this, provider.id) + } else { + !provider.allowsBaseUrlOverride || Prefs.baseUrl(this, provider.id).isNotBlank() + } + if (canFetch && Prefs.cachedModels(this, provider.id) == null) fetchModels() + } + + private fun fetchModels() { + val provider = selectedProvider + val apiKey = Prefs.apiKey(this, provider.id) + val baseUrl = Prefs.baseUrl(this, provider.id) + statusView.text = "Fetching models…" + + provider.listModels(apiKey, baseUrl) { models -> + runOnUiThread { + if (!models.isNullOrEmpty()) { + Prefs.saveModelCache(this, provider.id, models) + populateModelSpinner(models) + Toast.makeText(this, "${models.size} models loaded.", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Couldn't fetch models.", Toast.LENGTH_SHORT).show() + } + updateStatus() + } + } + } + + // ── UI refresh ───────────────────────────────────────────────────────────── + private fun refreshUI() { - val hasKey = Prefs.hasApiKey(this) - enterKeyGroup.visibility = if (hasKey) View.GONE else View.VISIBLE - clearBtn.visibility = if (hasKey) View.VISIBLE else View.GONE + val provider = selectedProvider + val hasKey = Prefs.hasApiKey(this, provider.id) + + apiKeyGroup.visibility = if (provider.requiresApiKey) View.VISIBLE else View.GONE + saveKeyBtn.visibility = if (!hasKey) View.VISIBLE else View.GONE + clearKeyBtn.visibility = if (hasKey) View.VISIBLE else View.GONE + if (provider.allowsBaseUrlOverride) { + baseUrlGroup.visibility = View.VISIBLE + baseUrlInput.setText(Prefs.baseUrl(this, provider.id)) + } else { + baseUrlGroup.visibility = View.GONE + } + + val cached = Prefs.cachedModels(this, provider.id) + if (cached != null) populateModelSpinner(cached) + else populateModelSpinner(listOf(ModelInfo(provider.defaultModel, provider.defaultModel))) + + updateStatus() + } + + private fun updateStatus() { + val provider = selectedProvider + val hasKey = !provider.requiresApiKey || Prefs.hasApiKey(this, provider.id) + val hasUrl = !provider.allowsBaseUrlOverride || Prefs.baseUrl(this, provider.id).isNotBlank() + val hasModel = Prefs.model(this, provider.id).isNotBlank() val micOk = hasMic() + statusView.text = buildString { - appendLine(if (hasKey) "✅ API key saved" else "❌ No API key — paste one above and tap Save") + if (provider.requiresApiKey) + appendLine(if (hasKey) "✅ API key saved" else "❌ No API key — paste one above and tap Save") + if (provider.allowsBaseUrlOverride) + appendLine(if (hasUrl) "✅ Base URL set" else "❌ Base URL required") + appendLine(if (hasModel) "✅ Model: ${Prefs.model(this@SetupActivity, provider.id)}" else "❌ No model selected — tap ↻ to fetch") appendLine(if (micOk) "✅ Microphone permission granted" else "❌ Microphone permission denied") appendLine() appendLine( - if (hasKey && micOk) - "Ready. Connect Android Auto and open Claude Auto from the car launcher." + if (hasKey && hasUrl && hasModel && micOk) + "Ready. Connect Android Auto and open the app from the car launcher." else "Fix the issues above before using the car app." ) diff --git a/app/src/main/res/layout/activity_setup.xml b/app/src/main/res/layout/activity_setup.xml index 26c804d..30eb390 100644 --- a/app/src/main/res/layout/activity_setup.xml +++ b/app/src/main/res/layout/activity_setup.xml @@ -1,64 +1,165 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:padding="32dp" - android:gravity="top"> + android:layout_height="match_parent"> - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Claude Auto" - android:textSize="26sp" - android:textStyle="bold" - android:layout_marginBottom="8dp" /> - - <!-- Shown only when no key is saved --> <LinearLayout - android:id="@+id/enterKeyGroup" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical"> + android:orientation="vertical" + android:padding="24dp"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="AI Auto" + android:textSize="26sp" + android:textStyle="bold" + android:layout_marginBottom="4dp" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Paste your API key from console.anthropic.com" + android:text="Configure your AI provider" android:textSize="14sp" + android:layout_marginBottom="24dp" /> + + <!-- Provider --> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Provider" + android:textSize="12sp" + android:textStyle="bold" + android:layout_marginBottom="4dp" /> + + <Spinner + android:id="@+id/providerSpinner" + android:layout_width="match_parent" + android:layout_height="wrap_content" android:layout_marginBottom="20dp" /> - <EditText - android:id="@+id/keyInput" + <!-- API key (hidden when provider doesn't require one) --> + <LinearLayout + android:id="@+id/apiKeyGroup" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="sk-ant-..." - android:inputType="textPassword" - android:fontFamily="monospace" - android:layout_marginBottom="12dp" /> + android:orientation="vertical" + android:layout_marginBottom="20dp"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="API Key" + android:textSize="12sp" + android:textStyle="bold" + android:layout_marginBottom="4dp" /> + + <EditText + android:id="@+id/keyInput" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="Paste your API key" + android:inputType="textPassword" + android:fontFamily="monospace" + android:layout_marginBottom="8dp" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> - <Button - android:id="@+id/saveBtn" + <Button + android:id="@+id/saveKeyBtn" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Save Key" + android:layout_marginEnd="8dp" /> + + <Button + android:id="@+id/clearKeyBtn" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Clear Key" + android:visibility="gone" /> + + </LinearLayout> + + </LinearLayout> + + <!-- Base URL (shown for Ollama / Custom) --> + <LinearLayout + android:id="@+id/baseUrlGroup" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Save API Key" - android:layout_marginBottom="24dp" /> + android:orientation="vertical" + android:visibility="gone" + android:layout_marginBottom="20dp"> - </LinearLayout> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Base URL" + android:textSize="12sp" + android:textStyle="bold" + android:layout_marginBottom="4dp" /> - <!-- Shown only when a key is already saved --> - <Button - android:id="@+id/clearBtn" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Clear Saved Key" - android:layout_marginBottom="24dp" /> + <EditText + android:id="@+id/baseUrlInput" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:inputType="textUri" + android:fontFamily="monospace" + android:layout_marginBottom="8dp" /> - <TextView - android:id="@+id/status" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:textSize="14sp" - android:lineSpacingMultiplier="1.4" /> + <Button + android:id="@+id/saveUrlBtn" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Save URL" /> + + </LinearLayout> + + <!-- Model --> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Model" + android:textSize="12sp" + android:textStyle="bold" + android:layout_marginBottom="4dp" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginBottom="24dp"> + + <Spinner + android:id="@+id/modelSpinner" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_marginEnd="8dp" /> + + <Button + android:id="@+id/fetchModelsBtn" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="↻" /> + + </LinearLayout> + + <!-- Status --> + <TextView + android:id="@+id/status" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textSize="14sp" + android:lineSpacingMultiplier="1.4" /> + + </LinearLayout> -</LinearLayout> +</ScrollView>