TalerUsbDevices.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.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager

import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

data class TalerUsbState(
    val enabled: Boolean = true,
    val devices: List<TalerUsbDeviceState> = emptyList(),
    val unavailable: Boolean = false,
    val checkId: Long = 0,
)

/** Application context only: permission and queued transfers survive screen rotation. */
class TalerUsbDevices internal constructor(
    context: Context,
    private val executor: ExecutorService,
    transport: TalerUsbTransport?,
) : AutoCloseable
{
    constructor(context: Context) : this(context, Executors.newSingleThreadExecutor(), null)

    private val app = context.applicationContext
    private val manager = app.getSystemService(Context.USB_SERVICE) as UsbManager
    private val preferences = app.getSharedPreferences("taler-usb-devices", Context.MODE_PRIVATE)
    private val session = TalerUsbSession(transport ?: AndroidTalerUsbTransport(manager))
    private val permissionAction = "${app.packageName}.TALER_USB_PERMISSION"

    @Volatile
    private var enabled = true

    // Older transport opt-out switches no longer gate connections.
    @Volatile
    private var talerEnabled = true

    @Volatile
    private var uri: String? = null
    private var uriOwner: Any? = null

    @Volatile
    private var closed = false
    private var checkId = 0L // Accessed only on the USB worker.
    private val pendingPermissions = mutableSetOf<String>()
    private val mutableState = MutableLiveData(TalerUsbState(enabled))

    private val receiver =
        object : BroadcastReceiver()
        {
            override fun onReceive(
                context: Context,
                intent: Intent,
            )
            {
                if (closed) return
                @Suppress("DEPRECATION")
                val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
                if (intent.action == permissionAction)
                {
                    // Immutable PendingIntent extras identify the request even on Android
                    // versions that do not fill in USB permission result extras.
                    intent.getStringExtra("deviceId")?.let { pendingPermissions.remove(it) }
                }
                if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED &&
                    device != null)
                {
                    pendingPermissions.remove(device.deviceName)
                    executor.execute { session.detached(device.deviceName) }
                }
                // Re-enumerate and check hasPermission; never trust a broadcast's grant flag.
                refresh()
            }
        }

    val state: LiveData<TalerUsbState> = mutableState

    init
    {
        ContextCompat.registerReceiver(
            app,
            receiver,
            IntentFilter().apply {
                addAction(permissionAction)
                addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
                addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
            },
            ContextCompat.RECEIVER_NOT_EXPORTED,
        )
    }

    fun setEnabled(value: Boolean)
    {
        enabled = value
        preferences.edit().putBoolean("enabled", value).apply()
        refresh()
    }

    fun setTalerEnabled(value: Boolean)
    {
        if (talerEnabled == value) return
        talerEnabled = value
        refresh()
    }

    /** Only the visible payment/refund screen supplies a URI; leaving it supplies null. */
    fun setUri(value: String?)
    {
        if (uri == value) return
        uri = value
        refresh()
    }

    fun present(
        owner: Any,
        value: String?,
    )
    {
        uriOwner = owner
        setUri(value)
    }

    /** A departing screen cannot clear the link already supplied by its successor. */
    fun dismiss(owner: Any)
    {
        if (uriOwner !== owner) return
        uriOwner = null
        setUri(null)
    }

    @Synchronized
    fun refresh(forceTransfer: Boolean = false)
    {
        if (closed) return
        executor.execute {
            if (forceTransfer)
            {
                session.invalidateTransfers()
                checkId++
            }
            val current =
                try
                {
                    TalerUsbState(enabled, session.synchronize(enabled && talerEnabled, uri), checkId = checkId)
                }
                catch (_: RuntimeException)
                {
                    TalerUsbState(enabled, unavailable = true, checkId = checkId)
                }
            if (!closed &&
                mutableState.value != current)
            {
                mutableState.postValue(current)
            }
        }
    }

    /** Explicit action only: denying access never causes a repeated permission popup. */
    fun requestPermission(id: String)
    {
        if (closed ||
            !enabled ||
            !talerEnabled ||
            id in pendingPermissions)
        {
            return
        }
        val device = manager.deviceList[id] ?: return
        if (!TalerUsbSession.supports(device.vendorId, device.productId)) return
        if (manager.hasPermission(device))
        {
            refresh()
            return
        }
        pendingPermissions.add(id)
        try
        {
            val intent =
                PendingIntent.getBroadcast(
                    app,
                    device.deviceId,
                    Intent(permissionAction).setPackage(app.packageName).putExtra("deviceId", id),
                    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
                )
            manager.requestPermission(device, intent)
        }
        catch (_: RuntimeException)
        {
            pendingPermissions.remove(id)
            mutableState.value = TalerUsbState(enabled, unavailable = true, checkId = mutableState.value?.checkId ?: 0)
        }
    }

    fun logout()
    {
        uriOwner = null
        uri = null
        setEnabled(true)
    }

    @Synchronized
    override fun close()
    {
        if (closed) return
        closed = true
        uriOwner = null
        uri = null
        app.unregisterReceiver(receiver)
        executor.execute {
            try
            {
                session.synchronize(false, null)
            }
            catch (_: RuntimeException)
            {
                // The device may detach before the final clear reaches the worker.
            }
        }
        executor.shutdown()
    }
}