TalerDevicesFragment.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.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.MainViewModel
import net.taler.merchantpos.R
import net.taler.merchantpos.compose.PosOutlinedCard
import net.taler.merchantpos.compose.PosTheme
class TalerDevicesFragment : Fragment()
{
private val model: MainViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View =
ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { PosTheme { TalerDevicesScreen(model.talerBluetooth, model.talerUsb) } }
}
}
internal enum class DeviceStep
{
Choose,
Replace,
Access,
OpenSettings,
Pair,
Find,
Connect,
Usb,
Test,
Remove,
RepairAndroid,
RepairDisplay,
}
@Composable
private fun TalerDevicesScreen(
bluetooth: TalerBluetoothDevice,
usb: TalerUsbDevices,
)
{
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column(
Modifier
.widthIn(max = 720.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
TalerDevicesContent(bluetooth, usb)
}
}
}
/** Fixed presentation state for documentation captures; never enables device transports. */
internal data class TalerDevicesPreview(
val bluetooth: TalerBleState,
val usb: TalerUsbState = TalerUsbState(),
val step: DeviceStep? = null,
)
@Composable
internal fun TalerDevicesContent(
bluetooth: TalerBluetoothDevice,
usb: TalerUsbDevices,
preview: TalerDevicesPreview? = null,
)
{
check(preview == null || BuildConfig.DEBUG)
LaunchedEffect(bluetooth, usb, preview) {
if (preview != null) return@LaunchedEffect
// Connection management replaces the old transport opt-in switches.
usb.setEnabled(true)
bluetooth.setEnabled(true)
}
val observedBle by bluetooth.state.observeAsState(TalerBleState())
val observedUsb by usb.state.observeAsState(TalerUsbState())
val ble = preview?.bluetooth ?: observedBle
val wired = preview?.usb ?: observedUsb
var step by rememberSaveable { mutableStateOf(preview?.step) }
var permissionDenied by rememberSaveable { mutableStateOf(false) }
var addingBluetooth by rememberSaveable { mutableStateOf(false) }
var usbCheck by rememberSaveable { mutableLongStateOf(-1L) }
fun close()
{
bluetooth.cancelDiscovery()
// Cancelling an unfinished new pairing must not reconnect in the background.
if (addingBluetooth &&
step == DeviceStep.Connect &&
ble.status != TalerBleStatus.Ready)
{
bluetooth.forget()
}
addingBluetooth = false
step = null
}
fun checkUsb()
{
usbCheck = wired.checkId
usb.refresh(forceTransfer = true)
}
val permission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
permissionDenied = result.values.any { !it }
bluetooth.refresh()
}
val systemSettings =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
bluetooth.refresh()
}
LaunchedEffect(step, ble.status) {
if (preview != null) return@LaunchedEffect
if (step == DeviceStep.Access &&
ble.status !in
setOf(
TalerBleStatus.PermissionRequired,
TalerBleStatus.BluetoothOff,
TalerBleStatus.Unsupported,
TalerBleStatus.LocationOff,
))
{
step = DeviceStep.OpenSettings
}
}
DisposableEffect(bluetooth, preview) { onDispose { if (preview == null) bluetooth.cancelDiscovery() } }
BackHandler(enabled = step != null) { close() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (ble.selected == null &&
wired.devices.isEmpty())
{
Text(stringResource(R.string.taler_devices_empty), style = MaterialTheme.typography.bodyLarge)
}
ble.selected?.let { selected ->
DeviceCard(selected.name, stringResource(ble.status.label())) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (ble.status == TalerBleStatus.Ready)
{
OutlinedButton(
onClick = {
bluetooth.testDisplay()
step = DeviceStep.Test
}
) {
Text(stringResource(R.string.taler_ble_test))
}
}
else
{
OutlinedButton(
onClick = {
if (ble.status == TalerBleStatus.PairingRequired)
{
step = DeviceStep.RepairAndroid
}
else
{
bluetooth.refresh()
step = DeviceStep.Connect
}
}
) { Text(stringResource(R.string.taler_ble_reconnect)) }
}
TextButton(
onClick = { step = DeviceStep.Remove },
) { Text(stringResource(R.string.taler_devices_remove)) }
}
}
}
wired.devices.forEachIndexed { index, device ->
DeviceCard(
stringResource(R.string.taler_usb_device, index + 1),
stringResource(usbStatus(device.status)),
) {
OutlinedButton(
onClick = {
step = DeviceStep.Usb
checkUsb()
}
) {
Text(stringResource(R.string.taler_usb_refresh))
}
}
}
Button(onClick = { step = DeviceStep.Choose }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.taler_devices_add))
}
OutlinedButton(
onClick = {
step = DeviceStep.Usb
checkUsb()
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.taler_devices_check_usb))
}
}
val current = step ?: return
val title =
when (current)
{
DeviceStep.Choose ->
{
R.string.taler_devices_add
}
DeviceStep.Replace ->
{
R.string.taler_devices_replace
}
DeviceStep.Access ->
{
R.string.taler_devices_access
}
DeviceStep.OpenSettings ->
{
R.string.taler_devices_open_lcd
}
DeviceStep.Pair ->
{
R.string.taler_devices_pair_lcd
}
DeviceStep.Find ->
{
R.string.taler_devices_choose_display
}
DeviceStep.Connect ->
{
if (ble.status ==
TalerBleStatus.Ready)
{
R.string.taler_devices_connected
}
else
{
R.string.taler_devices_connect
}
}
DeviceStep.Usb ->
{
R.string.taler_devices_check_usb
}
DeviceStep.Test ->
{
R.string.taler_ble_test
}
DeviceStep.Remove ->
{
R.string.taler_devices_remove
}
DeviceStep.RepairAndroid, DeviceStep.RepairDisplay ->
{
R.string.taler_devices_pair_again
}
}
AlertDialog(
onDismissRequest = { close() },
title = { Text(stringResource(title)) },
text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
when (current)
{
DeviceStep.Choose ->
{
OutlinedButton(
onClick = {
if (ble.selected != null)
{
step = DeviceStep.Replace
}
else
{
bluetooth.refresh()
step = DeviceStep.Access
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(R.string.taler_devices_bluetooth),
)
}
OutlinedButton(
onClick = {
step = DeviceStep.Usb
checkUsb()
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.taler_devices_usb))
}
}
DeviceStep.Replace ->
{
Text(stringResource(R.string.taler_devices_replace_hint))
}
DeviceStep.Access ->
{
Text(stringResource(ble.status.label()))
}
DeviceStep.OpenSettings ->
{
Text(stringResource(R.string.taler_devices_open_lcd_hint))
}
DeviceStep.Pair ->
{
Text(stringResource(R.string.taler_devices_pair_lcd_hint))
}
DeviceStep.Find ->
{
Text(
stringResource(
if (ble.status ==
TalerBleStatus.Disconnected)
{
R.string.taler_devices_scan_finished
}
else
{
ble.status.label()
},
),
)
ble.candidates.forEach { candidate ->
OutlinedButton(
onClick = {
addingBluetooth = true
bluetooth.select(candidate)
step =
DeviceStep.Connect
},
modifier = Modifier.fillMaxWidth()
) {
Text("${candidate.name} ${candidate.address.takeLast(5)}")
}
}
}
DeviceStep.Connect ->
{
Text(
stringResource(
if (ble.status == TalerBleStatus.Ready)
{
R.string.taler_devices_connected_hint
}
else
{
ble.status.label()
},
),
)
}
DeviceStep.Usb ->
{
if (wired.unavailable)
{
Text(stringResource(R.string.taler_usb_failed))
}
else if (wired.checkId <= usbCheck)
{
Text(stringResource(R.string.taler_devices_checking))
}
else if (wired.devices.isEmpty())
{
Text(stringResource(R.string.taler_usb_not_attached))
}
else
{
wired.devices.forEachIndexed { index, device ->
Text(stringResource(R.string.taler_usb_device, index + 1))
Text(stringResource(usbStatus(device.status)))
if (device.status == TalerUsbStatus.PermissionRequired)
{
OutlinedButton(onClick = { usb.requestPermission(device.id) }) {
Text(stringResource(R.string.taler_usb_allow_device, index + 1))
}
}
}
}
}
DeviceStep.Test ->
{
Text(
stringResource(
if (ble.hasResetLimitation)
{
R.string.taler_ble_reset_limitation
}
else
{
R.string.taler_devices_test_hint
},
),
)
}
DeviceStep.Remove ->
{
Text(stringResource(R.string.taler_ble_forget_explanation))
}
DeviceStep.RepairAndroid ->
{
Text(stringResource(R.string.taler_devices_repair_android))
}
DeviceStep.RepairDisplay ->
{
Text(stringResource(R.string.taler_devices_repair_lcd))
}
}
}
},
confirmButton = {
when (current)
{
DeviceStep.Choose ->
{
Unit
}
DeviceStep.Replace ->
{
DialogAction(R.string.taler_devices_replace) {
bluetooth.forget()
bluetooth.refresh()
step = DeviceStep.Access
}
}
DeviceStep.Access, DeviceStep.Find, DeviceStep.Connect ->
{
when (ble.status)
{
TalerBleStatus.PermissionRequired ->
{
DialogAction(
if (permissionDenied)
{
R.string.taler_ble_open_settings
}
else
{
R.string.taler_ble_allow
},
) {
if (permissionDenied)
{
systemSettings.launch(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:net.taler.merchantpos"),
),
)
}
else
{
permission.launch(bluetooth.requiredPermissions())
}
}
}
TalerBleStatus.BluetoothOff, TalerBleStatus.LocationOff ->
{
DialogAction(R.string.taler_ble_open_settings) {
systemSettings.launch(
Intent(
if (ble.status == TalerBleStatus.LocationOff)
{
Settings.ACTION_LOCATION_SOURCE_SETTINGS
}
else
{
Settings.ACTION_BLUETOOTH_SETTINGS
},
),
)
}
}
TalerBleStatus.Unsupported ->
{
DialogAction(android.R.string.ok) { close() }
}
TalerBleStatus.Ready ->
{
DialogAction(R.string.taler_devices_done) { close() }
}
TalerBleStatus.PairingRequired ->
{
DialogAction(R.string.taler_devices_pair_again) {
step =
DeviceStep.RepairAndroid
}
}
else ->
{
if (current == DeviceStep.Find &&
ble.status != TalerBleStatus.Scanning)
{
DialogAction(R.string.taler_devices_search_again) { bluetooth.startScan() }
}
else if (current == DeviceStep.Connect &&
ble.status in setOf(TalerBleStatus.Failed, TalerBleStatus.Disconnected))
{
DialogAction(R.string.taler_ble_reconnect) { bluetooth.refresh() }
}
}
}
}
DeviceStep.OpenSettings ->
{
DialogAction(R.string.taler_devices_next) { step = DeviceStep.Pair }
}
DeviceStep.Pair ->
{
DialogAction(R.string.taler_ble_scan) {
step = DeviceStep.Find
bluetooth.startScan()
}
}
DeviceStep.Usb ->
{
DialogAction(R.string.taler_usb_refresh) { checkUsb() }
}
DeviceStep.Test ->
{
DialogAction(R.string.taler_devices_done) { close() }
}
DeviceStep.Remove ->
{
DialogAction(R.string.taler_devices_remove) {
bluetooth.forget()
step = null
}
}
DeviceStep.RepairAndroid ->
{
DialogAction(R.string.taler_devices_next) { step = DeviceStep.RepairDisplay }
}
DeviceStep.RepairDisplay ->
{
DialogAction(R.string.taler_ble_reconnect) {
bluetooth.retryPairing()
step =
DeviceStep.Connect
}
}
}
},
dismissButton = {
if (current == DeviceStep.RepairAndroid)
{
DialogAction(
R.string.taler_ble_open_settings,
) { systemSettings.launch(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }
}
else if (current != DeviceStep.Test &&
!(current == DeviceStep.Connect &&
ble.status == TalerBleStatus.Ready))
{
DialogAction(
if (current ==
DeviceStep.Usb)
{
R.string.taler_devices_done
}
else
{
android.R.string.cancel
},
) { close() }
}
},
)
}
@Composable
private fun DialogAction(
label: Int,
action: () -> Unit,
)
{
TextButton(onClick = action) { Text(stringResource(label)) }
}
@Composable
private fun DeviceCard(
name: String,
status: String,
actions: @Composable () -> Unit,
)
{
PosOutlinedCard { padding ->
Column(Modifier.padding(padding), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(name, style = MaterialTheme.typography.titleMedium)
Text(
status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
actions()
}
}
}
private fun usbStatus(status: TalerUsbStatus): Int =
when (status)
{
TalerUsbStatus.Ready -> R.string.payment_device_status_connected
TalerUsbStatus.PermissionRequired -> R.string.taler_usb_permission_required
TalerUsbStatus.Failed -> R.string.taler_usb_failed
}