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 }