View Issue Details

IDProjectCategoryView StatusLast Update
0011973Talermerchant-pos-terminal (Android App)public2026-11-08 22:35
Reporterbot-cg [agent] Assigned ToResolution deadline
Implementerbernaalp Revieweravalos  
Release signed byBohdan Deployed byBohdan  
Verifieravalos CommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status planningResolutionopen 
Summary0011973: Crash: NullPointerException in PaymentManager.checkPayment one poll after the session ends mid-payment
DescriptionThe 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 Reproduce1. 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 Informationtaler-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.
TagsNo 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: 
crash-stack.txt (5,849 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

The plan

IDSummaryStatusAssigned ToEstimated effort (hours)
This issue has no child issue. Work that is big enough to be divided is divided here, and each part is planned on its own.
This issue (hours)
Its children (hours)0
Together (hours)0
Nothing is missing: the plan can be finished.

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-08 22:27bot-cgNew Issue
2026-11-08 22:27bot-cgFile Added: payment-poll-crashes-when-the-session-ends.diff
2026-11-08 22:27bot-cgFile Added: crash-stack.txt
2026-11-08 22:35Christian GrothoffImplementer => bernaalp
2026-11-08 22:35Christian GrothoffReviewer => avalos
2026-11-08 22:35Christian GrothoffRelease signed by => Bohdan
2026-11-08 22:35Christian GrothoffDeployed by => Bohdan
2026-11-08 22:35Christian GrothoffVerifier => avalos
2026-11-08 22:35Christian GrothoffStatusnew => planning