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