commit 041c1538d924563ae0850d010d7af198061c3963 parent 699466d892a2072b9d20c148ad4145fe39497cd6 Author: Iván Ávalos <avalos@disroot.org> Date: Fri, 15 May 2026 19:33:42 +0200 [common] move shared 2FA code into taler-kotlin-android, move all Android logic into net.taler.lib.android Diffstat:
54 files changed, 984 insertions(+), 1208 deletions(-)
diff --git a/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt b/cashier/src/main/java/net/taler/cashier/BalanceFragment.kt @@ -33,9 +33,9 @@ import net.taler.cashier.databinding.FragmentBalanceBinding import net.taler.cashier.withdraw.LastTransaction import net.taler.cashier.withdraw.WithdrawStatus import net.taler.common.Amount -import net.taler.common.exhaustive -import net.taler.common.fadeIn -import net.taler.common.fadeOut +import net.taler.lib.android.exhaustive +import net.taler.lib.android.fadeIn +import net.taler.lib.android.fadeOut sealed class BalanceResult { data class Error(val msg: String) : BalanceResult() diff --git a/cashier/src/main/java/net/taler/cashier/MainViewModel.kt b/cashier/src/main/java/net/taler/cashier/MainViewModel.kt @@ -34,7 +34,7 @@ import net.taler.cashier.config.ConfigManager import net.taler.cashier.withdraw.WithdrawManager import net.taler.common.Amount import net.taler.common.AmountParserException -import net.taler.common.isOnline +import net.taler.lib.android.isOnline private val TAG = MainViewModel::class.java.simpleName diff --git a/cashier/src/main/java/net/taler/cashier/Response.kt b/cashier/src/main/java/net/taler/cashier/Response.kt @@ -22,7 +22,7 @@ import io.ktor.client.call.body import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode import kotlinx.serialization.Serializable -import net.taler.common.isOnline +import net.taler.lib.android.isOnline import java.net.UnknownHostException class Response<out T> private constructor( diff --git a/cashier/src/main/java/net/taler/cashier/config/Config.kt b/cashier/src/main/java/net/taler/cashier/config/Config.kt @@ -16,34 +16,11 @@ package net.taler.cashier.config -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.descriptors.element -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.long -import kotlinx.serialization.json.longOrNull +import net.taler.common.Challenge import net.taler.common.Timestamp import okhttp3.Credentials -@Serializable -data class TokenSuccessResponse( - val expiration: Timestamp, - - @SerialName("access_token") - val accessToken: String, -) - sealed class TokenResult { data class Success( val expiration: Timestamp, @@ -83,100 +60,6 @@ data class ConfigResponse( val currency: String, ) -@Serializable -enum class TanChannel { - @SerialName("sms") - SMS, - - @SerialName("email") - EMAIL, -} - -@Serializable -data class Challenge( - @SerialName("challenge_id") - val challengeId: String, - - @SerialName("tan_channel") - val tanChannel: TanChannel, - - @SerialName("tan_info") - val tanInfo: String, -) - -@Serializable -data class ChallengesResponse( - val challenges: List<Challenge>, - - // True if **all** challenges must be solved (AND), false if - // it is sufficient to solve one of them (OR). - @SerialName("combi_and") - val combiAnd: Boolean, -) - -@Serializable -data class ChallengeConfirmRequest( - val tan: String, -) - -@Serializable -data class TokenRequest( - val scope: String, - val duration: TokenDuration -) - -@Serializable(with = TokenDuration.Serializer::class) -sealed class TokenDuration { - data object Forever : TokenDuration() - data class Micros(val us: Long) : TokenDuration() - - object Serializer : KSerializer<TokenDuration> { - // describe an object with a single property "d_us" - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TokenDuration") { - element<JsonElement>("d_us") - } - - override fun serialize(encoder: Encoder, value: TokenDuration) { - // we need a Json-specific encoder - val jsonEncoder = encoder as? JsonEncoder - ?: throw SerializationException("Can be serialized only by JSON") - // build the JSON object - val obj = when (value) { - is Forever -> buildJsonObject { - // for "forever", we still emit an object, - // here storing the literal string under "d_us" - put("d_us", JsonPrimitive("forever")) - } - is Micros -> buildJsonObject { - put("d_us", JsonPrimitive(value.us)) - } - } - jsonEncoder.encodeJsonElement(obj) - } - - override fun deserialize(decoder: Decoder): TokenDuration { - // we need a Json-specific decoder - val jsonDecoder = decoder as? JsonDecoder - ?: throw SerializationException("Can be deserialized only by JSON") - val element = jsonDecoder.decodeJsonElement() - if (element !is JsonObject) { - throw SerializationException("Expected JSON object for TokenDuration, got: $element") - } - // look up our single field - val field = element["d_us"] - ?: throw SerializationException("Missing 'd_us' field in $element") - return when { - field is JsonPrimitive && field.longOrNull != null -> - Micros(field.long) - field is JsonPrimitive && field.isString && field.content == "forever" -> - Forever - else -> - throw SerializationException("Invalid 'd_us' value: $field") - } - } - } -} - sealed class ConfigResult { data class Error(val authError: Boolean, val msg: String) : ConfigResult() data object Offline : 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 @@ -24,8 +24,6 @@ import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup import android.view.inputmethod.InputMethodManager -import android.widget.TextView -import android.widget.Toast import androidx.core.content.ContextCompat.getSystemService import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY @@ -34,21 +32,17 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import net.taler.cashier.MainViewModel import net.taler.cashier.R import net.taler.cashier.databinding.FragmentConfigBinding -import net.taler.common.exhaustive -import net.taler.common.showError -import kotlin.coroutines.resume +import net.taler.lib.android.exhaustive +import net.taler.lib.android.handleChallengeResponse +import net.taler.lib.android.showError private const val URL_BANK_TEST = "https://bank.demo.taler.net" private const val URL_BANK_TEST_REGISTER = "https://bank.demo.taler.net/webui/#/register" @@ -163,16 +157,26 @@ class ConfigFragment : Fragment() { } is ConfigResult.TanRequired -> { lifecycleScope.launch { + val baseUrl = ui.urlView.editText!!.text.toString().trim() + val username = ui.usernameView.editText!!.text.toString().trim() val solvedIds = handleChallengeResponse( - ui.urlView.editText!!.text.toString().trim(), - ui.usernameView.editText!!.text.toString().trim(), result.challenges, - result.combiAnd + result.combiAnd, + onRequestChallenge = { challengeId -> + withContext(Dispatchers.IO) { + configManager.requestChallenge(baseUrl, username, challengeId) + } + }, + onConfirmChallenge = { challengeId, tan -> + withContext(Dispatchers.IO) { + configManager.confirmChallenge(baseUrl, username, challengeId, tan) + } + } ) if (solvedIds.isNotEmpty()) { val config = Config( - bankUrl = ui.urlView.editText!!.text.toString().trim(), - username = ui.usernameView.editText!!.text.toString().trim(), + bankUrl = baseUrl, + username = username, password = ui.passwordView.editText!!.text.toString().trim() ) configManager.checkAndSaveConfig(config, solvedIds) @@ -193,142 +197,4 @@ class ConfigFragment : Fragment() { configManager.configResult.removeObservers(viewLifecycleOwner) } - private suspend fun handleChallengeResponse( - baseUrl: String, - username: String, - challenges: List<Challenge>, - combiAnd: Boolean, - ): List<String> { - if (challenges.isEmpty()) return emptyList() - val challengesToSolve = if (combiAnd) { - challenges - } else { - val selected = selectChallenge(challenges) ?: return emptyList() - listOf(selected) - } - - val solvedIds = mutableListOf<String>() - for (challenge in challengesToSolve) { - withContext(Dispatchers.IO) { - configManager.requestChallenge(baseUrl, username, challenge.challengeId) - } - - while (true) { - val tan = promptForTan(challenge) ?: return emptyList() - try { - withContext(Dispatchers.IO) { - configManager.confirmChallenge( - baseUrl, - username, - challenge.challengeId, - tan - ) - } - solvedIds.add(challenge.challengeId) - break - } catch (e: Exception) { - when (handleChallengeConfirmError(e)) { - ChallengeRetryDecision.Retry -> continue - ChallengeRetryDecision.Resend -> { - withContext(Dispatchers.IO) { - configManager.requestChallenge( - baseUrl, - username, - challenge.challengeId - ) - } - continue - } - ChallengeRetryDecision.Abort -> return emptyList() - } - } - } - } - return solvedIds - } - - private suspend fun selectChallenge(challenges: List<Challenge>): Challenge? = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - val labels = challenges.map { c -> - "${c.tanChannel}: ${c.tanInfo}" - }.toTypedArray() - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.mfa_choose_title) - .setItems(labels) { _, which -> - cont.resume(challenges[which]) - } - .setOnCancelListener { cont.resume(null) } - .show() - } - } - - private suspend fun promptForTan(challenge: Challenge): String? = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - val message = getString( - R.string.mfa_challenge_message, - challenge.tanChannel.name, - challenge.tanInfo - ) - val dialogView = layoutInflater.inflate( - R.layout.dialog_mfa_challenge, - null, - false - ) - val messageView = dialogView.findViewById<TextView>(R.id.mfaMessageView) - val inputLayout = dialogView.findViewById<TextInputLayout>(R.id.mfaCodeInputLayout) - val input = dialogView.findViewById<TextInputEditText>(R.id.mfaCodeInput) - messageView.text = message - inputLayout.isErrorEnabled = false - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.mfa_challenge_title) - .setView(dialogView) - .setPositiveButton(android.R.string.ok) { _, _ -> - cont.resume(input?.text?.toString()?.trim().orEmpty()) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - cont.resume(null) - } - .setOnCancelListener { cont.resume(null) } - .show() - } - } - - private suspend fun handleChallengeConfirmError(e: Exception): ChallengeRetryDecision = - withContext(Dispatchers.Main) { - if (e is io.ktor.client.plugins.ClientRequestException) { - when (e.response.status.value) { - 409 -> { - Toast.makeText( - requireContext(), - R.string.mfa_challenge_invalid, - Toast.LENGTH_LONG - ).show() - return@withContext ChallengeRetryDecision.Retry - } - 429 -> { - Toast.makeText( - requireContext(), - R.string.mfa_challenge_retry, - Toast.LENGTH_LONG - ).show() - return@withContext ChallengeRetryDecision.Resend - } - } - } - Toast.makeText( - requireContext(), - R.string.mfa_challenge_failed, - Toast.LENGTH_LONG - ).show() - ChallengeRetryDecision.Abort - } - - private enum class ChallengeRetryDecision { - Retry, - Resend, - Abort - } - } diff --git a/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt b/cashier/src/main/java/net/taler/cashier/config/ConfigManager.kt @@ -47,9 +47,14 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.buildJsonObject import net.taler.cashier.BuildConfig import net.taler.cashier.Response.Companion.response +import net.taler.common.ChallengeConfirmRequest +import net.taler.common.ChallengesResponse import net.taler.common.Timestamp +import net.taler.common.TokenDuration +import net.taler.common.TokenRequest +import net.taler.common.TokenSuccessResponse import net.taler.common.Version -import net.taler.common.getIncompatibleStringOrNull +import net.taler.lib.android.getIncompatibleStringOrNull val VERSION_BANK = Version.parse(BuildConfig.BACKEND_API_VERSION)!! private const val PREF_NAME = "net.taler.cashier.prefs" diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt b/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt @@ -34,9 +34,9 @@ import net.taler.cashier.withdraw.TransactionFragmentDirections.Companion.action import net.taler.cashier.withdraw.WithdrawResult.Error import net.taler.cashier.withdraw.WithdrawResult.InsufficientBalance import net.taler.cashier.withdraw.WithdrawResult.Success -import net.taler.common.exhaustive -import net.taler.common.fadeIn -import net.taler.common.fadeOut +import net.taler.lib.android.exhaustive +import net.taler.lib.android.fadeIn +import net.taler.lib.android.fadeOut import net.taler.lib.android.TalerNfcService class TransactionFragment : Fragment() { diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt b/cashier/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt @@ -35,8 +35,8 @@ import net.taler.cashier.HttpJsonResult.Success import net.taler.cashier.MainViewModel import net.taler.cashier.R import net.taler.common.Amount -import net.taler.common.QrCodeManager.makeQrCode -import net.taler.common.isOnline +import net.taler.lib.android.QrCodeManager.makeQrCode +import net.taler.lib.android.isOnline import org.json.JSONObject import java.util.concurrent.TimeUnit.SECONDS diff --git a/cashier/src/main/res/layout/dialog_mfa_challenge.xml b/cashier/src/main/res/layout/dialog_mfa_challenge.xml @@ -1,53 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> - -<!-- - ~ This file is part of GNU Taler - ~ (C) 2026 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/> - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingStart="24dp" - android:paddingTop="16dp" - android:paddingEnd="24dp" - android:paddingBottom="8dp"> - - <TextView - android:id="@+id/mfaMessageView" - style="@style/TextAppearance.Material3.BodyMedium" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="16dp" - android:textColor="?attr/colorOnSurfaceVariant" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/mfaCodeInputLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/mfa_challenge_code_hint" - app:boxBackgroundMode="outline" - app:boxBackgroundColor="@android:color/transparent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/mfaCodeInput" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="number" /> - </com.google.android.material.textfield.TextInputLayout> - -</LinearLayout> diff --git a/cashier/src/main/res/values/strings.xml b/cashier/src/main/res/values/strings.xml @@ -56,12 +56,4 @@ <string name="about_supported_bank_api">Bank API Version: %s</string> <string name="host_apdu_service_desc">Taler Cashier NFC payments</string> - - <string name="mfa_challenge_title">Two-factor authentication</string> - <string name="mfa_challenge_message">A confirmation code was sent via %1$s (%2$s). Enter the code to continue.</string> - <string name="mfa_challenge_code_hint">Verification code</string> - <string name="mfa_choose_title">Choose verification method</string> - <string name="mfa_challenge_invalid">Incorrect code. Please try again.</string> - <string name="mfa_challenge_retry">Too many attempts. A new code has been sent.</string> - <string name="mfa_challenge_failed">Verification failed. Please try again later.</string> </resources> diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/amount/AmountEntryFragment.kt @@ -69,7 +69,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import net.taler.common.Amount -import net.taler.common.navigate +import net.taler.lib.android.navigate import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.amount.AmountEntryFragmentDirections.Companion.actionAmountEntryToProcessPayment diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFetcherFragment.kt @@ -25,7 +25,7 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_SHORT import com.google.android.material.snackbar.Snackbar -import net.taler.common.navigate +import net.taler.lib.android.navigate import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.config.ConfigFetcherFragmentDirections.Companion.actionConfigFetcherToMerchantSettings import net.taler.merchantpos.databinding.FragmentConfigFetcherBinding diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigFragment.kt @@ -48,14 +48,10 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.button.MaterialButtonToggleGroup -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R @@ -65,9 +61,11 @@ import com.google.zxing.* import net.taler.merchantpos.MainActivity import android.text.format.DateFormat import com.google.zxing.common.HybridBinarizer +import net.taler.common.TokenDuration +import net.taler.lib.android.ChallengeCancelledException +import net.taler.lib.android.handleChallengeResponse import java.util.Calendar import java.util.Locale -import kotlin.coroutines.resume /** * Fragment that displays merchant settings, either by scanning a QR code @@ -341,154 +339,28 @@ class ConfigFragment : Fragment() { ) } } catch (e: ChallengeRequiredException) { - val solvedIds = handleChallengeResponse(baseUrl, username, e.challengeResponse) - if (solvedIds.isEmpty()) { - throw ChallengeCancelledException() - } - challengeIds = solvedIds - } - } - } - - private suspend fun handleChallengeResponse( - baseUrl: String, - username: String, - response: ChallengeResponse - ): List<String> { - if (response.challenges.isEmpty()) return emptyList() - val challengesToSolve = if (response.combi_and) { - response.challenges - } else { - val selected = selectChallenge(response.challenges) ?: return emptyList() - listOf(selected) - } - - val solvedIds = mutableListOf<String>() - for (challenge in challengesToSolve) { - withContext(Dispatchers.IO) { - configManager.requestChallenge(baseUrl, username, challenge.challenge_id) - } - - while (true) { - val tan = promptForTan(challenge) ?: return emptyList() - try { - withContext(Dispatchers.IO) { - configManager.confirmChallenge( - baseUrl, - username, - challenge.challenge_id, - tan - ) - } - solvedIds.add(challenge.challenge_id) - break - } catch (e: Exception) { - when (handleChallengeConfirmError(e)) { - ChallengeRetryDecision.Retry -> continue - ChallengeRetryDecision.Resend -> { - withContext(Dispatchers.IO) { - configManager.requestChallenge( - baseUrl, - username, - challenge.challenge_id - ) - } - continue + val solvedIds = handleChallengeResponse( + e.challengeResponse.challenges, + e.challengeResponse.combiAnd, + onRequestChallenge = { challengeId -> + withContext(Dispatchers.IO) { + configManager.requestChallenge(baseUrl, username, challengeId) + } + }, + onConfirmChallenge = { challengeId, tan -> + withContext(Dispatchers.IO) { + configManager.confirmChallenge(baseUrl, username, challengeId, tan) } - ChallengeRetryDecision.Abort -> return emptyList() - } - } - } - } - return solvedIds - } - - private suspend fun selectChallenge(challenges: List<Challenge>): Challenge? = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - val labels = challenges.map { c -> - "${c.tan_channel}: ${c.tan_info}" - }.toTypedArray() - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.mfa_choose_title) - .setItems(labels) { _, which -> - cont.resume(challenges[which]) } - .setOnCancelListener { cont.resume(null) } - .show() - } - } - - private suspend fun promptForTan(challenge: Challenge): String? = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - val message = getString( - R.string.mfa_challenge_message, - challenge.tan_channel, - challenge.tan_info - ) - val dialogView = layoutInflater.inflate( - R.layout.dialog_mfa_challenge, - null, - false ) - val messageView = dialogView.findViewById<TextView>(R.id.mfaMessageView) - val inputLayout = dialogView.findViewById<TextInputLayout>(R.id.mfaCodeInputLayout) - val input = dialogView.findViewById<TextInputEditText>(R.id.mfaCodeInput) - messageView.text = message - inputLayout.isErrorEnabled = false - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.mfa_challenge_title) - .setView(dialogView) - .setPositiveButton(android.R.string.ok) { _, _ -> - cont.resume(input?.text?.toString()?.trim().orEmpty()) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - cont.resume(null) - } - .setOnCancelListener { cont.resume(null) } - .show() - } - } - - private suspend fun handleChallengeConfirmError(e: Exception): ChallengeRetryDecision = - withContext(Dispatchers.Main) { - if (e is io.ktor.client.plugins.ClientRequestException) { - when (e.response.status.value) { - 409 -> { - Toast.makeText( - requireContext(), - R.string.mfa_challenge_invalid, - Toast.LENGTH_LONG - ).show() - return@withContext ChallengeRetryDecision.Retry - } - 429 -> { - Toast.makeText( - requireContext(), - R.string.mfa_challenge_retry, - Toast.LENGTH_LONG - ).show() - return@withContext ChallengeRetryDecision.Resend - } + if (solvedIds.isEmpty()) { + throw ChallengeCancelledException() } + challengeIds = solvedIds } - Toast.makeText( - requireContext(), - R.string.mfa_challenge_failed, - Toast.LENGTH_LONG - ).show() - ChallengeRetryDecision.Abort } - - private enum class ChallengeRetryDecision { - Retry, - Resend, - Abort } - private class ChallengeCancelledException : Exception() - // ─── CameraX integration ─────────────────────────────────────────── diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/ConfigManager.kt @@ -18,12 +18,10 @@ package net.taler.merchantpos.config import android.content.Context import android.content.Context.MODE_PRIVATE -import android.net.Uri -import android.util.Base64.NO_WRAP -import android.util.Base64.encodeToString import android.util.Log import androidx.annotation.UiThread import androidx.annotation.WorkerThread +import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import io.ktor.client.HttpClient @@ -31,43 +29,30 @@ import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders.Authorization +import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import io.ktor.http.contentType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import net.taler.common.ChallengeConfirmRequest +import net.taler.common.ChallengesResponse +import net.taler.common.TokenDuration +import net.taler.common.TokenRequest import net.taler.common.Version -import net.taler.common.getIncompatibleStringOrNull +import net.taler.lib.android.getIncompatibleStringOrNull import net.taler.merchantlib.ConfigResponse import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.MerchantConfig import net.taler.merchantpos.BuildConfig import net.taler.merchantpos.R -import androidx.core.net.toUri -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.descriptors.element -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.long -import kotlinx.serialization.json.longOrNull private const val SETTINGS_NAME = "taler-merchant-terminal" @@ -104,132 +89,12 @@ private data class LimitedTokenResponse( val token: String, val scope: String, val refreshable: Boolean, - val expiration: TokenExpiration -) - -@kotlinx.serialization.Serializable -data class ChallengeResponse( - val challenges: List<Challenge>, - val combi_and: Boolean -) - -@kotlinx.serialization.Serializable -data class Challenge( - val challenge_id: String, - val tan_channel: String, - val tan_info: String -) - -@kotlinx.serialization.Serializable -data class ChallengeConfirmRequest( - val tan: String -) - -class ChallengeRequiredException(val challengeResponse: ChallengeResponse) : Exception() - -@kotlinx.serialization.Serializable(with = TokenExpiration.Serializer::class) -sealed class TokenExpiration { - data class Seconds(val t_s: Long) : TokenExpiration() - object Never : TokenExpiration() - - object Serializer : KSerializer<TokenExpiration> { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("TokenExpiration") { - element<JsonElement>("t_s") - } - - override fun serialize(encoder: Encoder, value: TokenExpiration) { - val jsonEncoder = encoder as? JsonEncoder - ?: throw SerializationException("TokenExpiration can be serialized only by JSON") - val obj = when (value) { - is Seconds -> - buildJsonObject { put("t_s", JsonPrimitive(value.t_s)) } - Never -> - buildJsonObject { put("t_s", JsonPrimitive("never")) } - } - jsonEncoder.encodeJsonElement(obj) - } - - override fun deserialize(decoder: Decoder): TokenExpiration { - val jsonDecoder = decoder as? JsonDecoder - ?: throw SerializationException("TokenExpiration can be deserialized only by JSON") - val element = jsonDecoder.decodeJsonElement() - if (element !is JsonObject) { - throw SerializationException("Expected JSON object for TokenExpiration, got: $element") - } - val field = element["t_s"] - ?: throw SerializationException("Missing 't_s' in TokenExpiration: $element") - - return when { - field is JsonPrimitive && field.longOrNull != null -> - Seconds(field.long) - field is JsonPrimitive && field.isString && field.content == "never" -> - Never - else -> - throw SerializationException("Invalid 't_s' value in TokenExpiration: $field") - } - } - } -} - -@kotlinx.serialization.Serializable -data class TokenRequest( - val scope: String, - val duration: TokenDuration + // Using Unit here as a placeholder if we don't need the actual expiration object + // or we could use net.taler.common.TokenExpiration + val expiration: kotlinx.serialization.json.JsonElement ) - -@kotlinx.serialization.Serializable(with = TokenDuration.Serializer::class) -sealed class TokenDuration { - object Forever : TokenDuration() - data class Micros(val us: Long) : TokenDuration() - - object Serializer : KSerializer<TokenDuration> { - // describe an object with a single property "d_us" - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TokenDuration") { - element<JsonElement>("d_us") - } - - override fun serialize(encoder: Encoder, value: TokenDuration) { - // we need a Json-specific encoder - val jsonEncoder = encoder as? JsonEncoder - ?: throw SerializationException("Can be serialized only by JSON") - // build the JSON object - val obj = when (value) { - is Forever -> buildJsonObject { - // for "forever", we still emit an object, - // here storing the literal string under "d_us" - put("d_us", JsonPrimitive("forever")) - } - is Micros -> buildJsonObject { - put("d_us", JsonPrimitive(value.us)) - } - } - jsonEncoder.encodeJsonElement(obj) - } - - override fun deserialize(decoder: Decoder): TokenDuration { - // we need a Json-specific decoder - val jsonDecoder = decoder as? JsonDecoder - ?: throw SerializationException("Can be deserialized only by JSON") - val element = jsonDecoder.decodeJsonElement() - if (element !is JsonObject) { - throw SerializationException("Expected JSON object for TokenDuration, got: $element") - } - // look up our single field - val field = element["d_us"] - ?: throw SerializationException("Missing 'd_us' field in $element") - return when { - field is JsonPrimitive && field.longOrNull != null -> - Micros(field.long) - field is JsonPrimitive && field.isString && field.content == "forever" -> - Forever - else -> - throw SerializationException("Invalid 'd_us' value: $field") - } - } - } -} +class ChallengeRequiredException(val challengeResponse: ChallengesResponse) : Exception() /* -- Limited access token END -- */ @@ -418,7 +283,7 @@ class ConfigManager( } if (response.status == HttpStatusCode.Accepted) { - val challenge: ChallengeResponse = response.body() + val challenge: ChallengesResponse = response.body() throw ChallengeRequiredException(challenge) } diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryFragment.kt @@ -26,9 +26,9 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager -import net.taler.common.exhaustive -import net.taler.common.navigate -import net.taler.common.showError +import net.taler.lib.android.exhaustive +import net.taler.lib.android.navigate +import net.taler.lib.android.showError import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryItemAdapter.kt @@ -24,11 +24,10 @@ import android.widget.TextView import androidx.core.content.ContextCompat.getColor import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.common.toRelativeTime +import net.taler.lib.android.toRelativeTime import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.R import net.taler.merchantpos.history.HistoryItemAdapter.HistoryItemViewHolder -import java.util.ArrayList internal class HistoryItemAdapter(private val listener: RefundClickListener) : diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt @@ -21,7 +21,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import net.taler.common.assertUiThread +import net.taler.lib.android.assertUiThread import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.config.ConfigManager diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderAdapter.kt @@ -31,7 +31,7 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil.ItemCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter -import net.taler.common.base64Bitmap +import net.taler.lib.android.base64Bitmap import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigProduct import net.taler.merchantpos.order.OrderAdapter.OrderViewHolder diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderFragment.kt @@ -29,7 +29,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.transition.TransitionManager.beginDelayedTransition -import net.taler.common.navigate +import net.taler.lib.android.navigate import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.databinding.FragmentOrderBinding diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/OrderStateFragment.kt @@ -27,8 +27,8 @@ import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.selection.StorageStrategy import androidx.recyclerview.widget.LinearLayoutManager import net.taler.common.Amount -import net.taler.common.fadeIn -import net.taler.common.fadeOut +import net.taler.lib.android.fadeIn +import net.taler.lib.android.fadeOut import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.databinding.FragmentOrderStateBinding diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/order/ProductsFragment.kt @@ -30,7 +30,7 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.ViewHolder -import net.taler.common.base64Bitmap +import net.taler.lib.android.base64Bitmap import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.config.ConfigProduct diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt @@ -26,13 +26,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import net.taler.common.RelativeTime -import net.taler.common.assertUiThread +import net.taler.lib.android.assertUiThread import net.taler.merchantlib.CheckPaymentResponse -import net.taler.merchantlib.MinimalInventoryProduct import net.taler.merchantlib.MerchantApi +import net.taler.merchantlib.MinimalInventoryProduct import net.taler.merchantlib.PostOrderRequest import net.taler.merchantpos.MainActivity.Companion.TAG import net.taler.merchantpos.R diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt @@ -35,12 +35,12 @@ import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import kotlinx.coroutines.launch -import net.taler.common.QrCodeManager.makeQrCode -import net.taler.common.copyToClipBoard -import net.taler.common.fadeIn -import net.taler.common.fadeOut -import net.taler.common.shareText -import net.taler.common.showError +import net.taler.lib.android.QrCodeManager.makeQrCode +import net.taler.lib.android.copyToClipBoard +import net.taler.lib.android.fadeIn +import net.taler.lib.android.fadeOut +import net.taler.lib.android.shareText +import net.taler.lib.android.showError import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.merchantpos.MainViewModel diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundFragment.kt @@ -26,10 +26,10 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import net.taler.common.Amount import net.taler.common.AmountParserException -import net.taler.common.fadeIn -import net.taler.common.fadeOut -import net.taler.common.navigate -import net.taler.common.showError +import net.taler.lib.android.fadeIn +import net.taler.lib.android.fadeOut +import net.taler.lib.android.navigate +import net.taler.lib.android.showError import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundManager.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import net.taler.common.Amount -import net.taler.common.assertUiThread +import net.taler.lib.android.assertUiThread import net.taler.merchantlib.MerchantApi import net.taler.merchantlib.OrderHistoryEntry import net.taler.merchantlib.RefundRequest diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt @@ -26,7 +26,7 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import kotlinx.coroutines.launch -import net.taler.common.QrCodeManager.makeQrCode +import net.taler.lib.android.QrCodeManager.makeQrCode import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R diff --git a/merchant-terminal/src/main/res/layout/dialog_mfa_challenge.xml b/merchant-terminal/src/main/res/layout/dialog_mfa_challenge.xml @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingStart="24dp" - android:paddingTop="16dp" - android:paddingEnd="24dp" - android:paddingBottom="8dp"> - - <TextView - android:id="@+id/mfaMessageView" - style="@style/TextAppearance.Material3.BodyMedium" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="16dp" - android:textColor="?attr/colorOnSurfaceVariant" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/mfaCodeInputLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/mfa_challenge_code_hint" - app:boxBackgroundMode="outline" - app:boxBackgroundColor="@android:color/transparent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/mfaCodeInput" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="number" /> - </com.google.android.material.textfield.TextInputLayout> - -</LinearLayout> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -113,13 +113,6 @@ <string name="custom_duration">Custom duration</string> <string name="duration">Duration</string> <string name="token_validity_deadline">Token validity deadline:</string> - <string name="mfa_challenge_title">Two-factor authentication</string> - <string name="mfa_challenge_message">A confirmation code was sent via %1$s (%2$s). Enter the code to continue.</string> - <string name="mfa_challenge_code_hint">Verification code</string> - <string name="mfa_choose_title">Choose verification method</string> - <string name="mfa_challenge_invalid">Incorrect code. Please try again.</string> - <string name="mfa_challenge_retry">Too many attempts. A new code has been sent.</string> - <string name="mfa_challenge_failed">Verification failed. Please try again later.</string> <string name="expires_on">Expires on…</string> <string name="never_expires">Never expires</string> <string name="no_deadline_set">No deadline set</string> diff --git a/taler-kotlin-android/build.gradle b/taler-kotlin-android/build.gradle @@ -67,6 +67,7 @@ dependencies { implementation 'androidx.compose.foundation:foundation' implementation 'androidx.appcompat:appcompat:1.7.1' + implementation "com.google.android.material:material:$material_version" implementation 'androidx.core:core-ktx:1.17.0' implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' diff --git a/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt @@ -1,285 +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.common - -import android.Manifest.permission.ACCESS_NETWORK_STATE -import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Context.CONNECTIVITY_SERVICE -import android.content.Intent -import android.content.Intent.ACTION_SEND -import android.content.Intent.EXTRA_INITIAL_INTENTS -import android.graphics.BitmapFactory.decodeByteArray -import android.content.Intent.EXTRA_STREAM -import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION -import android.graphics.Bitmap -import android.net.ConnectivityManager -import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET -import android.os.Build.VERSION.SDK_INT -import android.os.Looper -import android.text.format.DateUtils.DAY_IN_MILLIS -import android.text.format.DateUtils.FORMAT_ABBREV_ALL -import android.text.format.DateUtils.FORMAT_ABBREV_MONTH -import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE -import android.text.format.DateUtils.FORMAT_NO_YEAR -import android.text.format.DateUtils.FORMAT_SHOW_DATE -import android.text.format.DateUtils.FORMAT_SHOW_TIME -import android.text.format.DateUtils.FORMAT_SHOW_YEAR -import android.text.format.DateUtils.MINUTE_IN_MILLIS -import android.text.format.DateUtils.formatDateTime -import android.text.format.DateUtils.getRelativeTimeSpanString -import android.util.Base64 -import android.util.Log -import android.view.View -import android.view.View.INVISIBLE -import android.view.View.VISIBLE -import android.view.inputmethod.InputMethodManager -import androidx.annotation.RequiresPermission -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.content.FileProvider -import androidx.core.content.getSystemService -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.navigation.NavDirections -import androidx.navigation.fragment.findNavController -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.taler.lib.android.ErrorBottomSheet -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import androidx.core.view.isVisible -import androidx.core.net.toUri - -fun View.fadeIn(endAction: () -> Unit = {}) { - if (isVisible && alpha == 1f) return - alpha = 0f - visibility = VISIBLE - animate().alpha(1f).withEndAction { - if (context != null) endAction.invoke() - }.start() -} - -fun View.fadeOut(endAction: () -> Unit = {}) { - if (visibility == INVISIBLE) return - animate().alpha(0f).withEndAction { - if (context == null) return@withEndAction - visibility = INVISIBLE - alpha = 1f - endAction.invoke() - }.start() -} - -fun View.hideKeyboard() { - getSystemService(context, InputMethodManager::class.java) - ?.hideSoftInputFromWindow(windowToken, 0) -} - -fun assertUiThread() { - check(Looper.getMainLooper().thread == Thread.currentThread()) -} - -/** - * Use this with 'when' expressions when you need it to handle all possibilities/branches. - */ -val <T> T.exhaustive: T - get() = this - -@RequiresPermission(ACCESS_NETWORK_STATE) -fun Context.isOnline(): Boolean { - val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - return if (SDK_INT < 29) { - @Suppress("DEPRECATION") - cm.activeNetworkInfo?.isConnected == true - } else { - val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false - capabilities.hasCapability(NET_CAPABILITY_INTERNET) - } -} - -fun FragmentActivity.showError(mainText: String, detailText: String = "") = ErrorBottomSheet - .newInstance(mainText, detailText) - .show(supportFragmentManager, "ERROR_BOTTOM_SHEET") - -fun FragmentActivity.showError(@StringRes mainId: Int, detailText: String = "") { - showError(getString(mainId), detailText) -} - -fun Fragment.showError(mainText: String, detailText: String = "") = ErrorBottomSheet - .newInstance(mainText, detailText) - .show(parentFragmentManager, "ERROR_BOTTOM_SHEET") - -fun Fragment.showError(@StringRes mainId: Int, detailText: String = "") { - showError(getString(mainId), detailText) -} - -fun Context.startActivitySafe(intent: Intent) { - try { - startActivity(intent) - } catch (e: ActivityNotFoundException) { - Log.e("taler-kotlin-android", "Error starting $intent", e) - } -} - -fun Context.canAppHandleUri(uri: String): Boolean { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = uri.toUri() - } - - return packageManager.queryIntentActivities(intent, 0).any { - it.activityInfo.packageName != packageName - } -} - -fun Context.openUri(uri: String, title: String, excludeOwn: Boolean = true) { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = uri.toUri() - } - - if (excludeOwn) { - val possiblePackageNames = mutableListOf<String>() - val possibleIntents = packageManager.queryIntentActivities(intent, 0).filter { - it.activityInfo.packageName != packageName - }.map { - val possibleIntent = Intent(intent) - possibleIntent.`package` = it.activityInfo.packageName - possiblePackageNames.add(it.activityInfo.packageName) - return@map possibleIntent - } - - val defaultResolveInfo = packageManager.resolveActivity(intent, 0) - if (defaultResolveInfo == null || possiblePackageNames.isEmpty()) return - - // If there is a default app to handle the intent (which is not the app), use it. - if (possiblePackageNames.contains(defaultResolveInfo.activityInfo.packageName)) { - startActivitySafe(intent) - } else { - val chooser = Intent.createChooser(possibleIntents[0], title) - chooser.putExtra(EXTRA_INITIAL_INTENTS, possibleIntents.drop(1).toTypedArray()) - startActivitySafe(chooser) - } - } else { - startActivitySafe(Intent.createChooser(intent, title)) - } -} - -fun Context.shareText(text: String) { - val intent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_TEXT, text) - type = "text/plain" - } - - startActivitySafe(Intent.createChooser(intent, null)) -} - -fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions) - -fun Long.toRelativeTime(context: Context): CharSequence { - val now = System.currentTimeMillis() - return if (now - this > DAY_IN_MILLIS * 2) { - val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_ABBREV_MONTH or FORMAT_NO_YEAR - formatDateTime(context, this, flags) - } else getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE) -} - -fun Long.toAbsoluteTime(context: Context): CharSequence { - val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR - return formatDateTime(context, this, flags) -} - -fun Long.toShortDate(context: Context): CharSequence { - val flags = FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR or FORMAT_ABBREV_ALL - return formatDateTime(context, this, flags) -} - -fun Version.getIncompatibleStringOrNull(context: Context, otherVersion: String): String? { - val other = Version.parse(otherVersion) ?: return context.getString(R.string.version_invalid) - val match = compare(other) ?: return context.getString(R.string.version_invalid) - if (match.compatible) return null - if (match.currentCmp < 0) return context.getString(R.string.version_too_old) - if (match.currentCmp > 0) return context.getString(R.string.version_too_new) - throw AssertionError("$this == $other") -} - -fun copyToClipBoard(context: Context, label: String, str: String) { - val clipboard = context.getSystemService<ClipboardManager>() - val clip = ClipData.newPlainText(label, str) - clipboard?.setPrimaryClip(clip) -} - -const val SHARE_QR_TEMP_PREFIX = "taler_qr_" -const val SHARE_QR_SIZE = 512 -const val SHARE_QR_QUALITY = 90 - -/** - * Share string as QR code via sharing dialog - * - * NOTE: make sure to properly setup file provider - * https://developer.android.com/training/secure-file-sharing/setup-sharing - */ -suspend fun String.shareAsQrCode(context: Context, authority: String, qrBitmap: Bitmap? = null) { - val qrBitmap = qrBitmap ?: QrCodeManager.makeQrCode( - text = this, - size = SHARE_QR_SIZE, - margin = 2, - errorCorrection = com.google.zxing.qrcode.decoder.ErrorCorrectionLevel.M, - centerLogo = null, - centerLogoSize = null, - drawBackground = false, - darkColor = android.graphics.Color.BLACK, - lightColor = android.graphics.Color.WHITE, - trimQuietZone = false, - ) - val outputDir = context.cacheDir - try { - val uri = withContext(Dispatchers.IO) { - val outputFile = File.createTempFile(SHARE_QR_TEMP_PREFIX, ".png", outputDir) - outputFile.deleteOnExit() - val stream = FileOutputStream(outputFile) - qrBitmap.compress(Bitmap.CompressFormat.PNG, SHARE_QR_QUALITY, stream) - stream.flush() - stream.close() - FileProvider.getUriForFile(context, authority, outputFile) - } - - // TODO: also allow saving QR to files (under a human-readable name?) - val intent = Intent(ACTION_SEND).apply { - putExtra(EXTRA_STREAM, uri) - clipData = ClipData.newRawUri("", uri) - addFlags(FLAG_GRANT_READ_URI_PERMISSION) - setType("image/png") - } - - val shareIntent = Intent.createChooser(intent, null) - context.startActivitySafe(shareIntent) - } catch(e: IOException) { - Log.d("taler-kotlin-android", "Failed to generate or store PNG image") - } -} - -private val REGEX_BASE64_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") - -val String.base64Bitmap: Bitmap? - get() = REGEX_BASE64_IMAGE.matchEntire(this)?.let { match -> - match.groups[2]?.value?.let { group -> - val decodedString = Base64.decode(group, Base64.DEFAULT) - decodeByteArray(decodedString, 0, decodedString.size) - } - } diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Auth.kt b/taler-kotlin-android/src/main/java/net/taler/common/Auth.kt @@ -0,0 +1,173 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.common + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull + +@Serializable +data class TokenSuccessResponse( + val expiration: Timestamp, + + @SerialName("access_token") + val accessToken: String, +) + +@Serializable +data class ChallengesResponse( + val challenges: List<Challenge>, + + @SerialName("combi_and") + val combiAnd: Boolean, +) + +@Serializable +enum class TanChannel { + @SerialName("sms") + SMS, + + @SerialName("email") + EMAIL, +} + +@Serializable +data class Challenge( + @SerialName("challenge_id") + val challengeId: String, + + @SerialName("tan_channel") + val tanChannel: TanChannel, + + @SerialName("tan_info") + val tanInfo: String, +) + +@Serializable +data class ChallengeConfirmRequest( + val tan: String, +) + +@Serializable +data class TokenRequest( + val scope: String, + val duration: TokenDuration +) + +@Serializable(with = TokenDuration.Serializer::class) +sealed class TokenDuration { + data object Forever : TokenDuration() + data class Micros(val us: Long) : TokenDuration() + + object Serializer : KSerializer<TokenDuration> { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TokenDuration") { + element<JsonElement>("d_us") + } + + override fun serialize(encoder: Encoder, value: TokenDuration) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("Can be serialized only by JSON") + val obj = when (value) { + is Forever -> buildJsonObject { + put("d_us", JsonPrimitive("forever")) + } + is Micros -> buildJsonObject { + put("d_us", JsonPrimitive(value.us)) + } + } + jsonEncoder.encodeJsonElement(obj) + } + + override fun deserialize(decoder: Decoder): TokenDuration { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("Can be deserialized only by JSON") + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonObject) { + throw SerializationException("Expected JSON object for TokenDuration, got: $element") + } + val field = element["d_us"] + ?: throw SerializationException("Missing 'd_us' field in $element") + return when { + field is JsonPrimitive && field.longOrNull != null -> + Micros(field.long) + field is JsonPrimitive && field.isString && field.content == "forever" -> + Forever + else -> + throw SerializationException("Invalid 'd_us' value: $field") + } + } + } +} + +@Serializable(with = TokenExpiration.Serializer::class) +sealed class TokenExpiration { + data class Seconds(val t_s: Long) : TokenExpiration() + data object Never : TokenExpiration() + + object Serializer : KSerializer<TokenExpiration> { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("TokenExpiration") { + element<JsonElement>("t_s") + } + + override fun serialize(encoder: Encoder, value: TokenExpiration) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("TokenExpiration can be serialized only by JSON") + val obj = when (value) { + is Seconds -> + buildJsonObject { put("t_s", JsonPrimitive(value.t_s)) } + Never -> + buildJsonObject { put("t_s", JsonPrimitive("never")) } + } + jsonEncoder.encodeJsonElement(obj) + } + + override fun deserialize(decoder: Decoder): TokenExpiration { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("TokenExpiration can be deserialized only by JSON") + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonObject) { + throw SerializationException("Expected JSON object for TokenExpiration, got: $element") + } + val field = element["t_s"] + ?: throw SerializationException("Missing 't_s' in TokenExpiration: $element") + + return when { + field is JsonPrimitive && field.longOrNull != null -> + Seconds(field.long) + field is JsonPrimitive && field.isString && field.content == "never" -> + Never + else -> + throw SerializationException("Invalid 't_s' value in TokenExpiration: $field") + } + } + } +} diff --git a/taler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt b/taler-kotlin-android/src/main/java/net/taler/common/QrCodeManager.kt @@ -1,185 +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.common - -import android.graphics.Bitmap -import android.graphics.Bitmap.Config.ARGB_8888 -import android.graphics.Bitmap.Config.RGB_565 -import android.graphics.Canvas -import android.graphics.Color.BLACK -import android.graphics.Color.WHITE -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import androidx.core.graphics.createBitmap -import androidx.core.graphics.set -import com.google.zxing.BarcodeFormat.QR_CODE -import com.google.zxing.EncodeHintType.ERROR_CORRECTION -import com.google.zxing.EncodeHintType.MARGIN -import com.google.zxing.qrcode.QRCodeWriter -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -enum class QrLogoSize(val size: Float) { - SMALL(0.15f), - MEDIUM(0.20f), - BIG(0.25f), -} - -object QrCodeManager { - - suspend fun makeQrCode( - text: String, - size: Int = 256, - margin: Int = 2, - errorCorrection: ErrorCorrectionLevel = ErrorCorrectionLevel.M, - centerLogo: Drawable? = null, - centerLogoSize: QrLogoSize? = QrLogoSize.MEDIUM, - drawBackground: Boolean? = false, - darkColor: Int = BLACK, - lightColor: Int = WHITE, - trimQuietZone: Boolean = false, - ): Bitmap = withContext(Dispatchers.IO) { - val qrCodeWriter = QRCodeWriter() - val hints = mapOf( - MARGIN to margin.coerceAtLeast(0), - ERROR_CORRECTION to errorCorrection, - ) - val bitMatrix = qrCodeWriter.encode(text, QR_CODE, size, size, hints) - val height = bitMatrix.height - val width = bitMatrix.width - val bmp = createBitmap(width, height, RGB_565) - for (x in 0 until width) { - for (y in 0 until height) { - bmp[x, y] = if (bitMatrix.get(x, y)) darkColor else lightColor - } - } - - val qrBitmap = if (trimQuietZone) trimQrQuietZone(bmp, lightColor) else bmp - - return@withContext if (centerLogo != null && centerLogoSize != null && drawBackground != null) { - addCenteredLogo(qrBitmap, centerLogo, centerLogoSize, drawBackground, lightColor) - } else { - qrBitmap - } - } - - private fun trimQrQuietZone(bitmap: Bitmap, lightColor: Int): Bitmap { - val width = bitmap.width - val height = bitmap.height - var minX = width - var minY = height - var maxX = -1 - var maxY = -1 - - for (y in 0 until height) { - for (x in 0 until width) { - if (bitmap.getPixel(x, y) != lightColor) { - if (x < minX) minX = x - if (x > maxX) maxX = x - if (y < minY) minY = y - if (y > maxY) maxY = y - } - } - } - - if (maxX < minX || maxY < minY) return bitmap - val croppedWidth = maxX - minX + 1 - val croppedHeight = maxY - minY + 1 - if (croppedWidth == width && croppedHeight == height) return bitmap - return Bitmap.createBitmap(bitmap, minX, minY, croppedWidth, croppedHeight) - } - - private fun addCenteredLogo( - qrBitmap: Bitmap, - logoDrawable: Drawable, - logoSize: QrLogoSize = QrLogoSize.MEDIUM, - drawBackground: Boolean = false, - logoBackgroundColor: Int = WHITE, - ): Bitmap { - val result = qrBitmap.copy(ARGB_8888, true) - val canvas = Canvas(result) - val logoBitmap = drawableToBitmap(logoDrawable) - - var logoMaxWidth = (result.width * logoSize.size).toInt() - val logoAspectRatio = logoBitmap.width.toFloat() / logoBitmap.height.toFloat() - var logoWidth = logoMaxWidth - var logoHeight = (logoWidth / logoAspectRatio).toInt().coerceAtLeast(1) - var horizontalPadding = (logoHeight * 0.12f).toInt() - var verticalPadding = (logoHeight * 0.09f).toInt() - - val maxOcclusionRatio = 0.11f - val currentOcclusionRatio = - ((logoWidth + horizontalPadding * 2f) * (logoHeight + verticalPadding * 2f)) / - (result.width.toFloat() * result.height.toFloat()) - if (currentOcclusionRatio > maxOcclusionRatio) { - val scale = kotlin.math.sqrt(maxOcclusionRatio / currentOcclusionRatio) - logoMaxWidth = (logoMaxWidth * scale).toInt().coerceAtLeast(1) - logoWidth = logoMaxWidth - logoHeight = (logoWidth / logoAspectRatio).toInt().coerceAtLeast(1) - horizontalPadding = (horizontalPadding * scale).toInt() - verticalPadding = (verticalPadding * scale).toInt() - } - - val centerX = result.width / 2 - val centerY = result.height / 2 - - if (drawBackground) { - val halfBackgroundWidth = (logoWidth / 2f) + horizontalPadding - val halfBackgroundHeight = (logoHeight / 2f) + verticalPadding - val backgroundRect = RectF( - centerX - halfBackgroundWidth, - centerY - halfBackgroundHeight, - centerX + halfBackgroundWidth, - centerY + halfBackgroundHeight, - ) - - val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = logoBackgroundColor - } - val cornerRadius = - halfBackgroundHeight // * 0.8f taler has circle in logo, so it can be fine - canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint) - } - - val destinationRect = Rect( - centerX - logoWidth / 2, - centerY - logoHeight / 2, - centerX + logoWidth / 2, - centerY + logoHeight / 2, - ) - canvas.drawBitmap(logoBitmap, null, destinationRect, Paint(Paint.ANTI_ALIAS_FLAG)) - return result - } - - private fun drawableToBitmap(drawable: Drawable): Bitmap { - if (drawable is BitmapDrawable && drawable.bitmap != null) { - return drawable.bitmap - } - val width = drawable.intrinsicWidth.coerceAtLeast(1) - val height = drawable.intrinsicHeight.coerceAtLeast(1) - val bitmap = createBitmap(width, height) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - return bitmap - } -} diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/AndroidUtils.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/AndroidUtils.kt @@ -0,0 +1,286 @@ +/* + * 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.lib.android + +import android.Manifest.permission.ACCESS_NETWORK_STATE +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Context.CONNECTIVITY_SERVICE +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.graphics.BitmapFactory.decodeByteArray +import android.content.Intent.EXTRA_STREAM +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION +import android.graphics.Bitmap +import android.net.ConnectivityManager +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.os.Build.VERSION.SDK_INT +import android.os.Looper +import android.text.format.DateUtils.DAY_IN_MILLIS +import android.text.format.DateUtils.FORMAT_ABBREV_ALL +import android.text.format.DateUtils.FORMAT_ABBREV_MONTH +import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE +import android.text.format.DateUtils.FORMAT_NO_YEAR +import android.text.format.DateUtils.FORMAT_SHOW_DATE +import android.text.format.DateUtils.FORMAT_SHOW_TIME +import android.text.format.DateUtils.FORMAT_SHOW_YEAR +import android.text.format.DateUtils.MINUTE_IN_MILLIS +import android.text.format.DateUtils.formatDateTime +import android.text.format.DateUtils.getRelativeTimeSpanString +import android.util.Base64 +import android.util.Log +import android.view.View +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresPermission +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat.getSystemService +import androidx.core.content.FileProvider +import androidx.core.content.getSystemService +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavDirections +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.taler.common.R +import net.taler.common.Version +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import androidx.core.view.isVisible +import androidx.core.net.toUri + +fun View.fadeIn(endAction: () -> Unit = {}) { + if (isVisible && alpha == 1f) return + alpha = 0f + visibility = VISIBLE + animate().alpha(1f).withEndAction { + if (context != null) endAction.invoke() + }.start() +} + +fun View.fadeOut(endAction: () -> Unit = {}) { + if (visibility == INVISIBLE) return + animate().alpha(0f).withEndAction { + if (context == null) return@withEndAction + visibility = INVISIBLE + alpha = 1f + endAction.invoke() + }.start() +} + +fun View.hideKeyboard() { + getSystemService(context, InputMethodManager::class.java) + ?.hideSoftInputFromWindow(windowToken, 0) +} + +fun assertUiThread() { + check(Looper.getMainLooper().thread == Thread.currentThread()) +} + +/** + * Use this with 'when' expressions when you need it to handle all possibilities/branches. + */ +val <T> T.exhaustive: T + get() = this + +@RequiresPermission(ACCESS_NETWORK_STATE) +fun Context.isOnline(): Boolean { + val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + return if (SDK_INT < 29) { + @Suppress("DEPRECATION") + cm.activeNetworkInfo?.isConnected == true + } else { + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false + capabilities.hasCapability(NET_CAPABILITY_INTERNET) + } +} + +fun FragmentActivity.showError(mainText: String, detailText: String = "") = ErrorBottomSheet + .newInstance(mainText, detailText) + .show(supportFragmentManager, "ERROR_BOTTOM_SHEET") + +fun FragmentActivity.showError(@StringRes mainId: Int, detailText: String = "") { + showError(getString(mainId), detailText) +} + +fun Fragment.showError(mainText: String, detailText: String = "") = ErrorBottomSheet + .newInstance(mainText, detailText) + .show(parentFragmentManager, "ERROR_BOTTOM_SHEET") + +fun Fragment.showError(@StringRes mainId: Int, detailText: String = "") { + showError(getString(mainId), detailText) +} + +fun Context.startActivitySafe(intent: Intent) { + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("taler-kotlin-android", "Error starting $intent", e) + } +} + +fun Context.canAppHandleUri(uri: String): Boolean { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = uri.toUri() + } + + return packageManager.queryIntentActivities(intent, 0).any { + it.activityInfo.packageName != packageName + } +} + +fun Context.openUri(uri: String, title: String, excludeOwn: Boolean = true) { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = uri.toUri() + } + + if (excludeOwn) { + val possiblePackageNames = mutableListOf<String>() + val possibleIntents = packageManager.queryIntentActivities(intent, 0).filter { + it.activityInfo.packageName != packageName + }.map { + val possibleIntent = Intent(intent) + possibleIntent.`package` = it.activityInfo.packageName + possiblePackageNames.add(it.activityInfo.packageName) + return@map possibleIntent + } + + val defaultResolveInfo = packageManager.resolveActivity(intent, 0) + if (defaultResolveInfo == null || possiblePackageNames.isEmpty()) return + + // If there is a default app to handle the intent (which is not the app), use it. + if (possiblePackageNames.contains(defaultResolveInfo.activityInfo.packageName)) { + startActivitySafe(intent) + } else { + val chooser = Intent.createChooser(possibleIntents[0], title) + chooser.putExtra(EXTRA_INITIAL_INTENTS, possibleIntents.drop(1).toTypedArray()) + startActivitySafe(chooser) + } + } else { + startActivitySafe(Intent.createChooser(intent, title)) + } +} + +fun Context.shareText(text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + + startActivitySafe(Intent.createChooser(intent, null)) +} + +fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions) + +fun Long.toRelativeTime(context: Context): CharSequence { + val now = System.currentTimeMillis() + return if (now - this > DAY_IN_MILLIS * 2) { + val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_ABBREV_MONTH or FORMAT_NO_YEAR + formatDateTime(context, this, flags) + } else getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE) +} + +fun Long.toAbsoluteTime(context: Context): CharSequence { + val flags = FORMAT_SHOW_TIME or FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR + return formatDateTime(context, this, flags) +} + +fun Long.toShortDate(context: Context): CharSequence { + val flags = FORMAT_SHOW_DATE or FORMAT_SHOW_YEAR or FORMAT_ABBREV_ALL + return formatDateTime(context, this, flags) +} + +fun Version.getIncompatibleStringOrNull(context: Context, otherVersion: String): String? { + val other = Version.parse(otherVersion) ?: return context.getString(R.string.version_invalid) + val match = compare(other) ?: return context.getString(R.string.version_invalid) + if (match.compatible) return null + if (match.currentCmp < 0) return context.getString(R.string.version_too_old) + if (match.currentCmp > 0) return context.getString(R.string.version_too_new) + throw AssertionError("$this == $other") +} + +fun copyToClipBoard(context: Context, label: String, str: String) { + val clipboard = context.getSystemService<ClipboardManager>() + val clip = ClipData.newPlainText(label, str) + clipboard?.setPrimaryClip(clip) +} + +const val SHARE_QR_TEMP_PREFIX = "taler_qr_" +const val SHARE_QR_SIZE = 512 +const val SHARE_QR_QUALITY = 90 + +/** + * Share string as QR code via sharing dialog + * + * NOTE: make sure to properly setup file provider + * https://developer.android.com/training/secure-file-sharing/setup-sharing + */ +suspend fun String.shareAsQrCode(context: Context, authority: String, qrBitmap: Bitmap? = null) { + val qrBitmap = qrBitmap ?: QrCodeManager.makeQrCode( + text = this, + size = SHARE_QR_SIZE, + margin = 2, + errorCorrection = com.google.zxing.qrcode.decoder.ErrorCorrectionLevel.M, + centerLogo = null, + centerLogoSize = null, + drawBackground = false, + darkColor = android.graphics.Color.BLACK, + lightColor = android.graphics.Color.WHITE, + trimQuietZone = false, + ) + val outputDir = context.cacheDir + try { + val uri = withContext(Dispatchers.IO) { + val outputFile = File.createTempFile(SHARE_QR_TEMP_PREFIX, ".png", outputDir) + outputFile.deleteOnExit() + val stream = FileOutputStream(outputFile) + qrBitmap.compress(Bitmap.CompressFormat.PNG, SHARE_QR_QUALITY, stream) + stream.flush() + stream.close() + FileProvider.getUriForFile(context, authority, outputFile) + } + + // TODO: also allow saving QR to files (under a human-readable name?) + val intent = Intent(ACTION_SEND).apply { + putExtra(EXTRA_STREAM, uri) + clipData = ClipData.newRawUri("", uri) + addFlags(FLAG_GRANT_READ_URI_PERMISSION) + setType("image/png") + } + + val shareIntent = Intent.createChooser(intent, null) + context.startActivitySafe(shareIntent) + } catch(e: IOException) { + Log.d("taler-kotlin-android", "Failed to generate or store PNG image") + } +} + +private val REGEX_BASE64_IMAGE = Regex("^data:image/(jpeg|png);base64,([A-Za-z0-9+/=]+)$") + +val String.base64Bitmap: Bitmap? + get() = REGEX_BASE64_IMAGE.matchEntire(this)?.let { match -> + match.groups[2]?.value?.let { group -> + val decodedString = Base64.decode(group, Base64.DEFAULT) + decodeByteArray(decodedString, 0, decodedString.size) + } + } diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/AnimatedQrCodeComposable.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/AnimatedQrCodeComposable.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.graphics.toColorInt import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import net.taler.common.QrCodeManager.makeQrCode +import net.taler.lib.android.QrCodeManager.makeQrCode const val QR_CORNER_RADIUS = 0.08f const val QR_STRIPE_WIDTH = 0.025f diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/MfaUtils.kt @@ -0,0 +1,157 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.lib.android + +import android.view.LayoutInflater +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import io.ktor.client.plugins.ClientRequestException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import net.taler.common.Challenge +import net.taler.common.R +import kotlin.coroutines.resume + +enum class ChallengeRetryDecision { + Retry, + Resend, + Abort +} + +class ChallengeCancelledException : Exception() + +suspend fun Fragment.handleChallengeResponse( + challenges: List<Challenge>, + combiAnd: Boolean, + onRequestChallenge: suspend (challengeId: String) -> Unit, + onConfirmChallenge: suspend (challengeId: String, tan: String) -> Unit, +): List<String> { + if (challenges.isEmpty()) return emptyList() + val challengesToSolve = if (combiAnd) { + challenges + } else { + val selected = selectChallenge(challenges) ?: return emptyList() + listOf(selected) + } + + val solvedIds = mutableListOf<String>() + for (challenge in challengesToSolve) { + onRequestChallenge(challenge.challengeId) + + while (true) { + val tan = promptForTan(challenge) ?: return emptyList() + try { + onConfirmChallenge(challenge.challengeId, tan) + solvedIds.add(challenge.challengeId) + break + } catch (e: Exception) { + when (handleChallengeConfirmError(e)) { + ChallengeRetryDecision.Retry -> continue + ChallengeRetryDecision.Resend -> { + onRequestChallenge(challenge.challengeId) + continue + } + ChallengeRetryDecision.Abort -> return emptyList() + } + } + } + } + return solvedIds +} + +suspend fun Fragment.selectChallenge(challenges: List<Challenge>): Challenge? = + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + val labels = challenges.map { c -> + "${c.tanChannel}: ${c.tanInfo}" + }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.mfa_choose_title) + .setItems(labels) { _, which -> + cont.resume(challenges[which]) + } + .setOnCancelListener { cont.resume(null) } + .show() + } + } + +suspend fun Fragment.promptForTan(challenge: Challenge): String? = + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + val message = getString( + R.string.mfa_challenge_message, + challenge.tanChannel.name, + challenge.tanInfo + ) + val dialogView = LayoutInflater.from(requireContext()).inflate( + R.layout.dialog_mfa_challenge, + null, + false + ) + val messageView = dialogView.findViewById<TextView>(R.id.mfaMessageView) + val inputLayout = dialogView.findViewById<TextInputLayout>(R.id.mfaCodeInputLayout) + val input = dialogView.findViewById<TextInputEditText>(R.id.mfaCodeInput) + messageView.text = message + inputLayout.isErrorEnabled = false + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.mfa_challenge_title) + .setView(dialogView) + .setPositiveButton(android.R.string.ok) { _, _ -> + cont.resume(input?.text?.toString()?.trim().orEmpty()) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + cont.resume(null) + } + .setOnCancelListener { cont.resume(null) } + .show() + } + } + +suspend fun Fragment.handleChallengeConfirmError(e: Exception): ChallengeRetryDecision = + withContext(Dispatchers.Main) { + if (e is ClientRequestException) { + when (e.response.status.value) { + 409 -> { + Toast.makeText( + requireContext(), + R.string.mfa_challenge_invalid, + Toast.LENGTH_LONG + ).show() + return@withContext ChallengeRetryDecision.Retry + } + 429 -> { + Toast.makeText( + requireContext(), + R.string.mfa_challenge_retry, + Toast.LENGTH_LONG + ).show() + return@withContext ChallengeRetryDecision.Resend + } + } + } + Toast.makeText( + requireContext(), + R.string.mfa_challenge_failed, + Toast.LENGTH_LONG + ).show() + ChallengeRetryDecision.Abort + } diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/QrCodeManager.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/QrCodeManager.kt @@ -0,0 +1,185 @@ +/* + * 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.lib.android + +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.Bitmap.Config.RGB_565 +import android.graphics.Canvas +import android.graphics.Color.BLACK +import android.graphics.Color.WHITE +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import com.google.zxing.BarcodeFormat.QR_CODE +import com.google.zxing.EncodeHintType.ERROR_CORRECTION +import com.google.zxing.EncodeHintType.MARGIN +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +enum class QrLogoSize(val size: Float) { + SMALL(0.15f), + MEDIUM(0.20f), + BIG(0.25f), +} + +object QrCodeManager { + + suspend fun makeQrCode( + text: String, + size: Int = 256, + margin: Int = 2, + errorCorrection: ErrorCorrectionLevel = ErrorCorrectionLevel.M, + centerLogo: Drawable? = null, + centerLogoSize: QrLogoSize? = QrLogoSize.MEDIUM, + drawBackground: Boolean? = false, + darkColor: Int = BLACK, + lightColor: Int = WHITE, + trimQuietZone: Boolean = false, + ): Bitmap = withContext(Dispatchers.IO) { + val qrCodeWriter = QRCodeWriter() + val hints = mapOf( + MARGIN to margin.coerceAtLeast(0), + ERROR_CORRECTION to errorCorrection, + ) + val bitMatrix = qrCodeWriter.encode(text, QR_CODE, size, size, hints) + val height = bitMatrix.height + val width = bitMatrix.width + val bmp = createBitmap(width, height, RGB_565) + for (x in 0 until width) { + for (y in 0 until height) { + bmp[x, y] = if (bitMatrix.get(x, y)) darkColor else lightColor + } + } + + val qrBitmap = if (trimQuietZone) trimQrQuietZone(bmp, lightColor) else bmp + + return@withContext if (centerLogo != null && centerLogoSize != null && drawBackground != null) { + addCenteredLogo(qrBitmap, centerLogo, centerLogoSize, drawBackground, lightColor) + } else { + qrBitmap + } + } + + private fun trimQrQuietZone(bitmap: Bitmap, lightColor: Int): Bitmap { + val width = bitmap.width + val height = bitmap.height + var minX = width + var minY = height + var maxX = -1 + var maxY = -1 + + for (y in 0 until height) { + for (x in 0 until width) { + if (bitmap.getPixel(x, y) != lightColor) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + if (maxX < minX || maxY < minY) return bitmap + val croppedWidth = maxX - minX + 1 + val croppedHeight = maxY - minY + 1 + if (croppedWidth == width && croppedHeight == height) return bitmap + return Bitmap.createBitmap(bitmap, minX, minY, croppedWidth, croppedHeight) + } + + private fun addCenteredLogo( + qrBitmap: Bitmap, + logoDrawable: Drawable, + logoSize: QrLogoSize = QrLogoSize.MEDIUM, + drawBackground: Boolean = false, + logoBackgroundColor: Int = WHITE, + ): Bitmap { + val result = qrBitmap.copy(ARGB_8888, true) + val canvas = Canvas(result) + val logoBitmap = drawableToBitmap(logoDrawable) + + var logoMaxWidth = (result.width * logoSize.size).toInt() + val logoAspectRatio = logoBitmap.width.toFloat() / logoBitmap.height.toFloat() + var logoWidth = logoMaxWidth + var logoHeight = (logoWidth / logoAspectRatio).toInt().coerceAtLeast(1) + var horizontalPadding = (logoHeight * 0.12f).toInt() + var verticalPadding = (logoHeight * 0.09f).toInt() + + val maxOcclusionRatio = 0.11f + val currentOcclusionRatio = + ((logoWidth + horizontalPadding * 2f) * (logoHeight + verticalPadding * 2f)) / + (result.width.toFloat() * result.height.toFloat()) + if (currentOcclusionRatio > maxOcclusionRatio) { + val scale = kotlin.math.sqrt(maxOcclusionRatio / currentOcclusionRatio) + logoMaxWidth = (logoMaxWidth * scale).toInt().coerceAtLeast(1) + logoWidth = logoMaxWidth + logoHeight = (logoWidth / logoAspectRatio).toInt().coerceAtLeast(1) + horizontalPadding = (horizontalPadding * scale).toInt() + verticalPadding = (verticalPadding * scale).toInt() + } + + val centerX = result.width / 2 + val centerY = result.height / 2 + + if (drawBackground) { + val halfBackgroundWidth = (logoWidth / 2f) + horizontalPadding + val halfBackgroundHeight = (logoHeight / 2f) + verticalPadding + val backgroundRect = RectF( + centerX - halfBackgroundWidth, + centerY - halfBackgroundHeight, + centerX + halfBackgroundWidth, + centerY + halfBackgroundHeight, + ) + + val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = logoBackgroundColor + } + val cornerRadius = + halfBackgroundHeight // * 0.8f taler has circle in logo, so it can be fine + canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint) + } + + val destinationRect = Rect( + centerX - logoWidth / 2, + centerY - logoHeight / 2, + centerX + logoWidth / 2, + centerY + logoHeight / 2, + ) + canvas.drawBitmap(logoBitmap, null, destinationRect, Paint(Paint.ANTI_ALIAS_FLAG)) + return result + } + + private fun drawableToBitmap(drawable: Drawable): Bitmap { + if (drawable is BitmapDrawable && drawable.bitmap != null) { + return drawable.bitmap + } + val width = drawable.intrinsicWidth.coerceAtLeast(1) + val height = drawable.intrinsicHeight.coerceAtLeast(1) + val bitmap = createBitmap(width, height) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } +} diff --git a/taler-kotlin-android/src/main/res/layout/dialog_mfa_challenge.xml b/taler-kotlin-android/src/main/res/layout/dialog_mfa_challenge.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!-- + ~ This file is part of GNU Taler + ~ (C) 2026 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/> + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingStart="24dp" + android:paddingTop="16dp" + android:paddingEnd="24dp" + android:paddingBottom="8dp"> + + <TextView + android:id="@+id/mfaMessageView" + style="@style/TextAppearance.MaterialComponents.Body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="16dp" + android:textColor="?attr/colorOnSurface" /> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/mfaCodeInputLayout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/mfa_challenge_code_hint" + app:boxBackgroundMode="outline" + app:boxBackgroundColor="@android:color/transparent"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/mfaCodeInput" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:imeOptions="actionDone" + android:inputType="number" /> + </com.google.android.material.textfield.TextInputLayout> + +</LinearLayout> diff --git a/taler-kotlin-android/src/main/res/values/strings.xml b/taler-kotlin-android/src/main/res/values/strings.xml @@ -22,4 +22,12 @@ <string name="close">Close</string> <string name="share">Share</string> <string name="copy">Copy</string> + + <string name="mfa_challenge_title">Two-factor authentication</string> + <string name="mfa_challenge_message">A confirmation code was sent via %1$s (%2$s). Enter the code to continue.</string> + <string name="mfa_challenge_code_hint">Verification code</string> + <string name="mfa_choose_title">Choose verification method</string> + <string name="mfa_challenge_invalid">Incorrect code. Please try again.</string> + <string name="mfa_challenge_retry">Too many attempts. A new code has been sent.</string> + <string name="mfa_challenge_failed">Verification failed. Please try again later.</string> </resources> diff --git a/wallet/src/main/java/net/taler/wallet/Utils.kt b/wallet/src/main/java/net/taler/wallet/Utils.kt @@ -60,8 +60,8 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import net.taler.common.Amount import net.taler.common.AmountParserException -import net.taler.common.showError -import net.taler.common.startActivitySafe +import net.taler.lib.android.showError +import net.taler.lib.android.startActivitySafe import net.taler.wallet.backend.TalerErrorInfo const val CURRENCY_BTC = "BITCOINBTC" diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt @@ -55,9 +55,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import androidx.core.content.ContextCompat -import net.taler.common.QrCodeManager -import net.taler.common.QrLogoSize -import net.taler.common.copyToClipBoard +import net.taler.lib.android.QrCodeManager +import net.taler.lib.android.QrLogoSize +import net.taler.lib.android.copyToClipBoard import net.taler.lib.android.AnimatedQrCodeComposable import net.taler.wallet.R diff --git a/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt b/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat.startActivity import kotlinx.coroutines.launch -import net.taler.common.shareAsQrCode +import net.taler.lib.android.shareAsQrCode import net.taler.wallet.BuildConfig import net.taler.wallet.R diff --git a/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/TransactionDepositComposable.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode.EXCHANGE_GENERIC_KYC_REQUIRED diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt @@ -89,7 +89,7 @@ import net.taler.common.ContractTokenFamily import net.taler.common.Exchange import net.taler.common.Merchant import net.taler.common.TalerUtils -import net.taler.common.base64Bitmap +import net.taler.lib.android.base64Bitmap import net.taler.wallet.R import net.taler.wallet.cleanExchange import net.taler.wallet.compose.BottomButtonBox diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -34,7 +34,7 @@ import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.Merchant import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode diff --git a/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt b/wallet/src/main/java/net/taler/wallet/refund/TransactionRefundComposable.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode diff --git a/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/ErrorTransactionComposable.kt @@ -38,9 +38,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.sp -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import net.taler.common.copyToClipBoard +import net.taler.lib.android.copyToClipBoard import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorInfo diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionDetailScreen.kt @@ -54,8 +54,8 @@ import androidx.compose.ui.unit.sp import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.Timestamp -import net.taler.common.copyToClipBoard -import net.taler.common.toAbsoluteTime +import net.taler.lib.android.copyToClipBoard +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.NavigateCallback import net.taler.wallet.R diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionStateComposable.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.common.RelativeTime import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.R import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.Banner diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransactionsComposable.kt @@ -65,7 +65,7 @@ import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.Timestamp -import net.taler.common.toRelativeTime +import net.taler.lib.android.toRelativeTime import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode import net.taler.wallet.backend.TalerErrorInfo diff --git a/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt b/wallet/src/main/java/net/taler/wallet/transactions/TransitionsComposable.kt @@ -45,7 +45,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.serialization.json.Json -import net.taler.common.copyToClipBoard +import net.taler.lib.android.copyToClipBoard import net.taler.wallet.R import net.taler.wallet.transactions.TransactionAction.* diff --git a/wallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt b/wallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import net.taler.common.QrLogoSize +import net.taler.lib.android.QrLogoSize import net.taler.wallet.R import net.taler.wallet.compose.ExpandableCard import net.taler.wallet.compose.QrCodeParams diff --git a/wallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt @@ -56,11 +56,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount import net.taler.common.CurrencySpecification +import net.taler.lib.android.canAppHandleUri +import net.taler.lib.android.copyToClipBoard +import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.CURRENCY_BTC import net.taler.wallet.R -import net.taler.common.canAppHandleUri -import net.taler.common.copyToClipBoard -import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.compose.ShareButton import net.taler.wallet.transactions.AccountRestriction import net.taler.wallet.transactions.AmountType diff --git a/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt b/wallet/src/main/java/net/taler/wallet/transfer/WireTransferDetailsScreen.kt @@ -26,8 +26,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import net.taler.common.openUri -import net.taler.common.shareText +import net.taler.lib.android.openUri +import net.taler.lib.android.shareText import net.taler.wallet.R import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.collectAsStateLifecycleAware diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt b/wallet/src/main/java/net/taler/wallet/withdraw/TransactionWithdrawalComposable.kt @@ -35,7 +35,7 @@ import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.common.RelativeTime import net.taler.common.Timestamp -import net.taler.common.toAbsoluteTime +import net.taler.lib.android.toAbsoluteTime import net.taler.wallet.BottomInsetsSpacer import net.taler.wallet.R import net.taler.wallet.backend.TalerErrorCode @@ -43,7 +43,6 @@ import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.cleanExchange import net.taler.wallet.transactions.AmountType -import net.taler.wallet.transactions.WithdrawalActions import net.taler.wallet.transactions.ErrorTransactionButton import net.taler.wallet.transactions.TransactionAction import net.taler.wallet.transactions.TransactionAction.Abort @@ -57,6 +56,7 @@ import net.taler.wallet.transactions.TransactionState import net.taler.wallet.transactions.TransactionStateComposable import net.taler.wallet.transactions.TransactionWithdrawal import net.taler.wallet.transactions.TransitionsComposable +import net.taler.wallet.transactions.WithdrawalActions import net.taler.wallet.transactions.WithdrawalDetails.ManualTransfer import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails