fuzzyworld

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

SetupActivity.kt

15.0 KB · history · blame

1package net.fuzzyworld.aichat
2
3import android.Manifest
4import android.content.pm.PackageManager
5import android.os.Bundle
6import android.os.Handler
7import android.os.Looper
8import android.speech.tts.TextToSpeech
9import android.speech.tts.Voice
10import android.view.View
11import android.widget.AdapterView
12import android.widget.ArrayAdapter
13import android.widget.Button
14import android.widget.EditText
15import android.widget.LinearLayout
16import android.widget.SeekBar
17import android.widget.Spinner
18import android.widget.TextView
19import android.widget.Toast
20import androidx.activity.result.contract.ActivityResultContracts
21import androidx.appcompat.app.AppCompatActivity
22import androidx.core.content.ContextCompat
23import kotlin.math.roundToInt
24
25class 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}