commit a6bcae6d08832a25162e93e238be8eda95e55875
parent a7bf749c741854c4dc82287852e7d4f1f6feac3e
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Thu, 8 Oct 2026 21:49:31 +0200
[pos] fix HTTP client lifecycle and bump version to 1.6.6
Diffstat:
7 files changed, 113 insertions(+), 11 deletions(-)
diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
@@ -23,6 +23,7 @@ import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.nio.channels.UnresolvedAddressException
+import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -36,6 +37,8 @@ class Response<out T> private constructor(
suspend fun <T> response(request: suspend () -> T): Response<T> {
return try {
success(request())
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Throwable) {
println(e)
failure(e)
diff --git a/merchant-lib/src/test/java/net/taler/merchantlib/ResponseCancellationTest.kt b/merchant-lib/src/test/java/net/taler/merchantlib/ResponseCancellationTest.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.merchantlib
+
+import java.io.IOException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.fail
+import org.junit.Test
+
+class ResponseCancellationTest {
+ @Test
+ fun cancellationPropagatesInsteadOfBecomingAnOrderError() = runBlocking {
+ val cancellation = CancellationException("Parent job is Completed")
+ try {
+ Response.response<Unit> { throw cancellation }
+ fail("Cancellation must propagate to the caller")
+ } catch (e: CancellationException) {
+ assertSame(cancellation, e)
+ }
+ }
+
+ @Test
+ fun networkErrorsStillBecomeFailures() = runBlocking {
+ Response.response<Unit> { throw IOException("Connection lost") }.assertFailure {
+ assertEquals("Connection lost", it)
+ }
+ }
+}
diff --git a/merchant-terminal/build.gradle b/merchant-terminal/build.gradle
@@ -13,8 +13,8 @@ android {
applicationId "net.taler.merchantpos"
minSdkVersion 23
targetSdkVersion 36
- versionCode 28
- versionName "1.6.5"
+ versionCode 29
+ versionName "1.6.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "BACKEND_API_VERSION", "\"20:0:8\"")
diff --git a/merchant-terminal/fastlane/metadata/android/en-US/changelogs/29.txt b/merchant-terminal/fastlane/metadata/android/en-US/changelogs/29.txt
@@ -0,0 +1,3 @@
+Bug fixes:
+- Fixed order creation failing with "Parent job is Completed" after closing and reopening the app.
+- Prevented cancelled requests from appearing as order creation errors.
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainViewModel.kt
@@ -21,7 +21,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.statement.*
import io.ktor.http.HttpStatusCode
-import net.taler.common.getDefaultHttpClient
+import net.taler.common.createDefaultHttpClient
import net.taler.merchantlib.MerchantApi
import net.taler.merchantpos.config.ConfigManager
import net.taler.merchantpos.history.HistoryManager
@@ -31,7 +31,7 @@ import net.taler.merchantpos.refund.RefundManager
class MainViewModel(app: Application) : AndroidViewModel(app) {
- private val httpClient = getDefaultHttpClient(followRedirect = true)
+ private val httpClient = createDefaultHttpClient(followRedirect = true)
private val api = MerchantApi(httpClient)
val orderManager = OrderManager(app)
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/HttpUtils.kt b/taler-kotlin-android/src/main/java/net/taler/common/HttpUtils.kt
@@ -35,19 +35,21 @@ import java.util.concurrent.TimeUnit
private val clientCache = mutableMapOf<String, HttpClient>()
+/** Returns a process-wide shared client. Callers must not close it or install owner-specific callbacks. */
fun getDefaultHttpClient(
withJson: Boolean = true,
followRedirect: Boolean = false,
logging: Boolean = true,
): HttpClient {
val key = "$withJson-$followRedirect-$logging"
- return clientCache.getOrPut(key) { createClient(withJson, followRedirect, logging) }
+ return clientCache.getOrPut(key) { createDefaultHttpClient(withJson, followRedirect, logging) }
}
-private fun createClient(
- withJson: Boolean,
- followRedirect: Boolean,
- logging: Boolean,
+/** Creates an independent client that its owner must close when no longer needed. */
+fun createDefaultHttpClient(
+ withJson: Boolean = true,
+ followRedirect: Boolean = false,
+ logging: Boolean = true,
): HttpClient = HttpClient(OkHttp) {
expectSuccess = true
followRedirects = followRedirect
@@ -89,4 +91,4 @@ fun String.toHttpMethod(): HttpMethod? = when(this) {
"HEAD" -> HttpMethod.Head
"OPTIONS" -> HttpMethod.Options
else -> null
-}
-\ No newline at end of file
+}
diff --git a/taler-kotlin-android/src/test/java/net/taler/common/HttpUtilsTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/HttpUtilsTest.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.common
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HttpUtilsTest {
+ @Test
+ fun closingOwnerDoesNotCloseSharedClientOrNextOwner() = runBlocking {
+ val shared = getDefaultHttpClient(followRedirect = true, logging = false)
+ val first = createDefaultHttpClient(followRedirect = true, logging = false)
+ try {
+ assertNotSame(shared, first)
+ first.close()
+ first.coroutineContext[Job]!!.join()
+
+ val reopened = createDefaultHttpClient(followRedirect = true, logging = false)
+ try {
+ assertNotSame(first, reopened)
+ assertNotSame(first.engine, reopened.engine)
+ assertTrue(reopened.coroutineContext[Job]!!.isActive)
+ assertTrue(reopened.engine.coroutineContext[Job]!!.isActive)
+ assertTrue(shared.coroutineContext[Job]!!.isActive)
+ assertSame(shared, getDefaultHttpClient(followRedirect = true, logging = false))
+ } finally {
+ reopened.close()
+ }
+ } finally {
+ first.close()
+ }
+ }
+}