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/config/Config.kt | 41 ++++++ .../net/taler/cashier/config/ConfigFragment.kt | 154 +++++++++++++++++++++ .../java/net/taler/cashier/config/ConfigManager.kt | 141 +++++++++++++++++++ 3 files changed, 336 insertions(+) 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/java/net/taler/cashier/config') 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 = "")) + } + +} -- cgit v1.2.3