taler-android

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

commit b1cc9e96782f07b3b5c75aebe75048ce1c981f97
parent 95de9ff11af35a5b2e324ad606a8d56b335b14c3
Author: Iván Ávalos <avalos@disroot.org>
Date:   Sat, 16 May 2026 16:57:21 +0200

[wallet] initial implementation of tokens UI

Diffstat:
Mwallet/src/main/java/net/taler/wallet/WalletNavHost.kt | 18++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/WalletNavigation.kt | 6++++++
Mwallet/src/main/java/net/taler/wallet/main/MainScreen.kt | 7+++++++
Mwallet/src/main/java/net/taler/wallet/main/MainViewModel.kt | 2++
Awallet/src/main/java/net/taler/wallet/tokens/TokenListScreen.kt | 429+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/tokens/TokenManager.kt | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/tokens/TokenResponses.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/res/values/strings.xml | 18++++++++++++++++++
8 files changed, 640 insertions(+), 0 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -53,6 +53,8 @@ import net.taler.wallet.peer.IncomingPushPaymentScreen import net.taler.wallet.peer.OutgoingPullScreen import net.taler.wallet.peer.OutgoingPushScreen import net.taler.wallet.settings.PerformanceStatsScreen +import net.taler.wallet.tokens.TokenListScreen +import net.taler.wallet.tokens.TokenViewMode import net.taler.wallet.transactions.TransactionDetailScreen import net.taler.wallet.transfer.WireTransferDetailsScreen import net.taler.wallet.withdraw.PromptWithdrawScreen @@ -252,6 +254,22 @@ fun WalletNavHost( onShowError = { onShowError(it) } ) } + composable<WalletDestination.DiscountList> { + TokenListScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + viewMode = TokenViewMode.Discounts, + ) + } + composable<WalletDestination.PassList> { + TokenListScreen( + model = model, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + viewMode = TokenViewMode.Passes, + ) + } composable<WalletDestination.SetDonau> { backStackEntry -> val dest = backStackEntry.toRoute<WalletDestination.SetDonau>() SetDonauScreen( diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -51,6 +51,12 @@ sealed interface WalletDestination { data class AddBankAccount(val bankAccountId: String? = null) : WalletDestination @Serializable + data object DiscountList : WalletDestination + + @Serializable + data object PassList : WalletDestination + + @Serializable data class DonauStatement(val host: String) : WalletDestination @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -291,6 +291,12 @@ fun MainScreen( onStatementClicked = { onNavigate(WalletDestination.DonauStatement(it), false) }, + onShowDiscounts = { + onNavigate(WalletDestination.DiscountList, false) + }, + onShowPasses = { + onNavigate(WalletDestination.PassList, false) + }, ) is ViewMode.Transactions -> { @@ -416,6 +422,7 @@ fun UriInputDialog( val isValidTalerUri = { uri: String -> uri.trim().startsWith("taler://", ignoreCase = true) || + uri.trim().startsWith("taler+http://", ignoreCase = true) || uri.trim().startsWith("payto://", ignoreCase = true) } diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -58,6 +58,7 @@ import net.taler.wallet.withdraw.WithdrawManager import net.taler.wallet.BuildConfig import net.taler.wallet.NetworkManager import net.taler.wallet.donau.DonauManager +import net.taler.wallet.tokens.TokenManager const val TAG = "taler-wallet" const val OBSERVABILITY_LIMIT = 100 @@ -113,6 +114,7 @@ class MainViewModel( val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope, balanceManager) val accountManager: AccountManager = AccountManager(api, viewModelScope) val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager) + val tokenManager: TokenManager = TokenManager(api) val donauManager: DonauManager = DonauManager(api, viewModelScope, exchangeManager) private val mAuthenticated = MutableStateFlow(false) diff --git a/wallet/src/main/java/net/taler/wallet/tokens/TokenListScreen.kt b/wallet/src/main/java/net/taler/wallet/tokens/TokenListScreen.kt @@ -0,0 +1,428 @@ +/* + * 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.wallet.tokens + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Badge +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import net.taler.common.RelativeTime +import net.taler.common.TalerUtils +import net.taler.common.Timestamp +import net.taler.lib.android.toAbsoluteTime +import net.taler.wallet.NavigateCallback +import net.taler.wallet.R +import net.taler.wallet.cleanExchange +import net.taler.wallet.compose.EmptyComposable +import net.taler.wallet.compose.ErrorComposable +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.compose.cardPaddings +import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.main.MainViewModel +import net.taler.wallet.ui.theme.TalerTheme + +enum class TokenViewMode { + Discounts, + Passes, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TokenListScreen( + model: MainViewModel, + viewMode: TokenViewMode, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val pages = listOf(TokenFilter.Valid, TokenFilter.Expired) + val pagerState = rememberPagerState { pages.size } + + val scope = rememberCoroutineScope() + val devMode by model.devMode.observeAsState(false) + val discounts by model.tokenManager.discounts.collectAsStateLifecycleAware(UiState.Loading) + val passes by model.tokenManager.passes.collectAsStateLifecycleAware(UiState.Loading) + + GlobalScaffold( + model = model, + modifier = Modifier.fillMaxSize(), + onNavigateBack = onNavigateBack, + title = { + Text(when (viewMode) { + TokenViewMode.Discounts -> stringResource(R.string.discounts_title) + TokenViewMode.Passes -> stringResource(R.string.passes_title) + }) + }, + tabs = { + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + ) { + pages.forEachIndexed { index, filter -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.scrollToPage(index) } }, + text = { + Text( + when (filter) { + TokenFilter.Valid -> stringResource(R.string.tokens_filter_available) + TokenFilter.Expired -> stringResource(R.string.tokens_filter_expired) + } + ) + }, + ) + } + } + } + ) { paddingValues -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + verticalAlignment = Alignment.Top, + ) { page -> + val filter = pages[page] + when (viewMode) { + TokenViewMode.Discounts -> DiscountList(discounts, filter, devMode) + TokenViewMode.Passes -> PassList(passes, filter, devMode) + } + } + } +} + +@Composable +fun DiscountList(uiState: UiState<List<DiscountListDetail>>, filter: TokenFilter, devMode: Boolean) { + TokenListContent(uiState, devMode) { discounts -> + val filtered = discounts.filterDiscounts(filter) + if (filtered.isEmpty()) { + EmptyComposable( + message = when (filter) { + is TokenFilter.Valid -> stringResource(R.string.passes_empty_valid) + is TokenFilter.Expired -> stringResource(R.string.passes_empty_expired) + }, + ) + } + LazyColumn(Modifier.fillMaxSize()) { + items(filtered) { discount -> + DiscountCard(discount) + } + } + } +} + +@Composable +fun PassList(uiState: UiState<List<SubscriptionListDetail>>, filter: TokenFilter, devMode: Boolean) { + TokenListContent(uiState, devMode) { passes -> + val filtered = passes.filterPasses(filter) + if (filtered.isEmpty()) { + EmptyComposable( + message = when (filter) { + is TokenFilter.Valid -> stringResource(R.string.passes_empty_valid) + is TokenFilter.Expired -> stringResource(R.string.passes_empty_expired) + }, + ) + } + LazyColumn(Modifier.fillMaxSize()) { + items(filtered) { pass -> + PassCard(pass) + } + } + } +} + +@Composable +fun <T> TokenListContent( + uiState: UiState<T>, + devMode: Boolean, + content: @Composable (T) -> Unit +) { + Box(Modifier.fillMaxSize()) { + when (uiState) { + is UiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center)) + is UiState.Error -> ErrorComposable(error = uiState.error, devMode = devMode) + is UiState.Success -> content(uiState.data) + } + } +} + +@Composable +fun DiscountCard(pass: DiscountListDetail) { + val isActive = remember(pass) { pass.isActive } + OutlinedCard(Modifier.cardPaddings()) { + Column { + ListItem( + headlineContent = { + Text(pass.name, style = MaterialTheme.typography.headlineMedium + .copy(fontWeight = FontWeight.Medium)) + }, + supportingContent = { + Column { + Text( + TalerUtils.getLocalizedString( + pass.descriptionI18n, + pass.description, + ), + style= MaterialTheme.typography.labelLarge, + ) + + Text( + // TODO: expose merchantName in wallet-core + stringResource( + R.string.pass_issuer, + cleanExchange(pass.merchantBaseUrl) + ), + modifier = Modifier.padding(top = 5.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + trailingContent = { + Badge( + containerColor = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = if (isActive) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) { + Text( + text = stringResource(R.string.discount_quantity, pass.tokensAvailable), + modifier = Modifier.padding(3.5.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + ) + + TokenValidityFooter(isActive, + pass.validityStart, + pass.validityEnd) + } + } +} + +@Composable +fun PassCard(pass: SubscriptionListDetail) { + val isActive = remember(pass) { pass.isActive } + OutlinedCard(Modifier.cardPaddings()) { + Column { + ListItem( + headlineContent = { + Text(pass.name, style = MaterialTheme.typography.headlineMedium + .copy(fontWeight = FontWeight.Medium)) + }, + supportingContent = { + Column { + Text( + TalerUtils.getLocalizedString( + pass.descriptionI18n, + pass.description, + ), + style = MaterialTheme.typography.labelLarge, + ) + + Text( + // TODO: expose merchantName in wallet-core + stringResource( + R.string.pass_issuer, + cleanExchange(pass.merchantBaseUrl) + ), + modifier = Modifier.padding(top = 5.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + trailingContent = { + if (isActive) { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Text( + text = stringResource(R.string.pass_active), + modifier = Modifier.padding(3.5.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + ) + + TokenValidityFooter(isActive, + pass.validityStart, + pass.validityEnd) + } + } +} + +@Composable +fun TokenValidityFooter( + isActive: Boolean, + validityStart: Timestamp, + validityEnd: Timestamp, +) { + val context = LocalContext.current + + HorizontalDivider() + + Box(modifier = Modifier + .background(if (isActive) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + }) + .fillMaxWidth() + .padding(vertical = 12.dp) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center, + ) { + if (isActive) { + Text( + text = stringResource( + R.string.token_valid_until, + validityEnd.ms.toAbsoluteTime(context), + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + Text( + text = stringResource( + R.string.token_valid_from, + validityStart.ms.toAbsoluteTime(context), + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +private val oneWeek = RelativeTime.fromMillis(86400000 * 7) + +@Preview +@Composable +fun DiscountListPreview() { + val now = Timestamp.now() + TalerSurface { + TalerTheme { + DiscountList( + uiState = UiState.Success( + data = listOf( + DiscountListDetail( + tokenFamilyHash = "", + tokenIssuePubHash = "", + merchantBaseUrl = "https://backend.demo.taler.net/", + name = "20% off", + description = "valid for fruits", + descriptionI18n = mapOf(), + validityStart = now - oneWeek, + validityEnd = now + oneWeek, + tokensAvailable = 3, + ), + DiscountListDetail( + tokenFamilyHash = "", + tokenIssuePubHash = "", + merchantBaseUrl = "https://backend.demo.taler.net/", + name = "Name", + description = "Description", + descriptionI18n = mapOf(), + validityStart = now + oneWeek, + validityEnd = now + oneWeek + oneWeek, + tokensAvailable = 2, + ), + ), + ), + filter = TokenFilter.Valid, + devMode = true, + ) + } + } +} + +@Preview +@Composable +fun PassListPreview() { + val now = Timestamp.now() + TalerSurface { + TalerTheme { + PassList( + uiState = UiState.Success( + data = listOf( + SubscriptionListDetail( + tokenFamilyHash = "", + tokenIssuePubHash = "", + merchantBaseUrl = "https://backend.demo.taler.net/", + name = "Premium pass", + description = "Provides you access to this mega description", + descriptionI18n = mapOf(), + validityStart = now - oneWeek, + validityEnd = now + oneWeek, + ), + + SubscriptionListDetail( + tokenFamilyHash = "", + tokenIssuePubHash = "", + merchantBaseUrl = "https://backend.demo.taler.net/", + name = "Name", + description = "Description", + descriptionI18n = mapOf(), + validityStart = now + oneWeek, + validityEnd = now + oneWeek + oneWeek, + ), + ), + ), + filter = TokenFilter.Valid, + devMode = true, + ) + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/tokens/TokenManager.kt b/wallet/src/main/java/net/taler/wallet/tokens/TokenManager.kt @@ -0,0 +1,83 @@ +/* + * 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.wallet.tokens + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import net.taler.common.Timestamp +import net.taler.wallet.backend.TalerErrorInfo +import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.backend.WalletResponse + +sealed class UiState<out T> { + object Loading: UiState<Nothing>() + data class Success<out T>(val data: T): UiState<T>() + data class Error(val error: TalerErrorInfo): UiState<Nothing>() +} + +fun <T, U> WalletResponse<T>.toUiState(transform: (r: T) -> U): UiState<U> = when(this) { + is WalletResponse.Success -> UiState.Success(transform(this.result)) + is WalletResponse.Error -> UiState.Error(this.error) +} + +sealed class TokenFilter() { + // data object All: TokenFilter() + data object Valid: TokenFilter() + data object Expired: TokenFilter() +} + +class TokenManager(private val api: WalletBackendApi){ + private val refreshTrigger = + MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) } + + @OptIn(ExperimentalCoroutinesApi::class) + val discounts: Flow<UiState<List<DiscountListDetail>>> = refreshTrigger.flatMapLatest { + flow { + emit(UiState.Loading) + emit(api.request("listDiscounts", ListDiscountsResponse.serializer()) + .toUiState { it.discounts }) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val passes: Flow<UiState<List<SubscriptionListDetail>>> = refreshTrigger.flatMapLatest { + flow { + emit(UiState.Loading) + emit(api.request("listSubscriptions", ListSubscriptionsResponse.serializer()) + .toUiState { it.subscriptions }) + } + } + + fun refresh() { refreshTrigger.tryEmit(Unit) } +} + +fun List<DiscountListDetail>.filterDiscounts(filter: TokenFilter): List<DiscountListDetail> { + return when(filter) { + TokenFilter.Valid -> filter { !it.isExpired } + TokenFilter.Expired -> filter { it.isExpired } + } +} + +fun List<SubscriptionListDetail>.filterPasses(filter: TokenFilter): List<SubscriptionListDetail> { + return when(filter) { + TokenFilter.Valid -> filter { !it.isExpired } + TokenFilter.Expired -> filter { it.isExpired } + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/tokens/TokenResponses.kt b/wallet/src/main/java/net/taler/wallet/tokens/TokenResponses.kt @@ -0,0 +1,75 @@ +/* + * 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.wallet.tokens + +import kotlinx.serialization.Serializable +import net.taler.common.Timestamp + +@Serializable +data class DiscountListDetail( + val tokenFamilyHash: String, + val tokenIssuePubHash: String, + val merchantBaseUrl: String, + val name: String, + val description: String, + val descriptionI18n: Map<String, String>, + val validityStart: Timestamp, + val validityEnd: Timestamp, + val tokensAvailable: Int, +) { + val isActive: Boolean get() { + val now = Timestamp.now() + return now in validityStart..validityEnd + } + + val isExpired: Boolean get() { + val now = Timestamp.now() + return now >= validityEnd + } +} + +@Serializable +data class ListDiscountsResponse( + val discounts: List<DiscountListDetail>, +) + +@Serializable +data class SubscriptionListDetail( + val tokenFamilyHash: String, + val tokenIssuePubHash: String, + val merchantBaseUrl: String, + val name: String, + val description: String, + val descriptionI18n: Map<String, String>, + val validityStart: Timestamp, + val validityEnd: Timestamp, +) { + val isActive: Boolean get() { + val now = Timestamp.now() + return now in validityStart..validityEnd + } + + val isExpired: Boolean get() { + val now = Timestamp.now() + return now >= validityEnd + } +} + +@Serializable +data class ListSubscriptionsResponse( + val subscriptions: List<SubscriptionListDetail>, +) +\ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <!-- must be minus sign! (− U+2212) --> <string name="balances_outbound_amount">−%1$s outgoing</string> <string name="balances_section_statements">Donation statements</string> + <string name="balances_section_tokens">Other assets</string> <string name="balances_title">Balances</string> <!-- Transactions --> @@ -205,6 +206,23 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="transactions_suspend">Suspend</string> <string name="transactions_title">Transactions</string> + <!-- Discounts/passes --> + <string name="discount_issuer">Redeemable at %1$s</string> + <!-- must be a × (U+2A09) times character --> + <string name="discount_quantity">× %1$d</string> + <string name="discounts_empty_expired">You don\'t have any expired discounts</string> + <string name="discounts_empty_valid">You don\'t have any valid discounts</string> + <string name="discounts_title">Discounts</string> + <string name="pass_active">Active</string> + <string name="pass_issuer">Provided by %1$s</string> + <string name="passes_empty_expired">You don\'t have any expired passes</string> + <string name="passes_empty_valid">You don\'t have any valid passes</string> + <string name="passes_title">Passes</string> + <string name="token_valid_from">Valid from %1$s</string> + <string name="tokens_filter_available">Available</string> + <string name="tokens_filter_expired">Expired</string> + <string name="token_valid_until">Valid until %1$s</string> + <!-- Donau --> <string name="donau_id">Donor tax payer ID</string>