View Issue Details

IDProjectCategoryView StatusLast Update
0012080Talerwallet (Android App)public2026-11-11 01:39
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status newResolutionopen 
Summary0012080: Android never shows when a peer payment link expires (receive / pay-request prompt, pending send/request) -- iOS and web do (patch attached)
DescriptionThe Android wallet never tells the user when a peer-to-peer payment link expires: not on the "Receive payment" prompt of a taler://pay-push link, not on the "Pay request" prompt of a taler://pay-pull link, and not on the detail page of a pending Send / Request. iOS shows "Expires: <date>" on both prompts and on pending peer transactions; the web wallet shows "Expires" on the pay-request screen and in the transaction details. The date matters: it is how long the user has to accept or pay, and for a link the user sent, when the money comes back if nobody takes it.

Screenshots (demo backend; links created with expiry 1 day):
- before-android-1-receive.png, before-android-2-payrequest.png: Android prompts: summary, total, button -- no date.
- before-android-3-send-detail.png: Android pending Send: QR, URI, amounts, purpose -- no date.
- before-ios-1-receive.png, before-ios-2-payrequest.png: iOS "Expires: November 12, 2026 at 2:21 AM".
- before-web-2-payrequest.png, before-web-3-send-detail.png: web "Expires" row.
- after-android-1-receive.png, after-android-2-payrequest.png, after-android-3-send-detail.png: with the patch.

wallet-core delivers the date already: PreparePeerPushCreditResponse / PreparePeerPullDebitResponse .contractTerms.purse_expiration and TransactionPeer*.info.expiration.

Code (taler-android origin/master 4c483527, wallet/src/main/java/net/taler/wallet/):
- peer/IncomingState.kt:58-62: PeerContractTerms(summary, amount) -- purse_expiration is not decoded
- peer/IncomingComposable.kt:115-195: PeerPullTermsComposable (both prompts) renders summary, total, fee, button
- transactions/Transactions.kt:577-581: PeerInfoShort.expiration is decoded, but none of peer/TransactionPeer{PushDebit,PullCredit,PushCredit,PullDebit}.kt reads it
iOS: Views/Sheets/P2P_Sheets/P2pReceiveURIView.swift:88, P2pPayURIView.swift:51-52, Views/Transactions/TransactionTypeDetail.swift:228-230. Web: screens/PeerReceiveScreen.tsx:118-123, routes/transaction-model.ts:532-585.

Patch (attached, android-peer-expiration.patch): decode purse_expiration into PeerContractTerms; a small PeerExpirationComposable (TransactionInfoComposable with the absolute date, nothing for "never") shown on the incoming prompt and, while the transaction is pending or in dialog state, in the four peer transaction details. One new string, peer_expiration = "Expires".

Verification: built :wallet:assembleFdroidSim; after-android-1/2 show "Expires / November 12, 2026 at 2:21 AM" on both prompts; after-android-3-send-detail.png shows "Expires / December 9, 2026 at 2:37 AM" (the exchange's default push expiration) on a pending Send.

Assisted by LLM
Steps To Reproduce1. Fund the wallet from https://bank.demo.taler.net/.
2. Let another wallet create a pay-push link and a pay-pull link (expiry e.g. 1 day) and open them: no expiry on either prompt.
3. Send money: the pending transaction's detail shows no expiry either.
TagsNo tags attached.
Attached Files
before-android-1-receive.png (52,494 bytes)   
before-android-1-receive.png (52,494 bytes)   
before-android-2-payrequest.png (48,025 bytes)   
before-android-2-payrequest.png (48,025 bytes)   
before-android-3-send-detail.png (129,884 bytes)   
before-android-3-send-detail.png (129,884 bytes)   
before-ios-1-receive.png (170,232 bytes)   
before-ios-1-receive.png (170,232 bytes)   
before-ios-2-payrequest.png (174,678 bytes)   
before-ios-2-payrequest.png (174,678 bytes)   
before-web-2-payrequest.png (87,082 bytes)   
before-web-2-payrequest.png (87,082 bytes)   
before-web-3-send-detail.png (175,076 bytes)   
before-web-3-send-detail.png (175,076 bytes)   
after-android-1-receive.png (69,677 bytes)   
after-android-1-receive.png (69,677 bytes)   
after-android-2-payrequest.png (65,065 bytes)   
after-android-2-payrequest.png (65,065 bytes)   
after-android-3-send-detail.png (132,083 bytes)   
after-android-3-send-detail.png (132,083 bytes)   
android-peer-expiration.patch (8,700 bytes)   
diff --git a/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt
index 4393e470..32858701 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/ExpirationComposable.kt
@@ -26,13 +26,30 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
+import net.taler.common.Timestamp
+import net.taler.lib.android.toAbsoluteTime
 import net.taler.wallet.R
 import net.taler.wallet.compose.NumericInputField
 import net.taler.wallet.compose.SelectionChip
 import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.transactions.TransactionInfoComposable
+
+/**
+ * When a peer payment link (purse) expires: until then the other party can
+ * accept/pay it, afterwards the money goes back. Nothing is shown for "never".
+ */
+@Composable
+fun PeerExpirationComposable(expiration: Timestamp?) {
+    if (expiration == null || expiration.ms < 0) return
+    TransactionInfoComposable(
+        label = stringResource(R.string.peer_expiration),
+        info = expiration.ms.toAbsoluteTime(LocalContext.current).toString(),
+    )
+}
 
 enum class ExpirationOption(val hours: Long) {
     DAYS_1(24),
diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt
index 2171f445..fbc0ec17 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingComposable.kt
@@ -125,6 +125,7 @@ fun ColumnScope.PeerPullTermsComposable(
         text = terms.contractTerms.summary,
         style = MaterialTheme.typography.headlineSmall,
     )
+    PeerExpirationComposable(terms.contractTerms.purseExpiration)
     Spacer(modifier = Modifier.weight(1f))
     Card(modifier = Modifier.fillMaxWidth()) {
         Column(
diff --git a/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt
index 6d0ae065..0e3eff51 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/IncomingState.kt
@@ -16,8 +16,10 @@
 
 package net.taler.wallet.peer
 
+import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 import net.taler.common.Amount
+import net.taler.common.Timestamp
 import net.taler.wallet.backend.TalerErrorInfo
 
 sealed class IncomingState
@@ -59,6 +61,8 @@ data class IncomingError(
 data class PeerContractTerms(
     val summary: String,
     val amount: Amount,
+    @SerialName("purse_expiration")
+    val purseExpiration: Timestamp? = null,
 )
 
 @Serializable
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt
index b14d4f5d..a5675889 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullCredit.kt
@@ -38,6 +38,7 @@ import net.taler.wallet.transactions.TransactionAction.Suspend
 import net.taler.wallet.transactions.TransactionAmountComposable
 import net.taler.wallet.transactions.TransactionInfoComposable
 import net.taler.wallet.transactions.TransactionMajorState.Done
+import net.taler.wallet.transactions.TransactionMajorState.Dialog
 import net.taler.wallet.transactions.TransactionMajorState.Pending
 import net.taler.wallet.transactions.TransactionMinorState.CreatePurse
 import net.taler.wallet.transactions.TransactionMinorState.Ready
@@ -92,6 +93,10 @@ fun ColumnScope.TransactionPeerPullCreditComposable(
         label = stringResource(id = R.string.send_peer_purpose),
         info = t.info.summary ?: "",
     )
+
+    if (t.txState.major == Pending || t.txState.major == Dialog) {
+        PeerExpirationComposable(t.info.expiration)
+    }
 }
 
 @Preview
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt
index ecc0377e..21ddc4b7 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPullDebit.kt
@@ -33,6 +33,7 @@ import net.taler.wallet.transactions.TransactionAction.Retry
 import net.taler.wallet.transactions.TransactionAction.Suspend
 import net.taler.wallet.transactions.TransactionAmountComposable
 import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Dialog
 import net.taler.wallet.transactions.TransactionMajorState.Pending
 import net.taler.wallet.transactions.TransactionPeerComposable
 import net.taler.wallet.transactions.TransactionPeerPullDebit
@@ -65,6 +66,10 @@ fun TransactionPeerPullDebitComposable(t: TransactionPeerPullDebit) {
         label = stringResource(id = R.string.send_peer_purpose),
         info = t.info.summary ?: "",
     )
+
+    if (t.txState.major == Pending || t.txState.major == Dialog) {
+        PeerExpirationComposable(t.info.expiration)
+    }
 }
 
 @Preview
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt
index aa7659a6..813707e2 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushCredit.kt
@@ -36,6 +36,7 @@ import net.taler.wallet.transactions.TransactionAction.Retry
 import net.taler.wallet.transactions.TransactionAction.Suspend
 import net.taler.wallet.transactions.TransactionAmountComposable
 import net.taler.wallet.transactions.TransactionInfoComposable
+import net.taler.wallet.transactions.TransactionMajorState.Dialog
 import net.taler.wallet.transactions.TransactionMajorState.Pending
 import net.taler.wallet.transactions.TransactionPeerComposable
 import net.taler.wallet.transactions.TransactionPeerPushCredit
@@ -73,6 +74,10 @@ fun ColumnScope.TransactionPeerPushCreditComposable(
         label = stringResource(id = R.string.send_peer_purpose),
         info = t.info.summary ?: "",
     )
+
+    if (t.txState.major == Pending || t.txState.major == Dialog) {
+        PeerExpirationComposable(t.info.expiration)
+    }
 }
 
 @Preview
diff --git a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt
index 4dc712b5..cc1f35fa 100644
--- a/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt
+++ b/wallet/src/main/java/net/taler/wallet/peer/TransactionPeerPushDebit.kt
@@ -47,6 +47,7 @@ import net.taler.wallet.transactions.TransactionAction.Suspend
 import net.taler.wallet.transactions.TransactionAmountComposable
 import net.taler.wallet.transactions.TransactionInfoComposable
 import net.taler.wallet.transactions.TransactionMajorState.Done
+import net.taler.wallet.transactions.TransactionMajorState.Dialog
 import net.taler.wallet.transactions.TransactionMajorState.Pending
 import net.taler.wallet.transactions.TransactionMinorState.CreatePurse
 import net.taler.wallet.transactions.TransactionMinorState.KycRequired
@@ -99,6 +100,10 @@ fun ColumnScope.TransactionPeerPushDebitComposable(
         label = stringResource(id = R.string.send_peer_purpose),
         info = t.info.summary ?: "",
     )
+
+    if (t.txState.major == Pending || t.txState.major == Dialog) {
+        PeerExpirationComposable(t.info.expiration)
+    }
 }
 
 @Composable
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
index 89750709..ff02094e 100644
--- a/wallet/src/main/res/values/strings.xml
+++ b/wallet/src/main/res/values/strings.xml
@@ -347,6 +347,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
     <string name="send_peer_expiration_days">Days</string>
     <string name="send_peer_expiration_hours">Hours</string>
     <string name="send_peer_expiration_period">Expires in</string>
+    <string name="peer_expiration">Expires</string>
     <string name="send_peer_payment_instruction">Scan this QR code to receive %1$s</string>
     <string name="send_peer_purpose">Purpose</string>
     <string name="send_peer_title">Send money to another wallet</string>
android-peer-expiration.patch (8,700 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-11 01:39bot-cgNew Issue
2026-11-11 01:39bot-cgFile Added: before-android-1-receive.png
2026-11-11 01:39bot-cgFile Added: before-android-2-payrequest.png
2026-11-11 01:39bot-cgFile Added: before-android-3-send-detail.png
2026-11-11 01:39bot-cgFile Added: before-ios-1-receive.png
2026-11-11 01:39bot-cgFile Added: before-ios-2-payrequest.png
2026-11-11 01:39bot-cgFile Added: before-web-2-payrequest.png
2026-11-11 01:39bot-cgFile Added: before-web-3-send-detail.png
2026-11-11 01:39bot-cgFile Added: after-android-1-receive.png
2026-11-11 01:39bot-cgFile Added: after-android-2-payrequest.png
2026-11-11 01:39bot-cgFile Added: after-android-3-send-detail.png
2026-11-11 01:39bot-cgFile Added: android-peer-expiration.patch