taler-android

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

ConfigManager.kt (25128B)


      1 /*
      2  * This file is part of GNU Taler
      3  * (C) 2020 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.config
     18 
     19 import android.content.Context
     20 import android.content.Context.MODE_PRIVATE
     21 import android.util.Log
     22 import androidx.annotation.UiThread
     23 import androidx.annotation.WorkerThread
     24 import androidx.core.net.toUri
     25 import androidx.lifecycle.LiveData
     26 import androidx.lifecycle.MutableLiveData
     27 import io.ktor.client.HttpClient
     28 import io.ktor.client.call.body
     29 import io.ktor.client.plugins.ClientRequestException
     30 import io.ktor.client.request.get
     31 import io.ktor.client.request.header
     32 import io.ktor.client.request.post
     33 import io.ktor.client.request.setBody
     34 import io.ktor.http.ContentType
     35 import io.ktor.http.HttpHeaders
     36 import io.ktor.http.HttpHeaders.Authorization
     37 import io.ktor.http.HttpStatusCode
     38 import io.ktor.http.HttpStatusCode.Companion.Unauthorized
     39 import io.ktor.http.contentType
     40 import kotlinx.coroutines.CoroutineScope
     41 import kotlinx.coroutines.Dispatchers
     42 import kotlinx.coroutines.Job
     43 import kotlinx.coroutines.delay
     44 import kotlinx.coroutines.launch
     45 import kotlinx.coroutines.runBlocking
     46 import kotlinx.coroutines.withContext
     47 import kotlinx.serialization.KSerializer
     48 import kotlinx.serialization.SerialName
     49 import kotlinx.serialization.Serializable
     50 import kotlinx.serialization.SerializationException
     51 import kotlinx.serialization.descriptors.SerialDescriptor
     52 import kotlinx.serialization.descriptors.buildClassSerialDescriptor
     53 import kotlinx.serialization.descriptors.element
     54 import kotlinx.serialization.encoding.Decoder
     55 import kotlinx.serialization.encoding.Encoder
     56 import kotlinx.serialization.json.Json
     57 import kotlinx.serialization.json.JsonDecoder
     58 import kotlinx.serialization.json.JsonElement
     59 import kotlinx.serialization.json.JsonEncoder
     60 import kotlinx.serialization.json.JsonObject
     61 import kotlinx.serialization.json.JsonPrimitive
     62 import kotlinx.serialization.json.buildJsonObject
     63 import kotlinx.serialization.json.long
     64 import kotlinx.serialization.json.longOrNull
     65 import net.taler.common.ChallengeConfirmRequest
     66 import net.taler.common.ChallengesResponse
     67 import net.taler.common.CurrencySpecification
     68 import net.taler.common.TokenDuration
     69 import net.taler.common.TokenRequest
     70 import net.taler.common.Version
     71 import net.taler.merchantlib.MerchantConfig
     72 import net.taler.merchantpos.BuildConfig
     73 import net.taler.merchantpos.R
     74 import net.taler.lib.android.getIncompatibleStringOrNull
     75 
     76 private const val SETTINGS_NAME = "taler-merchant-terminal"
     77 
     78 private const val SETTINGS_CONFIG_VERSION = "configVersion"
     79 
     80 internal const val CONFIG_VERSION_OLD = 0
     81 internal const val CONFIG_VERSION_NEW = 1
     82 
     83 // Old JSON config + basic auth config
     84 
     85 private const val SETTINGS_CONFIG_URL = "configUrl"
     86 private const val SETTINGS_USERNAME = "username"
     87 private const val SETTINGS_PASSWORD = "password"
     88 private const val SETTINGS_SAVE_PASSWORD = "savePassword"
     89 
     90 internal const val OLD_CONFIG_URL_DEMO = "https://docs.taler.net/_static/sample-pos-config.json"
     91 internal const val OLD_CONFIG_USERNAME_DEMO = ""
     92 internal const val OLD_CONFIG_PASSWORD_DEMO = ""
     93 
     94 // New merchant API + token config
     95 
     96 private const val SETTINGS_MERCHANT_URL = "merchantUrl"
     97 private const val SETTINGS_ACCESS_TOKEN = "accessToken"
     98 private const val SETTINGS_INITIAL_ORDER_SCREEN = "initialOrderScreen"
     99 private const val SETTINGS_CACHED_RUNTIME_CONFIG = "cachedRuntimeConfig"
    100 
    101 internal const val NEW_CONFIG_URL_DEMO = "my.taler-ops.ch"
    102 
    103 private val VERSION = Version.parse(BuildConfig.BACKEND_API_VERSION)!!
    104 private val json = Json {
    105     ignoreUnknownKeys = true
    106     encodeDefaults = true
    107 }
    108 
    109 private val TAG = ConfigManager::class.java.simpleName
    110 
    111 enum class InitialOrderScreen(val prefValue: String) {
    112     AmountEntry("amountEntry"),
    113     Inventory("inventory");
    114 
    115     companion object {
    116         fun fromPrefValue(value: String?): InitialOrderScreen {
    117             return entries.firstOrNull { it.prefValue == value } ?: AmountEntry
    118         }
    119     }
    120 }
    121 
    122 @kotlinx.serialization.Serializable
    123 private data class MerchantBackendConfigResponse(
    124     val version: String,
    125     val currency: String,
    126     val currencies: Map<String, CurrencySpecification> = emptyMap(),
    127 )
    128 
    129 @Serializable
    130 private data class ProductDetail(
    131     @SerialName("total_sold")
    132     val totalSold: Int? = null,
    133     @SerialName("total_lost")
    134     val totalLost: Int? = null,
    135     @SerialName("unit_total_sold")
    136     val unitTotalSold: String? = null,
    137     @SerialName("unit_total_lost")
    138     val unitTotalLost: String? = null,
    139 )
    140 
    141 @Serializable
    142 private data class CachedRuntimeConfig(
    143     val posConfig: PosConfig,
    144     val merchantConfig: MerchantConfig,
    145     val currency: String,
    146     val currencySpec: CurrencySpecification? = null,
    147 )
    148 
    149 /* -- Limited access token -- */
    150 @kotlinx.serialization.Serializable
    151 private data class LimitedTokenResponse(
    152     val token: String,
    153     val scope: String,
    154     val refreshable: Boolean,
    155     // Using Unit here as a placeholder if we don't need the actual expiration object
    156     // or we could use net.taler.common.TokenExpiration
    157     val expiration: kotlinx.serialization.json.JsonElement
    158 )
    159 
    160 class ChallengeRequiredException(val challengeResponse: ChallengesResponse) : Exception()
    161 
    162 /* -- Limited access token END -- */
    163 
    164 
    165 interface ConfigurationReceiver {
    166     /**
    167      * Returns null if the configuration was valid, or a error string for user display otherwise.
    168      */
    169     suspend fun onConfigurationReceived(
    170         posConfig: PosConfig,
    171         currency: String,
    172         currencySpec: CurrencySpecification?,
    173     ): String?
    174 
    175     suspend fun onInventoryUpdated(
    176         posConfig: PosConfig,
    177         currency: String,
    178         currencySpec: CurrencySpecification?,
    179     ): String? = onConfigurationReceived(posConfig, currency, currencySpec)
    180 }
    181 
    182 class ConfigManager(
    183     private val context: Context,
    184     private val scope: CoroutineScope,
    185     private val httpClient: HttpClient,
    186 ) {
    187 
    188     private val _sessionExpired = MutableLiveData<Unit>()
    189     val sessionExpired: LiveData<Unit> = _sessionExpired
    190 
    191     private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE)
    192     private val configurationReceivers = ArrayList<ConfigurationReceiver>()
    193     private var inventoryRefreshJob: Job? = null
    194     private var cachedRuntimeConfig: CachedRuntimeConfig? = null
    195 
    196     init {
    197         migrateLegacyPrefsIfNeeded()
    198     }
    199 
    200     @Volatile
    201     var config: Config =
    202         Config.New(
    203             merchantUrl = prefs.getString(SETTINGS_MERCHANT_URL, "")!!,
    204             accessToken = prefs.getString(SETTINGS_ACCESS_TOKEN, "")!!,
    205             savePassword = prefs.getBoolean(SETTINGS_SAVE_PASSWORD, true),
    206         )
    207 
    208     init {
    209         restoreCachedRuntimeConfig()
    210     }
    211 
    212     @Volatile
    213     var merchantConfig: MerchantConfig? = null
    214         private set
    215 
    216     @Volatile
    217     var currency: String? = null
    218         private set
    219 
    220     @Volatile
    221     var currencySpec: CurrencySpecification? = null
    222         private set
    223 
    224     @Volatile
    225     var backendVersion: String? = null
    226         private set
    227 
    228     private val mInitialOrderScreen = MutableLiveData(
    229         InitialOrderScreen.fromPrefValue(
    230             prefs.getString(SETTINGS_INITIAL_ORDER_SCREEN, InitialOrderScreen.AmountEntry.prefValue),
    231         )
    232     )
    233     val initialOrderScreenLiveData: LiveData<InitialOrderScreen> = mInitialOrderScreen
    234 
    235     var initialOrderScreen: InitialOrderScreen
    236         get() = mInitialOrderScreen.value ?: InitialOrderScreen.AmountEntry
    237         set(value) {
    238             prefs.edit()
    239                 .putString(SETTINGS_INITIAL_ORDER_SCREEN, value.prefValue)
    240                 .apply()
    241             mInitialOrderScreen.value = value
    242         }
    243 
    244     private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>()
    245     val configUpdateResult: LiveData<ConfigUpdateResult?> = mConfigUpdateResult
    246 
    247     fun addConfigurationReceiver(receiver: ConfigurationReceiver) {
    248         configurationReceivers.add(receiver)
    249         cachedRuntimeConfig?.let { cached ->
    250             scope.launch {
    251                 receiver.onConfigurationReceived(cached.posConfig, cached.currency, cached.currencySpec)
    252             }
    253         }
    254     }
    255 
    256     internal fun debugApplyFixture(
    257         posConfig: PosConfig,
    258         merchantConfig: MerchantConfig,
    259         currency: String,
    260         currencySpec: CurrencySpecification? = null,
    261         initialOrderScreen: InitialOrderScreen = InitialOrderScreen.Inventory,
    262     ) {
    263         config = Config.New(
    264             merchantUrl = merchantConfig.baseUrl,
    265             accessToken = "",
    266             savePassword = false,
    267         )
    268         this.merchantConfig = merchantConfig
    269         this.currency = currency
    270         this.currencySpec = currencySpec
    271         this.initialOrderScreen = initialOrderScreen
    272 
    273         val snapshot = CachedRuntimeConfig(
    274             posConfig = posConfig,
    275             merchantConfig = merchantConfig,
    276             currency = currency,
    277             currencySpec = currencySpec,
    278         )
    279         saveCachedRuntimeConfig(snapshot)
    280         runBlocking {
    281             configurationReceivers.forEach { receiver ->
    282                 receiver.onConfigurationReceived(posConfig, currency, currencySpec)
    283             }
    284         }
    285     }
    286 
    287     private fun migrateLegacyPrefsIfNeeded() {
    288         val legacyVersion = prefs.getInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW)
    289         if (legacyVersion == CONFIG_VERSION_OLD) {
    290             prefs.edit().clear().apply()
    291         }
    292     }
    293 
    294     @UiThread
    295     fun reloadConfig() {
    296         fetchConfig(config, save = true, inventoryOnly = true, silent = false)
    297     }
    298 
    299     @UiThread
    300     fun refreshConfigInBackground() {
    301         if (!config.isValid() || !config.hasPassword()) return
    302         fetchConfig(config, save = false, inventoryOnly = true, silent = true)
    303     }
    304 
    305     @UiThread
    306     fun refreshInventory() {
    307         inventoryRefreshJob?.cancel()
    308         inventoryRefreshJob = scope.launch {
    309             delay(350)
    310             fetchConfig(config, save = false, inventoryOnly = true, silent = true)
    311         }
    312     }
    313 
    314     @UiThread
    315     fun fetchConfig(config: Config, save: Boolean) {
    316         fetchConfig(config, save, inventoryOnly = false, silent = false)
    317     }
    318 
    319     @UiThread
    320     private fun fetchConfig(
    321         config: Config,
    322         save: Boolean,
    323         inventoryOnly: Boolean,
    324         silent: Boolean,
    325     ) {
    326         if (!silent) mConfigUpdateResult.value = null
    327         val configToSave = if (save) {
    328             if (config.savePassword()) config else when (val c = config) {
    329                 //is Config.Old -> c.copy(password = "")
    330                 is Config.New -> c.copy(accessToken = "")
    331             }
    332         } else null
    333 
    334         scope.launch(Dispatchers.IO) {
    335             try {
    336                 val url = when(val c = config) {
    337                     //is Config.Old -> c.configUrl
    338                     is Config.New -> c.merchantUrl.toUri()
    339                         .buildUpon()
    340                         .appendPath("private/pos")
    341                         .build()
    342                         .toString()
    343                 }
    344 
    345                 val rawToken = when (val c = config) {
    346                     is Config.New -> c.accessToken
    347                 }
    348 
    349                 val (posConfig, apiKey) = fetchPosConfig(url, rawToken)
    350 
    351                 val merchantConfig = when (config) {
    352                     //is Config.Old -> posConfig.merchantConfig!!
    353                     is Config.New -> MerchantConfig(config.merchantUrl, apiKey)
    354                 }
    355 
    356                 val backendConfig: MerchantBackendConfigResponse =
    357                     httpClient.get(merchantConfig.urlFor("config")).body()
    358                 onMerchantConfigReceived(
    359                     configToSave,
    360                     posConfig,
    361                     merchantConfig,
    362                     backendConfig,
    363                     inventoryOnly,
    364                     silent,
    365                 )
    366             } catch (e: Exception) {
    367                 Log.e(TAG, "Error retrieving merchant config", e)
    368                 val msg = if (e is ClientRequestException) {
    369                     context.getString(
    370                         if (e.response.status == Unauthorized) R.string.config_auth_error
    371                         else R.string.config_error_network
    372                     )
    373                 } else {
    374                     context.getString(R.string.config_error_malformed)
    375                 }
    376                 if (!silent) onNetworkError(msg)
    377             }
    378         }
    379     }
    380 
    381     @WorkerThread
    382     private suspend fun fetchPosConfig(url: String, rawToken: String): Pair<PosConfig, String> {
    383         try {
    384             val posConfig: PosConfig = httpClient.get(url) {
    385                 header(Authorization, "Bearer $rawToken")
    386             }.body()
    387             return posConfig to rawToken
    388         } catch (e: ClientRequestException) {
    389             if (e.response.status != Unauthorized || rawToken.startsWith("secret-token:")) throw e
    390         }
    391         val prefixedToken = "secret-token:$rawToken"
    392         val posConfig: PosConfig = httpClient.get(url) {
    393             header(Authorization, "Bearer $prefixedToken")
    394         }.body()
    395         return posConfig to prefixedToken
    396     }
    397 
    398     @WorkerThread
    399     private suspend fun onMerchantConfigReceived(
    400         newConfig: Config?,
    401         posConfig: PosConfig,
    402         merchantConfig: MerchantConfig,
    403         configResponse: MerchantBackendConfigResponse,
    404         inventoryOnly: Boolean,
    405         silent: Boolean,
    406     ) {
    407         val versionIncompatible =
    408             VERSION.getIncompatibleStringOrNull(context, configResponse.version)
    409         if (versionIncompatible != null) {
    410             Log.e(TAG, "Versions incompatible $configResponse")
    411             if (!silent) mConfigUpdateResult.postValue(ConfigUpdateResult.Error(versionIncompatible))
    412             return
    413         }
    414         val currencySpec = configResponse.currencies[configResponse.currency]
    415         val posConfigWithCachedStock = applyCachedStockData(posConfig)
    416         for (receiver in configurationReceivers) {
    417             val result = try {
    418                 if (inventoryOnly) {
    419                     receiver.onInventoryUpdated(posConfigWithCachedStock, configResponse.currency, currencySpec)
    420                 } else {
    421                     receiver.onConfigurationReceived(posConfigWithCachedStock, configResponse.currency, currencySpec)
    422                 }
    423             } catch (e: Exception) {
    424                 Log.e(TAG, "Error handling configuration by ${receiver::class.java.simpleName}", e)
    425                 context.getString(R.string.config_error_unknown)
    426             }
    427             if (result != null) { // error
    428                 if (!silent) mConfigUpdateResult.postValue(ConfigUpdateResult.Error(result))
    429                 return
    430             }
    431         }
    432         withContext(Dispatchers.Main) {
    433             newConfig?.let {
    434                 config = it
    435                 saveConfig(it)
    436             }
    437             this@ConfigManager.merchantConfig = merchantConfig
    438             this@ConfigManager.currency = configResponse.currency
    439             this@ConfigManager.currencySpec = currencySpec
    440             this@ConfigManager.backendVersion = configResponse.version
    441             saveCachedRuntimeConfig(
    442                 CachedRuntimeConfig(
    443                     posConfig = posConfigWithCachedStock,
    444                     merchantConfig = merchantConfig,
    445                     currency = configResponse.currency,
    446                     currencySpec = currencySpec,
    447                 )
    448             )
    449             if (!silent) {
    450                 mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency)
    451             }
    452         }
    453         scope.launch(Dispatchers.IO) {
    454             val enrichedPosConfig = enrichProductsWithStockDetails(posConfig, merchantConfig)
    455             if (enrichedPosConfig !== posConfig) {
    456                 for (receiver in configurationReceivers) {
    457                     try {
    458                         receiver.onInventoryUpdated(enrichedPosConfig, configResponse.currency, currencySpec)
    459                     } catch (e: Exception) {
    460                         Log.e(TAG, "Error updating stock for ${receiver::class.java.simpleName}", e)
    461                     }
    462                 }
    463                 saveCachedRuntimeConfig(
    464                     CachedRuntimeConfig(
    465                         posConfig = enrichedPosConfig,
    466                         merchantConfig = merchantConfig,
    467                         currency = configResponse.currency,
    468                         currencySpec = currencySpec,
    469                     )
    470                 )
    471             }
    472         }
    473     }
    474 
    475     private fun applyCachedStockData(posConfig: PosConfig): PosConfig {
    476         val cached = cachedRuntimeConfig ?: return posConfig
    477         val cachedByProductId = cached.posConfig.products
    478             .filter { it.productId != null }
    479             .associateBy { it.productId }
    480         var changed = false
    481         val products = posConfig.products.map { product ->
    482             val cachedProduct = product.productId?.let { cachedByProductId[it] }
    483                 ?: return@map product
    484             if (cachedProduct.totalSold == null && cachedProduct.totalLost == null &&
    485                 cachedProduct.unitTotalSold == null && cachedProduct.unitTotalLost == null
    486             ) return@map product
    487             changed = true
    488             product.copy(
    489                 totalSold = cachedProduct.totalSold,
    490                 totalLost = cachedProduct.totalLost,
    491                 unitTotalSold = cachedProduct.unitTotalSold,
    492                 unitTotalLost = cachedProduct.unitTotalLost,
    493             )
    494         }
    495         return if (changed) posConfig.copy(products = products) else posConfig
    496     }
    497 
    498     @WorkerThread
    499     private suspend fun enrichProductsWithStockDetails(
    500         posConfig: PosConfig,
    501         merchantConfig: MerchantConfig,
    502     ): PosConfig {
    503         val enrichedProducts = posConfig.products.map { product ->
    504             val pid = product.productId ?: return@map product
    505             if (product.stockLimit == null) return@map product
    506             try {
    507                 val url = merchantConfig.urlFor("private/products/" + pid)
    508                 val detail: ProductDetail = httpClient.get(url) {
    509                     header(Authorization, "Bearer " + merchantConfig.apiKey)
    510                 }.body()
    511                 product.copy(
    512                     totalLost = detail.totalLost ?: product.totalLost,
    513                     totalSold = detail.totalSold ?: product.totalSold,
    514                     unitTotalLost = detail.unitTotalLost ?: product.unitTotalLost,
    515                     unitTotalSold = detail.unitTotalSold ?: product.unitTotalSold,
    516                 )
    517             } catch (e: Exception) {
    518                 Log.w(TAG, "Failed to fetch stock details for " + pid, e)
    519                 product
    520             }
    521         }
    522         if (enrichedProducts == posConfig.products) return posConfig
    523         return posConfig.copy(products = enrichedProducts)
    524     }
    525 
    526     /**
    527      * POSTs to /instances/{username}/private/token with the user’s raw secret,
    528      * returns the new "write" token (without the "secret-token:" prefix).
    529      */
    530     @WorkerThread
    531     suspend fun fetchLimitedAccessToken(
    532         baseUrl: String,
    533         username: String,
    534         initialSecret: String,
    535         duration: TokenDuration,
    536         challengeIds: List<String> = emptyList()
    537     ): String {
    538         val tokenUrl = baseUrl.toUri()
    539             .buildUpon()
    540             .appendPath("instances")
    541             .appendPath(username)
    542             .appendPath("private")
    543             .appendPath("token")
    544             .build()
    545             .toString()
    546 
    547         val bearer = "Bearer secret-token:$initialSecret"
    548         val response = httpClient.post(tokenUrl) {
    549             header(HttpHeaders.Authorization, bearer)
    550             if (challengeIds.isNotEmpty()) {
    551                 header("Taler-Challenge-Ids", challengeIds.joinToString(","))
    552             }
    553             contentType(ContentType.Application.Json)
    554             setBody(TokenRequest(scope = "write", duration = duration))
    555         }
    556 
    557         if (response.status == HttpStatusCode.Accepted) {
    558             val challenge: ChallengesResponse = response.body()
    559             throw ChallengeRequiredException(challenge)
    560         }
    561 
    562         val resp: LimitedTokenResponse = response.body()
    563 
    564         return resp.token
    565     }
    566 
    567     suspend fun requestChallenge(
    568         baseUrl: String,
    569         username: String,
    570         challengeId: String
    571     ) {
    572         val challengeUrl = baseUrl.toUri()
    573             .buildUpon()
    574             .appendPath("instances")
    575             .appendPath(username)
    576             .appendPath("challenge")
    577             .appendPath(challengeId)
    578             .build()
    579             .toString()
    580 
    581         httpClient.post(challengeUrl) {
    582             contentType(ContentType.Application.Json)
    583             setBody(buildJsonObject { })
    584         }
    585     }
    586 
    587     suspend fun confirmChallenge(
    588         baseUrl: String,
    589         username: String,
    590         challengeId: String,
    591         tan: String
    592     ) {
    593         val confirmUrl = baseUrl.toUri()
    594             .buildUpon()
    595             .appendPath("instances")
    596             .appendPath(username)
    597             .appendPath("challenge")
    598             .appendPath(challengeId)
    599             .appendPath("confirm")
    600             .build()
    601             .toString()
    602 
    603         httpClient.post(confirmUrl) {
    604             contentType(ContentType.Application.Json)
    605             setBody(ChallengeConfirmRequest(tan = tan))
    606         }
    607     }
    608 
    609     @UiThread
    610     fun forgetPassword() {
    611         config = when (val c = config) {
    612             //is Config.Old -> c.copy(password = "")
    613             is Config.New -> c.copy(accessToken = "")
    614         }
    615         saveConfig(config)
    616         clearCachedRuntimeConfig()
    617         merchantConfig = null
    618         currency = null
    619         currencySpec = null
    620     }
    621 
    622     @UiThread
    623     fun logout() {
    624         inventoryRefreshJob?.cancel()
    625         val savePassword = config.savePassword()
    626         config = Config.New(
    627             merchantUrl = "",
    628             accessToken = "",
    629             savePassword = savePassword,
    630         )
    631         saveConfig(config)
    632         clearCachedRuntimeConfig()
    633         merchantConfig = null
    634         currency = null
    635         currencySpec = null
    636         mConfigUpdateResult.value = null
    637     }
    638 
    639     @UiThread
    640     private fun saveConfig(config: Config) {
    641         when (val c = config) {
    642 //            is Config.Old -> prefs.edit()
    643 //                .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_OLD)
    644 //                .putString(SETTINGS_CONFIG_URL, c.configUrl)
    645 //                .putString(SETTINGS_USERNAME, c.username)
    646 //                .putString(SETTINGS_PASSWORD, c.password)
    647 //                .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword)
    648 //                .apply()
    649             is Config.New -> prefs.edit()
    650                 .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW)
    651                 .putString(SETTINGS_MERCHANT_URL, c.merchantUrl)
    652                 .putString(SETTINGS_ACCESS_TOKEN, c.accessToken)
    653                 .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword)
    654                 .apply()
    655         }
    656     }
    657 
    658     private fun onNetworkError(msg: String) = scope.launch(Dispatchers.Main) {
    659         mConfigUpdateResult.value = ConfigUpdateResult.Error(msg)
    660     }
    661 
    662     private fun restoreCachedRuntimeConfig() {
    663         if (!config.isValid() || !config.hasPassword()) {
    664             clearCachedRuntimeConfig()
    665             return
    666         }
    667         val encoded = prefs.getString(SETTINGS_CACHED_RUNTIME_CONFIG, null) ?: return
    668         val restored = runCatching {
    669             json.decodeFromString<CachedRuntimeConfig>(encoded)
    670         }.getOrElse { error ->
    671             Log.e(TAG, "Failed to restore cached runtime config", error)
    672             clearCachedRuntimeConfig()
    673             return
    674         }
    675         cachedRuntimeConfig = restored
    676         merchantConfig = restored.merchantConfig
    677         currency = restored.currency
    678         currencySpec = restored.currencySpec
    679     }
    680 
    681     private fun saveCachedRuntimeConfig(snapshot: CachedRuntimeConfig) {
    682         cachedRuntimeConfig = snapshot
    683         prefs.edit()
    684             .putString(SETTINGS_CACHED_RUNTIME_CONFIG, json.encodeToString(CachedRuntimeConfig.serializer(), snapshot))
    685             .apply()
    686     }
    687 
    688     private fun clearCachedRuntimeConfig() {
    689         cachedRuntimeConfig = null
    690         prefs.edit().remove(SETTINGS_CACHED_RUNTIME_CONFIG).apply()
    691     }
    692 
    693     internal fun notifySessionExpired() {
    694         // do it on the Main thread
    695         _sessionExpired.postValue(Unit)
    696     }
    697 }
    698 
    699 sealed class ConfigUpdateResult {
    700     data class Error(val msg: String) : ConfigUpdateResult()
    701     data class Success(val currency: String) : ConfigUpdateResult()
    702 }