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:
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