View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011973 | Taler | merchant-pos-terminal (Android App) | public | 2026-11-08 22:27 | 2026-11-08 22:35 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | bernaalp | Reviewer | avalos | ||
| Release signed by | Bohdan | Deployed by | Bohdan | ||
| Verifier | avalos | Communicator | CM_7 | ||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | planning | Resolution | open | ||
| Summary | 0011973: Crash: NullPointerException in PaymentManager.checkPayment one poll after the session ends mid-payment | ||||
| Description | The payment screen polls the merchant backend once a second to see whether the customer has paid. If the merchant's session ends while it is doing that -- the access token expires, or the back office revokes it -- the next poll kills the app. 11-08 23:11:08.191 D taler-pos: Network error: Error 2015 (401): The merchant refused the request due to lack of authorization. - Check credentials in 'Authorization' header --------- beginning of crash 11-08 23:11:09.060 E AndroidRuntime: FATAL EXCEPTION: main 11-08 23:11:09.060 E AndroidRuntime: java.lang.NullPointerException at net.taler.merchantpos.payment.PaymentManager$checkPayment$1.invokeSuspend(PaymentManager.kt:238) at net.taler.merchantpos.payment.PaymentManager.checkPayment(PaymentManager.kt:237) at net.taler.merchantpos.payment.PaymentManager$checkTimer$1.onTick(PaymentManager.kt:79) at android.os.CountDownTimer$1.handleMessage(CountDownTimer.java:145) at android.os.Handler.dispatchMessage(Handler.java:110) at android.os.Looper.loopOnce(Looper.java:248) at android.os.Looper.loop(Looper.java:338) at android.app.ActivityThread.main(ActivityThread.java:9067) 869 milliseconds between the 401 and the fatal exception -- one tick of the poll timer. The process is killed, the activity is force-finished, and the cashier is back at the launcher with a customer halfway through paying. The session-expiry handling itself is fine and its message is written and translated. With the crash fixed, the same sequence shows 'Taler POS': 'Session expired - please re-enter your credentials' ['OK'] which is exactly right. The dialog never gets a chance on an unpatched build. WHY IT HAPPENS merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt:238 private fun checkPayment(orderId: String) = scope.launch { val merchantConfig = configManager.merchantConfig!! // line 238 api.checkOrder(merchantConfig, orderId).handle(...) } ConfigManager.merchantConfig is a `var MerchantConfig?` that is set to null the moment a request comes back 401 -- that is what ending the session means. The payment screen's CountDownTimer ticks every CHECK_INTERVAL regardless, and the tick after the 401 dereferences a field that has just been emptied. It is not one line. configManager.merchantConfig!! appears five times in this file and every one of them is reachable after the session has gone: line 94 createPayment charging an order line 238 checkPayment the poll timer, once a second line 372 cancelPayment the Cancel button, and the payment timeout line 401 tryDeleteOrder the Delete button line 422 forceDeleteOrder force-deleting a claimed order Line 238 is the one that fires by itself, which is why it is the one that was caught. Line 372 is the next most likely, because a cashier whose screen has just stopped responding presses Cancel. SUGGESTED FIX (patch attached, verified) With the patch the scenario goes from a fatal exception to the clean "Session expired" dialog. One helper, and the call sites through it: /** * The merchant configuration, or null once the session has ended. * * ConfigManager clears it the moment a request comes back 401, and the * payment screen polls once a second -- so between two ticks of * [checkTimer] the field can go from a configuration to nothing, and * every `!!` on it here was a crash on the main thread waiting for a * token to expire. Where there is no session left there is also nothing * useful to ask the backend, so the polling is stopped as well. */ private fun liveConfig(): MerchantConfig? { val merchantConfig = configManager.merchantConfig if (merchantConfig == null) { Log.d(TAG, "no merchant configuration left; stopping payment checks") stopPaymentChecks() } return merchantConfig } cancelPayment is the one exception: it does not take the helper, because cancelling has to keep working when the session is the thing that ended. It reads the field directly and skips only the "delete the abandoned order" step, so the error still reaches mPayment and the cashier still sees why. :merchant-terminal:testSimUnitTest passes with the patch. I am a reporter on this tracker and cannot triage or assign. The patch is attached as payment-poll-crashes-when-the-session-ends.diff and applies to merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt at 58b2417d. | ||||
| Steps To Reproduce | 1. Provision the terminal against a merchant backend. 2. Order by amount, enter any amount, press Charge. The payment screen opens and starts polling once a second. 3. While it is polling, end the session server side -- revoke the access token the terminal is using, or use a token that is about to expire. (The simulator mints a token of its own and revokes it through DELETE on the merchant's token endpoint, so no other session is touched.) 4. Wait a few seconds. Observed: within about one second of the first 401, the app dies with java.lang.NullPointerException at PaymentManager.kt:238, reached from the poll timer's onTick. The process is killed and the activity force finished. Expected: the session-expiry dialog ("Session expired - please re-enter your credentials"), which is what the app shows once the crash is fixed. Reproduced twice on two different emulators with the same stack. | ||||
| Additional Information | taler-android master at 58b2417d (2026-11-08), merchant-terminal 1.7.0, Android API 36 emulator. Found by the pos-simulator harness in the same tree (scenario token_revoked_mid_payment). Not specific to a screen size -- seen on a 2560x1600 tablet layout. | ||||
| Tags | No tags attached. | ||||
| Attached Files | payment-poll-crashes-when-the-session-ends.diff (4,267 bytes)
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
index 91672e4f..e39c6a78 100644
--- a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
@@ -27,6 +27,7 @@ import androidx.lifecycle.MutableLiveData
import net.taler.lib.android.assertUiThread
import net.taler.merchantlib.CheckPaymentResponse
import net.taler.merchantlib.MerchantApi
+import net.taler.merchantlib.MerchantConfig
import net.taler.merchantlib.OrderHistoryEntry
import net.taler.merchantlib.PostOrderRequest
import net.taler.merchantpos.MainActivity.Companion.TAG
@@ -91,8 +92,8 @@ class PaymentManager(
order: Order,
includeProducts: Boolean = true,
) = scope.launch {
- val merchantConfig = configManager.merchantConfig!!
- val payment = Payment(order, order.summary, configManager.currency!!)
+ val merchantConfig = liveConfig() ?: return@launch
+ val payment = Payment(order, order.summary, configManager.currency ?: return@launch)
mPayment.value = payment
val parts = order.toOrderParts(includeProducts)
val request =
@@ -235,7 +236,7 @@ class PaymentManager(
private fun checkPayment(orderId: String) =
scope.launch {
- val merchantConfig = configManager.merchantConfig!!
+ val merchantConfig = liveConfig() ?: return@launch
api.checkOrder(merchantConfig, orderId).handle(
{ error ->
// don't call onNetworkError() to not cancel payment, just keep trying
@@ -369,10 +370,13 @@ class PaymentManager(
fun cancelPayment(error: String? = null)
{
stopPaymentChecks()
- val merchantConfig = configManager.merchantConfig!!
+ // Not required: cancelling has to work even when the session is the
+ // thing that ended, and then there is nobody to delete the order at.
+ val merchantConfig = configManager.merchantConfig
mPayment.value?.let { payment ->
// A checkout order records cash or card already taken, so it stays for resuming.
- if (!payment.paid &&
+ if (merchantConfig != null &&
+ !payment.paid &&
payment.checkoutId == null)
{
payment.orderId?.let { orderId ->
@@ -398,7 +402,7 @@ class PaymentManager(
mDeleteNeedsForce.value = false
return
}
- val merchantConfig = configManager.merchantConfig!!
+ val merchantConfig = liveConfig() ?: return
val orderId = mPayment.value?.orderId ?: return
scope.launch {
val result = api.deleteOrder(merchantConfig, orderId)
@@ -419,7 +423,7 @@ class PaymentManager(
@UiThread
fun forceDeleteOrder()
{
- val merchantConfig = configManager.merchantConfig!!
+ val merchantConfig = liveConfig() ?: return
val orderId = mPayment.value?.orderId ?: return
Log.d(TAG, "Force deleting claimed order $orderId")
scope.launch {
@@ -437,6 +441,27 @@ class PaymentManager(
mDeleteNeedsForce.value = null
}
+ /**
+ * The merchant configuration, or null once the session has ended.
+ *
+ * `ConfigManager` clears it the moment a request comes back 401, and the
+ * payment screen polls once a second -- so between two ticks of
+ * [checkTimer] the field can go from a configuration to nothing, and
+ * every `!!` on it here was a crash on the main thread waiting for a
+ * token to expire. Where there is no session left there is also nothing
+ * useful to ask the backend, so the polling is stopped as well.
+ */
+ private fun liveConfig(): MerchantConfig?
+ {
+ val merchantConfig = configManager.merchantConfig
+ if (merchantConfig == null)
+ {
+ Log.d(TAG, "no merchant configuration left; stopping payment checks")
+ stopPaymentChecks()
+ }
+ return merchantConfig
+ }
+
private fun stopPaymentChecks()
{
checkTimer.cancel()
crash-stack.txt (5,849 bytes)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: FATAL EXCEPTION: main
11-08 23:25:36.451 11470 11470 E AndroidRuntime: Process: net.taler.merchantpos, PID: 11470
11-08 23:25:36.451 11470 11470 E AndroidRuntime: java.lang.NullPointerException
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at net.taler.merchantpos.payment.PaymentManager$checkPayment$1.invokeSuspend(PaymentManager.kt:238)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:375)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:358)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:134)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:53)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.BuildersKt.launch(Unknown Source:1)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:44)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source:1)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at net.taler.merchantpos.payment.PaymentManager.checkPayment(PaymentManager.kt:237)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at net.taler.merchantpos.payment.PaymentManager.access$checkPayment(PaymentManager.kt:52)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at net.taler.merchantpos.payment.PaymentManager$checkTimer$1.onTick(PaymentManager.kt:79)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at android.os.CountDownTimer$1.handleMessage(CountDownTimer.java:145)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at android.os.Handler.dispatchMessage(Handler.java:110)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at android.os.Looper.loopOnce(Looper.java:248)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at android.os.Looper.loop(Looper.java:338)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at android.app.ActivityThread.main(ActivityThread.java:9067)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at java.lang.reflect.Method.invoke(Native Method)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:593)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:932)
11-08 23:25:36.451 11470 11470 E AndroidRuntime: Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@8317e77, Dispatchers.Main.immediate]
11-08 23:25:36.455 690 11862 I DropBoxManagerService: add tag=data_app_crash isTagEnabled=true flags=0x2
11-08 23:25:36.460 690 2558 W ActivityTaskManager: Force finishing activity net.taler.merchantpos/.MainActivity
11-08 23:25:36.461 1119 1183 V WindowManagerShell: Transition requested (#32): android.os.BinderProxy@42ed15e TransitionRequestInfo { type = CLOSE, triggerTask = TaskInfo{userId=0 taskId=18 effectiveUid=10216 displayId=0 isRunning=true baseIntent=Intent { flg=0x10000000 cmp=net.taler.merchantpos/.MainActivity } baseActivity=ComponentInfo{net.taler.merchantpos/net.taler.merchantpos.MainActivity} topActivity=ComponentInfo{net.taler.merchantpos/net.taler.merchantpos.MainActivity} origActivity=null realActivity=ComponentInfo{net.taler.merchantpos/net.taler.merchantpos.MainActivity} numActivities=2 lastActiveTime=2408528 supportsMultiWindow=true resizeMode=1 isResizeable=true minWidth=-1 minHeight=-1 defaultMinSize=220 token=WCT{android.window.IWindowContainerToken$Stub$Proxy@86df93f} topActivityType=1 pictureInPictureParams=null shouldDockBigOverlays=false launchIntoPipHostTaskId=-1 lastParentTaskIdBeforePip=-1 displayCutoutSafeInsets=Rect(0, 0 - 0, 0) topActivityInfo=ActivityInfo{e7e820c net.taler.merchantpos.MainActivity} launchCookies=[] positionInParent=Point(0, 0) parentTaskId=-1 isFocused=true isVisible=true isVisibleRequested=true isTopActivityNoDisplay=false isSleeping=false locusId=null displayAreaFeatureId=1 isTopActivityTransparent=false isActivityStackTransparent=false lastNonFullscreenBounds=Rect(759, 134 - 1162, 946) capturedLink=null capturedLinkTimestamp=0 requestedVisibleTypes=-9 topActivityRequestOpenInBrowserEducationTimestamp=0 appCompatTaskInfo=AppCompatTaskInfo { topActivityInSizeCompat=false eligibleForLetterboxEducation= false isLetterboxEducationEnabled= true isLetterboxDoubleTapEnabled= false eligibleForUserAspectRatioButton= false topActivityBoundsLetterboxed= false isFromLetterboxDoubleTap= false topActivityLetterboxVerticalPosition= -1 topActivityLetterboxHorizontalPosition= -1 topActivityLetterboxWidth=-1 topActivityLetterboxHeight=-1 topActivityAppBounds=Rect(0, 0 - 1920, 1080) isUserFullscreenOverrideEnabled=false isSystemFullscreenOverrideEnabled=false hasMinAspectRatioOverride=false topActivityLetterboxBounds=null cameraCompatTaskInfo=CameraCompatTaskInfo { freeformCameraCompatMode=inactive}} topActivityMainWindowFrame=null}, pipChange = null, remoteTransition = null, displayChange = null, flags = 16, debugId = 32 }
11-08 23:25:36.461 557 609 I BpBinder: onLastStrongRef automatically unlinking death recipients:
11-08 23:25:36.464 11470 11470 I Process : Sending signal. PID: 11470 SIG: 9
11-08 23:25:36.474 557 557 I BpBinder: onLastStrongRef automatically unlinking death recipients:
| ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-08 22:27 | bot-cg | New Issue | |
| 2026-11-08 22:27 | bot-cg | File Added: payment-poll-crashes-when-the-session-ends.diff | |
| 2026-11-08 22:27 | bot-cg | File Added: crash-stack.txt | |
| 2026-11-08 22:35 | Christian Grothoff | Implementer | => bernaalp |
| 2026-11-08 22:35 | Christian Grothoff | Reviewer | => avalos |
| 2026-11-08 22:35 | Christian Grothoff | Release signed by | => Bohdan |
| 2026-11-08 22:35 | Christian Grothoff | Deployed by | => Bohdan |
| 2026-11-08 22:35 | Christian Grothoff | Verifier | => avalos |
| 2026-11-08 22:35 | Christian Grothoff | Status | new => planning |