fuzzyworld

ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / SetupActivity.kt

SetupActivity.kt

Blame: the commit that last changed each line · file · history

d522fe6d 2026-05-15 Pavel Safronov1package net.fuzzyworld.aichat
80eca30b 2026-05-14 Pavel Safronov2
3import android.Manifest
4import android.content.pm.PackageManager
5import android.os.Bundle
449ce939 2026-05-14 Pavel Safronov6import android.os.Handler
7import android.os.Looper
9090d7c8 2026-05-15 Pavel Safronov8import android.speech.tts.TextToSpeech
9import android.speech.tts.Voice
80eca30b 2026-05-14 Pavel Safronov10import android.view.View
449ce939 2026-05-14 Pavel Safronov11import android.widget.AdapterView
12import android.widget.ArrayAdapter
80eca30b 2026-05-14 Pavel Safronov13import android.widget.Button
14import android.widget.EditText
15import android.widget.LinearLayout
9090d7c8 2026-05-15 Pavel Safronov16import android.widget.SeekBar
449ce939 2026-05-14 Pavel Safronov17import android.widget.Spinner
80eca30b 2026-05-14 Pavel Safronov18import android.widget.TextView
19import android.widget.Toast
20import androidx.activity.result.contract.ActivityResultContracts
21import androidx.appcompat.app.AppCompatActivity
22import androidx.core.content.ContextCompat
9090d7c8 2026-05-15 Pavel Safronov23import kotlin.math.roundToInt
80eca30b 2026-05-14 Pavel Safronov24
25class SetupActivity : AppCompatActivity() {
26
449ce939 2026-05-14 Pavel Safronov27 private lateinit var providerSpinner: Spinner
28 private lateinit var apiKeyGroup: LinearLayout
9090d7c8 2026-05-15 Pavel Safronov29 private lateinit var apiKeyLabel: TextView
80eca30b 2026-05-14 Pavel Safronov30 private lateinit var keyInput: EditText
449ce939 2026-05-14 Pavel Safronov31 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 Safronov36 private lateinit var voiceSpinner: Spinner
37 private lateinit var speedSeekBar: SeekBar
38 private lateinit var speedLabel: TextView
80eca30b 2026-05-14 Pavel Safronov39 private lateinit var statusView: TextView
40
449ce939 2026-05-14 Pavel Safronov41 private var selectedProvider: AiProvider = AnthropicProvider
42 private var modelList: List<ModelInfo> = emptyList()
43 private var initialized = false
44
9090d7c8 2026-05-15 Pavel Safronov45 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 Safronov49 private val mainHandler = Handler(Looper.getMainLooper())
50
80eca30b 2026-05-14 Pavel Safronov51 private val requestMic = registerForActivityResult(
52 ActivityResultContracts.RequestPermission()
449ce939 2026-05-14 Pavel Safronov53 ) { _ -> updateStatus() }
80eca30b 2026-05-14 Pavel Safronov54
55 override fun onCreate(savedInstanceState: Bundle?) {
56 super.onCreate(savedInstanceState)
57 setContentView(R.layout.activity_setup)
58
449ce939 2026-05-14 Pavel Safronov59 providerSpinner = findViewById(R.id.providerSpinner)
60 apiKeyGroup = findViewById(R.id.apiKeyGroup)
9090d7c8 2026-05-15 Pavel Safronov61 apiKeyLabel = findViewById(R.id.apiKeyLabel)
449ce939 2026-05-14 Pavel Safronov62 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 Safronov68 voiceSpinner = findViewById(R.id.voiceSpinner)
69 speedSeekBar = findViewById(R.id.speedSeekBar)
70 speedLabel = findViewById(R.id.speedLabel)
449ce939 2026-05-14 Pavel Safronov71 statusView = findViewById(R.id.status)
72
73 setupProviderSpinner()
74 setupModelSpinner()
9090d7c8 2026-05-15 Pavel Safronov75 setupVoiceSection()
80eca30b 2026-05-14 Pavel Safronov76
449ce939 2026-05-14 Pavel Safronov77 saveKeyBtn.setOnClickListener {
80eca30b 2026-05-14 Pavel Safronov78 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 Safronov83 Prefs.saveApiKey(this, selectedProvider.id, key)
80eca30b 2026-05-14 Pavel Safronov84 keyInput.setText("")
85 Toast.makeText(this, "API key saved.", Toast.LENGTH_SHORT).show()
86 refreshUI()
449ce939 2026-05-14 Pavel Safronov87 maybeAutoFetch()
80eca30b 2026-05-14 Pavel Safronov88 }
89
449ce939 2026-05-14 Pavel Safronov90 clearKeyBtn.setOnClickListener {
91 Prefs.clearApiKey(this, selectedProvider.id)
80eca30b 2026-05-14 Pavel Safronov92 Toast.makeText(this, "API key cleared.", Toast.LENGTH_SHORT).show()
93 refreshUI()
94 }
95
449ce939 2026-05-14 Pavel Safronov96 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 Safronov110 if (!hasMic()) requestMic.launch(Manifest.permission.RECORD_AUDIO)
449ce939 2026-05-14 Pavel Safronov111
9090d7c8 2026-05-15 Pavel Safronov112 setupTts = TextToSpeech(this) { status -> onSetupTtsInit(status) }
113
449ce939 2026-05-14 Pavel Safronov114 // defer so all setSelection() calls during init fire before listeners start saving
115 mainHandler.post { initialized = true }
116 }
117
9090d7c8 2026-05-15 Pavel Safronov118 override fun onDestroy() {
119 super.onDestroy()
120 setupTts?.shutdown()
121 }
122
449ce939 2026-05-14 Pavel Safronov123 // ── 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 Safronov147 refreshUI()
148 }
149
449ce939 2026-05-14 Pavel Safronov150 // ── 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 Safronov187 // ── 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 Safronov268 // ── 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 Safronov302 private fun refreshUI() {
449ce939 2026-05-14 Pavel Safronov303 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 Safronov307 apiKeyLabel.visibility = if (!hasKey) View.VISIBLE else View.GONE
308 keyInput.visibility = if (!hasKey) View.VISIBLE else View.GONE
449ce939 2026-05-14 Pavel Safronov309 saveKeyBtn.visibility = if (!hasKey) View.VISIBLE else View.GONE
310 clearKeyBtn.visibility = if (hasKey) View.VISIBLE else View.GONE
80eca30b 2026-05-14 Pavel Safronov311
449ce939 2026-05-14 Pavel Safronov312 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 Safronov331 val micOk = hasMic()
449ce939 2026-05-14 Pavel Safronov332
80eca30b 2026-05-14 Pavel Safronov333 statusView.text = buildString {
449ce939 2026-05-14 Pavel Safronov334 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 Safronov339 appendLine(if (micOk) "✅ Microphone permission granted" else "❌ Microphone permission denied")
340 appendLine()
341 appendLine(
449ce939 2026-05-14 Pavel Safronov342 if (hasKey && hasUrl && hasModel && micOk)
343 "Ready. Connect Android Auto and open the app from the car launcher."
80eca30b 2026-05-14 Pavel Safronov344 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}