| 1 | package io.github.krolyxon.budctl |
| 2 | |
| 3 | import android.content.SharedPreferences |
| 4 | import androidx.compose.runtime.getValue |
| 5 | import androidx.compose.runtime.mutableStateListOf |
| 6 | import androidx.compose.runtime.mutableStateOf |
| 7 | import androidx.compose.runtime.setValue |
| 8 | import org.json.JSONArray |
| 9 | import org.json.JSONObject |
| 10 | |
| 11 | /** The pages, in order: the dot-matrix word and the caption above the page. */ |
| 12 | enum 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. */ |
| 21 | sealed 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. */ |
| 46 | data 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. */ |
| 49 | data class Draft(val preset: Int, val bands: List<EqBand>) |
| 50 | |
| 51 | /** The name asked for a new preset, or for renaming the preset [renaming]. */ |
| 52 | data class NamePrompt(val renaming: Int?, val text: String, val problem: String? = null) |
| 53 | |
| 54 | /** Where the app stands with the buds. */ |
| 55 | sealed 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 | */ |
| 71 | class 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 | } |