From 46355cbdcf8ae1a9d82db49dda68e72a2ef58c31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 07:22:27 +0100 Subject: [PATCH] fix(android): stop canceled work from surfacing failures (#105047) * fix(android): propagate request cancellation * chore: keep release notes in PR body --- .../ai/openclaw/app/node/LocationHandler.kt | 3 ++ .../ai/openclaw/app/node/MotionHandler.kt | 5 +++ .../ai/openclaw/app/voice/TalkSpeakClient.kt | 3 ++ .../openclaw/app/node/LocationHandlerTest.kt | 24 +++++++++++ .../ai/openclaw/app/node/MotionHandlerTest.kt | 42 +++++++++++++++++++ .../openclaw/app/voice/TalkSpeakClientTest.kt | 18 ++++++++ 6 files changed, 95 insertions(+) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt index e49085699a40..aedf9bdbf489 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.pm.PackageManager import android.location.LocationManager import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive @@ -160,6 +161,8 @@ class LocationHandler private constructor( code = "LOCATION_TIMEOUT", message = "LOCATION_TIMEOUT: no fix in time", ) + } catch (err: CancellationException) { + throw err } catch (err: Throwable) { val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" return GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt index a353acb38746..536ffd989ee8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/MotionHandler.kt @@ -9,6 +9,7 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.SystemClock import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -317,6 +318,8 @@ class MotionHandler private constructor( ) } catch (err: IllegalArgumentException) { GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE") + } catch (err: CancellationException) { + throw err } catch (err: Throwable) { GatewaySession.InvokeResult.error( code = "MOTION_UNAVAILABLE", @@ -353,6 +356,8 @@ class MotionHandler private constructor( ) } catch (err: IllegalArgumentException) { GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE") + } catch (err: CancellationException) { + throw err } catch (err: Throwable) { GatewaySession.InvokeResult.error( code = "MOTION_UNAVAILABLE", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt index f6a31e438abe..b246032012f8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkSpeakClient.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.voice import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -58,6 +59,8 @@ internal class TalkSpeakClient( paramsJson = json.encodeToString(TalkSpeakRequest.from(text = text, directive = directive)), timeoutMs = 45_000, ) + } catch (err: CancellationException) { + throw err } catch (err: Throwable) { return TalkSpeakResult.Failure(err.message ?: "talk.speak request failed") } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt index d65f536b62c1..94336ee9089e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt @@ -3,11 +3,13 @@ package ai.openclaw.app.node import ai.openclaw.app.LocationMode import android.content.Context import android.location.LocationManager +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test class LocationHandlerTest : NodeHandlerRobolectricTest() { @@ -205,6 +207,28 @@ class LocationHandlerTest : NodeHandlerRobolectricTest() { assertEquals("LOCATION_UNAVAILABLE", result.error?.code) assertEquals("gps offline", result.error?.message) } + + @Test + fun handleLocationGet_propagatesParentCancellation() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + failure = CancellationException("request retired"), + ), + ) + + try { + handler.handleLocationGet(null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("request retired", err.message) + } + } } private class FakeLocationDataSource( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt index b6cda9241fdf..ff0117f91a94 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/MotionHandlerTest.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.node import android.content.Context +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray @@ -9,6 +10,7 @@ import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test class MotionHandlerTest : NodeHandlerRobolectricTest() { @@ -89,6 +91,46 @@ class MotionHandlerTest : NodeHandlerRobolectricTest() { assertEquals("MOTION_UNAVAILABLE", result.error?.code) assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true) } + + @Test + fun handleMotionActivity_propagatesParentCancellation() = + runTest { + val handler = + MotionHandler.forTesting( + appContext(), + FakeMotionDataSource( + hasPermission = true, + activityError = CancellationException("invoke retired"), + ), + ) + + try { + handler.handleMotionActivity(null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("invoke retired", err.message) + } + } + + @Test + fun handleMotionPedometer_propagatesParentCancellation() = + runTest { + val handler = + MotionHandler.forTesting( + appContext(), + FakeMotionDataSource( + hasPermission = true, + pedometerError = CancellationException("invoke retired"), + ), + ) + + try { + handler.handleMotionPedometer(null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("invoke retired", err.message) + } + } } private class FakeMotionDataSource( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt index 77cadfc47f15..c32df959b3cc 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkSpeakClientTest.kt @@ -2,9 +2,11 @@ package ai.openclaw.app.voice import ai.openclaw.app.gateway.GatewayConnectErrorDetails import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test class TalkSpeakClientTest { @@ -128,4 +130,20 @@ class TalkSpeakClientTest { val result = client.synthesize(text = "Hello", directive = null) assertTrue(result is TalkSpeakResult.FallbackToLocal) } + + @Test + fun propagatesRequestCancellation() = + runTest { + val client = + TalkSpeakClient( + requestDetailed = { _, _, _ -> throw CancellationException("talk stopped") }, + ) + + try { + client.synthesize(text = "Hello", directive = null) + fail("expected cancellation to propagate") + } catch (err: CancellationException) { + assertEquals("talk stopped", err.message) + } + } }