diff options
author | Torsten Grote <t@grobox.de> | 2020-08-27 16:42:03 -0300 |
---|---|---|
committer | Torsten Grote <t@grobox.de> | 2020-08-27 16:42:03 -0300 |
commit | 53d99e46e6b34d4437f46266cb797a65c0736803 (patch) | |
tree | 23f21d022abea280132e3440b3825a71483c7bd2 /cashier | |
parent | ed3f86481a71517e7bf6ffa46dc8d160b508ec38 (diff) | |
download | taler-android-53d99e46e6b34d4437f46266cb797a65c0736803.tar.gz taler-android-53d99e46e6b34d4437f46266cb797a65c0736803.tar.bz2 taler-android-53d99e46e6b34d4437f46266cb797a65c0736803.zip |
[cashier] don't crash on unexpected network input
Diffstat (limited to 'cashier')
-rw-r--r-- | cashier/build.gradle | 16 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/BalanceFragment.kt | 11 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/HttpHelper.kt | 53 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/MainActivity.kt | 7 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/MainViewModel.kt | 132 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/Response.kt | 86 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/config/Config.kt | 41 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt (renamed from cashier/src/main/java/net/taler/cashier/ConfigFragment.kt) | 19 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt | 141 | ||||
-rw-r--r-- | cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt | 56 | ||||
-rw-r--r-- | cashier/src/main/res/navigation/nav_graph.xml | 2 |
11 files changed, 385 insertions, 179 deletions
diff --git a/cashier/build.gradle b/cashier/build.gradle index 916758b..4defd7a 100644 --- a/cashier/build.gradle +++ b/cashier/build.gradle @@ -14,10 +14,13 @@ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' -apply plugin: 'androidx.navigation.safeargs.kotlin' +plugins { + id "com.android.application" + id "kotlin-android" + id "kotlin-android-extensions" + id "kotlinx-serialization" + id "androidx.navigation.safeargs.kotlin" +} android { compileSdkVersion 29 @@ -66,8 +69,9 @@ dependencies { implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - // https://github.com/square/okhttp/releases - implementation "com.squareup.okhttp3:okhttp:3.12.12" + implementation "io.ktor:ktor-client:$ktor_version" + implementation "io.ktor:ktor-client-okhttp:$ktor_version" + implementation "io.ktor:ktor-client-serialization-jvm:$ktor_version" testImplementation 'junit:junit:4.13' diff --git a/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt b/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt index d899e7d..cdfa142 100644 --- a/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt +++ b/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt @@ -47,6 +47,7 @@ sealed class BalanceResult { class BalanceFragment : Fragment() { private val viewModel: MainViewModel by activityViewModels() + private val configManager by lazy { viewModel.configManager} private val withdrawManager by lazy { viewModel.withdrawManager } override fun onCreateView( @@ -78,7 +79,7 @@ class BalanceFragment : Fragment() { true } else false } - viewModel.currency.observe(viewLifecycleOwner, Observer { currency -> + configManager.currency.observe(viewLifecycleOwner, Observer { currency -> currencyView.text = currency }) confirmWithdrawalButton.setOnClickListener { onAmountConfirmed(getAmountFromView()) } @@ -87,7 +88,7 @@ class BalanceFragment : Fragment() { override fun onStart() { super.onStart() // update balance if there's a config - if (viewModel.hasConfig()) { + if (configManager.hasConfig()) { viewModel.getBalance() } } @@ -107,12 +108,12 @@ class BalanceFragment : Fragment() { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { R.id.action_reconfigure -> { - findNavController().navigate(viewModel.configDestination) + findNavController().navigate(configManager.configDestination) true } R.id.action_lock -> { viewModel.lock() - findNavController().navigate(viewModel.configDestination) + findNavController().navigate(configManager.configDestination) true } else -> super.onOptionsItemSelected(item) @@ -148,7 +149,7 @@ class BalanceFragment : Fragment() { private fun getAmountFromView(): Amount { val str = amountView.editText!!.text.toString() - val currency = viewModel.currency.value!! + val currency = configManager.currency.value!! if (str.isBlank()) return Amount.zero(currency) return Amount.fromString(currency, str) } diff --git a/cashier/src/main/java/net/taler/cashier/HttpHelper.kt b/cashier/src/main/java/net/taler/cashier/HttpHelper.kt index 003c2f6..fd48b2d 100644 --- a/cashier/src/main/java/net/taler/cashier/HttpHelper.kt +++ b/cashier/src/main/java/net/taler/cashier/HttpHelper.kt @@ -18,12 +18,15 @@ package net.taler.cashier import android.util.Log import androidx.annotation.WorkerThread +import net.taler.cashier.config.Config +import okhttp3.Authenticator import okhttp3.Credentials -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okhttp3.Route import org.json.JSONException import org.json.JSONObject @@ -47,23 +50,23 @@ object HttpHelper { Log.e(TAG, "Error retrieving $url", e) return HttpJsonResult.Error(0) } - return if (response.code() == 200 && response.body() != null) { - val jsonObject = JSONObject(response.body()!!.string()) + return if (response.code == 200 && response.body != null) { + val jsonObject = JSONObject(response.body!!.string()) HttpJsonResult.Success(jsonObject) } else { - Log.e(TAG, "Received status ${response.code()} from $url expected 200") - HttpJsonResult.Error(response.code(), getErrorBody(response)) + Log.e(TAG, "Received status ${response.code} from $url expected 200") + HttpJsonResult.Error(response.code, getErrorBody(response)) } } - private val MEDIA_TYPE_JSON = MediaType.parse("$MIME_TYPE_JSON; charset=utf-8") + private val MEDIA_TYPE_JSON = "$MIME_TYPE_JSON; charset=utf-8".toMediaTypeOrNull() @WorkerThread fun makeJsonPostRequest(url: String, body: JSONObject, config: Config): HttpJsonResult { val request = Request.Builder() .addHeader("Accept", MIME_TYPE_JSON) .url(url) - .post(RequestBody.create(MEDIA_TYPE_JSON, body.toString())) + .post(body.toString().toRequestBody(MEDIA_TYPE_JSON)) .build() val response = try { getHttpClient(config.username, config.password) @@ -73,31 +76,33 @@ object HttpHelper { Log.e(TAG, "Error retrieving $url", e) return HttpJsonResult.Error(0) } - return if (response.code() == 200 && response.body() != null) { - val jsonObject = JSONObject(response.body()!!.string()) + return if (response.code == 200 && response.body != null) { + val jsonObject = JSONObject(response.body!!.string()) HttpJsonResult.Success(jsonObject) } else { - Log.e(TAG, "Received status ${response.code()} from $url expected 200") - HttpJsonResult.Error(response.code(), getErrorBody(response)) + Log.e(TAG, "Received status ${response.code} from $url expected 200") + HttpJsonResult.Error(response.code, getErrorBody(response)) } } private fun getHttpClient(username: String, password: String) = - OkHttpClient.Builder().authenticator { _, response -> - val credential = Credentials.basic(username, password) - if (credential == response.request().header("Authorization")) { - // If we already failed with these credentials, don't retry - return@authenticator null + OkHttpClient.Builder().authenticator(object : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + val credential = Credentials.basic(username, password) + if (credential == response.request.header("Authorization")) { + // If we already failed with these credentials, don't retry + return null + } + return response + .request + .newBuilder() + .header("Authorization", credential) + .build() } - response - .request() - .newBuilder() - .header("Authorization", credential) - .build() - }.build() + }).build() private fun getErrorBody(response: Response): String? { - val body = response.body()?.string() ?: return null + val body = response.body?.string() ?: return null Log.e(TAG, "Response body: $body") return try { val json = JSONObject(body) diff --git a/cashier/src/main/java/net/taler/cashier/MainActivity.kt b/cashier/src/main/java/net/taler/cashier/MainActivity.kt index 0559b38..ae31be5 100644 --- a/cashier/src/main/java/net/taler/cashier/MainActivity.kt +++ b/cashier/src/main/java/net/taler/cashier/MainActivity.kt @@ -30,6 +30,7 @@ import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() + private val configManager by lazy { viewModel.configManager} private lateinit var nav: NavController override fun onCreate(savedInstanceState: Bundle?) { @@ -43,13 +44,13 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() - if (!viewModel.hasConfig()) { - nav.navigate(viewModel.configDestination) + if (!configManager.hasConfig()) { + nav.navigate(configManager.configDestination) } } override fun onBackPressed() { - if (!viewModel.hasConfig() && nav.currentDestination?.id == R.id.configFragment) { + if (!configManager.hasConfig() && nav.currentDestination?.id == R.id.configFragment) { // we are in the configuration screen and need a config to continue val intent = Intent(ACTION_MAIN).apply { addCategory(CATEGORY_HOME) diff --git a/cashier/src/main/java/net/taler/cashier/MainViewModel.kt b/cashier/src/main/java/net/taler/cashier/MainViewModel.kt index a25467b..95d94d7 100644 --- a/cashier/src/main/java/net/taler/cashier/MainViewModel.kt +++ b/cashier/src/main/java/net/taler/cashier/MainViewModel.kt @@ -16,126 +16,54 @@ package net.taler.cashier -import android.annotation.SuppressLint import android.app.Application import android.util.Log -import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV -import androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM -import androidx.security.crypto.MasterKeys -import androidx.security.crypto.MasterKeys.AES256_GCM_SPEC +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.features.json.serializer.KotlinxSerializer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import net.taler.cashier.HttpHelper.makeJsonGetRequest +import net.taler.cashier.config.ConfigManager import net.taler.cashier.withdraw.WithdrawManager -import net.taler.common.getIncompatibleStringOrNull import net.taler.common.isOnline import net.taler.lib.common.Amount import net.taler.lib.common.AmountParserException -import net.taler.lib.common.Version private val TAG = MainViewModel::class.java.simpleName -private val VERSION_BANK = Version(0, 0, 0) -private const val PREF_NAME = "net.taler.cashier.prefs" -private const val PREF_KEY_BANK_URL = "bankUrl" -private const val PREF_KEY_USERNAME = "username" -private const val PREF_KEY_PASSWORD = "password" -private const val PREF_KEY_CURRENCY = "currency" - class MainViewModel(private val app: Application) : AndroidViewModel(app) { - val configDestination = ConfigFragmentDirections.actionGlobalConfigFragment() - - private val masterKeyAlias = MasterKeys.getOrCreate(AES256_GCM_SPEC) - private val prefs = EncryptedSharedPreferences.create( - PREF_NAME, masterKeyAlias, app, AES256_SIV, AES256_GCM - ) - - internal var config = Config( - bankUrl = prefs.getString(PREF_KEY_BANK_URL, "")!!, - username = prefs.getString(PREF_KEY_USERNAME, "")!!, - password = prefs.getString(PREF_KEY_PASSWORD, "")!! - ) - - private val mCurrency = MutableLiveData<String>( - prefs.getString(PREF_KEY_CURRENCY, null) - ) - internal val currency: LiveData<String> = mCurrency - - private val mConfigResult = MutableLiveData<ConfigResult>() - val configResult: LiveData<ConfigResult> = mConfigResult + private val httpClient = HttpClient(OkHttp) { + engine { + config { + retryOnConnectionFailure(true) + } + } + install(JsonFeature) { + serializer = KotlinxSerializer( + Json { + ignoreUnknownKeys = true + } + ) + } + } + val configManager = ConfigManager(app, viewModelScope, httpClient) private val mBalance = MutableLiveData<BalanceResult>() val balance: LiveData<BalanceResult> = mBalance internal val withdrawManager = WithdrawManager(app, this) - fun hasConfig() = config.bankUrl.isNotEmpty() - && config.username.isNotEmpty() - && config.password.isNotEmpty() - - /** - * Start observing [configResult] after calling this to get the result async. - * Warning: Ignore null results that are used to reset old results. - */ - @UiThread - fun checkAndSaveConfig(config: Config) { - mConfigResult.value = null - viewModelScope.launch(Dispatchers.IO) { - val url = "${config.bankUrl}/config" - Log.d(TAG, "Checking config: $url") - val result = when (val response = makeJsonGetRequest(url, config)) { - is HttpJsonResult.Success -> { - // check if bank's version is compatible with app - val version = response.json.getString("version") - val versionIncompatible = VERSION_BANK.getIncompatibleStringOrNull(app, version) - if (versionIncompatible != null) { - ConfigResult.Error(false, versionIncompatible) - } else { - val currency = response.json.getString("currency") - try { - mCurrency.postValue(currency) - prefs.edit().putString(PREF_KEY_CURRENCY, currency).apply() - // save config - saveConfig(config) - ConfigResult.Success - } catch (e: Exception) { - ConfigResult.Error(false, "Invalid Config: ${response.json}") - } - } - } - is HttpJsonResult.Error -> { - if (response.statusCode > 0 && app.isOnline()) { - ConfigResult.Error(response.statusCode == 401, response.msg) - } else { - ConfigResult.Offline - } - } - } - mConfigResult.postValue(result) - } - } - - @WorkerThread - @SuppressLint("ApplySharedPref") - private fun saveConfig(config: Config) { - this.config = config - prefs.edit() - .putString(PREF_KEY_BANK_URL, config.bankUrl) - .putString(PREF_KEY_USERNAME, config.username) - .putString(PREF_KEY_PASSWORD, config.password) - .commit() - } - fun getBalance() = viewModelScope.launch(Dispatchers.IO) { - check(hasConfig()) { "No config to get balance" } + check(configManager.hasConfig()) { "No config to get balance" } + val config = configManager.config val url = "${config.bankUrl}/accounts/${config.username}/balance" Log.d(TAG, "Checking balance at $url") val result = when (val response = makeJsonGetRequest(url, config)) { @@ -163,19 +91,7 @@ class MainViewModel(private val app: Application) : AndroidViewModel(app) { } fun lock() { - saveConfig(config.copy(password = "")) + configManager.lock() } } - -data class Config( - val bankUrl: String, - val username: String, - val password: String -) - -sealed class ConfigResult { - class Error(val authError: Boolean, val msg: String) : ConfigResult() - object Offline : ConfigResult() - object Success : ConfigResult() -} diff --git a/cashier/src/main/java/net/taler/cashier/Response.kt b/cashier/src/main/java/net/taler/cashier/Response.kt new file mode 100644 index 0000000..0ad39d0 --- /dev/null +++ b/cashier/src/main/java/net/taler/cashier/Response.kt @@ -0,0 +1,86 @@ +/* + * 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.cashier + +import android.content.Context +import android.util.Log +import io.ktor.client.call.receive +import io.ktor.client.features.ResponseException +import io.ktor.http.HttpStatusCode +import kotlinx.serialization.Serializable +import net.taler.common.isOnline +import java.net.UnknownHostException + +class Response<out T> private constructor( + private val value: Any? +) { + companion object { + suspend fun <T> response(request: suspend () -> T): Response<T> { + return try { + Response(request()) + } catch (e: Throwable) { + Log.e("HttpClient", "Error getting request", e) + Response(getFailure(e)) + } + } + + private suspend fun getFailure(e: Throwable): Failure = when (e) { + is ResponseException -> Failure(e, getExceptionString(e), e.response?.status) + else -> Failure(e, e.toString()) + } + + private suspend fun getExceptionString(e: ResponseException): String { + val response = e.response ?: return e.toString() + return try { + Log.e("TEST", "TRY RECEIVE $response") + val error: Error = response.receive() + "Error ${error.code}: ${error.hint}" + } catch (ex: Exception) { + "Status code: ${response.status.value}" + } + } + } + + private val isFailure: Boolean get() = value is Failure + + suspend fun onSuccess(block: suspend (result: T) -> Unit): Response<T> { + @Suppress("UNCHECKED_CAST") + if (!isFailure) block(value as T) + return this + } + + suspend fun onError(block: suspend (failure: Failure) -> Unit): Response<T> { + if (value is Failure) block(value) + return this + } + + data class Failure( + val exception: Throwable, + val msg: String, + val statusCode: HttpStatusCode? = null + ) { + fun isOffline(context: Context): Boolean { + return exception is UnknownHostException && !context.isOnline() + } + } + + @Serializable + private class Error( + val code: Int?, + val hint: String? + ) +} diff --git a/cashier/src/main/java/net/taler/cashier/config/Config.kt b/cashier/src/main/java/net/taler/cashier/config/Config.kt new file mode 100644 index 0000000..b50cf92 --- /dev/null +++ b/cashier/src/main/java/net/taler/cashier/config/Config.kt @@ -0,0 +1,41 @@ +/* + * 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.cashier.config + +import kotlinx.serialization.Serializable +import net.taler.lib.common.Version +import okhttp3.Credentials + +data class Config( + val bankUrl: String, + val username: String, + val password: String +) { + val basicAuth: String get() = Credentials.basic(username, password) +} + +@Serializable +data class ConfigResponse( + val version: String, + val currency: String +) + +sealed class ConfigResult { + class Error(val authError: Boolean, val msg: String) : ConfigResult() + object Offline : ConfigResult() + object Success : ConfigResult() +} diff --git a/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt b/cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt index 71495f3..a7aaf2f 100644 --- a/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt +++ b/cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt @@ -14,7 +14,7 @@ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -package net.taler.cashier +package net.taler.cashier.config import android.os.Bundle import android.text.method.LinkMovementMethod @@ -34,6 +34,8 @@ import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.fragment_config.* +import net.taler.cashier.MainViewModel +import net.taler.cashier.R import net.taler.common.exhaustive private const val URL_BANK_TEST = "https://bank.test.taler.net" @@ -42,6 +44,7 @@ private const val URL_BANK_TEST_REGISTER = "$URL_BANK_TEST/accounts/register" class ConfigFragment : Fragment() { private val viewModel: MainViewModel by activityViewModels() + private val configManager by lazy { viewModel.configManager} override fun onCreateView( inflater: LayoutInflater, @@ -53,13 +56,13 @@ class ConfigFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (savedInstanceState == null) { - if (viewModel.config.bankUrl.isBlank()) { + if (configManager.config.bankUrl.isBlank()) { urlView.editText!!.setText(URL_BANK_TEST) } else { - urlView.editText!!.setText(viewModel.config.bankUrl) + urlView.editText!!.setText(configManager.config.bankUrl) } - usernameView.editText!!.setText(viewModel.config.username) - passwordView.editText!!.setText(viewModel.config.password) + usernameView.editText!!.setText(configManager.config.username) + passwordView.editText!!.setText(configManager.config.password) } else { urlView.editText!!.setText(savedInstanceState.getCharSequence("urlView")) usernameView.editText!!.setText(savedInstanceState.getCharSequence("usernameView")) @@ -76,8 +79,8 @@ class ConfigFragment : Fragment() { saveButton.visibility = INVISIBLE progressBar.visibility = VISIBLE // kick off check and observe result - viewModel.checkAndSaveConfig(config) - viewModel.configResult.observe(viewLifecycleOwner, onConfigResult) + configManager.checkAndSaveConfig(config) + configManager.configResult.observe(viewLifecycleOwner, onConfigResult) // hide keyboard val inputMethodManager = getSystemService(requireContext(), InputMethodManager::class.java)!! @@ -145,7 +148,7 @@ class ConfigFragment : Fragment() { }.exhaustive saveButton.visibility = VISIBLE progressBar.visibility = INVISIBLE - viewModel.configResult.removeObservers(viewLifecycleOwner) + configManager.configResult.removeObservers(viewLifecycleOwner) } } diff --git a/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt b/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt new file mode 100644 index 0000000..a18073d --- /dev/null +++ b/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt @@ -0,0 +1,141 @@ +/* + * 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.cashier.config + +import android.annotation.SuppressLint +import android.app.Application +import android.util.Log +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders.Authorization +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.taler.cashier.Response +import net.taler.cashier.Response.Companion.response +import net.taler.common.getIncompatibleStringOrNull +import net.taler.lib.common.Version + +private val VERSION_BANK = Version(0, 0, 0) +private const val PREF_NAME = "net.taler.cashier.prefs" +private const val PREF_KEY_BANK_URL = "bankUrl" +private const val PREF_KEY_USERNAME = "username" +private const val PREF_KEY_PASSWORD = "password" +private const val PREF_KEY_CURRENCY = "currency" + +private val TAG = ConfigManager::class.java.simpleName + +class ConfigManager( + private val app: Application, + private val scope: CoroutineScope, + private val httpClient: HttpClient +) { + + val configDestination = ConfigFragmentDirections.actionGlobalConfigFragment() + + private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + private val prefs = EncryptedSharedPreferences.create( + PREF_NAME, masterKeyAlias, app, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + internal var config = Config( + bankUrl = prefs.getString(PREF_KEY_BANK_URL, "")!!, + username = prefs.getString(PREF_KEY_USERNAME, "")!!, + password = prefs.getString(PREF_KEY_PASSWORD, "")!! + ) + + private val mCurrency = MutableLiveData<String>( + prefs.getString(PREF_KEY_CURRENCY, null) + ) + internal val currency: LiveData<String> = mCurrency + + private val mConfigResult = MutableLiveData<ConfigResult>() + val configResult: LiveData<ConfigResult> = mConfigResult + + fun hasConfig() = config.bankUrl.isNotEmpty() + && config.username.isNotEmpty() + && config.password.isNotEmpty() + + /** + * Start observing [configResult] after calling this to get the result async. + * Warning: Ignore null results that are used to reset old results. + */ + @UiThread + fun checkAndSaveConfig(config: Config) = scope.launch { + mConfigResult.value = null + checkConfig(config).onError { failure -> + val result = if (failure.isOffline(app)) { + ConfigResult.Offline + } else { + ConfigResult.Error(failure.statusCode == Unauthorized, failure.msg) + } + mConfigResult.postValue(result) + }.onSuccess { response -> + val versionIncompatible = + VERSION_BANK.getIncompatibleStringOrNull(app, response.version) + val result = if (versionIncompatible != null) { + ConfigResult.Error(false, versionIncompatible) + } else { + mCurrency.postValue(response.currency) + prefs.edit().putString(PREF_KEY_CURRENCY, response.currency).apply() + // save config + saveConfig(config) + ConfigResult.Success + } + mConfigResult.postValue(result) + } + } + + private suspend fun checkConfig(config: Config): Response<ConfigResponse> = + withContext(Dispatchers.IO) { + val url = "${config.bankUrl}/config" + Log.d(TAG, "Checking config: $url") + response { + httpClient.get(url) { + // TODO why does that not fail already? + header(Authorization, config.basicAuth) + } as ConfigResponse + } + } + + @WorkerThread + @SuppressLint("ApplySharedPref") + internal fun saveConfig(config: Config) { + this.config = config + prefs.edit() + .putString(PREF_KEY_BANK_URL, config.bankUrl) + .putString(PREF_KEY_USERNAME, config.username) + .putString(PREF_KEY_PASSWORD, config.password) + .commit() + } + + fun lock() { + saveConfig(config.copy(password = "")) + } + +} diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt b/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt index 9f3cf54..30ff3d8 100644 --- a/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt +++ b/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt @@ -54,10 +54,10 @@ class WithdrawManager( get() = viewModel.viewModelScope private val config - get() = viewModel.config + get() = viewModel.configManager.config private val currency: String? - get() = viewModel.currency.value + get() = viewModel.configManager.currency.value private var withdrawStatusCheck: Job? = null @@ -93,13 +93,17 @@ class WithdrawManager( val body = JSONObject(map) val result = when (val response = makeJsonPostRequest(url, body, config)) { is Success -> { - val talerUri = response.json.getString("taler_withdraw_uri") - val withdrawResult = WithdrawResult.Success( - id = response.json.getString("withdrawal_id"), - talerUri = talerUri, - qrCode = makeQrCode(talerUri) - ) - withdrawResult + try { + val talerUri = response.json.getString("taler_withdraw_uri") + val withdrawResult = WithdrawResult.Success( + id = response.json.getString("withdrawal_id"), + talerUri = talerUri, + qrCode = makeQrCode(talerUri) + ) + withdrawResult + } catch (e: Exception) { + WithdrawResult.Error(e.toString()) + } } is Error -> { if (response.statusCode > 0 && app.isOnline()) { @@ -147,25 +151,29 @@ class WithdrawManager( val response = makeJsonGetRequest(url, config) if (response !is Success) return@launch // ignore errors and continue trying val oldStatus = mWithdrawStatus.value - when { - response.json.getBoolean("aborted") -> { - cancelWithdrawStatusCheck() - mWithdrawStatus.postValue(WithdrawStatus.Aborted) - } - response.json.getBoolean("confirmation_done") -> { - if (oldStatus !is WithdrawStatus.Success) { + try { + when { + response.json.getBoolean("aborted") -> { cancelWithdrawStatusCheck() - mWithdrawStatus.postValue(WithdrawStatus.Success) - viewModel.getBalance() + mWithdrawStatus.postValue(WithdrawStatus.Aborted) } - } - response.json.getBoolean("selection_done") -> { - // only update status, if there's none, yet - // so we don't re-notify or overwrite newer status info - if (oldStatus == null) { - mWithdrawStatus.postValue(WithdrawStatus.SelectionDone(withdrawalId)) + response.json.getBoolean("confirmation_done") -> { + if (oldStatus !is WithdrawStatus.Success) { + cancelWithdrawStatusCheck() + mWithdrawStatus.postValue(WithdrawStatus.Success) + viewModel.getBalance() + } + } + response.json.getBoolean("selection_done") -> { + // only update status, if there's none, yet + // so we don't re-notify or overwrite newer status info + if (oldStatus == null) { + mWithdrawStatus.postValue(WithdrawStatus.SelectionDone(withdrawalId)) + } } } + } catch (e: Exception) { + mWithdrawStatus.postValue(WithdrawStatus.Error(e.toString())) } } diff --git a/cashier/src/main/res/navigation/nav_graph.xml b/cashier/src/main/res/navigation/nav_graph.xml index 49f8881..9cce316 100644 --- a/cashier/src/main/res/navigation/nav_graph.xml +++ b/cashier/src/main/res/navigation/nav_graph.xml @@ -23,7 +23,7 @@ <fragment android:id="@+id/configFragment" - android:name="net.taler.cashier.ConfigFragment" + android:name="net.taler.cashier.config.ConfigFragment" android:label="ConfigFragment" tools:layout="@layout/fragment_config"> <action |