| 1 | package io.github.krolyxon.budctl |
| 2 | |
| 3 | import android.annotation.SuppressLint |
| 4 | import android.bluetooth.BluetoothAdapter |
| 5 | import android.bluetooth.BluetoothDevice |
| 6 | import android.bluetooth.BluetoothManager |
| 7 | import android.bluetooth.BluetoothProfile |
| 8 | import android.content.Context |
| 9 | import java.io.IOException |
| 10 | import java.util.Timer |
| 11 | import java.util.UUID |
| 12 | import java.util.concurrent.ConcurrentHashMap |
| 13 | import java.util.concurrent.CountDownLatch |
| 14 | import java.util.concurrent.TimeUnit |
| 15 | import java.util.concurrent.atomic.AtomicBoolean |
| 16 | import kotlin.concurrent.schedule |
| 17 | import org.json.JSONArray |
| 18 | import 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. |
| 26 | class 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 | } |