commit 53d99e46e6b34d4437f46266cb797a65c0736803
parent ed3f86481a71517e7bf6ffa46dc8d160b508ec38
Author: Torsten Grote <t@grobox.de>
Date: Thu, 27 Aug 2020 16:42:03 -0300
[cashier] don't crash on unexpected network input
Diffstat:
13 files changed, 529 insertions(+), 323 deletions(-)
diff --git 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
@@ -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
@@ -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 <http://www.gnu.org/licenses/>
- */
-
-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<ConfigResult> { 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/config/ConfigFragment.kt 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 <http://www.gnu.org/licenses/>
+ */
+
+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<ConfigResult> { 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
@@ -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
@@ -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
@@ -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
diff --git a/merchant-lib/build.gradle b/merchant-lib/build.gradle
@@ -54,5 +54,5 @@ dependencies {
testImplementation 'junit:junit:4.13'
testImplementation "io.ktor:ktor-client-mock-jvm:$ktor_version"
testImplementation "io.ktor:ktor-client-logging-jvm:$ktor_version"
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.8'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.9'
}