taler-android

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

commit f147a929d07285b982481c9d9a636feb3f617eb4
parent f3c4ec152537c0ddf207fec5ec10425c8d64c70a
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Sun, 30 Aug 2026 18:09:14 +0200

[merchant-terminal] small updates

Diffstat:
Mmerchant-terminal/.gitignore | 1+
Mmerchant-terminal/build.gradle | 5+++++
Mmerchant-terminal/fastlane/metadata/android/en-US/images/icon.png | 0
Amerchant-terminal/src/experimental/java/net/taler/merchantpos/payment/UsbSerialNfcDevice.kt | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmerchant-terminal/src/main/AndroidManifest.xml | 1-
Mmerchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt | 6+++++-
Mmerchant-terminal/src/main/java/net/taler/merchantpos/config/GeneralSettingsFragment.kt | 47++++++++++++++++++++++++++++++++++++++++++++++-
Mmerchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mmerchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt | 10++++++++++
Mmerchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt | 45++++++++++++++++++++++++++++++++++++++++++++-
Mmerchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt | 43+++++++++++++++++++++++++++++++++++++++++++
Mmerchant-terminal/src/main/res/values-de/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-es/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-fi/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-fr/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-it/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-iw/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-ru/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-sv/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-tr/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values-uk/strings.xml | 7+++++++
Mmerchant-terminal/src/main/res/values/strings.xml | 7+++++++
Amerchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryPaginationTest.kt | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amerchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentUriTest.kt | 48++++++++++++++++++++++++++++++++++++++++++++++++
24 files changed, 617 insertions(+), 29 deletions(-)

diff --git a/merchant-terminal/.gitignore b/merchant-terminal/.gitignore @@ -1,2 +1,3 @@ /build /.bundle +/src/debug/assets/screenshot-products/*.png diff --git a/merchant-terminal/build.gradle b/merchant-terminal/build.gradle @@ -91,6 +91,11 @@ dependencies { implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" + // Experimental external NFC URI broadcaster connected through Android USB host mode. + // Its source is kept under src/experimental and is intentionally excluded from builds. + // FIXME: Add an experimental build variant before enabling this dependency. + // implementation "com.github.mik3y:usb-serial-for-android:3.10.0" + testImplementation 'androidx.test.ext:junit:1.3.0' testImplementation 'org.robolectric:robolectric:4.16.1' diff --git a/merchant-terminal/fastlane/metadata/android/en-US/images/icon.png b/merchant-terminal/fastlane/metadata/android/en-US/images/icon.png Binary files differ. diff --git a/merchant-terminal/src/experimental/java/net/taler/merchantpos/payment/UsbSerialNfcDevice.kt b/merchant-terminal/src/experimental/java/net/taler/merchantpos/payment/UsbSerialNfcDevice.kt @@ -0,0 +1,184 @@ +/* + * 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. If not, see <http://www.gnu.org/licenses/>. + */ + +package net.taler.merchantpos.payment + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.util.Log +import androidx.core.content.ContextCompat +import com.hoho.android.usbserial.driver.UsbSerialPort +import com.hoho.android.usbserial.driver.UsbSerialProber +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Experimental USB-host integration; this source set is intentionally not built. + * + * Sends the active payment or refund URI to the optional USB-connected NFC broadcaster. + * + * The broadcaster accepts a URL terminated by a line feed and clears its NFC + * payload when it receives an empty line. + */ +class UsbSerialNfcDevice(private val context: Context) : AutoCloseable { + + private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val lock = Any() + private val permissionAction = "${context.packageName}.USB_SERIAL_NFC_PERMISSION" + + private var started = false + private var desiredPayload = RESET_PAYLOAD + private var lastSentPayload: String? = null + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + permissionAction -> { + if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + enqueueSend(force = true) + } else { + Log.w(TAG, "USB serial permission denied") + } + } + + UsbManager.ACTION_USB_DEVICE_ATTACHED -> enqueueSend(force = true) + } + } + } + + fun start() { + synchronized(lock) { + if (started) return + started = true + } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter().apply { + addAction(permissionAction) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + }, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + enqueueSend(force = true) + } + + fun setUri(uri: String?) { + val payload = uri?.takeIf(String::isNotBlank)?.plus("\n") ?: RESET_PAYLOAD + val changed = synchronized(lock) { + if (desiredPayload == payload) false else { + desiredPayload = payload + true + } + } + if (changed) enqueueSend() + } + + private fun enqueueSend(force: Boolean = false) { + val shouldSend = synchronized(lock) { + started && (force || desiredPayload != lastSentPayload) + } + if (!shouldSend) return + + executor.execute { + val payload = synchronized(lock) { + if (!started) return@execute + desiredPayload + } + send(if (payload == RESET_PAYLOAD) listOf(payload) else listOf(RESET_PAYLOAD, payload)) + } + } + + private fun send(payloads: List<String>) { + val driver = UsbSerialProber.getDefaultProber() + .findAllDrivers(usbManager) + .firstOrNull() + ?: return + val device = driver.device + val connection = usbManager.openDevice(device) + if (connection == null) { + requestPermission(device) + return + } + + val port = driver.ports.firstOrNull() ?: run { + connection.close() + return + } + try { + port.open(connection) + port.setParameters( + BAUD_RATE, + DATA_BITS, + UsbSerialPort.STOPBITS_1, + UsbSerialPort.PARITY_NONE, + ) + payloads.forEach { payload -> + port.write(payload.toByteArray(Charsets.UTF_8), WRITE_TIMEOUT_MILLIS) + } + synchronized(lock) { + if (desiredPayload == payloads.last()) lastSentPayload = payloads.last() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to write to USB NFC device", e) + } finally { + try { + port.close() + } catch (e: Exception) { + Log.w(TAG, "Failed to close USB serial port", e) + } + connection.close() + } + } + + private fun requestPermission(device: UsbDevice) { + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(permissionAction).setPackage(context.packageName), + PendingIntent.FLAG_IMMUTABLE, + ) + usbManager.requestPermission(device, permissionIntent) + } + + override fun close() { + synchronized(lock) { + if (!started) return + desiredPayload = RESET_PAYLOAD + lastSentPayload = null + } + // Keep ordering with a preceding URI write, but do not skip the reset + // after marking this instance inactive. + executor.execute { send(listOf(RESET_PAYLOAD)) } + context.unregisterReceiver(receiver) + synchronized(lock) { started = false } + executor.shutdown() + } + + private companion object { + const val TAG = "UsbSerialNfcDevice" + const val BAUD_RATE = 115200 + const val DATA_BITS = 8 + const val WRITE_TIMEOUT_MILLIS = 1000 + const val RESET_PAYLOAD = "\n" + } +} diff --git a/merchant-terminal/src/main/AndroidManifest.xml b/merchant-terminal/src/main/AndroidManifest.xml @@ -29,7 +29,6 @@ <uses-feature android:name="android.hardware.camera" android:required="false" /> - <application android:allowBackup="true" android:fullBackupContent="@xml/backup_descriptor" diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -122,7 +122,11 @@ class MainActivity : AppCompatActivity() { TalerNfcService.startService(this) model.paymentManager.payment.observe(this) { payment -> - payment?.talerPayUri?.let { + val paymentNfcUri = payment?.talerPayUri?.takeIf { + !payment.claimed && !payment.paid && payment.error == null + } + paymentNfcUri?.let { + TalerNfcService.clearNdefPayload(this) TalerNfcService.setUri(this, it) } ?: run { TalerNfcService.clearNdefPayload(this) diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/config/GeneralSettingsFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/config/GeneralSettingsFragment.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -41,6 +42,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable @@ -198,11 +200,54 @@ private fun GeneralSettingsScreen( var selectedInitialOrderScreen by rememberSaveable { mutableStateOf(initialSelectedOrderScreen) } + var logoutConfirmationStep by rememberSaveable { mutableStateOf(0) } val selectedInitialOrderLabel = initialOrderOptions.firstOrNull { it.screen == selectedInitialOrderScreen }?.label ?: initialOrderOptions.firstOrNull()?.label.orEmpty() PosTheme { + when (logoutConfirmationStep) { + 1 -> AlertDialog( + onDismissRequest = { logoutConfirmationStep = 0 }, + title = { Text(stringResource(R.string.settings_logout_button)) }, + text = { Text(stringResource(R.string.settings_logout_warning)) }, + confirmButton = { + Button(onClick = { logoutConfirmationStep = 2 }) { + Text(stringResource(R.string.payment_back_button)) + } + }, + dismissButton = { + TextButton(onClick = { logoutConfirmationStep = 0 }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + + 2 -> AlertDialog( + onDismissRequest = { logoutConfirmationStep = 0 }, + title = { Text(stringResource(R.string.settings_logout_button)) }, + text = { Text(stringResource(R.string.settings_logout_confirmation)) }, + confirmButton = { + Button( + onClick = { + logoutConfirmationStep = 0 + onLogoutClick() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_logout_button)) + } + }, + dismissButton = { + TextButton(onClick = { logoutConfirmationStep = 0 }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -267,7 +312,7 @@ private fun GeneralSettingsScreen( } Button( modifier = Modifier.fillMaxWidth(), - onClick = onLogoutClick, + onClick = { logoutConfirmationStep = 1 }, shape = SettingsControlShape, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer, 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 @@ -58,9 +58,7 @@ class HistoryManager( private val mError = MutableLiveData<HistoryError?>(null) val error: LiveData<HistoryError?> = mError - private val loadedItems = mutableListOf<OrderHistoryEntry>() - private var nextOffset: Long? = null - private var reachedEnd = false + private val pagination = HistoryPagination() @UiThread internal fun fetchHistory() = fetchHistoryPage(reset = true) @@ -68,7 +66,7 @@ class HistoryManager( @UiThread internal fun loadMoreHistoryIfNeeded(lastVisibleIndex: Int) { val currentItems = (mItems.value as? HistoryResult.Success)?.items ?: return - if (mIsLoading.value == true || mIsLoadingMore.value == true || reachedEnd) return + if (mIsLoading.value == true || mIsLoadingMore.value == true || pagination.reachedEnd) return if (currentItems.isEmpty()) return if (lastVisibleIndex < currentItems.lastIndex - LOAD_MORE_THRESHOLD) return fetchHistoryPage(reset = false) @@ -83,7 +81,7 @@ class HistoryManager( mIsLoadingMore.value = true } val merchantConfig = configManager.merchantConfig!! - val offset = if (reset) null else nextOffset + val offset = if (reset) null else pagination.nextOffset api.getOrderHistory( merchantConfig = merchantConfig, limit = -PAGE_SIZE, @@ -92,20 +90,7 @@ class HistoryManager( assertUiThread() mIsLoading.value = false mIsLoadingMore.value = false - if (reset) { - loadedItems.clear() - reachedEnd = false - } - val page = response.orders - val existingOrderIds = loadedItems.mapTo(mutableSetOf()) { it.orderId } - val newItems = page.filterNot { it.orderId in existingOrderIds } - if (newItems.isNotEmpty()) { - loadedItems += newItems - nextOffset = newItems.lastOrNull()?.rowId ?: loadedItems.lastOrNull()?.rowId - } - if (page.size < PAGE_SIZE || newItems.isEmpty()) { - reachedEnd = true - } + val newItems = pagination.addPage(response.orders, reset) publishItems() enrichOrders(newItems) } @@ -155,13 +140,12 @@ class HistoryManager( } private fun publishItems() { - mItems.value = HistoryResult.Success(loadedItems.toList()) + mItems.value = HistoryResult.Success(pagination.items) } @UiThread internal fun debugSetHistory(items: List<OrderHistoryEntry>) { - loadedItems.clear() - loadedItems.addAll(items) + pagination.replaceItems(items) publishItems() } @@ -189,9 +173,7 @@ class HistoryManager( orderId: String, transform: (OrderHistoryEntry) -> OrderHistoryEntry, ) { - val index = loadedItems.indexOfFirst { it.orderId == orderId } - if (index == -1) return - loadedItems[index] = transform(loadedItems[index]) + if (!pagination.updateItem(orderId, transform)) return publishItems() } @@ -202,3 +184,60 @@ class HistoryManager( mError.value = HistoryError(mainResId, msg) } } + +internal class HistoryPagination { + private val loadedItems = mutableListOf<OrderHistoryEntry>() + + var nextOffset: Long? = null + private set + + var reachedEnd: Boolean = false + private set + + val items: List<OrderHistoryEntry> + get() = loadedItems.toList() + + /** + * Adds one backend page and returns only entries that were not loaded before. + * + * The backend's limit is an upper bound, so a short page does not prove that + * iteration is complete. Depending on backend cursor semantics, a page may + * also repeat the order at [nextOffset]. In that case it is de-duplicated. + */ + fun addPage(page: List<OrderHistoryEntry>, reset: Boolean): List<OrderHistoryEntry> { + if (reset) { + loadedItems.clear() + nextOffset = null + reachedEnd = false + } + + val previousOffset = nextOffset + val existingOrderIds = loadedItems.mapTo(mutableSetOf()) { it.orderId } + val newItems = page.filterNot { it.orderId in existingOrderIds } + loadedItems += newItems + + val pageOffset = page.lastOrNull()?.rowId + val cursorAdvanced = pageOffset != null && (reset || pageOffset != previousOffset) + if (pageOffset != null) nextOffset = pageOffset + + reachedEnd = page.isEmpty() || newItems.isEmpty() || !cursorAdvanced + return newItems + } + + fun replaceItems(items: List<OrderHistoryEntry>) { + loadedItems.clear() + loadedItems.addAll(items) + nextOffset = items.lastOrNull()?.rowId + reachedEnd = false + } + + fun updateItem( + orderId: String, + transform: (OrderHistoryEntry) -> OrderHistoryEntry, + ): Boolean { + val index = loadedItems.indexOfFirst { it.orderId == orderId } + if (index == -1) return false + loadedItems[index] = transform(loadedItems[index]) + return true + } +} diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/Payment.kt @@ -24,7 +24,17 @@ data class Payment( val currency: String, val orderId: String? = null, val talerPayUri: String? = null, + val insecureTalerPayUri: String? = null, + val httpsUpgradeAccepted: Boolean = false, + val insecureUriAccepted: Boolean = false, val claimed: Boolean = false, val paid: Boolean = false, val error: String? = null ) + +private const val INSECURE_TALER_SCHEME = "taler+http://" + +internal fun upgradeTalerPayUriToHttps(uri: String): String? { + if (!uri.startsWith(INSECURE_TALER_SCHEME, ignoreCase = true)) return null + return "taler://${uri.substring(INSECURE_TALER_SCHEME.length)}" +} 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 @@ -176,7 +176,25 @@ class PaymentManager( } when (response) { is CheckPaymentResponse.Unpaid -> { - mPayment.value = currentValue.copy(talerPayUri = response.talerPayUri) + val upgradedUri = upgradeTalerPayUriToHttps(response.talerPayUri) + mPayment.value = when { + upgradedUri == null -> currentValue.copy( + talerPayUri = response.talerPayUri, + insecureTalerPayUri = null, + ) + currentValue.httpsUpgradeAccepted -> currentValue.copy( + talerPayUri = upgradedUri, + insecureTalerPayUri = null, + ) + currentValue.insecureUriAccepted -> currentValue.copy( + talerPayUri = response.talerPayUri, + insecureTalerPayUri = null, + ) + else -> currentValue.copy( + talerPayUri = null, + insecureTalerPayUri = response.talerPayUri, + ) + } } is CheckPaymentResponse.Claimed -> { mPayment.value = currentValue.copy(claimed = true) @@ -189,6 +207,31 @@ class PaymentManager( } } + @UiThread + fun tryHttpsPaymentUri() { + val currentValue = mPayment.value ?: return + val insecureUri = currentValue.insecureTalerPayUri ?: return + val upgradedUri = upgradeTalerPayUriToHttps(insecureUri) ?: return + mPayment.value = currentValue.copy( + talerPayUri = upgradedUri, + insecureTalerPayUri = null, + httpsUpgradeAccepted = true, + insecureUriAccepted = false, + ) + } + + @UiThread + fun continueWithInsecurePaymentUri() { + val currentValue = mPayment.value ?: return + val insecureUri = currentValue.insecureTalerPayUri ?: return + mPayment.value = currentValue.copy( + talerPayUri = insecureUri, + insecureTalerPayUri = null, + httpsUpgradeAccepted = false, + insecureUriAccepted = true, + ) + } + private fun onNetworkError(error: String) { assertUiThread() Log.d(TAG, "Network error: $error") 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 @@ -87,6 +87,9 @@ class ProcessPaymentFragment : Fragment() { payment = it, deviceHasNfc = deviceHasNfc, showForceDeleteDialog = deleteNeedsForce == true, + onTryHttps = paymentManager::tryHttpsPaymentUri, + onContinueAsIs = paymentManager::continueWithInsecurePaymentUri, + onCancelInsecurePayment = paymentManager::tryDeleteOrder, onCancel = { if (it.claimed) { paymentManager.tryDeleteOrder() @@ -174,6 +177,9 @@ private fun ProcessPaymentScreen( payment: Payment, deviceHasNfc: Boolean, showForceDeleteDialog: Boolean, + onTryHttps: () -> Unit, + onContinueAsIs: () -> Unit, + onCancelInsecurePayment: () -> Unit, onCancel: () -> Unit, onForceDelete: () -> Unit, onDismissForceDelete: () -> Unit, @@ -201,6 +207,40 @@ private fun ProcessPaymentScreen( } }, ) + } else if (payment.insecureTalerPayUri != null) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.insecure_payment_dialog_title)) }, + text = { Text(stringResource(R.string.insecure_payment_dialog_message)) }, + confirmButton = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onTryHttps, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.insecure_payment_dialog_try_https)) + } + OutlinedButton( + onClick = onContinueAsIs, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.insecure_payment_dialog_continue_as_is)) + } + OutlinedButton( + onClick = onCancelInsecurePayment, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.insecure_payment_dialog_cancel_order)) + } + } + }, + ) } PosTheme { @@ -452,6 +492,9 @@ internal fun PaymentScreenContent(payment: Payment) { payment = payment, deviceHasNfc = false, showForceDeleteDialog = false, + onTryHttps = {}, + onContinueAsIs = {}, + onCancelInsecurePayment = {}, onCancel = {}, onForceDelete = {}, onDismissForceDelete = {}, diff --git a/merchant-terminal/src/main/res/values-de/strings.xml b/merchant-terminal/src/main/res/values-de/strings.xml @@ -131,6 +131,8 @@ <string name="settings_instance_button">Anmeldeeinstellungen ändern</string> <string name="settings_logout_button">Abmelden</string> <string name="settings_about_title">Über</string> + <string name="settings_logout_warning">Beim Abmelden werden die Händlerverbindung, die Anmeldedaten und die zwischengespeicherte Konfiguration von diesem Gerät entfernt. Sie müssen erneut eine Verbindung herstellen, bevor Sie das Terminal verwenden können.</string> + <string name="settings_logout_confirmation">Möchten Sie sich wirklich abmelden?</string> <string name="amount_entry_charge">Kassieren</string> <string name="amount_entry_create_order_charge">Bestellung erstellen (Betrag kassieren)</string> <string name="amount_entry_backspace">Löschen</string> @@ -138,6 +140,11 @@ <string name="force_delete_dialog_title">Löschen der Bestellung erzwingen?</string> <string name="force_delete_dialog_message">Diese Bestellung wurde von einer Wallet beansprucht. Erzwungenes Löschen hebt Bestandssperren auf und verhindert den Abschluss der Zahlung in der Wallet.</string> <string name="force_delete_dialog_confirm">Löschen erzwingen</string> + <string name="insecure_payment_dialog_title">Problem mit dem Zahlungslink</string> + <string name="insecure_payment_dialog_message">Die Bestellung wurde erstellt, aber ihr Zahlungslink lässt sich möglicherweise nicht in der Wallet des Kunden öffnen. Sie können einen korrigierten Zahlungslink versuchen, unverändert fortfahren oder die Bestellung stornieren.</string> + <string name="insecure_payment_dialog_try_https">Korrigierten Link versuchen</string> + <string name="insecure_payment_dialog_continue_as_is">Unverändert fortfahren</string> + <string name="insecure_payment_dialog_cancel_order">Bestellung stornieren</string> <string-array name="settings_language_values"> <item></item> <item>en</item> diff --git a/merchant-terminal/src/main/res/values-es/strings.xml b/merchant-terminal/src/main/res/values-es/strings.xml @@ -105,6 +105,8 @@ <string name="settings_instance_description">Cambia la URL del portal del comercio, el nombre de usuario, la contraseña y otros ajustes.</string> <string name="settings_instance_button">Modificar ajustes de inicio de sesión</string> <string name="settings_logout_button">Cerrar sesión</string> + <string name="settings_logout_warning">Al cerrar sesión, se eliminarán de este dispositivo la conexión con el comercio, las credenciales de inicio de sesión y la configuración almacenada en caché. Tendrá que volver a conectarse antes de usar el terminal.</string> + <string name="settings_logout_confirmation">¿Seguro que quiere cerrar sesión?</string> <string name="amount_entry_label">Importe</string> <string name="amount_entry_charge">Cobrar</string> <string name="amount_entry_create_order_charge">Crear pedido (cobrar importe)</string> @@ -142,4 +144,9 @@ <string name="force_delete_dialog_title">¿Forzar la eliminación del pedido?</string> <string name="force_delete_dialog_message">Este pedido ha sido reclamado por una cartera. Forzar su eliminación liberará cualquier bloqueo de inventario e impedirá que la cartera complete el pago.</string> <string name="force_delete_dialog_confirm">Forzar eliminación</string> + <string name="insecure_payment_dialog_title">Problema con el enlace de pago</string> + <string name="insecure_payment_dialog_message">El pedido se creó, pero es posible que el enlace de pago no se abra en la cartera del cliente. Puedes probar un enlace de pago corregido, continuar tal cual o cancelar el pedido.</string> + <string name="insecure_payment_dialog_try_https">Probar enlace corregido</string> + <string name="insecure_payment_dialog_continue_as_is">Continuar tal cual</string> + <string name="insecure_payment_dialog_cancel_order">Cancelar pedido</string> </resources> diff --git a/merchant-terminal/src/main/res/values-fi/strings.xml b/merchant-terminal/src/main/res/values-fi/strings.xml @@ -99,6 +99,8 @@ <string name="settings_instance_description">Muuta kauppiasportaalin URL-osoitetta, käyttäjätunnusta, salasanaa ja muita asetuksia.</string> <string name="settings_instance_button">Muokkaa kirjautumisasetuksia</string> <string name="settings_logout_button">Kirjaudu ulos</string> + <string name="settings_logout_warning">Uloskirjautuminen poistaa kauppiasyhteyden, kirjautumistiedot ja välimuistiin tallennetut asetukset tältä laitteelta. Sinun on muodostettava yhteys uudelleen ennen päätteen käyttöä.</string> + <string name="settings_logout_confirmation">Haluatko varmasti kirjautua ulos?</string> <string name="config_merchant_url">Kauppiasportaalin URL</string> <string name="amount_entry_label">Summa</string> <string name="amount_entry_charge">Veloita</string> @@ -142,4 +144,9 @@ <string name="force_delete_dialog_title">Pakotetaanko tilauksen poistaminen?</string> <string name="force_delete_dialog_message">Lompakko on lunastanut tämän tilauksen. Pakotettu poistaminen vapauttaa kaikki varastovaraukset ja estää lompakkoa suorittamasta maksua.</string> <string name="force_delete_dialog_confirm">Pakota poisto</string> + <string name="insecure_payment_dialog_title">Maksulinkin ongelma</string> + <string name="insecure_payment_dialog_message">Tilaus luotiin, mutta sen maksulinkki ei ehkä avaudu asiakkaan lompakossa. Voit kokeilla korjattua maksulinkkiä, jatkaa sellaisenaan tai peruuttaa tilauksen.</string> + <string name="insecure_payment_dialog_try_https">Kokeile korjattua linkkiä</string> + <string name="insecure_payment_dialog_continue_as_is">Jatka sellaisenaan</string> + <string name="insecure_payment_dialog_cancel_order">Peruuta tilaus</string> </resources> diff --git a/merchant-terminal/src/main/res/values-fr/strings.xml b/merchant-terminal/src/main/res/values-fr/strings.xml @@ -122,6 +122,8 @@ <string name="settings_instance_button">Modifier les paramètres de connexion</string> <string name="settings_logout_button">Se déconnecter</string> <string name="settings_about_title">À propos</string> + <string name="settings_logout_warning">La déconnexion supprimera de cet appareil la connexion au commerçant, les identifiants de connexion et la configuration mise en cache. Vous devrez vous reconnecter avant d’utiliser le terminal.</string> + <string name="settings_logout_confirmation">Voulez-vous vraiment vous déconnecter ?</string> <string name="amount_entry_label">Montant</string> <string name="amount_entry_charge">Encaisser</string> <string name="amount_entry_create_order_charge">Créer une commande (encaisser le montant)</string> @@ -146,4 +148,9 @@ <string name="force_delete_dialog_title">Forcer la suppression de la commande ?</string> <string name="force_delete_dialog_message">Cette commande a été réclamée par un portefeuille (wallet). Forcer sa suppression libère les verrous de stock et empêche le portefeuille de finaliser le paiement.</string> <string name="force_delete_dialog_confirm">Forcer la suppression</string> + <string name="insecure_payment_dialog_title">Problème avec le lien de paiement</string> + <string name="insecure_payment_dialog_message">La commande a été créée, mais son lien de paiement risque de ne pas s’ouvrir dans le portefeuille du client. Vous pouvez essayer un lien de paiement corrigé, continuer tel quel ou annuler la commande.</string> + <string name="insecure_payment_dialog_try_https">Essayer le lien corrigé</string> + <string name="insecure_payment_dialog_continue_as_is">Continuer tel quel</string> + <string name="insecure_payment_dialog_cancel_order">Annuler la commande</string> </resources> diff --git a/merchant-terminal/src/main/res/values-it/strings.xml b/merchant-terminal/src/main/res/values-it/strings.xml @@ -87,6 +87,8 @@ <string name="settings_instance_description">Modifica l’URL del portale commerciante, il nome utente, la password e altre impostazioni.</string> <string name="settings_instance_button">Modifica impostazioni di accesso</string> <string name="settings_logout_button">Disconnetti</string> + <string name="settings_logout_warning">Disconnettendoti, la connessione all’esercente, le credenziali di accesso e la configurazione memorizzata nella cache verranno rimosse da questo dispositivo. Dovrai riconnetterti prima di usare il terminale.</string> + <string name="settings_logout_confirmation">Vuoi davvero disconnetterti?</string> <string name="config_label">Impostazioni commerciante</string> <string name="config_old_label">File JSON (vecchio)</string> <string name="config_setup_password">Password</string> @@ -149,6 +151,11 @@ <string name="force_delete_dialog_title">Eliminare forzatamente l’ordine?</string> <string name="force_delete_dialog_message">Questo ordine è stato reclamato da un portafoglio. L’eliminazione forzata rilascerà tutti i blocchi dell’inventario e impedirà al portafoglio di completare il pagamento.</string> <string name="force_delete_dialog_confirm">Elimina forzatamente</string> + <string name="insecure_payment_dialog_title">Problema con il link di pagamento</string> + <string name="insecure_payment_dialog_message">L’ordine è stato creato, ma il link di pagamento potrebbe non aprirsi nel portafoglio del cliente. Puoi provare un link di pagamento corretto, continuare così com’è oppure annullare l’ordine.</string> + <string name="insecure_payment_dialog_try_https">Prova il link corretto</string> + <string name="insecure_payment_dialog_continue_as_is">Continua così com’è</string> + <string name="insecure_payment_dialog_cancel_order">Annulla ordine</string> <string name="order_delete">Cancella ordine</string> <string name="error_order_creation">Errore: impossibile creare l’ordine</string> <string name="error_inventory_unavailable">Errore: inventario disponibile insufficiente</string> diff --git a/merchant-terminal/src/main/res/values-iw/strings.xml b/merchant-terminal/src/main/res/values-iw/strings.xml @@ -46,6 +46,8 @@ <string name="settings_instance_description">שינוי כתובת פורטל הסוחר, שם המשתמש, הסיסמה והגדרות נוספות.</string> <string name="settings_instance_button">שינוי הגדרות התחברות</string> <string name="settings_logout_button">התנתק</string> + <string name="settings_logout_warning">ההתנתקות תמחק מהמכשיר את החיבור לבית העסק, את פרטי ההתחברות ואת התצורה השמורה במטמון. יהיה צורך להתחבר מחדש לפני השימוש במסוף.</string> + <string name="settings_logout_confirmation">האם לצאת מהמערכת?</string> <string name="config_old_label">קובץ JSON (ישן)</string> <string name="config_setup_password">סיסמה</string> <string name="config_old_deprecation">שיטת תצורה זו מיושנת, השתמשו בתצורת API הסוחר החדשה.</string> @@ -140,4 +142,9 @@ <string name="force_delete_dialog_title">לכפות את מחיקת ההזמנה?</string> <string name="force_delete_dialog_message">ההזמנה נתבעה על ידי ארנק. מחיקה כפויה שלה תשחרר את כל נעילות המלאי ותמנע מהארנק להשלים את התשלום.</string> <string name="force_delete_dialog_confirm">מחיקה כפויה</string> + <string name="insecure_payment_dialog_title">בעיה בקישור התשלום</string> + <string name="insecure_payment_dialog_message">ההזמנה נוצרה, אך ייתכן שקישור התשלום לא ייפתח בארנק של הלקוח. אפשר לנסות קישור תשלום מתוקן, להמשיך כפי שהוא או לבטל את ההזמנה.</string> + <string name="insecure_payment_dialog_try_https">ניסיון עם קישור מתוקן</string> + <string name="insecure_payment_dialog_continue_as_is">המשך כפי שהוא</string> + <string name="insecure_payment_dialog_cancel_order">ביטול ההזמנה</string> </resources> diff --git a/merchant-terminal/src/main/res/values-ru/strings.xml b/merchant-terminal/src/main/res/values-ru/strings.xml @@ -99,6 +99,8 @@ <string name="settings_instance_description">Изменить URL портала продавца, имя пользователя, пароль и другие настройки.</string> <string name="settings_instance_button">Изменить настройки входа</string> <string name="settings_logout_button">Выйти</string> + <string name="settings_logout_warning">При выходе из системы с этого устройства будут удалены подключение к продавцу, учетные данные и кэшированная конфигурация. Перед использованием терминала потребуется подключиться снова.</string> + <string name="settings_logout_confirmation">Вы уверены, что хотите выйти из системы?</string> <string name="config_merchant_url">URL портала продавца</string> <string name="amount_entry_label">Сумма</string> <string name="amount_entry_charge">Списать</string> @@ -142,4 +144,9 @@ <string name="force_delete_dialog_title">Принудительно удалить заказ?</string> <string name="force_delete_dialog_message">Этот заказ был заявлен кошельком. Принудительное удаление снимет все блокировки запасов и не позволит кошельку завершить оплату.</string> <string name="force_delete_dialog_confirm">Удалить принудительно</string> + <string name="insecure_payment_dialog_title">Проблема с платёжной ссылкой</string> + <string name="insecure_payment_dialog_message">Заказ создан, но платёжная ссылка может не открыться в кошельке клиента. Можно попробовать исправленную платёжную ссылку, продолжить как есть или отменить заказ.</string> + <string name="insecure_payment_dialog_try_https">Попробовать исправленную ссылку</string> + <string name="insecure_payment_dialog_continue_as_is">Продолжить как есть</string> + <string name="insecure_payment_dialog_cancel_order">Отменить заказ</string> </resources> diff --git a/merchant-terminal/src/main/res/values-sv/strings.xml b/merchant-terminal/src/main/res/values-sv/strings.xml @@ -95,6 +95,8 @@ <string name="settings_instance_description">Ändra handlarportalens URL, användarnamn, lösenord och andra inställningar.</string> <string name="settings_instance_button">Ändra inloggningsinställningar</string> <string name="settings_logout_button">Logga ut</string> + <string name="settings_logout_warning">När du loggar ut tas handlaranslutningen, inloggningsuppgifterna och den cachelagrade konfigurationen bort från enheten. Du måste ansluta igen innan du kan använda terminalen.</string> + <string name="settings_logout_confirmation">Är du säker på att du vill logga ut?</string> <string name="config_merchant_url">Handlarportalens URL</string> <string name="amount_entry_label">Belopp</string> <string name="amount_entry_charge">Debitera</string> @@ -142,4 +144,9 @@ <string name="force_delete_dialog_title">Tvinga borttagning av beställningen?</string> <string name="force_delete_dialog_message">Den här beställningen har reserverats av en plånbok. En tvingad borttagning frigör alla lagerlås och hindrar plånboken från att slutföra betalningen.</string> <string name="force_delete_dialog_confirm">Tvinga borttagning</string> + <string name="insecure_payment_dialog_title">Problem med betalningslänken</string> + <string name="insecure_payment_dialog_message">Beställningen skapades, men betalningslänken kanske inte kan öppnas i kundens plånbok. Du kan prova en korrigerad betalningslänk, fortsätta som den är eller avbryta beställningen.</string> + <string name="insecure_payment_dialog_try_https">Prova korrigerad länk</string> + <string name="insecure_payment_dialog_continue_as_is">Fortsätt som den är</string> + <string name="insecure_payment_dialog_cancel_order">Avbryt beställning</string> </resources> diff --git a/merchant-terminal/src/main/res/values-tr/strings.xml b/merchant-terminal/src/main/res/values-tr/strings.xml @@ -106,6 +106,8 @@ <string name="settings_instance_description">Satıcı portalı URL’sini, kullanıcı adını, şifreyi ve diğer ayarları değiştirin.</string> <string name="settings_instance_button">Oturum açma ayarlarını değiştir</string> <string name="settings_logout_button">Oturumu kapat</string> + <string name="settings_logout_warning">Oturumu kapattığınızda satıcı bağlantısı, giriş bilgileri ve önbelleğe alınmış yapılandırma bu cihazdan kaldırılır. Terminali kullanmadan önce yeniden bağlanmanız gerekir.</string> + <string name="settings_logout_confirmation">Oturumu kapatmak istediğinizden emin misiniz?</string> <string name="amount_entry_label">Tutar</string> <string name="amount_entry_charge">Ücret al</string> <string name="amount_entry_create_order_charge">Sipariş oluştur (tutarı ücretlendir)</string> @@ -142,4 +144,9 @@ <string name="force_delete_dialog_title">Sipariş zorla silinsin mi?</string> <string name="force_delete_dialog_message">Bu sipariş bir cüzdan tarafından talep edildi. Zorla silinmesi tüm envanter kilitlerini kaldıracak ve cüzdanın ödemeyi tamamlamasını engelleyecektir.</string> <string name="force_delete_dialog_confirm">Zorla sil</string> + <string name="insecure_payment_dialog_title">Ödeme bağlantısı sorunu</string> + <string name="insecure_payment_dialog_message">Sipariş oluşturuldu, ancak ödeme bağlantısı müşterinin cüzdanında açılmayabilir. Düzeltilmiş bir ödeme bağlantısını deneyebilir, olduğu gibi devam edebilir veya siparişi iptal edebilirsiniz.</string> + <string name="insecure_payment_dialog_try_https">Düzeltilmiş bağlantıyı dene</string> + <string name="insecure_payment_dialog_continue_as_is">Olduğu gibi devam et</string> + <string name="insecure_payment_dialog_cancel_order">Siparişi iptal et</string> </resources> diff --git a/merchant-terminal/src/main/res/values-uk/strings.xml b/merchant-terminal/src/main/res/values-uk/strings.xml @@ -105,6 +105,8 @@ <string name="settings_instance_description">Змінити URL порталу продавця, ім’я користувача, пароль та інші налаштування.</string> <string name="settings_instance_button">Змінити налаштування входу</string> <string name="settings_logout_button">Вийти</string> + <string name="settings_logout_warning">Після виходу з системи з цього пристрою буде видалено підключення до продавця, облікові дані та кешовану конфігурацію. Перед використанням термінала потрібно буде підключитися знову.</string> + <string name="settings_logout_confirmation">Ви впевнені, що хочете вийти з системи?</string> <string name="amount_entry_label">Сума</string> <string name="amount_entry_charge">Стягнути</string> <string name="amount_entry_create_order_charge">Створити замовлення (стягнути суму)</string> @@ -142,4 +144,9 @@ <string name="force_delete_dialog_title">Примусово видалити замовлення?</string> <string name="force_delete_dialog_message">Це замовлення було заявлено гаманцем. Примусове видалення зніме всі блокування запасів і не дозволить гаманцю завершити оплату.</string> <string name="force_delete_dialog_confirm">Примусово видалити</string> + <string name="insecure_payment_dialog_title">Проблема з платіжним посиланням</string> + <string name="insecure_payment_dialog_message">Замовлення створено, але платіжне посилання може не відкритися в гаманці клієнта. Можна спробувати виправлене платіжне посилання, продовжити як є або скасувати замовлення.</string> + <string name="insecure_payment_dialog_try_https">Спробувати виправлене посилання</string> + <string name="insecure_payment_dialog_continue_as_is">Продовжити як є</string> + <string name="insecure_payment_dialog_cancel_order">Скасувати замовлення</string> </resources> diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -54,6 +54,8 @@ <string name="settings_instance_description">Change merchant portal URL, username, password, and other settings.</string> <string name="settings_instance_button">Modify login settings</string> <string name="settings_logout_button">Log out</string> + <string name="settings_logout_warning">Logging out removes the merchant connection, login credentials, and cached configuration from this device. You will need to connect again before using the terminal.</string> + <string name="settings_logout_confirmation">Are you sure you want to log out?</string> <string name="settings_about_title">About</string> <string name="config_old_label">JSON file (old)</string> <string name="config_setup_password">Password</string> @@ -92,6 +94,11 @@ <string name="force_delete_dialog_title">Force delete order?</string> <string name="force_delete_dialog_message">This order has been claimed by a wallet. Force deleting it will release any inventory locks and prevent the wallet from completing the payment.</string> <string name="force_delete_dialog_confirm">Force delete</string> + <string name="insecure_payment_dialog_title">Payment link problem</string> + <string name="insecure_payment_dialog_message">The order was created, but its payment link may not open in the customer’s wallet. You can try a fixed payment link, proceed as is, or cancel the order.</string> + <string name="insecure_payment_dialog_try_https">Try fixed link</string> + <string name="insecure_payment_dialog_continue_as_is">Continue as is</string> + <string name="insecure_payment_dialog_cancel_order">Cancel order</string> <string name="payment_share_uri">Share payment URI</string> <string name="payment_copy_uri">Copy payment URI</string> diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryPaginationTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/history/HistoryPaginationTest.kt @@ -0,0 +1,90 @@ +/* + * 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 + * this program; if not, see <http://www.gnu.org/licenses/>. + */ + +package net.taler.merchantpos.history + +import net.taler.common.Amount +import net.taler.common.Timestamp +import net.taler.merchantlib.OrderHistoryEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HistoryPaginationTest { + + @Test + fun `short intermediate page keeps pagination open`() { + val pagination = HistoryPagination() + pagination.addPage((100L downTo 81L).map(::entry), reset = true) + + val shortPage = pagination.addPage((81L downTo 75L).map(::entry), reset = false) + + assertEquals((80L downTo 75L).toList(), shortPage.map { it.rowId }) + assertEquals(75L, pagination.nextOffset) + assertFalse(pagination.reachedEnd) + + val followingPage = pagination.addPage((75L downTo 56L).map(::entry), reset = false) + + assertEquals((74L downTo 56L).toList(), followingPage.map { it.rowId }) + assertEquals((100L downTo 56L).toList(), pagination.items.map { it.rowId }) + assertFalse(pagination.reachedEnd) + } + + @Test + fun `next page de-duplicates an inclusive cursor and advances`() { + val pagination = HistoryPagination() + pagination.addPage((100L downTo 81L).map(::entry), reset = true) + + val added = pagination.addPage((81L downTo 62L).map(::entry), reset = false) + + assertEquals((80L downTo 62L).toList(), added.map { it.rowId }) + assertEquals((100L downTo 62L).toList(), pagination.items.map { it.rowId }) + assertEquals(62L, pagination.nextOffset) + assertFalse(pagination.reachedEnd) + } + + @Test + fun `page containing only the cursor marks the end`() { + val pagination = HistoryPagination() + pagination.addPage(listOf(entry(2), entry(1)), reset = true) + + val added = pagination.addPage(listOf(entry(1)), reset = false) + + assertTrue(added.isEmpty()) + assertTrue(pagination.reachedEnd) + } + + @Test + fun `empty page marks the end`() { + val pagination = HistoryPagination() + pagination.addPage(listOf(entry(1)), reset = true) + + pagination.addPage(emptyList(), reset = false) + + assertTrue(pagination.reachedEnd) + } + + private fun entry(rowId: Long) = OrderHistoryEntry( + orderId = "order-$rowId", + rowId = rowId, + timestamp = Timestamp.fromMillis(rowId * 1_000), + amount = Amount("KUDOS", rowId, 0), + summary = "Order $rowId", + paid = false, + refundable = false, + ) +} diff --git a/merchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentUriTest.kt b/merchant-terminal/src/test/java/net/taler/merchantpos/payment/PaymentUriTest.kt @@ -0,0 +1,48 @@ +/* + * 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.merchantpos.payment + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PaymentUriTest { + + @Test + fun upgradesInsecurePaymentUriWithoutChangingPayload() { + val insecureUri = "taler+http://pay/merchant.example/instances/shop/order/session?c=token" + + assertEquals( + "taler://pay/merchant.example/instances/shop/order/session?c=token", + upgradeTalerPayUriToHttps(insecureUri), + ) + } + + @Test + fun recognizesInsecureSchemeCaseInsensitively() { + assertEquals( + "taler://pay/merchant.example/order/session", + upgradeTalerPayUriToHttps("TALER+HTTP://pay/merchant.example/order/session"), + ) + } + + @Test + fun leavesOtherUrisAlone() { + assertNull(upgradeTalerPayUriToHttps("taler://pay/merchant.example/order/session")) + assertNull(upgradeTalerPayUriToHttps("https://merchant.example/order/session")) + } +}