TalerBluetoothDevice.kt
/*
* This file is part of GNU Taler
* (C) 2026 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.merchantpos.devices
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import net.taler.merchantpos.BuildConfig
enum class TalerBleStatus
{
Disabled,
Unsupported,
PermissionRequired,
BluetoothOff,
LocationOff,
Disconnected,
Scanning,
Connecting,
Pairing,
PairingRequired,
Connected,
Sending,
Ready,
TooLong,
Failed,
}
data class TalerBleCandidate(
val address: String,
val name: String,
)
data class TalerBleState(
val enabled: Boolean = true,
val status: TalerBleStatus = TalerBleStatus.Disabled,
val selected: TalerBleCandidate? = null,
val candidates: List<TalerBleCandidate> = emptyList(),
val hasResetLimitation: Boolean = false,
)
/**
* Owns a single selected LCD connection, independent of the Activity across rotation.
* All Bluetooth callbacks and state transitions are serialized on the main looper.
*/
@SuppressLint("MissingPermission") // Runtime checks precede scanning/connecting; revocation is caught.
class TalerBluetoothDevice(
context: Context,
) : AutoCloseable
{
private val app = context.applicationContext
private val adapter = (app.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
private val preferences = app.getSharedPreferences("taler-bluetooth-device", Context.MODE_PRIVATE)
private val handler = Handler(Looper.getMainLooper())
private val queue = TalerBleUrlQueue()
private var enabled = true // Pairing, rather than a transport switch, controls access.
private var selected =
preferences.getString("address", null)?.let {
TalerBleCandidate(it, preferences.getString("name", null) ?: TalerBleProtocol.NAME)
}
private var foreground = false
private var talerEnabled = true
private var closed = false
private var owner: Any? = null
private var uri: String? = null
private var scan: ScanCallback? = null
private var gatt: BluetoothGatt? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var clearCharacteristic: BluetoothGattCharacteristic? = null
private var statusCharacteristic: BluetoothGattCharacteristic? = null
private var writeCharacteristic: BluetoothGattCharacteristic? = null
private var pairing = false
private var pairingRequired = false
private var ready = false
private var deadline: Runnable? = null
private var reconnect: Runnable? = null
private var failures = 0
private var hasResetLimitation = false
private val found = linkedMapOf<String, TalerBleCandidate>()
private val mutableState = MutableLiveData(TalerBleState(enabled, selected = selected))
private val active get() = enabled && talerEnabled && foreground && !closed
private val bondReceiver =
object : BroadcastReceiver()
{
override fun onReceive(
context: Context,
intent: Intent,
)
{
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED ||
closed)
{
return
}
@Suppress("DEPRECATION")
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return
handler.post {
if (closed ||
device.address != selected?.address ||
!permitted())
{
return@post
}
try
{
when (device.bondState)
{
BluetoothDevice.BOND_BONDING ->
{
if (active ||
pairing)
{
pairing = true
publish(TalerBleStatus.Pairing)
timeout(60_000)
}
}
BluetoothDevice.BOND_BONDED ->
{
if (pairing)
{
pairing = false
cancelDeadline()
if (!active)
{
releaseGatt()
publish(TalerBleStatus.Disconnected)
}
else
{
gatt?.let { negotiateMtu(it) } ?: connect()
}
}
}
BluetoothDevice.BOND_NONE ->
{
if (pairing ||
ready)
{
requirePairing()
}
}
}
}
catch (
_: SecurityException,
)
{
pairing = false
releaseGatt()
publish(TalerBleStatus.PermissionRequired)
}
}
}
}
private val callback =
object : BluetoothGattCallback()
{
override fun onConnectionStateChange(
connection: BluetoothGatt,
status: Int,
newState: Int,
) = dispatch(connection) {
if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "Connection state: $newState, status: $status")
if (status != BluetoothGatt.GATT_SUCCESS ||
newState == BluetoothProfile.STATE_DISCONNECTED)
{
if (pairing ||
authenticationFailure(status))
{
requirePairing()
return@dispatch
}
fail()
return@dispatch
}
if (newState == BluetoothProfile.STATE_CONNECTED)
{
if (!active)
{
releaseGatt()
return@dispatch
}
if (connection.discoverServices()) timeout() else fail()
}
}
override fun onServicesDiscovered(
connection: BluetoothGatt,
status: Int,
) = dispatch(connection) {
if (BuildConfig.DEBUG)
{
Log.d("taler-pos-ble", "Service discovery status: $status")
connection.services.forEach { service ->
Log.d(
"taler-pos-ble",
"Service: ${service.uuid}, characteristics: ${
service.characteristics.map {
"${it.uuid} (${it.properties})"
}
}",
)
}
}
val service = connection.getService(TalerBleProtocol.SERVICE)
val url =
listOf(TalerBleProtocol.URL, TalerBleProtocol.STANDARD_URI)
.mapNotNull { service?.getCharacteristic(it) }
.firstOrNull { it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 }
if (status != BluetoothGatt.GATT_SUCCESS ||
url == null ||
url.properties and BluetoothGattCharacteristic.PROPERTY_WRITE == 0)
{
fail(retry = false)
return@dispatch
}
characteristic = url
clearCharacteristic =
service
?.getCharacteristic(TalerBleProtocol.CONTROL)
?.takeIf { it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 }
statusCharacteristic =
service
?.getCharacteristic(TalerBleProtocol.STATUS)
?.takeIf { it.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0 }
// This physical firmware acknowledges empty writes but leaves the QR visible.
// Expose the limitation instead of substituting an unrelated website URL.
hasResetLimitation = url.uuid == TalerBleProtocol.STANDARD_URI && clearCharacteristic == null
if (statusCharacteristic != null &&
connection.device.bondState != BluetoothDevice.BOND_BONDED)
{
beginPairing(connection.device)
}
else
{
negotiateMtu(connection)
}
}
override fun onMtuChanged(
connection: BluetoothGatt,
mtu: Int,
status: Int,
) = dispatch(connection) {
if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "MTU: $mtu, status: $status")
if (status == BluetoothGatt.GATT_SUCCESS) queue.mtu = mtu.coerceAtLeast(23)
if (characteristic != null &&
!ready)
{
verifyConnection(connection)
}
}
override fun onCharacteristicWrite(
connection: BluetoothGatt,
value: BluetoothGattCharacteristic,
status: Int,
) = dispatch(connection) {
if (value.uuid != writeCharacteristic?.uuid ||
!queue.writing)
{
return@dispatch
}
if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "Write acknowledged, status: $status")
cancelDeadline()
queue.complete(status == BluetoothGatt.GATT_SUCCESS)
if (status != BluetoothGatt.GATT_SUCCESS)
{
if (authenticationFailure(status)) requirePairing() else fail()
return@dispatch
}
failures = 0
updateUrl()
}
@Deprecated("Used by Android before API 33")
override fun onCharacteristicRead(
connection: BluetoothGatt,
value: BluetoothGattCharacteristic,
status: Int,
)
{
@Suppress("DEPRECATION")
readStatus(connection, value, value.value ?: byteArrayOf(), status)
}
override fun onCharacteristicRead(
connection: BluetoothGatt,
value: BluetoothGattCharacteristic,
bytes: ByteArray,
status: Int,
)
{
readStatus(connection, value, bytes, status)
}
}
val state: LiveData<TalerBleState> = mutableState
init
{
// Bluetooth broadcasts can come from the privileged Bluetooth process.
ContextCompat.registerReceiver(
app,
bondReceiver,
IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED),
ContextCompat.RECEIVER_EXPORTED,
)
}
fun requiredPermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31)
{
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
}
else
{
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
private fun permitted() =
requiredPermissions().all {
ContextCompat.checkSelfPermission(app, it) == PackageManager.PERMISSION_GRANTED
}
private fun availability(scanning: Boolean = false): TalerBleStatus? =
when
{
adapter == null -> TalerBleStatus.Unsupported
!permitted() -> TalerBleStatus.PermissionRequired
!adapter.isEnabled -> TalerBleStatus.BluetoothOff
scanning && Build.VERSION.SDK_INT < 31 &&
!(app.getSystemService(Context.LOCATION_SERVICE) as LocationManager).let {
if (Build.VERSION.SDK_INT >= 28)
{
it.isLocationEnabled
}
else
{
it.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
it.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
} -> TalerBleStatus.LocationOff
else -> null
}
private fun publish(status: TalerBleStatus)
{
val updated = TalerBleState(enabled, status, selected, found.values.toList(), hasResetLimitation)
if (mutableState.value == updated) return
if (BuildConfig.DEBUG &&
mutableState.value?.status != status)
{
Log.d("taler-pos-ble", "State: $status")
}
mutableState.value = updated
}
fun setEnabled(value: Boolean)
{
enabled = value
preferences.edit().putBoolean("enabled", value).apply()
if (value) refresh() else suspendConnection()
}
fun setForeground(value: Boolean)
{
foreground = value
if (value)
{
refresh()
}
else
{
uri = null
owner = null
// The Android pairing dialog may temporarily stop the Activity.
// Never send payment data in the background; let the bounded pairing finish.
if (!pairing) suspendConnection()
}
}
fun setTalerEnabled(value: Boolean)
{
if (talerEnabled == value) return
talerEnabled = value
if (value) refresh() else suspendConnection()
}
fun present(
source: Any,
value: String?,
)
{
owner = source
uri = value
updateUrl()
}
fun dismiss(source: Any)
{
if (owner !== source) return
owner = null
uri = null
updateUrl()
}
/** Harmless QR/NFC demonstration, automatically reset to idle; never creates an order. */
fun testDisplay()
{
if (!active ||
!ready)
{
return
}
val test = Any()
present(test, TalerBleProtocol.TEST_URL)
handler.postDelayed({ dismiss(test) }, 5000)
}
fun refresh()
{
if (closed) return
if (!active)
{
publish(if (enabled) TalerBleStatus.Disconnected else TalerBleStatus.Disabled)
return
}
try
{
availability()?.let {
pairing = false
stopScan()
releaseGatt()
publish(it)
return
}
if (pairingRequired)
{
publish(TalerBleStatus.PairingRequired)
return
}
if (pairing)
{
publish(TalerBleStatus.Pairing)
return
}
if (gatt == null &&
scan == null &&
reconnect == null)
{
if (selected == null) publish(TalerBleStatus.Disconnected) else connect()
}
else if (ready)
{
updateUrl()
}
}
catch (_: RuntimeException)
{
fail()
}
}
fun startScan()
{
if (!active) return
try
{
availability(scanning = true)?.let {
publish(it)
return
}
stopScan()
// Keep an existing connection until the user explicitly disconnects it.
if (gatt != null) return
cancelReconnect()
found.clear()
val callback =
object : ScanCallback()
{
override fun onScanResult(
callbackType: Int,
result: ScanResult,
)
{
handler.post {
if (scan !== this ||
!active)
{
return@post
}
val record = result.scanRecord ?: return@post
if (!TalerBleProtocol.isCandidate(
record.deviceName,
record.serviceUuids.orEmpty().map { it.uuid },
))
{
return@post
}
try
{
val candidate =
TalerBleCandidate(
result.device.address,
record.deviceName ?: TalerBleProtocol.NAME,
)
found[candidate.address] = candidate
publish(TalerBleStatus.Scanning)
}
catch (_: SecurityException)
{
stopScan()
publish(TalerBleStatus.PermissionRequired)
}
}
}
override fun onScanFailed(errorCode: Int)
{
handler.post {
if (scan === this)
{
stopScan()
publish(TalerBleStatus.Failed)
}
}
}
}
scan = callback
// Legacy firmware advertises only Battery Service; match names as well as UUIDs.
val scanner =
adapter?.bluetoothLeScanner ?: run {
scan = null
publish(TalerBleStatus.BluetoothOff)
return
}
scanner.startScan(
null,
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
callback,
)
publish(TalerBleStatus.Scanning)
handler.postDelayed(
{
if (scan === callback)
{
stopScan()
publish(TalerBleStatus.Disconnected)
}
},
10000
)
}
catch (_: RuntimeException)
{
stopScan()
publish(TalerBleStatus.Failed)
}
}
fun cancelDiscovery()
{
if (scan == null) return
stopScan()
publish(TalerBleStatus.Disconnected)
}
fun select(candidate: TalerBleCandidate)
{
if (!active ||
found[candidate.address] != candidate ||
gatt != null)
{
return
}
selected = candidate
pairingRequired = false
hasResetLimitation = false
preferences
.edit()
.putString("address", candidate.address)
.putString("name", candidate.name)
.apply()
failures = 0
stopScan()
connect()
}
fun forget()
{
pairing = false
pairingRequired = false
selected = null
hasResetLimitation = false
preferences
.edit()
.remove("address")
.remove("name")
.apply()
uri = null
owner = null
suspendConnection()
}
private fun connect()
{
if (!active ||
selected == null ||
gatt != null)
{
return
}
try
{
availability()?.let {
publish(it)
return
}
val device = adapter?.getRemoteDevice(selected!!.address) ?: return
if (TalerBleProtocol.isBondedFirmwareName(selected?.name) &&
device.bondState != BluetoothDevice.BOND_BONDED)
{
beginPairing(device)
return
}
publish(TalerBleStatus.Connecting)
queue.reset()
gatt = device.connectGatt(app, false, callback, BluetoothDevice.TRANSPORT_LE)
if (gatt == null) fail() else timeout()
}
catch (_: RuntimeException)
{
fail()
}
}
private fun beginPairing(device: BluetoothDevice)
{
pairing = true
cancelReconnect()
publish(TalerBleStatus.Pairing)
timeout(60_000)
if (device.bondState != BluetoothDevice.BOND_BONDING &&
!device.createBond())
{
requirePairing()
}
}
private fun negotiateMtu(connection: BluetoothGatt)
{
if (characteristic == null)
{
if (connection.discoverServices()) timeout() else fail()
}
else if (connection.requestMtu(TalerBleProtocol.CAPACITY + 3))
{
timeout()
}
else
{
verifyConnection(connection)
}
}
private fun verifyConnection(connection: BluetoothGatt)
{
val status = statusCharacteristic
if (status != null)
{
if (connection.readCharacteristic(status)) timeout() else fail()
}
else
{
ready = true
cancelDeadline()
updateUrl()
}
}
private fun readStatus(
connection: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
bytes: ByteArray,
status: Int,
) = dispatch(connection) {
if (characteristic.uuid != statusCharacteristic?.uuid ||
ready)
{
return@dispatch
}
if (BuildConfig.DEBUG)
{
Log.d(
"taler-pos-ble",
"Status read: result=$status, length=${bytes.size}, handle=${characteristic.instanceId}",
)
}
cancelDeadline()
if (status != BluetoothGatt.GATT_SUCCESS)
{
if (authenticationFailure(status)) requirePairing() else fail()
}
else if (bytes.size != 6 ||
bytes[0] != 1.toByte())
{
fail(retry = false)
}
else
{
ready = true
failures = 0
updateUrl()
}
}
private fun authenticationFailure(status: Int) =
status == BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION ||
status == BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION
private fun requirePairing()
{
pairing = false
pairingRequired = true
cancelReconnect()
releaseGatt()
publish(TalerBleStatus.PairingRequired)
}
fun retryPairing()
{
pairingRequired = false
failures = 0
refresh()
}
private fun dispatch(
connection: BluetoothGatt,
action: () -> Unit,
)
{
handler.post {
if (gatt !== connection) return@post // Ignore callbacks from an old connection.
try
{
action()
}
catch (_: RuntimeException)
{
fail()
}
}
}
private fun updateUrl()
{
queue.desired =
if (active &&
selected != null)
{
uri.orEmpty()
}
else
{
""
}
if (!ready) return
val connection = gatt ?: return
val url = characteristic ?: return
val bytes = queue.next()
if (bytes == null)
{
if (queue.settled)
{
if (!active ||
selected == null)
{
releaseGatt()
publish(if (enabled) TalerBleStatus.Disconnected else TalerBleStatus.Disabled)
}
else
{
publish(if (queue.tooLong) TalerBleStatus.TooLong else TalerBleStatus.Ready)
}
}
return
}
try
{
val target = if (bytes.isEmpty()) clearCharacteristic ?: url else url
val payload =
if (bytes.isEmpty() &&
clearCharacteristic != null)
{
byteArrayOf(1)
}
else
{
bytes
}
writeCharacteristic = target
if (BuildConfig.DEBUG) Log.d("taler-pos-ble", "Writing ${bytes.size} bytes")
val started =
if (Build.VERSION.SDK_INT >= 33)
{
connection.writeCharacteristic(target, payload, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothStatusCodes.SUCCESS
}
else
{
@Suppress("DEPRECATION")
target.value = payload
target.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
connection.writeCharacteristic(target)
}
if (started)
{
publish(TalerBleStatus.Sending)
timeout()
}
else
{
fail()
}
}
catch (_: RuntimeException)
{
fail()
}
}
private fun timeout(milliseconds: Long = 10000)
{
cancelDeadline()
deadline = Runnable { if (pairing) requirePairing() else fail() }.also { handler.postDelayed(it, milliseconds) }
}
private fun cancelDeadline()
{
deadline?.let(handler::removeCallbacks)
deadline = null
}
private fun cancelReconnect()
{
reconnect?.let(handler::removeCallbacks)
reconnect = null
}
private fun fail(retry: Boolean = true)
{
if (pairing)
{
requirePairing()
return
}
releaseGatt()
publish(TalerBleStatus.Failed)
if (retry &&
active &&
selected != null &&
failures++ < 3)
{
reconnect =
Runnable {
reconnect = null
connect()
}.also { handler.postDelayed(it, 3000L * failures) }
}
}
private fun stopScan()
{
val previous = scan
scan = null
try
{
if (previous != null) adapter?.bluetoothLeScanner?.stopScan(previous)
}
catch (_: RuntimeException)
{
// The adapter may be off or its permission revoked during cleanup.
}
}
private fun suspendConnection()
{
pairing = false
stopScan()
cancelReconnect()
if (ready)
{
updateUrl()
}
else
{
releaseGatt()
publish(if (enabled) TalerBleStatus.Disconnected else TalerBleStatus.Disabled)
}
}
private fun releaseGatt()
{
cancelDeadline()
ready = false
characteristic = null
clearCharacteristic = null
statusCharacteristic = null
writeCharacteristic = null
val previous = gatt
gatt = null
queue.reset()
try
{
previous?.disconnect()
}
catch (_: RuntimeException)
{
// Still close the GATT handle if the adapter rejects disconnection.
}
try
{
previous?.close()
}
catch (_: RuntimeException)
{
// Local state is already cleared; an unavailable adapter cannot retain it.
}
}
fun logout()
{
forget()
setEnabled(true)
}
override fun close()
{
if (closed) return
closed = true
pairing = false
app.unregisterReceiver(bondReceiver)
uri = null
owner = null
// Keep the callback alive just long enough to acknowledge the idle screen.
suspendConnection()
}
}