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()
    }
}