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 014eead6669..e9f520e9a35 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 @@ -8,27 +8,85 @@ import androidx.core.content.ContextCompat import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -class LocationHandler( +internal interface LocationDataSource { + fun hasFinePermission(context: Context): Boolean + + fun hasCoarsePermission(context: Context): Boolean + + suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload +} + +private class DefaultLocationDataSource( + private val capture: LocationCaptureManager, +) : LocationDataSource { + override fun hasFinePermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + override fun hasCoarsePermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + override suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload = + capture.getLocation( + desiredProviders = desiredProviders, + maxAgeMs = maxAgeMs, + timeoutMs = timeoutMs, + isPrecise = isPrecise, + ) +} + +class LocationHandler private constructor( private val appContext: Context, - private val location: LocationCaptureManager, + private val dataSource: LocationDataSource, private val json: Json, private val isForeground: () -> Boolean, private val locationPreciseEnabled: () -> Boolean, ) { - fun hasFineLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } + constructor( + appContext: Context, + location: LocationCaptureManager, + json: Json, + isForeground: () -> Boolean, + locationPreciseEnabled: () -> Boolean, + ) : this( + appContext = appContext, + dataSource = DefaultLocationDataSource(location), + json = json, + isForeground = isForeground, + locationPreciseEnabled = locationPreciseEnabled, + ) - fun hasCoarseLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED + fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext) + + fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext) + + companion object { + internal fun forTesting( + appContext: Context, + dataSource: LocationDataSource, + json: Json = Json { ignoreUnknownKeys = true }, + isForeground: () -> Boolean = { true }, + locationPreciseEnabled: () -> Boolean = { true }, + ): LocationHandler = + LocationHandler( + appContext = appContext, + dataSource = dataSource, + json = json, + isForeground = isForeground, + locationPreciseEnabled = locationPreciseEnabled, ) } @@ -39,7 +97,7 @@ class LocationHandler( message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open", ) } - if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { + if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) { return GatewaySession.InvokeResult.error( code = "LOCATION_PERMISSION_REQUIRED", message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", @@ -49,9 +107,9 @@ class LocationHandler( val preciseEnabled = locationPreciseEnabled() val accuracy = when (desiredAccuracy) { - "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + "precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced" "coarse" -> "coarse" - else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced" } val providers = when (accuracy) { @@ -61,7 +119,7 @@ class LocationHandler( } try { val payload = - location.getLocation( + dataSource.fetchLocation( desiredProviders = providers, maxAgeMs = maxAgeMs, timeoutMs = timeoutMs, 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 new file mode 100644 index 00000000000..9605077fa8b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt @@ -0,0 +1,88 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocationHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = false, + coarseGranted = false, + ), + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleLocationGet_requiresForegroundBeforeLocationPermission() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + ), + isForeground = { false }, + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code) + } + + @Test + fun hasFineLocationPermission_reflectsDataSource() { + val denied = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true), + ) + assertFalse(denied.hasFineLocationPermission()) + assertTrue(denied.hasCoarseLocationPermission()) + + val granted = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false), + ) + assertTrue(granted.hasFineLocationPermission()) + assertFalse(granted.hasCoarseLocationPermission()) + } +} + +private class FakeLocationDataSource( + private val fineGranted: Boolean, + private val coarseGranted: Boolean, +) : LocationDataSource { + override fun hasFinePermission(context: Context): Boolean = fineGranted + + override fun hasCoarsePermission(context: Context): Boolean = coarseGranted + + override suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload { + throw IllegalStateException( + "LocationHandlerTest: fetchLocation must not run in this scenario", + ) + } +}