TalerUsbSession.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

internal data class TalerUsbDevice(
    val id: String,
    val permitted: Boolean,
)

internal interface TalerUsbTransport
{
    /** Lists attached supported devices, including those still awaiting USB permission. */
    fun devices(): List<TalerUsbDevice>

    /** True only if the entire payload was transferred. An empty URL requests idle. */
    fun write(
        device: TalerUsbDevice,
        payload: ByteArray,
    ): Boolean
}

enum class TalerUsbStatus
{
    PermissionRequired,
    Ready,
    Failed
}

data class TalerUsbDeviceState(
    val id: String,
    val status: TalerUsbStatus,
)

/** Runs on one worker, so a late write can never overtake cancellation or the next order. */
internal class TalerUsbSession(
    private val transport: TalerUsbTransport,
)
{
    private val sent = mutableMapOf<String, String>()

    fun invalidateTransfers()
    {
        sent.replaceAll { _, _ -> "\u0000" }
    }

    fun detached(id: String)
    {
        sent.remove(id)
    }

    fun synchronize(
        enabled: Boolean,
        uri: String?,
    ): List<TalerUsbDeviceState>
    {
        val devices = transport.devices()
        sent.keys.retainAll(devices.map { it.id }.toSet())
        val payload = if (enabled) uri.orEmpty() else ""
        return devices.map { device ->
            val status =
                when
                {
                    !device.permitted ->
                    {
                        sent.remove(device.id)
                        TalerUsbStatus.PermissionRequired
                    }

                    // Never touch an unused device while the integration is off. Clear a
                    // device previously used by this session even when being switched off.
                    !enabled && device.id !in sent ->
                    {
                        TalerUsbStatus.Ready
                    }

                    sent[device.id] == payload ->
                    {
                        TalerUsbStatus.Ready
                    }

                    else ->
                    {
                        // Remember that it may have received data even on a short write,
                        // so disabling the integration still attempts to clear it.
                        sent[device.id] = "\u0000"
                        val bytes = payload.toByteArray(Charsets.UTF_8)
                        val success =
                            try
                            {
                                bytes.size <= MAX_PAYLOAD_BYTES && transport.write(device, bytes)
                            }
                            catch (_: RuntimeException)
                            {
                                false
                            }
                        if (success)
                        {
                            sent[device.id] = payload
                            TalerUsbStatus.Ready
                        }
                        else
                        {
                            TalerUsbStatus.Failed
                        }
                    }
                }
            TalerUsbDeviceState(device.id, status)
        }
    }

    companion object
    {
        // Android versions before P limit USB transfers to 16 KiB.
        const val MAX_PAYLOAD_BYTES = 16 * 1024

        fun supports(
            vendorId: Int,
            productId: Int,
        ) = vendorId == 0xd00d && productId == 0x8f71
    }
}