From c20171ddfc1313aed920d230a54985fe213f19e7 Mon Sep 17 00:00:00 2001 From: NianJiu <3235467914@qq.com> Date: Fri, 3 Jul 2026 04:20:07 +0800 Subject: [PATCH] fix: require Android contact and calendar write permissions (#99204) * fix: require Android contact and calendar write permissions * test(android): cover partial permission grants --------- Co-authored-by: NianJiuZst <180004567+users.noreply.github.com> Co-authored-by: Peter Steinberger --- .../ai/openclaw/app/node/DeviceHandler.kt | 8 +++- .../ai/openclaw/app/node/DeviceHandlerTest.kt | 44 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index 0fad41f5d1fd..30eb6009bbd8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -426,14 +426,18 @@ class DeviceHandler private constructor( put( "contacts", permissionStateJson( - granted = hasPermission(Manifest.permission.READ_CONTACTS), + granted = + hasPermission(Manifest.permission.READ_CONTACTS) && + hasPermission(Manifest.permission.WRITE_CONTACTS), promptableWhenDenied = true, ), ) put( "calendar", permissionStateJson( - granted = hasPermission(Manifest.permission.READ_CALENDAR), + granted = + hasPermission(Manifest.permission.READ_CALENDAR) && + hasPermission(Manifest.permission.WRITE_CALENDAR), promptableWhenDenied = true, ), ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt index 4c8c7a9dc33f..9ca306987985 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.node -import android.content.Context +import android.Manifest +import android.app.Application import android.content.pm.ApplicationInfo import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -15,6 +16,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf @RunWith(RobolectricTestRunner::class) class DeviceHandlerTest { @@ -274,6 +276,31 @@ class DeviceHandlerTest { assertTrue(!callLog.getValue("promptable").jsonPrimitive.boolean) } + @Test + fun handleDevicePermissions_requiresReadAndWritePermissionPairs() { + val app = appContext() + val handler = DeviceHandler(app) + val permissionPairs = + listOf( + Triple("contacts", Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), + Triple("calendar", Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR), + ) + + for ((key, readPermission, writePermission) in permissionPairs) { + shadowOf(app).denyPermissions(readPermission, writePermission) + + shadowOf(app).grantPermissions(readPermission) + assertEquals("$key read-only", "denied", permissionStatus(handler.handleDevicePermissions(null).payloadJson, key)) + + shadowOf(app).denyPermissions(readPermission) + shadowOf(app).grantPermissions(writePermission) + assertEquals("$key write-only", "denied", permissionStatus(handler.handleDevicePermissions(null).payloadJson, key)) + + shadowOf(app).grantPermissions(readPermission) + assertEquals("$key read-write", "granted", permissionStatus(handler.handleDevicePermissions(null).payloadJson, key)) + } + } + @Test fun handleDeviceHealth_returnsExpectedShape() { val handler = DeviceHandler(appContext()) @@ -423,12 +450,25 @@ class DeviceHandlerTest { assertTrue(isSystemDeviceApp(appInfo)) } - private fun appContext(): Context = RuntimeEnvironment.getApplication() + private fun appContext(): Application = RuntimeEnvironment.getApplication() private fun parsePayload(payloadJson: String?): JsonObject { val jsonString = payloadJson ?: error("expected payload") return Json.parseToJsonElement(jsonString).jsonObject } + + private fun permissionStatus( + payloadJson: String?, + key: String, + ): String = + parsePayload(payloadJson) + .getValue("permissions") + .jsonObject + .getValue(key) + .jsonObject + .getValue("status") + .jsonPrimitive + .content } private class FakeDeviceAppSource(