taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

PaymentOptionsManager.kt (8665B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2026 Taler Systems S.A.
      4  *
      5  * GNU Taler is free software; you can redistribute it and/or modify it under the
      6  * terms of the GNU General Public License as published by the Free Software
      7  * Foundation; either version 3, or (at your option) any later version.
      8  *
      9  * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     10  * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11  * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12  *
     13  * You should have received a copy of the GNU General Public License along with
     14  * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15  */
     16 
     17 package net.taler.merchantpos.paymentoptions
     18 
     19 import android.content.Context
     20 import android.net.Uri
     21 import android.os.Looper
     22 import android.util.Log
     23 
     24 import androidx.lifecycle.LiveData
     25 import androidx.lifecycle.MutableLiveData
     26 
     27 import kotlinx.coroutines.CancellationException
     28 
     29 data class PaymentOptionsSummary(
     30     val options: List<PaymentOptionState> = emptyList(),
     31 )
     32 {
     33     val enabled: List<PaymentOptionState> get() = options.filter { it.enabled }
     34 
     35     val enabledCount: Int get() = enabled.size
     36 }
     37 
     38 /**
     39  * Owns the per-option switches and the polled state of each option in [options].
     40  *
     41  * Essential options stay enabled; others are reached while their switch is on.
     42  * Turning an optional method off keeps its
     43  * stored settings and any session, so that re-enabling does not require setting it
     44  * up again; only [logout] clears sessions.
     45  */
     46 class PaymentOptionsManager(
     47     context: Context,
     48     val options: List<PaymentOption>,
     49 )
     50 {
     51     private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
     52     private val mutableSummary = MutableLiveData(PaymentOptionsSummary())
     53     private val mutableActionFailed = MutableLiveData(false)
     54 
     55     val summary: LiveData<PaymentOptionsSummary> = mutableSummary
     56     val actionFailed: LiveData<Boolean> = mutableActionFailed
     57 
     58     /** Card providers and enabled options that currently report payment devices. */
     59     fun connectionStates(summary: PaymentOptionsSummary): List<Pair<PaymentOption, PaymentOptionState>> =
     60         options.mapNotNull { option ->
     61             summary.options
     62                 .firstOrNull { it.id == option.id }
     63                 ?.takeIf { it.enabled && (option.tracksConnection || it.devices.isNotEmpty()) }
     64                 ?.let { option to it }
     65         }
     66 
     67     init
     68     {
     69         // Defer SDK and Bluetooth connection checks until the first poll.
     70         publish(options.map { PaymentOptionState(it.id, enabled = isEnabled(it), status = setupStatus(it)) })
     71     }
     72 
     73     private fun setupStatus(option: PaymentOption): PaymentOptionStatus =
     74         try
     75         {
     76             if (option.isConfigured()) PaymentOptionStatus.Disabled else PaymentOptionStatus.NotConfigured
     77         }
     78         catch (_: RuntimeException)
     79         {
     80             PaymentOptionStatus.Unavailable
     81         }
     82 
     83     fun option(id: String): PaymentOption? = options.firstOrNull { it.id == id }
     84 
     85     fun state(id: String): PaymentOptionState =
     86         mutableSummary.value?.options?.firstOrNull { it.id == id } ?: PaymentOptionState(id)
     87 
     88     fun isEnabled(option: PaymentOption): Boolean =
     89         option.alwaysEnabled || preferences.getBoolean(optionKey(option.id), option.enabledByDefault)
     90 
     91     fun setEnabled(
     92         id: String,
     93         enabled: Boolean,
     94     )
     95     {
     96         val option = option(id) ?: return
     97         preferences.edit().putBoolean(optionKey(id), option.alwaysEnabled || enabled).apply()
     98         dismissError()
     99         refresh()
    100     }
    101 
    102     fun refresh()
    103     {
    104         val states =
    105             options.map { option ->
    106                 val enabled = isEnabled(option)
    107                 try
    108                 {
    109                     if (!enabled)
    110                     {
    111                         // Setup can change while an option is off. Publish that
    112                         // change so its enable switch updates without probing a reader.
    113                         PaymentOptionState(
    114                             option.id,
    115                             enabled = false,
    116                             status = setupStatus(option),
    117                         )
    118                     }
    119                     else
    120                     {
    121                         option.probe().copy(id = option.id, enabled = true)
    122                     }
    123                 }
    124                 catch (_: RuntimeException)
    125                 {
    126                     PaymentOptionState(option.id, enabled = enabled, status = PaymentOptionStatus.Unavailable)
    127                 }
    128             }
    129         publish(states)
    130     }
    131 
    132     private fun publish(states: List<PaymentOptionState>)
    133     {
    134         val updated = PaymentOptionsSummary(states)
    135         if (mutableSummary.value == updated) return
    136         logTransitions(mutableSummary.value, updated)
    137         // Probing talks to payment SDKs, so it is polled off the main thread; only the
    138         // one-off calls from the settings screens arrive on it.
    139         if (Looper.myLooper() == Looper.getMainLooper())
    140         {
    141             mutableSummary.value = updated
    142         }
    143         else
    144         {
    145             mutableSummary.postValue(updated)
    146         }
    147     }
    148 
    149     /**
    150      * Reports terminal status, wooooow :)
    151      */
    152     fun terminalStatus(summary: PaymentOptionsSummary): PaymentOptionStatus =
    153         connectionStates(summary).maxOfOrNull { (option, state) ->
    154             if (option.tracksConnection)
    155             {
    156                 state.status
    157             }
    158             else if (state.devices.any { it.connected })
    159             {
    160                 PaymentOptionStatus.Connected
    161             }
    162             else
    163             {
    164                 PaymentOptionStatus.Disconnected
    165             }
    166         } ?: PaymentOptionStatus.Disabled
    167 
    168     fun hasEnabledTerminal(summary: PaymentOptionsSummary): Boolean = connectionStates(summary).isNotEmpty()
    169 
    170     /** Renews any refreshable session the enabled options hold, then re-reads status. */
    171     suspend fun maintainSessions()
    172     {
    173         options.forEach { option ->
    174             if (!isEnabled(option)) return@forEach
    175             try
    176             {
    177                 option.maintainSession()
    178             }
    179             catch (e: CancellationException)
    180             {
    181                 throw e
    182             }
    183             catch (_: Exception)
    184             {
    185                 // An option that cannot renew its session must not stop the others.
    186             }
    187         }
    188         refresh()
    189     }
    190 
    191     /** Offers a deep link to each option; true once one has taken it. */
    192     suspend fun handleRedirect(uri: Uri): Boolean
    193     {
    194         options.forEach { option ->
    195             val consumed =
    196                 try
    197                 {
    198                     option.handleRedirect(uri)
    199                 }
    200                 catch (e: CancellationException)
    201                 {
    202                     throw e
    203                 }
    204                 catch (_: Exception)
    205                 {
    206                     false
    207                 }
    208             if (consumed)
    209             {
    210                 refresh()
    211                 return true
    212             }
    213         }
    214         return false
    215     }
    216 
    217     /**
    218      * One line per status change, so that a session lapsing can be placed on a
    219      * timeline afterwards instead of guessed at. Never logs tokens or account data.
    220      */
    221     private fun logTransitions(
    222         old: PaymentOptionsSummary?,
    223         new: PaymentOptionsSummary,
    224     )
    225     {
    226         new.options.forEach { state ->
    227             val before = old?.options?.firstOrNull { it.id == state.id }?.status
    228             if (before != state.status)
    229             {
    230                 Log.d(LOG_TAG, "${state.id}: $before → ${state.status} (enabled=${state.enabled})")
    231             }
    232         }
    233     }
    234 
    235     fun reportActionFailure()
    236     {
    237         mutableActionFailed.value = true
    238         refresh()
    239     }
    240 
    241     fun dismissError()
    242     {
    243         if (mutableActionFailed.value != false) mutableActionFailed.value = false
    244     }
    245 
    246     /** Merchant logout: resets every switch to its default and drops every session. */
    247     fun logout()
    248     {
    249         preferences.edit().clear().apply()
    250         options.forEach { option ->
    251             try
    252             {
    253                 option.onLogout()
    254             }
    255             catch (_: RuntimeException)
    256             {
    257                 // An option that cannot clear its session must not block merchant logout.
    258             }
    259         }
    260         dismissError()
    261         refresh()
    262     }
    263 
    264     private companion object
    265     {
    266         const val LOG_TAG = "taler-pos-payments"
    267         const val PREFERENCES = "payment-terminals"
    268 
    269         fun optionKey(id: String) = "option.$id.enabled"
    270     }
    271 }