ConfigManager.kt
/*
* This file is part of GNU Taler
* (C) 2020 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.config
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.util.Log
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import net.taler.common.ChallengeConfirmRequest
import net.taler.common.ChallengesResponse
import net.taler.common.CurrencySpecification
import net.taler.common.Event
import net.taler.common.TokenDuration
import net.taler.common.TokenRequest
import net.taler.common.Version
import net.taler.lib.android.getIncompatibleStringOrNull
import net.taler.merchantlib.MerchantConfig
import net.taler.merchantpos.BuildConfig
import net.taler.merchantpos.R
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.timeout
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpHeaders.Authorization
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpStatusCode.Companion.Unauthorized
import io.ktor.http.contentType
import io.ktor.serialization.ContentConvertException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
private const val SETTINGS_NAME = "taler-merchant-terminal"
private const val SETTINGS_CONFIG_VERSION = "configVersion"
internal const val CONFIG_VERSION_OLD = 0
internal const val CONFIG_VERSION_NEW = 1
// Old JSON config + basic auth config
private const val SETTINGS_CONFIG_URL = "configUrl"
private const val SETTINGS_USERNAME = "username"
private const val SETTINGS_PASSWORD = "password"
private const val SETTINGS_SAVE_PASSWORD = "savePassword"
internal const val OLD_CONFIG_URL_DEMO = "https://docs.taler.net/_static/sample-pos-config.json"
internal const val OLD_CONFIG_USERNAME_DEMO = ""
internal const val OLD_CONFIG_PASSWORD_DEMO = ""
// New merchant API + token config
private const val SETTINGS_MERCHANT_URL = "merchantUrl"
private const val SETTINGS_ACCESS_TOKEN = "accessToken"
private const val SETTINGS_INITIAL_ORDER_SCREEN = "initialOrderScreen"
private const val SETTINGS_CACHED_RUNTIME_CONFIG = "cachedRuntimeConfig"
internal const val NEW_CONFIG_URL_DEMO = "my.taler-ops.ch"
private val VERSION = Version.parse(BuildConfig.BACKEND_API_VERSION)!!
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val TAG = ConfigManager::class.java.simpleName
enum class InitialOrderScreen(
val prefValue: String,
)
{
AmountEntry("amountEntry"),
Inventory("inventory"),
;
companion object
{
fun fromPrefValue(value: String?): InitialOrderScreen =
entries.firstOrNull { it.prefValue == value } ?: AmountEntry
}
}
@Serializable
private data class MerchantBackendConfigResponse(
val version: String,
val currency: String,
val currencies: Map<String, CurrencySpecification> = emptyMap(),
)
@Serializable
private data class ProductDetail(
@SerialName("total_sold")
val totalSold: Int? = null,
@SerialName("total_lost")
val totalLost: Int? = null,
@SerialName("unit_total_sold")
val unitTotalSold: String? = null,
@SerialName("unit_total_lost")
val unitTotalLost: String? = null,
)
@Serializable
private data class CachedRuntimeConfig(
val posConfig: PosConfig,
val merchantConfig: MerchantConfig,
val currency: String,
val currencySpec: CurrencySpecification? = null,
)
// -- Limited access token --
@Serializable
private data class LimitedTokenResponse(
val token: String,
val scope: String,
val refreshable: Boolean,
// Using Unit here as a placeholder if we don't need the actual expiration object
// or we could use net.taler.common.TokenExpiration
val expiration: JsonElement,
)
class ChallengeRequiredException(
val challengeResponse: ChallengesResponse,
) : Exception()
// -- Limited access token END --
interface ConfigurationReceiver
{
/**
* Returns null if the configuration was valid, or an error string for user display otherwise.
*/
suspend fun onConfigurationReceived(
posConfig: PosConfig,
currency: String,
currencySpec: CurrencySpecification?,
): String?
/** Applies an inventory refresh; returns null on success or an error for display. */
suspend fun onInventoryUpdated(
posConfig: PosConfig,
currency: String,
currencySpec: CurrencySpecification?,
): String? = onConfigurationReceived(posConfig, currency, currencySpec)
}
class ConfigManager(
private val context: Context,
private val scope: CoroutineScope,
private val httpClient: HttpClient,
private val requestTimeoutMillis: Long = 30_000,
private val beforeConnection: suspend (String) -> Unit = {},
)
{
private val prefs = context.getSharedPreferences(SETTINGS_NAME, MODE_PRIVATE)
private val configurationReceivers = ArrayList<ConfigurationReceiver>()
private var inventoryRefreshJob: Job? = null
private var configFetchJob: Job? = null
private var fetchingConfig: Config? = null
private var fetchingSilently = false
private fun HttpRequestBuilder.configTimeout()
{
timeout {
requestTimeoutMillis = this@ConfigManager.requestTimeoutMillis
socketTimeoutMillis = this@ConfigManager.requestTimeoutMillis
connectTimeoutMillis = minOf(15_000, this@ConfigManager.requestTimeoutMillis)
}
}
@UiThread
fun cancelConfigFetch()
{
configFetchJob?.cancel()
configFetchJob = null
fetchingConfig = null
mConfigUpdateResult.value = null
}
private var cachedRuntimeConfig: CachedRuntimeConfig? = null
init
{
migrateLegacyPrefsIfNeeded()
}
private val _sessionExpired = MutableLiveData<Event<Unit>>()
private val mMerchantIdentity = MutableLiveData<String?>(null)
private val mInitialOrderScreen =
MutableLiveData(
InitialOrderScreen.fromPrefValue(
prefs.getString(SETTINGS_INITIAL_ORDER_SCREEN, InitialOrderScreen.AmountEntry.prefValue),
),
)
private val mConfigUpdateResult = MutableLiveData<ConfigUpdateResult?>()
val sessionExpired: LiveData<Event<Unit>> = _sessionExpired
val merchantIdentity: LiveData<String?> = mMerchantIdentity
val initialOrderScreenLiveData: LiveData<InitialOrderScreen> = mInitialOrderScreen
val configUpdateResult: LiveData<ConfigUpdateResult?> = mConfigUpdateResult
@Volatile
var config: Config =
Config.New(
merchantUrl = prefs.getString(SETTINGS_MERCHANT_URL, "")!!,
accessToken = prefs.getString(SETTINGS_ACCESS_TOKEN, "")!!,
savePassword = prefs.getBoolean(SETTINGS_SAVE_PASSWORD, true),
)
@Volatile
var merchantConfig: MerchantConfig? = null
private set(value)
{
field = value
val identity = value?.baseUrl?.trimEnd('/')
if (mMerchantIdentity.value != identity) mMerchantIdentity.value = identity
}
@Volatile
var currency: String? = null
private set
@Volatile
var currencySpec: CurrencySpecification? = null
private set
@Volatile
var backendVersion: String? = null
private set
var initialOrderScreen: InitialOrderScreen
get() = mInitialOrderScreen.value ?: InitialOrderScreen.AmountEntry
set(value)
{
prefs
.edit()
.putString(SETTINGS_INITIAL_ORDER_SCREEN, value.prefValue)
.apply()
mInitialOrderScreen.value = value
}
init
{
restoreCachedRuntimeConfig()
}
fun addConfigurationReceiver(receiver: ConfigurationReceiver)
{
configurationReceivers.add(receiver)
cachedRuntimeConfig?.let { cached ->
scope.launch {
receiver.onConfigurationReceived(cached.posConfig, cached.currency, cached.currencySpec)
}
}
}
internal fun debugApplyFixture(
posConfig: PosConfig,
merchantConfig: MerchantConfig,
currency: String,
currencySpec: CurrencySpecification? = null,
initialOrderScreen: InitialOrderScreen = InitialOrderScreen.Inventory,
)
{
config =
Config.New(
merchantUrl = merchantConfig.baseUrl,
accessToken = "",
savePassword = false,
)
this.merchantConfig = merchantConfig
this.currency = currency
this.currencySpec = currencySpec
this.initialOrderScreen = initialOrderScreen
val snapshot =
CachedRuntimeConfig(
posConfig = posConfig,
merchantConfig = merchantConfig,
currency = currency,
currencySpec = currencySpec,
)
// Screenshot fixtures must not replace the signed-in merchant's saved cache.
cachedRuntimeConfig = snapshot
runBlocking {
configurationReceivers.forEach { receiver ->
receiver.onConfigurationReceived(posConfig, currency, currencySpec)
}
}
}
private fun migrateLegacyPrefsIfNeeded()
{
val legacyVersion = prefs.getInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW)
if (legacyVersion == CONFIG_VERSION_OLD)
{
prefs.edit().clear().apply()
}
}
@UiThread
fun reloadConfig()
{
fetchConfig(config, save = true, inventoryOnly = true, silent = false)
}
@UiThread
fun refreshConfigInBackground()
{
if (!config.isValid() ||
!config.hasPassword())
{
return
}
fetchConfig(config, save = false, inventoryOnly = true, silent = true)
}
@UiThread
fun refreshInventory()
{
inventoryRefreshJob?.cancel()
inventoryRefreshJob =
scope.launch {
delay(350)
fetchConfig(config, save = false, inventoryOnly = true, silent = true)
}
}
@UiThread
fun fetchConfig(
config: Config,
save: Boolean,
)
{
fetchConfig(config, save, inventoryOnly = false, silent = false)
}
@UiThread
private fun fetchConfig(
config: Config,
save: Boolean,
inventoryOnly: Boolean,
silent: Boolean,
)
{
if (configFetchJob?.isActive == true)
{
if (silent || (!fetchingSilently && fetchingConfig == config)) return
configFetchJob?.cancel()
}
fetchingConfig = config
fetchingSilently = silent
if (!silent) mConfigUpdateResult.value = null
val configToSave =
if (save)
{
if (config.savePassword())
{
config
}
else
{
when (val c = config)
{
// is Config.Old -> c.copy(password = "")
is Config.New -> c.copy(accessToken = "")
}
}
}
else
{
null
}
configFetchJob =
scope.launch(Dispatchers.IO) {
try
{
val url =
when (val c = config)
{
// is Config.Old -> c.configUrl
is Config.New ->
{
c.merchantUrl
.toUri()
.buildUpon()
.appendPath("private")
.appendPath("pos")
.build()
.toString()
}
}
val rawToken =
when (val c = config)
{
is Config.New -> c.accessToken
}
beforeConnection(url)
val (posConfig, apiKey) = fetchPosConfig(url, rawToken)
val merchantConfig =
when (config)
{
// is Config.Old -> posConfig.merchantConfig!!
is Config.New -> MerchantConfig(config.merchantUrl, apiKey)
}
val backendConfig: MerchantBackendConfigResponse =
httpClient
.get(merchantConfig.urlFor("config")) {
configTimeout()
}.body()
onMerchantConfigReceived(
configToSave,
posConfig,
merchantConfig,
backendConfig,
inventoryOnly,
silent,
)
}
catch (e: CancellationException)
{
throw e
}
catch (_: LocalNetworkAccessDenied)
{
if (!silent)
{
withContext(Dispatchers.Main) {
mConfigUpdateResult.value =
ConfigUpdateResult.Error(
context.getString(R.string.config_local_network_required),
returnToConfig = true,
)
}
}
}
catch (e: Exception)
{
Log.e(TAG, "Error retrieving merchant config", e)
val msg =
if (e is ClientRequestException)
{
context.getString(
if (e.response.status == Unauthorized)
{
R.string.config_auth_error
}
else
{
R.string.config_error_network
},
)
}
else
{
context.getString(
if (e is SerializationException || e is ContentConvertException)
{
R.string.config_error_malformed
}
else
{
R.string.config_error_network
},
)
}
if (!silent) onNetworkError(msg)
}
}
}
@WorkerThread
private suspend fun fetchPosConfig(
url: String,
rawToken: String,
): Pair<PosConfig, String>
{
try
{
val posConfig: PosConfig =
httpClient
.get(url) {
configTimeout()
header(Authorization, "Bearer $rawToken")
}.body()
return posConfig to rawToken
}
catch (e: ClientRequestException)
{
if (e.response.status != Unauthorized ||
rawToken.startsWith("secret-token:"))
{
throw e
}
}
val prefixedToken = "secret-token:$rawToken"
val posConfig: PosConfig =
httpClient
.get(url) {
configTimeout()
header(Authorization, "Bearer $prefixedToken")
}.body()
return posConfig to prefixedToken
}
@WorkerThread
private suspend fun onMerchantConfigReceived(
newConfig: Config?,
posConfig: PosConfig,
merchantConfig: MerchantConfig,
configResponse: MerchantBackendConfigResponse,
inventoryOnly: Boolean,
silent: Boolean,
)
{
val versionIncompatible =
VERSION.getIncompatibleStringOrNull(context, configResponse.version)
if (versionIncompatible != null)
{
Log.e(TAG, "Versions incompatible $configResponse")
if (!silent) onNetworkError(versionIncompatible)
return
}
val currencySpec = configResponse.currencies[configResponse.currency]
val posConfigWithCachedStock = applyCachedStockData(posConfig)
for (receiver in configurationReceivers)
{
val result =
try
{
if (inventoryOnly)
{
receiver.onInventoryUpdated(posConfigWithCachedStock, configResponse.currency, currencySpec)
}
else
{
receiver.onConfigurationReceived(
posConfigWithCachedStock,
configResponse.currency,
currencySpec,
)
}
}
catch (e: Exception)
{
if (e is CancellationException) throw e
Log.e(TAG, "Error handling configuration by ${receiver::class.java.simpleName}", e)
context.getString(R.string.config_error_unknown)
}
if (result != null)
{ // error
if (!silent) onNetworkError(result)
return
}
}
withContext(Dispatchers.Main) {
newConfig?.let {
config = it
saveConfig(it)
}
this@ConfigManager.merchantConfig = merchantConfig
this@ConfigManager.currency = configResponse.currency
this@ConfigManager.currencySpec = currencySpec
this@ConfigManager.backendVersion = configResponse.version
saveCachedRuntimeConfig(
CachedRuntimeConfig(
posConfig = posConfigWithCachedStock,
merchantConfig = merchantConfig,
currency = configResponse.currency,
currencySpec = currencySpec,
),
)
if (!silent)
{
mConfigUpdateResult.value = ConfigUpdateResult.Success(configResponse.currency)
}
}
scope.launch(Dispatchers.IO) {
val enrichedPosConfig = enrichProductsWithStockDetails(posConfig, merchantConfig)
if (enrichedPosConfig !== posConfig)
{
for (receiver in configurationReceivers)
{
try
{
receiver.onInventoryUpdated(enrichedPosConfig, configResponse.currency, currencySpec)
}
catch (e: Exception)
{
if (e is CancellationException) throw e
Log.e(TAG, "Error updating stock for ${receiver::class.java.simpleName}", e)
}
}
saveCachedRuntimeConfig(
CachedRuntimeConfig(
posConfig = enrichedPosConfig,
merchantConfig = merchantConfig,
currency = configResponse.currency,
currencySpec = currencySpec,
),
)
}
}
}
private fun applyCachedStockData(posConfig: PosConfig): PosConfig
{
val cached = cachedRuntimeConfig ?: return posConfig
val cachedByProductId =
cached.posConfig.products
.filter { it.productId != null }
.associateBy { it.productId }
var changed = false
val products =
posConfig.products.map { product ->
val cachedProduct =
product.productId?.let { cachedByProductId[it] }
?: return@map product
if (cachedProduct.totalSold == null &&
cachedProduct.totalLost == null &&
cachedProduct.unitTotalSold == null &&
cachedProduct.unitTotalLost == null)
{
return@map product
}
changed = true
product.copy(
totalSold = cachedProduct.totalSold,
totalLost = cachedProduct.totalLost,
unitTotalSold = cachedProduct.unitTotalSold,
unitTotalLost = cachedProduct.unitTotalLost,
)
}
return if (changed) posConfig.copy(products = products) else posConfig
}
@WorkerThread
private suspend fun enrichProductsWithStockDetails(
posConfig: PosConfig,
merchantConfig: MerchantConfig,
): PosConfig
{
val enrichedProducts =
posConfig.products.map { product ->
val pid = product.productId ?: return@map product
if (product.stockLimit == null) return@map product
try
{
val url = merchantConfig.urlFor("private/products/" + pid)
val detail: ProductDetail =
httpClient
.get(url) {
configTimeout()
header(Authorization, "Bearer " + merchantConfig.apiKey)
}.body()
product.copy(
totalLost = detail.totalLost ?: product.totalLost,
totalSold = detail.totalSold ?: product.totalSold,
unitTotalLost = detail.unitTotalLost ?: product.unitTotalLost,
unitTotalSold = detail.unitTotalSold ?: product.unitTotalSold,
)
}
catch (e: Exception)
{
if (e is CancellationException) throw e
Log.w(TAG, "Failed to fetch stock details for " + pid, e)
product
}
}
if (enrichedProducts == posConfig.products) return posConfig
return posConfig.copy(products = enrichedProducts)
}
/**
* POSTs to /instances/{username}/private/token with the user’s raw secret,
* returns the new "write" token (without the "secret-token:" prefix).
*/
@WorkerThread
suspend fun fetchLimitedAccessToken(
baseUrl: String,
username: String,
initialSecret: String,
duration: TokenDuration,
challengeIds: List<String> = emptyList(),
): String
{
val tokenUrl =
baseUrl
.toUri()
.buildUpon()
.appendPath("instances")
.appendPath(username)
.appendPath("private")
.appendPath("token")
.build()
.toString()
beforeConnection(baseUrl)
val bearer = "Bearer secret-token:$initialSecret"
val response =
httpClient.post(tokenUrl) {
configTimeout()
header(HttpHeaders.Authorization, bearer)
if (challengeIds.isNotEmpty())
{
header("Taler-Challenge-Ids", challengeIds.joinToString(","))
}
contentType(ContentType.Application.Json)
setBody(TokenRequest(scope = "write", duration = duration))
}
if (response.status == HttpStatusCode.Accepted)
{
val challenge: ChallengesResponse = response.body()
throw ChallengeRequiredException(challenge)
}
val resp: LimitedTokenResponse = response.body()
return resp.token
}
suspend fun requestChallenge(
baseUrl: String,
username: String,
challengeId: String,
)
{
val challengeUrl =
baseUrl
.toUri()
.buildUpon()
.appendPath("instances")
.appendPath(username)
.appendPath("challenge")
.appendPath(challengeId)
.build()
.toString()
beforeConnection(baseUrl)
httpClient.post(challengeUrl) {
configTimeout()
contentType(ContentType.Application.Json)
setBody(buildJsonObject { })
}
}
suspend fun confirmChallenge(
baseUrl: String,
username: String,
challengeId: String,
tan: String,
)
{
val confirmUrl =
baseUrl
.toUri()
.buildUpon()
.appendPath("instances")
.appendPath(username)
.appendPath("challenge")
.appendPath(challengeId)
.appendPath("confirm")
.build()
.toString()
beforeConnection(baseUrl)
httpClient.post(confirmUrl) {
configTimeout()
contentType(ContentType.Application.Json)
setBody(ChallengeConfirmRequest(tan = tan))
}
}
@UiThread
fun forgetPassword()
{
config =
when (val c = config)
{
// is Config.Old -> c.copy(password = "")
is Config.New -> c.copy(accessToken = "")
}
saveConfig(config)
clearCachedRuntimeConfig()
merchantConfig = null
currency = null
currencySpec = null
}
@UiThread
fun logout()
{
inventoryRefreshJob?.cancel()
val savePassword = config.savePassword()
config =
Config.New(
merchantUrl = "",
accessToken = "",
savePassword = savePassword,
)
saveConfig(config)
clearCachedRuntimeConfig()
merchantConfig = null
currency = null
currencySpec = null
mConfigUpdateResult.value = null
}
@UiThread
private fun saveConfig(config: Config)
{
when (val c = config)
{
// is Config.Old -> prefs.edit()
// .putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_OLD)
// .putString(SETTINGS_CONFIG_URL, c.configUrl)
// .putString(SETTINGS_USERNAME, c.username)
// .putString(SETTINGS_PASSWORD, c.password)
// .putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword)
// .apply()
is Config.New ->
{
prefs
.edit()
.putInt(SETTINGS_CONFIG_VERSION, CONFIG_VERSION_NEW)
.putString(SETTINGS_MERCHANT_URL, c.merchantUrl)
.putString(SETTINGS_ACCESS_TOKEN, c.accessToken)
.putBoolean(SETTINGS_SAVE_PASSWORD, c.savePassword)
.apply()
}
}
}
private suspend fun onNetworkError(msg: String) =
withContext(Dispatchers.Main) {
mConfigUpdateResult.value = ConfigUpdateResult.Error(msg)
}
private fun restoreCachedRuntimeConfig()
{
if (!config.isValid() ||
!config.hasPassword())
{
clearCachedRuntimeConfig()
return
}
val encoded = prefs.getString(SETTINGS_CACHED_RUNTIME_CONFIG, null) ?: return
val restored =
runCatching {
json.decodeFromString<CachedRuntimeConfig>(encoded)
}.getOrElse { error ->
Log.e(TAG, "Failed to restore cached runtime config", error)
clearCachedRuntimeConfig()
return
}
cachedRuntimeConfig = restored
merchantConfig = restored.merchantConfig
currency = restored.currency
currencySpec = restored.currencySpec
}
private fun saveCachedRuntimeConfig(snapshot: CachedRuntimeConfig)
{
cachedRuntimeConfig = snapshot
prefs
.edit()
.putString(SETTINGS_CACHED_RUNTIME_CONFIG, json.encodeToString(CachedRuntimeConfig.serializer(), snapshot))
.apply()
}
private fun clearCachedRuntimeConfig()
{
cachedRuntimeConfig = null
prefs.edit().remove(SETTINGS_CACHED_RUNTIME_CONFIG).apply()
}
@UiThread
internal fun handleUnauthorized(requestUrl: String, authorization: String?)
{
val merchant = merchantConfig ?: return
val privateUrl = merchant.urlFor("private").trimEnd('/') + "/"
// Login, MFA and token-prefix probes share the HTTP client with the
// active merchant. Their failures must not invalidate its session.
if (!requestUrl.startsWith(privateUrl) ||
requestUrl.substringBefore('?') == privateUrl + "token" ||
authorization != "Bearer ${merchant.apiKey}")
{
return
}
forgetPassword()
_sessionExpired.value = Event(Unit)
}
}
sealed class ConfigUpdateResult
{
data class Error(
val msg: String,
val returnToConfig: Boolean = false,
) : ConfigUpdateResult()
data class Success(
val currency: String,
) : ConfigUpdateResult()
}