PaymentOptionsManager.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.paymentoptions
import android.content.Context
import android.net.Uri
import android.os.Looper
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CancellationException
data class PaymentOptionsSummary(
val options: List<PaymentOptionState> = emptyList(),
)
{
val enabled: List<PaymentOptionState> get() = options.filter { it.enabled }
val enabledCount: Int get() = enabled.size
}
/**
* Owns the per-option switches and the polled state of each option in [options].
*
* Essential options stay enabled; others are reached while their switch is on.
* Turning an optional method off keeps its
* stored settings and any session, so that re-enabling does not require setting it
* up again; only [logout] clears sessions.
*/
class PaymentOptionsManager(
context: Context,
val options: List<PaymentOption>,
)
{
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private val mutableSummary = MutableLiveData(PaymentOptionsSummary())
private val mutableActionFailed = MutableLiveData(false)
val summary: LiveData<PaymentOptionsSummary> = mutableSummary
val actionFailed: LiveData<Boolean> = mutableActionFailed
/** Card providers and enabled options that currently report payment devices. */
fun connectionStates(summary: PaymentOptionsSummary): List<Pair<PaymentOption, PaymentOptionState>> =
options.mapNotNull { option ->
summary.options
.firstOrNull { it.id == option.id }
?.takeIf { it.enabled && (option.tracksConnection || it.devices.isNotEmpty()) }
?.let { option to it }
}
init
{
// Defer SDK and Bluetooth connection checks until the first poll.
publish(options.map { PaymentOptionState(it.id, enabled = isEnabled(it), status = setupStatus(it)) })
}
private fun setupStatus(option: PaymentOption): PaymentOptionStatus =
try
{
if (option.isConfigured()) PaymentOptionStatus.Disabled else PaymentOptionStatus.NotConfigured
}
catch (_: RuntimeException)
{
PaymentOptionStatus.Unavailable
}
fun option(id: String): PaymentOption? = options.firstOrNull { it.id == id }
fun state(id: String): PaymentOptionState =
mutableSummary.value?.options?.firstOrNull { it.id == id } ?: PaymentOptionState(id)
fun isEnabled(option: PaymentOption): Boolean =
option.alwaysEnabled || preferences.getBoolean(optionKey(option.id), option.enabledByDefault)
fun setEnabled(
id: String,
enabled: Boolean,
)
{
val option = option(id) ?: return
preferences.edit().putBoolean(optionKey(id), option.alwaysEnabled || enabled).apply()
dismissError()
refresh()
}
fun refresh()
{
val states =
options.map { option ->
val enabled = isEnabled(option)
try
{
if (!enabled)
{
// Setup can change while an option is off. Publish that
// change so its enable switch updates without probing a reader.
PaymentOptionState(
option.id,
enabled = false,
status = setupStatus(option),
)
}
else
{
option.probe().copy(id = option.id, enabled = true)
}
}
catch (_: RuntimeException)
{
PaymentOptionState(option.id, enabled = enabled, status = PaymentOptionStatus.Unavailable)
}
}
publish(states)
}
private fun publish(states: List<PaymentOptionState>)
{
val updated = PaymentOptionsSummary(states)
if (mutableSummary.value == updated) return
logTransitions(mutableSummary.value, updated)
// Probing talks to payment SDKs, so it is polled off the main thread; only the
// one-off calls from the settings screens arrive on it.
if (Looper.myLooper() == Looper.getMainLooper())
{
mutableSummary.value = updated
}
else
{
mutableSummary.postValue(updated)
}
}
/**
* Reports terminal status, wooooow :)
*/
fun terminalStatus(summary: PaymentOptionsSummary): PaymentOptionStatus =
connectionStates(summary).maxOfOrNull { (option, state) ->
if (option.tracksConnection)
{
state.status
}
else if (state.devices.any { it.connected })
{
PaymentOptionStatus.Connected
}
else
{
PaymentOptionStatus.Disconnected
}
} ?: PaymentOptionStatus.Disabled
fun hasEnabledTerminal(summary: PaymentOptionsSummary): Boolean = connectionStates(summary).isNotEmpty()
/** Renews any refreshable session the enabled options hold, then re-reads status. */
suspend fun maintainSessions()
{
options.forEach { option ->
if (!isEnabled(option)) return@forEach
try
{
option.maintainSession()
}
catch (e: CancellationException)
{
throw e
}
catch (_: Exception)
{
// An option that cannot renew its session must not stop the others.
}
}
refresh()
}
/** Offers a deep link to each option; true once one has taken it. */
suspend fun handleRedirect(uri: Uri): Boolean
{
options.forEach { option ->
val consumed =
try
{
option.handleRedirect(uri)
}
catch (e: CancellationException)
{
throw e
}
catch (_: Exception)
{
false
}
if (consumed)
{
refresh()
return true
}
}
return false
}
/**
* One line per status change, so that a session lapsing can be placed on a
* timeline afterwards instead of guessed at. Never logs tokens or account data.
*/
private fun logTransitions(
old: PaymentOptionsSummary?,
new: PaymentOptionsSummary,
)
{
new.options.forEach { state ->
val before = old?.options?.firstOrNull { it.id == state.id }?.status
if (before != state.status)
{
Log.d(LOG_TAG, "${state.id}: $before → ${state.status} (enabled=${state.enabled})")
}
}
}
fun reportActionFailure()
{
mutableActionFailed.value = true
refresh()
}
fun dismissError()
{
if (mutableActionFailed.value != false) mutableActionFailed.value = false
}
/** Merchant logout: resets every switch to its default and drops every session. */
fun logout()
{
preferences.edit().clear().apply()
options.forEach { option ->
try
{
option.onLogout()
}
catch (_: RuntimeException)
{
// An option that cannot clear its session must not block merchant logout.
}
}
dismissError()
refresh()
}
private companion object
{
const val LOG_TAG = "taler-pos-payments"
const val PREFERENCES = "payment-terminals"
fun optionKey(id: String) = "option.$id.enabled"
}
}