Krolyxon
Commit a41a513

Branches

Refs →

budctlandroidappsrcmainkotliniogithubkrolyxonbudctlApp.kt

499 lines · 19.8 KB · Kotlin

1package io.github.krolyxon.budctl
2
3import android.content.SharedPreferences
4import androidx.compose.runtime.getValue
5import androidx.compose.runtime.mutableStateListOf
6import androidx.compose.runtime.mutableStateOf
7import androidx.compose.runtime.setValue
8import org.json.JSONArray
9import org.json.JSONObject
10
11/** The pages, in order: the dot-matrix word and the caption above the page. */
12enum class Page(val word: String, val caption: String) {
13 Overview("overview", "overview · battery, noise control, sound"),
14 Equalizer("eq", "equalizer · presets, and the bands of yours"),
15 Gestures("gestures", "gestures · taps and holds on each bud"),
16 Earbuds("buds", "earbud settings · connection, find, firmware"),
17 App("app", "app · colors, motion, debug log"),
18}
19
20/** What a write is for. While one is on its way, its row says "Saving…" and takes no other. */
21sealed interface Item {
22 data object Read : Item
23
24 data object Noise : Item
25
26 data class Switch(val key: String) : Item
27
28 data object Bass : Item
29
30 /** A preset chosen, saved, renamed or deleted. */
31 data class Preset(val id: Int) : Item
32
33 data object NewPreset : Item
34
35 /** A gesture of one bud given an action. */
36 data class Gesture(val side: String, val gesture: String) : Item
37
38 /** A mode put in the hold cycle or taken out. */
39 data class Cycle(val mode: String) : Item
40
41 /** The buds told to ring, or to stop. */
42 data object Find : Item
43}
44
45/** An action picked for a gesture on the screen, not on the buds yet. */
46data class TouchDraft(val side: String, val gesture: String, val action: String)
47
48/** A custom preset's bands as moved on the screen, not saved on the buds yet. */
49data class Draft(val preset: Int, val bands: List<EqBand>)
50
51/** The name asked for a new preset, or for renaming the preset [renaming]. */
52data class NamePrompt(val renaming: Int?, val text: String, val problem: String? = null)
53
54/** Where the app stands with the buds. */
55sealed interface Connection {
56 data object Demo : Connection
57
58 data object Connecting : Connection
59
60 data object Live : Connection
61
62 /** `kind` as the worker names it: `usage`, `not-found`, `busy` or `failed`. */
63 data class Failed(val kind: String, val message: String) : Connection
64}
65
66/**
67 * What the screen shows and what a touch does, as the desktop app's `update` (crates/budctl-gui):
68 * every change goes to the worker as a command, and the buds' read-back, the worker's answer,
69 * changes the screen. With the demo buds, a change shows at once.
70 */
71class App(
72 val demo: Boolean,
73 private val prefs: SharedPreferences,
74 /** Queues a command for the worker: null, or why it was not queued. */
75 private val send: (String) -> String?,
76 /** Starts the worker again, as after the debug switch. */
77 private val restart: () -> Unit,
78) {
79 var buds by mutableStateOf(if (demo) Buds.DEMO else Buds.UNKNOWN)
80 var connection by mutableStateOf<Connection>(if (demo) Connection.Demo else Connection.Connecting)
81 var permitted by mutableStateOf(demo)
82 var debug by mutableStateOf(prefs.getBoolean(DEBUG, false))
83 private set
84 var materialYou by mutableStateOf(prefs.getBoolean(MATERIAL_YOU, true))
85 private set
86 var motion by mutableStateOf(prefs.getBoolean(MOTION, true))
87 private set
88
89 /** The noise-cancelling level to ask for: the buds' own, once they report one. */
90 var ancLevel by mutableStateOf("deep")
91 private set
92
93 /** A bass level moved on the screen and not set on the buds yet. */
94 var bassDraft by mutableStateOf<Int?>(null)
95 private set
96
97 /** "Turn dual connection off?" is open. */
98 var confirmDual by mutableStateOf(false)
99 private set
100
101 /** The custom preset whose bands are open. */
102 var editing by mutableStateOf<Int?>(null)
103 private set
104
105 var draft by mutableStateOf<Draft?>(null)
106 private set
107
108 /** "Delete <preset>?" is open, for this preset. */
109 var confirmDelete by mutableStateOf<Int?>(null)
110 private set
111
112 var prompt by mutableStateOf<NamePrompt?>(null)
113 private set
114
115 var touchDraft by mutableStateOf<TouchDraft?>(null)
116 private set
117
118 /** The buds were told to ring and not yet to stop. */
119 var ringing by mutableStateOf(false)
120 private set
121
122 /** "Ring your buds?" is open. */
123 var confirmFind by mutableStateOf(false)
124 private set
125
126 /** The commands sent and not answered yet, oldest first: each gets one event, in order. */
127 private val inFlight = mutableStateListOf<Item>()
128
129 /** The worker's log lines, the debug dump with them. */
130 val log = mutableStateListOf<String>()
131
132 val problem: String? get() = (connection as? Connection.Failed)?.message
133
134 fun saving(item: Item) = item in inFlight
135
136 fun read() {
137 if (connection is Connection.Failed) connection = Connection.Connecting
138 command(Item.Read, JSONObject().put("command", "read"))
139 }
140
141 /** [mode] `off`, `transparency` or `anc`; [level] for `anc`, else the level the buds are at. */
142 fun noise(mode: String, level: String? = null) {
143 val json = JSONObject().put("command", "noise").put("mode", mode)
144 if (mode == "anc") json.put("level", level ?: ancLevel)
145 command(Item.Noise, json)
146 }
147
148 fun switch(key: String, on: Boolean) {
149 // Turning dual connection off may drop one of two connected devices: ask first.
150 if (key == DUAL && !on) {
151 confirmDual = true
152 return
153 }
154 command(Item.Switch(key), JSONObject().put("command", "switch").put("key", key).put("on", on))
155 }
156
157 fun answerDual(yes: Boolean) {
158 confirmDual = false
159 if (yes) command(Item.Switch(DUAL), JSONObject().put("command", "switch").put("key", DUAL).put("on", false))
160 }
161
162 /** The bass level slider moved: shown at once, written when the finger lifts ([saveBass]). */
163 fun moveBass(level: Int) {
164 bassDraft = level
165 }
166
167 fun saveBass() {
168 val level = bassDraft ?: return
169 if (level == buds.bass) {
170 bassDraft = null
171 return
172 }
173 command(Item.Bass, JSONObject().put("command", "bass").put("level", level))
174 }
175
176 fun selectPreset(id: Int) {
177 val preset = buds.preset(id) ?: return
178 if (buds.eq == Buds.key(preset)) return
179 val json = if (preset.custom) customEq(preset, emptyList()) else JSONObject().put("command", "eq").put("preset", preset.name)
180 command(Item.Preset(id), json)
181 }
182
183 /** Opens a custom preset's bands, or closes them. */
184 fun editPreset(id: Int) {
185 editing = if (editing == id) null else id
186 }
187
188 fun doneEditing() {
189 editing = null
190 }
191
192 /** Moves a band on the screen, within the preset's range. Nothing goes to the buds yet. */
193 fun moveBand(id: Int, hz: Int, gain: Int) {
194 val preset = buds.preset(id) ?: return
195 val range = preset.range ?: Int.MIN_VALUE..Int.MAX_VALUE
196 val bands = draft?.takeIf { it.preset == id }?.bands ?: preset.bands
197 draft = Draft(id, bands.map { if (it.hz == hz) it.copy(gain = gain.coerceIn(range)) else it })
198 }
199
200 /** Saves the moved bands. If a write to the preset is on its way, they go after it. */
201 fun saveBands(id: Int) {
202 val bands = draft?.takeIf { it.preset == id }?.bands ?: return
203 val preset = buds.preset(id) ?: return
204 if (Item.Preset(id) !in inFlight) command(Item.Preset(id), customEq(preset, bands))
205 }
206
207 fun deletePreset(id: Int) {
208 confirmDelete = id
209 }
210
211 fun answerDelete(yes: Boolean) {
212 val id = confirmDelete
213 confirmDelete = null
214 val preset = id?.let(buds::preset) ?: return
215 if (!yes) return
216 if (editing == id) editing = null
217 command(Item.Preset(preset.id), JSONObject().put("command", "delete-eq").put("preset", Buds.key(preset)))
218 }
219
220 fun newPreset() {
221 prompt = NamePrompt(null, "")
222 }
223
224 fun renamePreset(id: Int) {
225 prompt = NamePrompt(id, buds.preset(id)?.name ?: "")
226 }
227
228 fun typeName(text: String) {
229 prompt = prompt?.copy(text = text, problem = null)
230 }
231
232 fun cancelName() {
233 prompt = null
234 }
235
236 /** Creates or renames the preset, if the name will do. What the buds refuse comes back as an error. */
237 fun submitName() {
238 val asked = prompt ?: return
239 val text = asked.text
240 val taken = buds.presets?.presets.orEmpty().any { it.custom && it.name == text && it.id != asked.renaming }
241 val problem = when {
242 text.isEmpty() || text.trim() != text -> "A name cannot be empty, or start or end with a space."
243 text.any(Char::isISOControl) -> "A name cannot hold control characters."
244 taken -> "A preset is called $text already."
245 else -> null
246 }
247 if (problem != null) {
248 prompt = asked.copy(problem = problem)
249 return
250 }
251 prompt = null
252 val id = asked.renaming
253 if (id == null) {
254 command(Item.NewPreset, JSONObject().put("command", "create-eq").put("name", text))
255 } else {
256 val preset = buds.preset(id) ?: return
257 if (preset.name != text) {
258 command(Item.Preset(id), JSONObject().put("command", "rename-eq").put("preset", Buds.key(preset)).put("name", text))
259 }
260 }
261 }
262
263 /** Gives a gesture an action, written at once. A pick made while a write is on its way goes after it. */
264 fun pickAction(side: String, gesture: String, action: String) {
265 touchDraft = TouchDraft(side, gesture, action)
266 saveTouch()
267 }
268
269 private fun saveTouch() {
270 val draft = touchDraft ?: return
271 when {
272 buds.action(draft.side, draft.gesture) == draft.action -> touchDraft = null
273 Item.Gesture(draft.side, draft.gesture) !in inFlight -> command(
274 Item.Gesture(draft.side, draft.gesture),
275 JSONObject().put("command", "touch").put("side", draft.side).put("gesture", draft.gesture).put("action", draft.action),
276 )
277 }
278 }
279
280 /** Rings the buds, after "Ring your buds?" (the sound is loud), or stops them. */
281 fun find() {
282 if (ringing) command(Item.Find, JSONObject().put("command", "find").put("ring", false)) else confirmFind = true
283 }
284
285 fun answerFind(ring: Boolean) {
286 confirmFind = false
287 if (ring) command(Item.Find, JSONObject().put("command", "find").put("ring", true))
288 }
289
290 /** Puts a mode in the hold cycle or takes it out. The buds need two modes to step through. */
291 fun cycle(mode: String, on: Boolean) {
292 val now = buds.touch?.holdCycle ?: return
293 val modes = now.filter { it != mode } + if (on) listOf(mode) else emptyList()
294 if (modes.size >= 2 && inFlight.none { it is Item.Cycle }) {
295 command(Item.Cycle(mode), JSONObject().put("command", "hold-cycle").put("modes", JSONArray(modes)))
296 }
297 }
298
299 private fun customEq(preset: EqPreset, bands: List<EqBand>) = JSONObject()
300 .put("command", "custom-eq")
301 .put("preset", Buds.key(preset))
302 .put("bands", JSONArray(bands.map { JSONObject().put("hz", it.hz).put("gain", it.gain) }))
303
304 private fun command(item: Item, json: JSONObject) {
305 if (item != Item.Read && item in inFlight) return
306 if (demo) return pretend(item, json)
307 inFlight += item
308 send(json.toString())?.let { failure ->
309 inFlight.remove(item)
310 connection = Connection.Failed("failed", failure)
311 }
312 }
313
314 /** One event from the worker (docs/android.md#boundary), on the main thread. */
315 fun answer(event: JSONObject) {
316 when (event.getString("event")) {
317 "found" -> buds = buds.copy(name = event.getString("name"))
318 "lost" -> {
319 connection = Connection.Failed("not-found", event.getString("message"))
320 buds = buds.copy(connected = false)
321 }
322 else -> {
323 val item = inFlight.removeFirstOrNull()
324 when (event.getString("event")) {
325 "read" -> {
326 buds = buds.read(event)
327 buds.noise?.level?.let { ancLevel = it }
328 connection = Connection.Live
329 }
330 "noise" -> {
331 val now = parseNoise(event)
332 buds = buds.copy(noise = now)
333 now.level?.let { ancLevel = it }
334 connection = Connection.Live
335 }
336 "switch" -> {
337 val now = parseSwitch(event.getJSONObject("switch"))
338 buds = buds.copy(switches = buds.switches.map { if (it.key == now.key) now else it })
339 connection = Connection.Live
340 }
341 "eq" -> {
342 buds = buds.withEq(event.getString("preset"))
343 connection = Connection.Live
344 }
345 "custom-eq" -> {
346 val saved = parsePreset(event.getJSONObject("preset"))
347 buds = buds.withCustom(saved)
348 connection = Connection.Live
349 // Bands moved while this write was on its way go out now.
350 draft?.takeIf { it.preset == saved.id }?.let {
351 if (it.bands == saved.bands) draft = null else saveBands(saved.id)
352 }
353 }
354 "presets" -> {
355 buds = buds.withPresets(parsePresets(event.getJSONObject("eq")))
356 connection = Connection.Live
357 // A new preset opens its bands: it is the one in use.
358 if (item == Item.NewPreset) editing = buds.inUse()?.id
359 }
360 "touch" -> {
361 val gestures = event.getJSONArray("gestures").let { a -> List(a.length()) { parseGesture(a.getJSONObject(it)) } }
362 buds = buds.copy(touch = buds.touch?.copy(gestures = gestures))
363 connection = Connection.Live
364 // An action picked while this write was on its way goes out now.
365 if (item is Item.Gesture) saveTouch()
366 }
367 "ringing" -> {
368 ringing = event.getBoolean("ring")
369 connection = Connection.Live
370 }
371 "hold-cycle" -> {
372 val modes = event.getJSONArray("modes").let { a -> List(a.length()) { a.getString(it) } }
373 buds = buds.copy(touch = buds.touch?.copy(holdCycle = modes))
374 connection = Connection.Live
375 }
376 "bass" -> {
377 val level = event.getInt("level")
378 buds = buds.copy(bass = level)
379 if (bassDraft == level) bassDraft = null
380 connection = Connection.Live
381 }
382 "failed" -> {
383 val kind = event.getString("kind")
384 connection = Connection.Failed(kind, event.getString("message"))
385 if (kind == "not-found") buds = buds.copy(connected = false)
386 if (item == Item.Bass) bassDraft = null
387 // The buds keep what they had: show that, not what was picked.
388 if (item is Item.Preset && draft?.preset == item.id) draft = null
389 if (item is Item.Gesture && touchDraft?.let { Item.Gesture(it.side, it.gesture) } == item) touchDraft = null
390 }
391 }
392 }
393 }
394 }
395
396 /** The demo buds take a change at once. */
397 private fun pretend(item: Item, json: JSONObject) {
398 when (item) {
399 Item.Read -> {}
400 Item.Noise -> {
401 val mode = json.getString("mode")
402 val level = if (mode == "anc") json.getString("level") else buds.noise?.level
403 buds = buds.copy(noise = NoiseControl(mode, level))
404 level?.let { ancLevel = it }
405 }
406 is Item.Switch -> {
407 val on = json.getBoolean("on")
408 buds = buds.copy(switches = buds.switches.map { if (it.key == item.key) it.copy(on = on) else it })
409 }
410 Item.Bass -> {
411 buds = buds.copy(bass = json.getInt("level"))
412 bassDraft = null
413 }
414 is Item.Preset, Item.NewPreset -> pretendEq(json)
415 is Item.Gesture -> {
416 val action = json.getString("action")
417 val touch = buds.touch ?: return
418 buds = buds.copy(
419 touch = touch.copy(
420 gestures = touch.gestures.map {
421 if (it.side == item.side && it.gesture == item.gesture && it.group == null) it.copy(action = action) else it
422 },
423 ),
424 )
425 touchDraft = null
426 }
427 is Item.Cycle -> {
428 val modes = json.getJSONArray("modes").let { a -> List(a.length()) { a.getString(it) } }
429 buds = buds.copy(touch = buds.touch?.copy(holdCycle = modes))
430 }
431 Item.Find -> ringing = json.getBoolean("ring")
432 }
433 }
434
435 /** The demo buds' presets, changed as the real ones would be. */
436 private fun pretendEq(json: JSONObject) {
437 val presets = buds.presets?.presets.orEmpty()
438 when (json.getString("command")) {
439 "eq" -> buds = buds.withEq(json.getString("preset"))
440 "custom-eq" -> {
441 val key = json.getString("preset")
442 val preset = presets.find { Buds.key(it) == key } ?: return
443 val bands = json.getJSONArray("bands").let { a -> List(a.length()) { a.getJSONObject(it) } }
444 .associate { it.getInt("hz") to it.getInt("gain") }
445 buds = buds.withCustom(preset.copy(bands = preset.bands.map { it.copy(gain = bands[it.hz] ?: it.gain) }))
446 draft = null
447 }
448 "create-eq" -> {
449 val id = (presets.maxOfOrNull { it.id } ?: 3) + 1
450 val flat = (presets.firstOrNull { it.custom }?.bands ?: DEMO_BANDS).map { it.copy(gain = 0) }
451 buds = buds.withCustom(EqPreset(json.getString("name"), id, true, flat, -6..6))
452 editing = id
453 }
454 "rename-eq" -> {
455 val key = json.getString("preset")
456 val preset = presets.find { Buds.key(it) == key } ?: return
457 buds = buds.withCustom(preset.copy(name = json.getString("name")))
458 }
459 "delete-eq" -> {
460 val key = json.getString("preset")
461 val left = presets.filterNot { Buds.key(it) == key }
462 val current = buds.eq.takeUnless { it == key } ?: left.first().name
463 buds = buds.withPresets(EqPresets(current, left))
464 }
465 }
466 }
467
468 fun addLine(line: String) {
469 log.add(line)
470 if (log.size > MAX_LINES) log.removeRange(0, log.size - MAX_LINES)
471 }
472
473 fun toggleDebug() {
474 debug = !debug
475 prefs.edit().putBoolean(DEBUG, debug).apply()
476 if (!demo) restart()
477 }
478
479 fun toggleMaterialYou() {
480 materialYou = !materialYou
481 prefs.edit().putBoolean(MATERIAL_YOU, materialYou).apply()
482 }
483
484 fun toggleMotion() {
485 motion = !motion
486 prefs.edit().putBoolean(MOTION, motion).apply()
487 }
488
489 private companion object {
490 const val DUAL = "dual"
491 const val DEBUG = "debug"
492 const val MATERIAL_YOU = "material_you"
493 const val MOTION = "motion"
494 const val MAX_LINES = 1_000
495
496 /** The bands a demo preset starts with when there is no custom one to copy. */
497 val DEMO_BANDS = listOf(50, 250, 1000, 4000, 8000, 16000).map { EqBand(it, 0) }
498 }
499}