ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / SetupActivity.kt
SetupActivity.kt
| 1 | package net.fuzzyworld.aichat |
| 2 | |
| 3 | import android.Manifest |
| 4 | import android.content.pm.PackageManager |
| 5 | import android.os.Bundle |
| 6 | import android.os.Handler |
| 7 | import android.os.Looper |
| 8 | import android.speech.tts.TextToSpeech |
| 9 | import android.speech.tts.Voice |
| 10 | import android.view.View |
| 11 | import android.widget.AdapterView |
| 12 | import android.widget.ArrayAdapter |
| 13 | import android.widget.Button |
| 14 | import android.widget.EditText |
| 15 | import android.widget.LinearLayout |
| 16 | import android.widget.SeekBar |
| 17 | import android.widget.Spinner |
| 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 |
| 23 | import kotlin.math.roundToInt |
| 24 | |
| 25 | class SetupActivity : AppCompatActivity() { |
| 26 | |
| 27 | private lateinit var providerSpinner: Spinner |
| 28 | private lateinit var apiKeyGroup: LinearLayout |
| 29 | private lateinit var apiKeyLabel: TextView |
| 30 | private lateinit var keyInput: EditText |
| 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 |
| 36 | private lateinit var voiceSpinner: Spinner |
| 37 | private lateinit var speedSeekBar: SeekBar |
| 38 | private lateinit var speedLabel: TextView |
| 39 | private lateinit var statusView: TextView |
| 40 | |
| 41 | private var selectedProvider: AiProvider = AnthropicProvider |
| 42 | private var modelList: List<ModelInfo> = emptyList() |
| 43 | private var initialized = false |
| 44 | |
| 45 | private var setupTts: TextToSpeech? = null |
| 46 | private var setupTtsReady = false |
| 47 | private var voiceOptions: List<Voice?> = listOf(null) // null = system default |
| 48 | |
| 49 | private val mainHandler = Handler(Looper.getMainLooper()) |
| 50 | |
| 51 | private val requestMic = registerForActivityResult( |
| 52 | ActivityResultContracts.RequestPermission() |
| 53 | ) { _ -> updateStatus() } |
| 54 | |
| 55 | override fun onCreate(savedInstanceState: Bundle?) { |
| 56 | super.onCreate(savedInstanceState) |
| 57 | setContentView(R.layout.activity_setup) |
| 58 | |
| 59 | providerSpinner = findViewById(R.id.providerSpinner) |
| 60 | apiKeyGroup = findViewById(R.id.apiKeyGroup) |
| 61 | apiKeyLabel = findViewById(R.id.apiKeyLabel) |
| 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) |
| 68 | voiceSpinner = findViewById(R.id.voiceSpinner) |
| 69 | speedSeekBar = findViewById(R.id.speedSeekBar) |
| 70 | speedLabel = findViewById(R.id.speedLabel) |
| 71 | statusView = findViewById(R.id.status) |
| 72 | |
| 73 | setupProviderSpinner() |
| 74 | setupModelSpinner() |
| 75 | setupVoiceSection() |
| 76 | |
| 77 | saveKeyBtn.setOnClickListener { |
| 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 | } |
| 83 | Prefs.saveApiKey(this, selectedProvider.id, key) |
| 84 | keyInput.setText("") |
| 85 | Toast.makeText(this, "API key saved.", Toast.LENGTH_SHORT).show() |
| 86 | refreshUI() |
| 87 | maybeAutoFetch() |
| 88 | } |
| 89 | |
| 90 | clearKeyBtn.setOnClickListener { |
| 91 | Prefs.clearApiKey(this, selectedProvider.id) |
| 92 | Toast.makeText(this, "API key cleared.", Toast.LENGTH_SHORT).show() |
| 93 | refreshUI() |
| 94 | } |
| 95 | |
| 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 | |
| 110 | if (!hasMic()) requestMic.launch(Manifest.permission.RECORD_AUDIO) |
| 111 | |
| 112 | setupTts = TextToSpeech(this) { status -> onSetupTtsInit(status) } |
| 113 | |
| 114 | // defer so all setSelection() calls during init fire before listeners start saving |
| 115 | mainHandler.post { initialized = true } |
| 116 | } |
| 117 | |
| 118 | override fun onDestroy() { |
| 119 | super.onDestroy() |
| 120 | setupTts?.shutdown() |
| 121 | } |
| 122 | |
| 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 | |
| 147 | refreshUI() |
| 148 | } |
| 149 | |
| 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 | |
| 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 | |
| 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 | |
| 302 | private fun refreshUI() { |
| 303 | val provider = selectedProvider |
| 304 | val hasKey = Prefs.hasApiKey(this, provider.id) |
| 305 | |
| 306 | apiKeyGroup.visibility = if (provider.requiresApiKey) View.VISIBLE else View.GONE |
| 307 | apiKeyLabel.visibility = if (!hasKey) View.VISIBLE else View.GONE |
| 308 | keyInput.visibility = if (!hasKey) View.VISIBLE else View.GONE |
| 309 | saveKeyBtn.visibility = if (!hasKey) View.VISIBLE else View.GONE |
| 310 | clearKeyBtn.visibility = if (hasKey) View.VISIBLE else View.GONE |
| 311 | |
| 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() |
| 331 | val micOk = hasMic() |
| 332 | |
| 333 | statusView.text = buildString { |
| 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") |
| 339 | appendLine(if (micOk) "✅ Microphone permission granted" else "❌ Microphone permission denied") |
| 340 | appendLine() |
| 341 | appendLine( |
| 342 | if (hasKey && hasUrl && hasModel && micOk) |
| 343 | "Ready. Connect Android Auto and open the app from the car launcher." |
| 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 | } |