taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 079ded8d43a4015ea4de5d85b5d65b1f26e80130
parent 1b02b10123de5b936afed0a6d3625c5b9faed9e1
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue,  8 Jul 2025 16:50:11 +0200

[wallet] allow sharing banking QR as image

bug 0010144

Diffstat:
Mtaler-kotlin-android/src/main/java/net/taler/common/AndroidUtils.kt | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mwallet/src/main/AndroidManifest.xml | 10++++++++++
Mwallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt | 45+++++++++++++++++++++++++--------------------
Mwallet/src/main/java/net/taler/wallet/compose/ShareButton.kt | 21+++++++++++++++------
Mwallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt | 3+++
Mwallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt | 10++++++++--
Awallet/src/main/res/xml/file_paths.xml | 22++++++++++++++++++++++
7 files changed, 137 insertions(+), 32 deletions(-)

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 @@ -23,10 +23,13 @@ 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.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.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Looper import android.text.format.DateUtils.DAY_IN_MILLIS @@ -48,15 +51,23 @@ 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 (visibility == VISIBLE && alpha == 1f) return + if (isVisible && alpha == 1f) return alpha = 0f visibility = VISIBLE animate().alpha(1f).withEndAction { @@ -127,7 +138,7 @@ fun Context.startActivitySafe(intent: Intent) { fun Context.canAppHandleUri(uri: String): Boolean { val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(uri) + data = uri.toUri() } return packageManager.queryIntentActivities(intent, 0).any { @@ -137,7 +148,7 @@ fun Context.canAppHandleUri(uri: String): Boolean { fun Context.openUri(uri: String, title: String, excludeOwn: Boolean = true) { val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(uri) + data = uri.toUri() } if (excludeOwn) { @@ -209,4 +220,43 @@ 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) { + val qrBitmap = QrCodeManager.makeQrCode(this, SHARE_QR_SIZE) + 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") + } } \ No newline at end of file diff --git a/wallet/src/main/AndroidManifest.xml b/wallet/src/main/AndroidManifest.xml @@ -94,6 +94,16 @@ android:name="android.nfc.cardemulation.host_apdu_service" android:resource="@xml/apduservice" /> </service> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="net.taler.wallet.fileprovider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/file_paths" /> + </provider> </application> <queries> diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt @@ -16,9 +16,6 @@ package net.taler.wallet.compose -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -61,6 +58,7 @@ fun ColumnScope.QrCodeUriComposable( clipBoardLabel: String, buttonText: String = stringResource(R.string.copy), showContents: Boolean = true, + shareAsQrCode: Boolean = false, inBetween: (@Composable ColumnScope.() -> Unit)? = null, ) { val qrCodeSize = getQrCodeSize() @@ -75,7 +73,7 @@ fun ColumnScope.QrCodeUriComposable( modifier = Modifier .size(qrCodeSize) .align(CenterHorizontally) - .padding(vertical = if (showContents) 8.dp else 0.dp), + .padding(bottom = if (showContents) 8.dp else 0.dp), bitmap = qrCode, contentDescription = stringResource(id = R.string.button_scan_qr_code), ) @@ -83,35 +81,42 @@ fun ColumnScope.QrCodeUriComposable( if (inBetween != null) inBetween() val scrollState = rememberScrollState() if (showContents) { - Box(modifier = Modifier.padding(16.dp)) { - Text( - modifier = Modifier.horizontalScroll(scrollState), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodyLarge, - text = talerUri, - ) + if (!shareAsQrCode) { + Box(modifier = Modifier.padding(16.dp)) { + Text( + modifier = Modifier.horizontalScroll(scrollState), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyLarge, + text = talerUri, + ) + } } + Row( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { - CopyToClipboardButton( - label = clipBoardLabel, - content = talerUri, - buttonText = buttonText, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + if (!shareAsQrCode) { + CopyToClipboardButton( + label = clipBoardLabel, + content = talerUri, + buttonText = buttonText, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) ) - ) + } + ShareButton( content = talerUri, + shareAsQrCode = shareAsQrCode, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) + ), ) } } diff --git a/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt b/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt @@ -29,10 +29,13 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier 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.wallet.R @Composable @@ -41,19 +44,25 @@ fun ShareButton( modifier: Modifier = Modifier, buttonText: String = stringResource(R.string.share), colors: ButtonColors = ButtonDefaults.buttonColors(), + shareAsQrCode: Boolean = false, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() Button( modifier = modifier, colors = colors, onClick = { - val sendIntent: Intent = Intent().apply { - action = ACTION_SEND - putExtra(EXTRA_TEXT, content) - type = "text/plain" + if (shareAsQrCode) { + scope.launch { content.shareAsQrCode(context, "net.taler.wallet.fileprovider") } + } else { + val sendIntent: Intent = Intent().apply { + action = ACTION_SEND + putExtra(EXTRA_TEXT, content) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + startActivity(context, shareIntent, null) } - val shareIntent = Intent.createChooser(sendIntent, null) - startActivity(context, shareIntent, null) }, ) { Icon( diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt @@ -17,6 +17,8 @@ package net.taler.wallet.peer import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator @@ -113,6 +115,7 @@ fun ColumnScope.PeerQrCode( ) if (state.minor == Ready && talerUri != null) { + Spacer(Modifier.height(8.dp)) QrCodeUriComposable( talerUri = talerUri, clipBoardLabel = "Push payment", diff --git a/wallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt b/wallet/src/main/java/net/taler/wallet/transfer/PaytoQrCard.kt @@ -16,10 +16,14 @@ package net.taler.wallet.transfer +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import net.taler.wallet.R import net.taler.wallet.compose.ExpandableCard import net.taler.wallet.compose.QrCodeUriComposable @@ -39,7 +43,6 @@ fun PaytoQrCard( else -> return } - // TODO: copy/share actions ExpandableCard( expanded = expanded, setExpanded = setExpanded, @@ -50,8 +53,11 @@ fun PaytoQrCard( QrCodeUriComposable( talerUri = qrCode.qrContent, clipBoardLabel = label, - showContents = false, + showContents = true, + shareAsQrCode = true, ) + + Spacer(Modifier.height(8.dp)) }, ) } \ No newline at end of file diff --git a/wallet/src/main/res/xml/file_paths.xml b/wallet/src/main/res/xml/file_paths.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ This file is part of GNU Taler + ~ (C) 2025 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/> + --> + +<resources> + <paths> + <cache-path name="cache" path="." /> + </paths> +</resources> +\ No newline at end of file