From 53d99e46e6b34d4437f46266cb797a65c0736803 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 27 Aug 2020 16:42:03 -0300 Subject: [cashier] don't crash on unexpected network input --- .../main/java/net/taler/cashier/BalanceFragment.kt | 11 +- .../main/java/net/taler/cashier/ConfigFragment.kt | 151 -------------------- .../src/main/java/net/taler/cashier/HttpHelper.kt | 53 +++---- .../main/java/net/taler/cashier/MainActivity.kt | 7 +- .../main/java/net/taler/cashier/MainViewModel.kt | 132 ++++-------------- .../src/main/java/net/taler/cashier/Response.kt | 86 ++++++++++++ .../main/java/net/taler/cashier/config/Config.kt | 41 ++++++ .../net/taler/cashier/config/ConfigFragment.kt | 154 +++++++++++++++++++++ .../java/net/taler/cashier/config/ConfigManager.kt | 141 +++++++++++++++++++ .../net/taler/cashier/withdraw/WithdrawManager.kt | 56 ++++---- cashier/src/main/res/navigation/nav_graph.xml | 2 +- 11 files changed, 518 insertions(+), 316 deletions(-) delete mode 100644 cashier/src/main/java/net/taler/cashier/ConfigFragment.kt create mode 100644 cashier/src/main/java/net/taler/cashier/Response.kt create mode 100644 cashier/src/main/java/net/taler/cashier/config/Config.kt create mode 100644 cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt create mode 100644 cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt (limited to 'cashier/src/main') 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/ConfigFragment.kt b/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt deleted file mode 100644 index 71495f3..0000000 --- a/cashier/src/main/java/net/taler/cashier/ConfigFragment.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * 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 - */ - -package net.taler.cashier - -import android.os.Bundle -import android.text.method.LinkMovementMethod -import android.view.LayoutInflater -import android.view.View -import android.view.View.INVISIBLE -import android.view.View.VISIBLE -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.text.HtmlCompat -import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -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.common.exhaustive - -private const val URL_BANK_TEST = "https://bank.test.taler.net" -private const val URL_BANK_TEST_REGISTER = "$URL_BANK_TEST/accounts/register" - -class ConfigFragment : Fragment() { - - private val viewModel: MainViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_config, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - if (viewModel.config.bankUrl.isBlank()) { - urlView.editText!!.setText(URL_BANK_TEST) - } else { - urlView.editText!!.setText(viewModel.config.bankUrl) - } - usernameView.editText!!.setText(viewModel.config.username) - passwordView.editText!!.setText(viewModel.config.password) - } else { - urlView.editText!!.setText(savedInstanceState.getCharSequence("urlView")) - usernameView.editText!!.setText(savedInstanceState.getCharSequence("usernameView")) - passwordView.editText!!.setText(savedInstanceState.getCharSequence("passwordView")) - } - saveButton.setOnClickListener { - val config = Config( - bankUrl = urlView.editText!!.text.toString(), - username = usernameView.editText!!.text.toString(), - password = passwordView.editText!!.text.toString() - ) - if (checkConfig(config)) { - // show progress - saveButton.visibility = INVISIBLE - progressBar.visibility = VISIBLE - // kick off check and observe result - viewModel.checkAndSaveConfig(config) - viewModel.configResult.observe(viewLifecycleOwner, onConfigResult) - // hide keyboard - val inputMethodManager = - getSystemService(requireContext(), InputMethodManager::class.java)!! - inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) - } - } - demoView.text = HtmlCompat.fromHtml( - getString(R.string.config_demo_hint, URL_BANK_TEST_REGISTER), FROM_HTML_MODE_LEGACY - ) - demoView.movementMethod = LinkMovementMethod.getInstance() - } - - override fun onStart() { - super.onStart() - // focus on password if it is the only missing value (like after locking) - if (urlView.editText!!.text.isNotBlank() - && usernameView.editText!!.text.isNotBlank() - && passwordView.editText!!.text.isBlank() - ) { - passwordView.editText!!.requestFocus() - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - // for some reason automatic restore isn't working at the moment!? - outState.putCharSequence("urlView", urlView.editText?.text) - outState.putCharSequence("usernameView", usernameView.editText?.text) - outState.putCharSequence("passwordView", passwordView.editText?.text) - } - - private fun checkConfig(config: Config): Boolean { - if (!config.bankUrl.startsWith("https://")) { - urlView.error = getString(R.string.config_bank_url_error) - urlView.requestFocus() - return false - } - if (config.username.isBlank()) { - usernameView.error = getString(R.string.config_username_error) - usernameView.requestFocus() - return false - } - urlView.isErrorEnabled = false - return true - } - - private val onConfigResult = Observer { result -> - if (result == null) return@Observer - when (result) { - is ConfigResult.Success -> { - val action = ConfigFragmentDirections.actionConfigFragmentToBalanceFragment() - findNavController().navigate(action) - } - ConfigResult.Offline -> { - Snackbar.make(requireView(), R.string.config_error_offline, LENGTH_LONG).show() - } - is ConfigResult.Error -> { - if (result.authError) { - Snackbar.make(requireView(), R.string.config_error_auth, LENGTH_LONG).show() - } else { - val str = getString(R.string.config_error, result.msg) - Snackbar.make(requireView(), str, LENGTH_LONG).show() - } - } - }.exhaustive - saveButton.visibility = VISIBLE - progressBar.visibility = INVISIBLE - viewModel.configResult.removeObservers(viewLifecycleOwner) - } - -} 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( - prefs.getString(PREF_KEY_CURRENCY, null) - ) - internal val currency: LiveData = mCurrency - - private val mConfigResult = MutableLiveData() - val configResult: LiveData = 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() val balance: LiveData = 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 + */ + +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 private constructor( + private val value: Any? +) { + companion object { + suspend fun response(request: suspend () -> T): Response { + 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 { + @Suppress("UNCHECKED_CAST") + if (!isFailure) block(value as T) + return this + } + + suspend fun onError(block: suspend (failure: Failure) -> Unit): Response { + 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 + */ + +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/config/ConfigFragment.kt b/cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt new file mode 100644 index 0000000..a7aaf2f --- /dev/null +++ b/cashier/src/main/java/net/taler/cashier/config/ConfigFragment.kt @@ -0,0 +1,154 @@ +/* + * 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 + */ + +package net.taler.cashier.config + +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.content.ContextCompat.getSystemService +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +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" +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, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_config, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + if (configManager.config.bankUrl.isBlank()) { + urlView.editText!!.setText(URL_BANK_TEST) + } else { + urlView.editText!!.setText(configManager.config.bankUrl) + } + 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")) + passwordView.editText!!.setText(savedInstanceState.getCharSequence("passwordView")) + } + saveButton.setOnClickListener { + val config = Config( + bankUrl = urlView.editText!!.text.toString(), + username = usernameView.editText!!.text.toString(), + password = passwordView.editText!!.text.toString() + ) + if (checkConfig(config)) { + // show progress + saveButton.visibility = INVISIBLE + progressBar.visibility = VISIBLE + // kick off check and observe result + configManager.checkAndSaveConfig(config) + configManager.configResult.observe(viewLifecycleOwner, onConfigResult) + // hide keyboard + val inputMethodManager = + getSystemService(requireContext(), InputMethodManager::class.java)!! + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + } + } + demoView.text = HtmlCompat.fromHtml( + getString(R.string.config_demo_hint, URL_BANK_TEST_REGISTER), FROM_HTML_MODE_LEGACY + ) + demoView.movementMethod = LinkMovementMethod.getInstance() + } + + override fun onStart() { + super.onStart() + // focus on password if it is the only missing value (like after locking) + if (urlView.editText!!.text.isNotBlank() + && usernameView.editText!!.text.isNotBlank() + && passwordView.editText!!.text.isBlank() + ) { + passwordView.editText!!.requestFocus() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + // for some reason automatic restore isn't working at the moment!? + outState.putCharSequence("urlView", urlView.editText?.text) + outState.putCharSequence("usernameView", usernameView.editText?.text) + outState.putCharSequence("passwordView", passwordView.editText?.text) + } + + private fun checkConfig(config: Config): Boolean { + if (!config.bankUrl.startsWith("https://")) { + urlView.error = getString(R.string.config_bank_url_error) + urlView.requestFocus() + return false + } + if (config.username.isBlank()) { + usernameView.error = getString(R.string.config_username_error) + usernameView.requestFocus() + return false + } + urlView.isErrorEnabled = false + return true + } + + private val onConfigResult = Observer { result -> + if (result == null) return@Observer + when (result) { + is ConfigResult.Success -> { + val action = ConfigFragmentDirections.actionConfigFragmentToBalanceFragment() + findNavController().navigate(action) + } + ConfigResult.Offline -> { + Snackbar.make(requireView(), R.string.config_error_offline, LENGTH_LONG).show() + } + is ConfigResult.Error -> { + if (result.authError) { + Snackbar.make(requireView(), R.string.config_error_auth, LENGTH_LONG).show() + } else { + val str = getString(R.string.config_error, result.msg) + Snackbar.make(requireView(), str, LENGTH_LONG).show() + } + } + }.exhaustive + saveButton.visibility = VISIBLE + progressBar.visibility = INVISIBLE + 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 + */ + +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( + prefs.getString(PREF_KEY_CURRENCY, null) + ) + internal val currency: LiveData = mCurrency + + private val mConfigResult = MutableLiveData() + val configResult: LiveData = 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 = + 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 @@