ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / SetupActivity.kt
SetupActivity.kt
d522fe6d 2026-05-15 Pavel Safronov | 1 | package net.fuzzyworld.aichat |
80eca30b 2026-05-14 Pavel Safronov | 2 | |
| 3 | import android.Manifest | |
| 4 | import android.content.pm.PackageManager | |
| 5 | import android.os.Bundle | |
449ce939 2026-05-14 Pavel Safronov | 6 | import android.os.Handler |
| 7 | import android.os.Looper | |
9090d7c8 2026-05-15 Pavel Safronov | 8 | import android.speech.tts.TextToSpeech |
| 9 | import android.speech.tts.Voice | |
80eca30b 2026-05-14 Pavel Safronov | 10 | import android.view.View |
449ce939 2026-05-14 Pavel Safronov | 11 | import android.widget.AdapterView |
| 12 | import android.widget.ArrayAdapter | |
80eca30b 2026-05-14 Pavel Safronov | 13 | import android.widget.Button |
| 14 | import android.widget.EditText | |
| 15 | import android.widget.LinearLayout | |
9090d7c8 2026-05-15 Pavel Safronov | 16 | import android.widget.SeekBar |
449ce939 2026-05-14 Pavel Safronov | 17 | import android.widget.Spinner |
80eca30b 2026-05-14 Pavel Safronov | 18 | import android.widget.TextView |
| 19 | import android.widget.Toast | |
| 20 | import androidx.activity.result.contract.ActivityResultContracts | |
| 21 | import androidx.appcompat.app.AppCompatActivity | |
| 22 | import androidx.core.content.ContextCompat | |
9090d7c8 2026-05-15 Pavel Safronov | 23 | import kotlin.math.roundToInt |
80eca30b 2026-05-14 Pavel Safronov | 24 | |
| 25 | class SetupActivity : AppCompatActivity() { | |
| 26 | ||
449ce939 2026-05-14 Pavel Safronov | 27 | private lateinit var providerSpinner: Spinner |
| 28 | private lateinit var apiKeyGroup: LinearLayout | |
9090d7c8 2026-05-15 Pavel Safronov | 29 | private lateinit var apiKeyLabel: TextView |
80eca30b 2026-05-14 Pavel Safronov | 30 | private lateinit var keyInput: EditText |
449ce939 2026-05-14 Pavel Safronov | 31 | private lateinit var saveKeyBtn: Button |
| 32 | private lateinit var clearKeyBtn: Button | |
| 33 | private lateinit var baseUrlGroup: LinearLayout | |
| 34 | private lateinit var baseUrlInput: EditText | |
| 35 | private lateinit var modelSpinner: Spinner | |
9090d7c8 2026-05-15 Pavel Safronov | 36 | private lateinit var voiceSpinner: Spinner |
| 37 | private lateinit var speedSeekBar: SeekBar | |
| 38 | private lateinit var speedLabel: TextView | |
80eca30b 2026-05-14 Pavel Safronov | 39 | private lateinit var statusView: TextView |
| 40 | ||
449ce939 2026-05-14 Pavel Safronov | 41 | private var selectedProvider: AiProvider = AnthropicProvider |
| 42 | private var modelList: List<ModelInfo> = emptyList() | |
| 43 | private var initialized = false | |
| 44 | ||
9090d7c8 2026-05-15 Pavel Safronov | 45 | private var setupTts: TextToSpeech? = null |
| 46 | private var setupTtsReady = false | |
| 47 | private var voiceOptions: List<Voice?> = listOf(null) // null = system default | |
| 48 | ||
449ce939 2026-05-14 Pavel Safronov | 49 | private val mainHandler = Handler(Looper.getMainLooper()) |
| 50 | ||
80eca30b 2026-05-14 Pavel Safronov | 51 | private val requestMic = registerForActivityResult( |
| 52 | ActivityResultContracts.RequestPermission() | |
449ce939 2026-05-14 Pavel Safronov | 53 | ) { _ -> updateStatus() } |
80eca30b 2026-05-14 Pavel Safronov | 54 | |
| 55 | override fun onCreate(savedInstanceState: Bundle?) { | |
| 56 | super.onCreate(savedInstanceState) | |
| 57 | setContentView(R.layout.activity_setup) | |
| 58 | ||
449ce939 2026-05-14 Pavel Safronov | 59 | providerSpinner = findViewById(R.id.providerSpinner) |
| 60 | apiKeyGroup = findViewById(R.id.apiKeyGroup) | |
9090d7c8 2026-05-15 Pavel Safronov | 61 | apiKeyLabel = findViewById(R.id.apiKeyLabel) |
449ce939 2026-05-14 Pavel Safronov | 62 | keyInput = findViewById(R.id.keyInput) |
| 63 | saveKeyBtn = findViewById(R.id.saveKeyBtn) | |
| 64 | clearKeyBtn = findViewById(R.id.clearKeyBtn) | |
| 65 | baseUrlGroup = findViewById(R.id.baseUrlGroup) | |
| 66 | baseUrlInput = findViewById(R.id.baseUrlInput) | |
| 67 | modelSpinner = findViewById(R.id.modelSpinner) | |
9090d7c8 2026-05-15 Pavel Safronov | 68 | voiceSpinner = findViewById(R.id.voiceSpinner) |
| 69 | speedSeekBar = findViewById(R.id.speedSeekBar) | |
| 70 | speedLabel = findViewById(R.id.speedLabel) | |
449ce939 2026-05-14 Pavel Safronov | 71 | statusView = findViewById(R.id.status) |
| 72 | ||
| 73 | setupProviderSpinner() | |
| 74 | setupModelSpinner() | |
9090d7c8 2026-05-15 Pavel Safronov | 75 | setupVoiceSection() |
80eca30b 2026-05-14 Pavel Safronov | 76 | |
449ce939 2026-05-14 Pavel Safronov | 77 | saveKeyBtn.setOnClickListener { |
80eca30b 2026-05-14 Pavel Safronov | 78 | val key = keyInput.text.toString().trim() |
| 79 | if (key.isBlank()) { | |
| 80 | Toast.makeText(this, "Paste your API key first.", Toast.LENGTH_SHORT).show() | |
| 81 | return@setOnClickListener | |
| 82 | } | |
449ce939 2026-05-14 Pavel Safronov | 83 | Prefs.saveApiKey(this, selectedProvider.id, key) |
80eca30b 2026-05-14 Pavel Safronov | 84 | keyInput.setText("") |
| 85 | Toast.makeText(this, "API key saved.", Toast.LENGTH_SHORT).show() | |
| 86 | refreshUI() | |
449ce939 2026-05-14 Pavel Safronov | 87 | maybeAutoFetch() |
80eca30b 2026-05-14 Pavel Safronov | 88 | } |
| 89 | ||
449ce939 2026-05-14 Pavel Safronov | 90 | clearKeyBtn.setOnClickListener { |
| 91 | Prefs.clearApiKey(this, selectedProvider.id) | |
80eca30b 2026-05-14 Pavel Safronov | 92 | Toast.makeText(this, "API key cleared.", Toast.LENGTH_SHORT).show() |
| 93 | refreshUI() | |
| 94 | } | |
| 95 | ||
449ce939 2026-05-14 Pavel Safronov | 96 | findViewById<Button>(R.id.saveUrlBtn).setOnClickListener { |
| 97 | val url = baseUrlInput.text.toString().trim() | |
| 98 | if (url.isBlank()) { | |
| 99 | Toast.makeText(this, "Enter a base URL first.", Toast.LENGTH_SHORT).show() | |
| 100 | return@setOnClickListener | |
| 101 | } | |
| 102 | Prefs.saveBaseUrl(this, selectedProvider.id, url) | |
| 103 | Toast.makeText(this, "URL saved.", Toast.LENGTH_SHORT).show() | |
| 104 | refreshUI() | |
| 105 | maybeAutoFetch() | |
| 106 | } | |
| 107 | ||
| 108 | findViewById<Button>(R.id.fetchModelsBtn).setOnClickListener { fetchModels() } | |
| 109 | ||
80eca30b 2026-05-14 Pavel Safronov | 110 | if (!hasMic()) requestMic.launch(Manifest.permission.RECORD_AUDIO) |
449ce939 2026-05-14 Pavel Safronov | 111 | |
9090d7c8 2026-05-15 Pavel Safronov | 112 | setupTts = TextToSpeech(this) { status -> onSetupTtsInit(status) } |
| 113 | ||
449ce939 2026-05-14 Pavel Safronov | 114 | // defer so all setSelection() calls during init fire before listeners start saving |
| 115 | mainHandler.post { initialized = true } | |
| 116 | } | |
| 117 | ||
9090d7c8 2026-05-15 Pavel Safronov | 118 | override fun onDestroy() { |
| 119 | super.onDestroy() | |
| 120 | setupTts?.shutdown() | |
| 121 | } | |
| 122 | ||
449ce939 2026-05-14 Pavel Safronov | 123 | // ── Provider spinner ─────────────────────────────────────────────────────── |
| 124 | ||
| 125 | private fun setupProviderSpinner() { | |
| 126 | val names = ProviderRegistry.all.map { it.displayName } | |
| 127 | providerSpinner.adapter = ArrayAdapter( | |
| 128 | this, android.R.layout.simple_spinner_item, names | |
| 129 | ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } | |
| 130 | ||
| 131 | val savedId = Prefs.selectedProviderId(this) | |
| 132 | val savedIdx = ProviderRegistry.all.indexOfFirst { it.id == savedId }.coerceAtLeast(0) | |
| 133 | selectedProvider = ProviderRegistry.all[savedIdx] | |
| 134 | providerSpinner.setSelection(savedIdx) | |
| 135 | ||
| 136 | providerSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { | |
| 137 | override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { | |
| 138 | if (!initialized) return | |
| 139 | selectedProvider = ProviderRegistry.all[pos] | |
| 140 | Prefs.saveProviderId(this@SetupActivity, selectedProvider.id) | |
| 141 | refreshUI() | |
| 142 | maybeAutoFetch() | |
| 143 | } | |
| 144 | override fun onNothingSelected(parent: AdapterView<*>?) {} | |
| 145 | } | |
| 146 | ||
80eca30b 2026-05-14 Pavel Safronov | 147 | refreshUI() |
| 148 | } | |
| 149 | ||
449ce939 2026-05-14 Pavel Safronov | 150 | // ── Model spinner ────────────────────────────────────────────────────────── |
| 151 | ||
| 152 | private fun setupModelSpinner() { | |
| 153 | modelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { | |
| 154 | override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { | |
| 155 | if (!initialized) return | |
| 156 | val model = modelList.getOrNull(pos) ?: return | |
| 157 | Prefs.saveModel(this@SetupActivity, selectedProvider.id, model.id) | |
| 158 | updateStatus() | |
| 159 | } | |
| 160 | override fun onNothingSelected(parent: AdapterView<*>?) {} | |
| 161 | } | |
| 162 | ||
| 163 | val cached = Prefs.cachedModels(this, selectedProvider.id) | |
| 164 | if (cached != null) populateModelSpinner(cached) | |
| 165 | else populateModelSpinner(listOf(ModelInfo(selectedProvider.defaultModel, selectedProvider.defaultModel))) | |
| 166 | ||
| 167 | maybeAutoFetch() | |
| 168 | } | |
| 169 | ||
| 170 | private fun populateModelSpinner(models: List<ModelInfo>) { | |
| 171 | modelList = models | |
| 172 | val savedModel = Prefs.model(this, selectedProvider.id) | |
| 173 | val adapter = ArrayAdapter( | |
| 174 | this, | |
| 175 | android.R.layout.simple_spinner_item, | |
| 176 | models.map { it.displayName }, | |
| 177 | ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } | |
| 178 | ||
| 179 | val prev = initialized | |
| 180 | initialized = false | |
| 181 | modelSpinner.adapter = adapter | |
| 182 | val idx = models.indexOfFirst { it.id == savedModel }.coerceAtLeast(0) | |
| 183 | modelSpinner.setSelection(idx) | |
| 184 | initialized = prev | |
| 185 | } | |
| 186 | ||
9090d7c8 2026-05-15 Pavel Safronov | 187 | // ── Voice section ────────────────────────────────────────────────────────── |
| 188 | ||
| 189 | private fun setupVoiceSection() { | |
| 190 | voiceSpinner.adapter = ArrayAdapter( | |
| 191 | this, android.R.layout.simple_spinner_item, listOf("Loading voices…") | |
| 192 | ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } | |
| 193 | ||
| 194 | voiceSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { | |
| 195 | override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { | |
| 196 | if (!initialized) return | |
| 197 | Prefs.saveVoiceId(this@SetupActivity, voiceOptions.getOrNull(pos)?.name ?: "") | |
| 198 | } | |
| 199 | override fun onNothingSelected(parent: AdapterView<*>?) {} | |
| 200 | } | |
| 201 | ||
| 202 | val savedRate = Prefs.speechRate(this) | |
| 203 | speedSeekBar.progress = speedToProgress(savedRate) | |
| 204 | speedLabel.text = "%.1f×".format(savedRate) | |
| 205 | ||
| 206 | speedSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { | |
| 207 | override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) { | |
| 208 | val speed = progressToSpeed(progress) | |
| 209 | speedLabel.text = "%.1f×".format(speed) | |
| 210 | if (fromUser) Prefs.saveSpeechRate(this@SetupActivity, speed) | |
| 211 | } | |
| 212 | override fun onStartTrackingTouch(bar: SeekBar?) {} | |
| 213 | override fun onStopTrackingTouch(bar: SeekBar?) {} | |
| 214 | }) | |
| 215 | ||
| 216 | findViewById<Button>(R.id.testVoiceBtn).setOnClickListener { | |
| 217 | if (!setupTtsReady) { | |
| 218 | Toast.makeText(this, "Voice engine not ready yet.", Toast.LENGTH_SHORT).show() | |
| 219 | return@setOnClickListener | |
| 220 | } | |
| 221 | val voice = voiceOptions.getOrNull(voiceSpinner.selectedItemPosition) | |
| 222 | setupTts?.apply { | |
| 223 | setSpeechRate(progressToSpeed(speedSeekBar.progress)) | |
| 224 | if (voice != null) setVoice(voice) | |
| 225 | speak( | |
| 226 | "The AI assistant is ready to help. Here is how it will sound in your car.", | |
| 227 | TextToSpeech.QUEUE_FLUSH, null, "test" | |
| 228 | ) | |
| 229 | } | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | private fun onSetupTtsInit(status: Int) { | |
| 234 | if (status != TextToSpeech.SUCCESS) return | |
| 235 | setupTtsReady = true | |
| 236 | ||
| 237 | val voices = (setupTts?.voices ?: emptySet()) | |
| 238 | .filter { !it.isNetworkConnectionRequired } | |
| 239 | .sortedWith(compareByDescending<Voice> { it.quality }.thenBy { it.locale.displayName }.thenBy { it.name }) | |
| 240 | ||
| 241 | val localeCount = voices.groupBy { it.locale.displayName }.mapValues { it.value.size } | |
| 242 | val displayNames = mutableListOf("System default") | |
| 243 | voices.forEach { v -> | |
| 244 | val base = v.locale.displayName | |
| 245 | displayNames += if ((localeCount[base] ?: 1) > 1) "$base — ${v.name}" else base | |
| 246 | } | |
| 247 | ||
| 248 | voiceOptions = listOf(null) + voices | |
| 249 | ||
| 250 | runOnUiThread { | |
| 251 | val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayNames) | |
| 252 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) | |
| 253 | ||
| 254 | val prev = initialized | |
| 255 | initialized = false | |
| 256 | voiceSpinner.adapter = adapter | |
| 257 | val savedId = Prefs.voiceId(this) | |
| 258 | val idx = if (savedId.isBlank()) 0 | |
| 259 | else voiceOptions.indexOfFirst { it?.name == savedId }.coerceAtLeast(0) | |
| 260 | voiceSpinner.setSelection(idx) | |
| 261 | initialized = prev | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | private fun progressToSpeed(progress: Int) = 0.5f + progress * 0.1f | |
| 266 | private fun speedToProgress(speed: Float) = ((speed - 0.5f) / 0.1f).roundToInt().coerceIn(0, 15) | |
| 267 | ||
449ce939 2026-05-14 Pavel Safronov | 268 | // ── Model fetch ──────────────────────────────────────────────────────────── |
| 269 | ||
| 270 | private fun maybeAutoFetch() { | |
| 271 | val provider = selectedProvider | |
| 272 | val canFetch = if (provider.requiresApiKey) { | |
| 273 | Prefs.hasApiKey(this, provider.id) | |
| 274 | } else { | |
| 275 | !provider.allowsBaseUrlOverride || Prefs.baseUrl(this, provider.id).isNotBlank() | |
| 276 | } | |
| 277 | if (canFetch && Prefs.cachedModels(this, provider.id) == null) fetchModels() | |
| 278 | } | |
| 279 | ||
| 280 | private fun fetchModels() { | |
| 281 | val provider = selectedProvider | |
| 282 | val apiKey = Prefs.apiKey(this, provider.id) | |
| 283 | val baseUrl = Prefs.baseUrl(this, provider.id) | |
| 284 | statusView.text = "Fetching models…" | |
| 285 | ||
| 286 | provider.listModels(apiKey, baseUrl) { models -> | |
| 287 | runOnUiThread { | |
| 288 | if (!models.isNullOrEmpty()) { | |
| 289 | Prefs.saveModelCache(this, provider.id, models) | |
| 290 | populateModelSpinner(models) | |
| 291 | Toast.makeText(this, "${models.size} models loaded.", Toast.LENGTH_SHORT).show() | |
| 292 | } else { | |
| 293 | Toast.makeText(this, "Couldn't fetch models.", Toast.LENGTH_SHORT).show() | |
| 294 | } | |
| 295 | updateStatus() | |
| 296 | } | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | // ── UI refresh ───────────────────────────────────────────────────────────── | |
| 301 | ||
80eca30b 2026-05-14 Pavel Safronov | 302 | private fun refreshUI() { |
449ce939 2026-05-14 Pavel Safronov | 303 | val provider = selectedProvider |
| 304 | val hasKey = Prefs.hasApiKey(this, provider.id) | |
| 305 | ||
| 306 | apiKeyGroup.visibility = if (provider.requiresApiKey) View.VISIBLE else View.GONE | |
9090d7c8 2026-05-15 Pavel Safronov | 307 | apiKeyLabel.visibility = if (!hasKey) View.VISIBLE else View.GONE |
| 308 | keyInput.visibility = if (!hasKey) View.VISIBLE else View.GONE | |
449ce939 2026-05-14 Pavel Safronov | 309 | saveKeyBtn.visibility = if (!hasKey) View.VISIBLE else View.GONE |
| 310 | clearKeyBtn.visibility = if (hasKey) View.VISIBLE else View.GONE | |
80eca30b 2026-05-14 Pavel Safronov | 311 | |
449ce939 2026-05-14 Pavel Safronov | 312 | if (provider.allowsBaseUrlOverride) { |
| 313 | baseUrlGroup.visibility = View.VISIBLE | |
| 314 | baseUrlInput.setText(Prefs.baseUrl(this, provider.id)) | |
| 315 | } else { | |
| 316 | baseUrlGroup.visibility = View.GONE | |
| 317 | } | |
| 318 | ||
| 319 | val cached = Prefs.cachedModels(this, provider.id) | |
| 320 | if (cached != null) populateModelSpinner(cached) | |
| 321 | else populateModelSpinner(listOf(ModelInfo(provider.defaultModel, provider.defaultModel))) | |
| 322 | ||
| 323 | updateStatus() | |
| 324 | } | |
| 325 | ||
| 326 | private fun updateStatus() { | |
| 327 | val provider = selectedProvider | |
| 328 | val hasKey = !provider.requiresApiKey || Prefs.hasApiKey(this, provider.id) | |
| 329 | val hasUrl = !provider.allowsBaseUrlOverride || Prefs.baseUrl(this, provider.id).isNotBlank() | |
| 330 | val hasModel = Prefs.model(this, provider.id).isNotBlank() | |
80eca30b 2026-05-14 Pavel Safronov | 331 | val micOk = hasMic() |
449ce939 2026-05-14 Pavel Safronov | 332 | |
80eca30b 2026-05-14 Pavel Safronov | 333 | statusView.text = buildString { |
449ce939 2026-05-14 Pavel Safronov | 334 | if (provider.requiresApiKey) |
| 335 | appendLine(if (hasKey) "✅ API key saved" else "❌ No API key — paste one above and tap Save") | |
| 336 | if (provider.allowsBaseUrlOverride) | |
| 337 | appendLine(if (hasUrl) "✅ Base URL set" else "❌ Base URL required") | |
| 338 | appendLine(if (hasModel) "✅ Model: ${Prefs.model(this@SetupActivity, provider.id)}" else "❌ No model selected — tap ↻ to fetch") | |
80eca30b 2026-05-14 Pavel Safronov | 339 | appendLine(if (micOk) "✅ Microphone permission granted" else "❌ Microphone permission denied") |
| 340 | appendLine() | |
| 341 | appendLine( | |
449ce939 2026-05-14 Pavel Safronov | 342 | if (hasKey && hasUrl && hasModel && micOk) |
| 343 | "Ready. Connect Android Auto and open the app from the car launcher." | |
80eca30b 2026-05-14 Pavel Safronov | 344 | else |
| 345 | "Fix the issues above before using the car app." | |
| 346 | ) | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | private fun hasMic() = ContextCompat.checkSelfPermission( | |
| 351 | this, Manifest.permission.RECORD_AUDIO | |
| 352 | ) == PackageManager.PERMISSION_GRANTED | |
| 353 | } |