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 }