Krolyxon
Commit 606eee1

Branches

Refs →

budctlandroidappsrcmainkotliniogithubkrolyxonbudctlBluetooth.kt

159 lines · 5.8 KB · Kotlin

1package io.github.krolyxon.budctl
2
3import android.annotation.SuppressLint
4import android.bluetooth.BluetoothAdapter
5import android.bluetooth.BluetoothDevice
6import android.bluetooth.BluetoothManager
7import android.bluetooth.BluetoothProfile
8import android.content.Context
9import java.io.IOException
10import java.util.Timer
11import java.util.UUID
12import java.util.concurrent.ConcurrentHashMap
13import java.util.concurrent.CountDownLatch
14import java.util.concurrent.TimeUnit
15import java.util.concurrent.atomic.AtomicBoolean
16import kotlin.concurrent.schedule
17import org.json.JSONArray
18import org.json.JSONObject
19
20/**
21 * The phone's side of the worker (docs/android.md#bluetooth): the paired devices and one RFCOMM
22 * link, used by the Rust worker on its own thread, and where its events and log lines go.
23 * Written from Android's API reference.
24 */
25@SuppressLint("MissingPermission") // The activity starts the worker only once BLUETOOTH_CONNECT is granted.
26class Bluetooth(
27 context: Context,
28 private val onEvent: (String) -> Unit,
29 private val onLog: (String) -> Unit,
30) : Platform {
31 private val adapter: BluetoothAdapter? = context.getSystemService(BluetoothManager::class.java)?.adapter
32 private val profiles = ConcurrentHashMap<Int, BluetoothProfile>()
33 private val profilesReady = CountDownLatch(AUDIO_PROFILES.size)
34
35 @Volatile
36 private var link: Link? = null
37
38 init {
39 val listener = object : BluetoothProfile.ServiceListener {
40 override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
41 profiles[profile] = proxy
42 profilesReady.countDown()
43 }
44
45 override fun onServiceDisconnected(profile: Int) {
46 profiles.remove(profile)
47 }
48 }
49 for (profile in AUDIO_PROFILES) {
50 if (adapter?.getProfileProxy(context, listener, profile) != true) profilesReady.countDown()
51 }
52 }
53
54 /** Whether the buds' audio link is up: their control channel only opens then. */
55 private fun connected(device: BluetoothDevice): Boolean {
56 // The proxies come soon after start; this runs on the worker's thread, so it may wait.
57 profilesReady.await(PROFILE_WAIT_MS, TimeUnit.MILLISECONDS)
58 return profiles.values.any { it.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED }
59 }
60
61 override fun pairedDevices(): String {
62 val list = JSONArray()
63 for (device in adapter?.bondedDevices.orEmpty()) {
64 list.put(
65 JSONObject()
66 .put("address", device.address)
67 .put("name", device.name ?: "")
68 .put("alias", device.alias ?: device.name ?: device.address)
69 .put("connected", connected(device))
70 .put("uuids", JSONArray(device.uuids.orEmpty().map { it.uuid.toString() })),
71 )
72 }
73 return list.toString()
74 }
75
76 override fun connect(address: String, service: String): String? {
77 close()
78 val adapter = adapter ?: return failure("not-connected", "this phone has no Bluetooth")
79 if (!adapter.isEnabled) return failure("not-connected", "Bluetooth is off")
80 val device = adapter.getRemoteDevice(address)
81 val socket = try {
82 device.createRfcommSocketToServiceRecord(UUID.fromString(service))
83 } catch (e: IOException) {
84 return failure("failed", "could not make an RFCOMM socket: ${e.message}")
85 }
86 // connect() takes no timeout: a watchdog closes the socket after 8 s, when budctl gives
87 // up on Linux too.
88 val timedOut = AtomicBoolean(false)
89 val watchdog = Timer("budctl-connect", true)
90 watchdog.schedule(CONNECT_TIMEOUT_MS) {
91 timedOut.set(true)
92 quietly { socket.close() }
93 }
94 try {
95 socket.connect()
96 } catch (e: IOException) {
97 quietly { socket.close() }
98 val detail = e.message ?: "refused"
99 return when {
100 timedOut.get() -> failure("timeout", "the control channel did not connect in 8 s")
101 // The audio link is up but the control channel refused: someone else holds it.
102 connected(device) -> failure("busy", detail)
103 else -> failure("not-connected", "the buds are not connected ($detail)")
104 }
105 } finally {
106 watchdog.cancel()
107 }
108 link = Link(socket.inputStream, socket.outputStream, socket)
109 return null
110 }
111
112 override fun send(bytes: ByteArray): String? {
113 val link = link ?: return "no link is open"
114 return try {
115 link.send(bytes)
116 null
117 } catch (e: IOException) {
118 e.message ?: "the link failed"
119 }
120 }
121
122 override fun recv(timeoutMs: Long): ByteArray? {
123 val link = link ?: return ByteArray(0)
124 return link.recv(timeoutMs)
125 }
126
127 override fun close() {
128 link?.close()
129 link = null
130 }
131
132 override fun event(json: String) = onEvent(json)
133
134 override fun log(line: String) = onLog(line)
135
136 /** Lets go of everything, once the app is done with Bluetooth. */
137 fun release() {
138 close()
139 for ((profile, proxy) in profiles) adapter?.closeProfileProxy(profile, proxy)
140 profiles.clear()
141 }
142
143 private fun failure(kind: String, message: String): String =
144 JSONObject().put("kind", kind).put("message", message).toString()
145
146 private inline fun quietly(block: () -> Unit) {
147 try {
148 block()
149 } catch (_: IOException) {
150 // Closing what is already closed.
151 }
152 }
153
154 private companion object {
155 val AUDIO_PROFILES = listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET)
156 const val PROFILE_WAIT_MS = 2_000L
157 const val CONNECT_TIMEOUT_MS = 8_000L
158 }
159}