diff --git a/CHANGELOG.md b/CHANGELOG.md
index baa3c2f687e..ac9b8cfd6b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
+- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
### Fixes
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index f9bf03b1a3d..c8cf255c127 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
android:maxSdkVersion="32" />
+
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
index dcf1e3bee89..c2bce9a247a 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
@@ -110,6 +110,10 @@ class NodeRuntime(context: Context) {
appContext = appContext,
)
+ private val callLogHandler: CallLogHandler = CallLogHandler(
+ appContext = appContext,
+ )
+
private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext,
)
@@ -151,6 +155,7 @@ class NodeRuntime(context: Context) {
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
+ callLogHandler = callLogHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CallLogHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CallLogHandler.kt
new file mode 100644
index 00000000000..af242dfac69
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CallLogHandler.kt
@@ -0,0 +1,247 @@
+package ai.openclaw.app.node
+
+import android.Manifest
+import android.content.Context
+import android.provider.CallLog
+import androidx.core.content.ContextCompat
+import ai.openclaw.app.gateway.GatewaySession
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.put
+
+private const val DEFAULT_CALL_LOG_LIMIT = 25
+
+internal data class CallLogRecord(
+ val number: String?,
+ val cachedName: String?,
+ val date: Long,
+ val duration: Long,
+ val type: Int,
+)
+
+internal data class CallLogSearchRequest(
+ val limit: Int, // Number of records to return
+ val offset: Int, // Offset value
+ val cachedName: String?, // Search by contact name
+ val number: String?, // Search by phone number
+ val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
+ val dateStart: Long?, // Query start time (timestamp)
+ val dateEnd: Long?, // Query end time (timestamp)
+ val duration: Long?, // Search by duration (seconds)
+ val type: Int?, // Search by call log type
+)
+
+internal interface CallLogDataSource {
+ fun hasReadPermission(context: Context): Boolean
+
+ fun search(context: Context, request: CallLogSearchRequest): List
+}
+
+private object SystemCallLogDataSource : CallLogDataSource {
+ override fun hasReadPermission(context: Context): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CALL_LOG
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+
+ override fun search(context: Context, request: CallLogSearchRequest): List {
+ val resolver = context.contentResolver
+ val projection = arrayOf(
+ CallLog.Calls.NUMBER,
+ CallLog.Calls.CACHED_NAME,
+ CallLog.Calls.DATE,
+ CallLog.Calls.DURATION,
+ CallLog.Calls.TYPE,
+ )
+
+ // Build selection and selectionArgs for filtering
+ val selections = mutableListOf()
+ val selectionArgs = mutableListOf()
+
+ request.cachedName?.let {
+ selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
+ selectionArgs.add("%$it%")
+ }
+
+ request.number?.let {
+ selections.add("${CallLog.Calls.NUMBER} LIKE ?")
+ selectionArgs.add("%$it%")
+ }
+
+ // Support time range query
+ if (request.dateStart != null && request.dateEnd != null) {
+ selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
+ selectionArgs.add(request.dateStart.toString())
+ selectionArgs.add(request.dateEnd.toString())
+ } else if (request.dateStart != null) {
+ selections.add("${CallLog.Calls.DATE} >= ?")
+ selectionArgs.add(request.dateStart.toString())
+ } else if (request.dateEnd != null) {
+ selections.add("${CallLog.Calls.DATE} <= ?")
+ selectionArgs.add(request.dateEnd.toString())
+ } else if (request.date != null) {
+ // Compatible with the old date parameter (exact match)
+ selections.add("${CallLog.Calls.DATE} = ?")
+ selectionArgs.add(request.date.toString())
+ }
+
+ request.duration?.let {
+ selections.add("${CallLog.Calls.DURATION} = ?")
+ selectionArgs.add(it.toString())
+ }
+
+ request.type?.let {
+ selections.add("${CallLog.Calls.TYPE} = ?")
+ selectionArgs.add(it.toString())
+ }
+
+ val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
+ val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
+
+ val sortOrder = "${CallLog.Calls.DATE} DESC"
+
+ resolver.query(
+ CallLog.Calls.CONTENT_URI,
+ projection,
+ selection,
+ selectionArgsArray,
+ sortOrder,
+ ).use { cursor ->
+ if (cursor == null) return emptyList()
+
+ val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
+ val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
+ val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
+ val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
+ val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
+
+ // Skip offset rows
+ if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
+ // Successfully moved to offset position
+ }
+
+ val out = mutableListOf()
+ var count = 0
+ while (cursor.moveToNext() && count < request.limit) {
+ out += CallLogRecord(
+ number = cursor.getString(numberIndex),
+ cachedName = cursor.getString(cachedNameIndex),
+ date = cursor.getLong(dateIndex),
+ duration = cursor.getLong(durationIndex),
+ type = cursor.getInt(typeIndex),
+ )
+ count++
+ }
+ return out
+ }
+ }
+}
+
+class CallLogHandler private constructor(
+ private val appContext: Context,
+ private val dataSource: CallLogDataSource,
+) {
+ constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
+
+ fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
+ if (!dataSource.hasReadPermission(appContext)) {
+ return GatewaySession.InvokeResult.error(
+ code = "CALL_LOG_PERMISSION_REQUIRED",
+ message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
+ )
+ }
+
+ val request = parseSearchRequest(paramsJson)
+ ?: return GatewaySession.InvokeResult.error(
+ code = "INVALID_REQUEST",
+ message = "INVALID_REQUEST: expected JSON object",
+ )
+
+ return try {
+ val callLogs = dataSource.search(appContext, request)
+ GatewaySession.InvokeResult.ok(
+ buildJsonObject {
+ put(
+ "callLogs",
+ buildJsonArray {
+ callLogs.forEach { add(callLogJson(it)) }
+ },
+ )
+ }.toString(),
+ )
+ } catch (err: Throwable) {
+ GatewaySession.InvokeResult.error(
+ code = "CALL_LOG_UNAVAILABLE",
+ message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
+ )
+ }
+ }
+
+ private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
+ if (paramsJson.isNullOrBlank()) {
+ return CallLogSearchRequest(
+ limit = DEFAULT_CALL_LOG_LIMIT,
+ offset = 0,
+ cachedName = null,
+ number = null,
+ date = null,
+ dateStart = null,
+ dateEnd = null,
+ duration = null,
+ type = null,
+ )
+ }
+
+ val params = try {
+ Json.parseToJsonElement(paramsJson).asObjectOrNull()
+ } catch (_: Throwable) {
+ null
+ } ?: return null
+
+ val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
+ .coerceIn(1, 200)
+ val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
+ .coerceAtLeast(0)
+ val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
+ val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
+ val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
+ val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
+ val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
+ val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
+ val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
+
+ return CallLogSearchRequest(
+ limit = limit,
+ offset = offset,
+ cachedName = cachedName,
+ number = number,
+ date = date,
+ dateStart = dateStart,
+ dateEnd = dateEnd,
+ duration = duration,
+ type = type,
+ )
+ }
+
+ private fun callLogJson(callLog: CallLogRecord): JsonObject {
+ return buildJsonObject {
+ put("number", JsonPrimitive(callLog.number))
+ put("cachedName", JsonPrimitive(callLog.cachedName))
+ put("date", JsonPrimitive(callLog.date))
+ put("duration", JsonPrimitive(callLog.duration))
+ put("type", JsonPrimitive(callLog.type))
+ }
+ }
+
+ companion object {
+ internal fun forTesting(
+ appContext: Context,
+ dataSource: CallLogDataSource,
+ ): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
+ }
+}
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 de3b24df193..b888e3edaea 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
@@ -212,6 +212,13 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
+ put(
+ "callLog",
+ permissionStateJson(
+ granted = hasPermission(Manifest.permission.READ_CALL_LOG),
+ promptableWhenDenied = true,
+ ),
+ )
put(
"motion",
permissionStateJson(
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
index 5ce86340965..0dd8047596b 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
@@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
+import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -84,6 +85,7 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable,
),
+ NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
)
val all: List =
@@ -187,6 +189,9 @@ object InvokeCommandRegistry {
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
),
+ InvokeCommandSpec(
+ name = OpenClawCallLogCommand.Search.rawValue,
+ ),
InvokeCommandSpec(
name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild,
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
index f2b79159009..880be1ab4e3 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
@@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
+import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
@@ -27,6 +28,7 @@ class InvokeDispatcher(
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
+ private val callLogHandler: CallLogHandler,
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
@@ -161,6 +163,9 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
+ // CallLog command
+ OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
+
// Debug commands
"debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs()
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
index 95ba2912b09..3a8e6cdd2be 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
@@ -13,6 +13,7 @@ enum class OpenClawCapability(val rawValue: String) {
Contacts("contacts"),
Calendar("calendar"),
Motion("motion"),
+ CallLog("callLog"),
}
enum class OpenClawCanvasCommand(val rawValue: String) {
@@ -137,3 +138,12 @@ enum class OpenClawMotionCommand(val rawValue: String) {
const val NamespacePrefix: String = "motion."
}
}
+
+enum class OpenClawCallLogCommand(val rawValue: String) {
+ Search("callLog.search"),
+ ;
+
+ companion object {
+ const val NamespacePrefix: String = "callLog."
+ }
+}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
index 28487439c0b..ba48b9f3cfa 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
@@ -121,6 +121,7 @@ private enum class PermissionToggle {
Calendar,
Motion,
Sms,
+ CallLog,
}
private enum class SpecialAccessToggle {
@@ -288,6 +289,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
}
+ var enableCallLog by
+ rememberSaveable {
+ mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
+ }
var pendingPermissionToggle by remember { mutableStateOf(null) }
var pendingSpecialAccessToggle by remember { mutableStateOf(null) }
@@ -304,6 +309,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
+ PermissionToggle.CallLog -> enableCallLog = enabled
}
}
@@ -331,6 +337,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
+ PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@@ -352,6 +359,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableCalendar,
enableMotion,
enableSms,
+ enableCallLog,
smsAvailable,
motionAvailable,
) {
@@ -367,6 +375,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS"
+ if (enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
}
@@ -595,6 +604,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms,
smsAvailable = smsAvailable,
+ enableCallLog = enableCallLog,
context = context,
onDiscoveryChange = { checked ->
requestPermissionToggle(
@@ -692,6 +702,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
}
},
+ onCallLogChange = { checked ->
+ requestPermissionToggle(
+ PermissionToggle.CallLog,
+ checked,
+ listOf(Manifest.permission.READ_CALL_LOG),
+ )
+ },
)
OnboardingStep.FinalCheck ->
FinalStep(
@@ -1282,6 +1299,7 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean,
enableSms: Boolean,
smsAvailable: Boolean,
+ enableCallLog: Boolean,
context: Context,
onDiscoveryChange: (Boolean) -> Unit,
onLocationChange: (Boolean) -> Unit,
@@ -1294,6 +1312,7 @@ private fun PermissionsStep(
onCalendarChange: (Boolean) -> Unit,
onMotionChange: (Boolean) -> Unit,
onSmsChange: (Boolean) -> Unit,
+ onCallLogChange: (Boolean) -> Unit,
) {
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
val locationGranted =
@@ -1424,6 +1443,15 @@ private fun PermissionsStep(
onCheckedChange = onSmsChange,
)
}
+ InlineDivider()
+ PermissionToggleRow(
+ title = "Call Log",
+ subtitle = "callLog.search",
+ checked = enableCallLog,
+ granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
+ onCheckedChange = onCallLogChange,
+ )
+ Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
index e4558244fa6..22183776366 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
@@ -218,6 +218,18 @@ fun SettingsSheet(viewModel: MainViewModel) {
calendarPermissionGranted = readOk && writeOk
}
+ var callLogPermissionGranted by
+ remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
+ PackageManager.PERMISSION_GRANTED,
+ )
+ }
+ val callLogPermissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ callLogPermissionGranted = granted
+ }
+
var motionPermissionGranted by
remember {
mutableStateOf(
@@ -266,6 +278,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
+ callLogPermissionGranted =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
+ PackageManager.PERMISSION_GRANTED
motionPermissionGranted =
!motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
@@ -601,6 +616,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
},
)
+ HorizontalDivider(color = mobileBorder)
+ ListItem(
+ modifier = Modifier.fillMaxWidth(),
+ colors = listItemColors,
+ headlineContent = { Text("Call Log", style = mobileHeadline) },
+ supportingContent = { Text("Search recent call history.", style = mobileCallout) },
+ trailingContent = {
+ Button(
+ onClick = {
+ if (callLogPermissionGranted) {
+ openAppSettings(context)
+ } else {
+ callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
+ }
+ },
+ colors = settingsPrimaryButtonColors(),
+ shape = RoundedCornerShape(14.dp),
+ ) {
+ Text(
+ if (callLogPermissionGranted) "Manage" else "Grant",
+ style = mobileCallout.copy(fontWeight = FontWeight.Bold),
+ )
+ }
+ },
+ )
if (motionAvailable) {
HorizontalDivider(color = mobileBorder)
ListItem(
@@ -782,7 +822,7 @@ private fun openNotificationListenerSettings(context: Context) {
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
- PackageManager.PERMISSION_GRANTED
+ PackageManager.PERMISSION_GRANTED
}
private fun isNotificationListenerEnabled(context: Context): Boolean {
@@ -792,5 +832,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
- sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
+ sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/CallLogHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CallLogHandlerTest.kt
new file mode 100644
index 00000000000..21f4f7dd82a
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CallLogHandlerTest.kt
@@ -0,0 +1,193 @@
+package ai.openclaw.app.node
+
+import android.content.Context
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CallLogHandlerTest : NodeHandlerRobolectricTest() {
+ @Test
+ fun handleCallLogSearch_requiresPermission() {
+ val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false))
+
+ val result = handler.handleCallLogSearch(null)
+
+ assertFalse(result.ok)
+ assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code)
+ }
+
+ @Test
+ fun handleCallLogSearch_rejectsInvalidJson() {
+ val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true))
+
+ val result = handler.handleCallLogSearch("invalid json")
+
+ assertFalse(result.ok)
+ assertEquals("INVALID_REQUEST", result.error?.code)
+ }
+
+ @Test
+ fun handleCallLogSearch_returnsCallLogs() {
+ val callLog =
+ CallLogRecord(
+ number = "+123456",
+ cachedName = "lixuankai",
+ date = 1709280000000L,
+ duration = 60L,
+ type = 1,
+ )
+ val handler =
+ CallLogHandler.forTesting(
+ appContext(),
+ FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
+ )
+
+ val result = handler.handleCallLogSearch("""{"limit":1}""")
+
+ assertTrue(result.ok)
+ val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
+ val callLogs = payload.getValue("callLogs").jsonArray
+ assertEquals(1, callLogs.size)
+ assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
+ assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
+ assertEquals(1709280000000L, callLogs.first().jsonObject.getValue("date").jsonPrimitive.content.toLong())
+ assertEquals(60L, callLogs.first().jsonObject.getValue("duration").jsonPrimitive.content.toLong())
+ assertEquals(1, callLogs.first().jsonObject.getValue("type").jsonPrimitive.content.toInt())
+ }
+
+ @Test
+ fun handleCallLogSearch_withFilters() {
+ val callLog =
+ CallLogRecord(
+ number = "+123456",
+ cachedName = "lixuankai",
+ date = 1709280000000L,
+ duration = 120L,
+ type = 2,
+ )
+ val handler =
+ CallLogHandler.forTesting(
+ appContext(),
+ FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
+ )
+
+ val result = handler.handleCallLogSearch(
+ """{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}"""
+ )
+
+ assertTrue(result.ok)
+ val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
+ val callLogs = payload.getValue("callLogs").jsonArray
+ assertEquals(1, callLogs.size)
+ assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
+ }
+
+ @Test
+ fun handleCallLogSearch_withPagination() {
+ val callLogs =
+ listOf(
+ CallLogRecord(
+ number = "+123456",
+ cachedName = "lixuankai",
+ date = 1709280000000L,
+ duration = 60L,
+ type = 1,
+ ),
+ CallLogRecord(
+ number = "+654321",
+ cachedName = "lixuankai2",
+ date = 1709280001000L,
+ duration = 120L,
+ type = 2,
+ ),
+ )
+ val handler =
+ CallLogHandler.forTesting(
+ appContext(),
+ FakeCallLogDataSource(canRead = true, searchResults = callLogs),
+ )
+
+ val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""")
+
+ assertTrue(result.ok)
+ val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
+ val callLogsResult = payload.getValue("callLogs").jsonArray
+ assertEquals(1, callLogsResult.size)
+ assertEquals("lixuankai2", callLogsResult.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
+ }
+
+ @Test
+ fun handleCallLogSearch_withDefaultParams() {
+ val callLog =
+ CallLogRecord(
+ number = "+123456",
+ cachedName = "lixuankai",
+ date = 1709280000000L,
+ duration = 60L,
+ type = 1,
+ )
+ val handler =
+ CallLogHandler.forTesting(
+ appContext(),
+ FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
+ )
+
+ val result = handler.handleCallLogSearch(null)
+
+ assertTrue(result.ok)
+ val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
+ val callLogs = payload.getValue("callLogs").jsonArray
+ assertEquals(1, callLogs.size)
+ assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
+ }
+
+ @Test
+ fun handleCallLogSearch_withNullFields() {
+ val callLog =
+ CallLogRecord(
+ number = null,
+ cachedName = null,
+ date = 1709280000000L,
+ duration = 60L,
+ type = 1,
+ )
+ val handler =
+ CallLogHandler.forTesting(
+ appContext(),
+ FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
+ )
+
+ val result = handler.handleCallLogSearch("""{"limit":1}""")
+
+ assertTrue(result.ok)
+ val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
+ val callLogs = payload.getValue("callLogs").jsonArray
+ assertEquals(1, callLogs.size)
+ // Verify null values are properly serialized
+ val callLogObj = callLogs.first().jsonObject
+ assertTrue(callLogObj.containsKey("number"))
+ assertTrue(callLogObj.containsKey("cachedName"))
+ }
+}
+
+private class FakeCallLogDataSource(
+ private val canRead: Boolean,
+ private val searchResults: List = emptyList(),
+) : CallLogDataSource {
+ override fun hasReadPermission(context: Context): Boolean = canRead
+
+ override fun search(context: Context, request: CallLogSearchRequest): List {
+ val startIndex = request.offset.coerceAtLeast(0)
+ val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
+ return if (startIndex < searchResults.size) {
+ searchResults.subList(startIndex, endIndex)
+ } else {
+ emptyList()
+ }
+ }
+}
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 e40e2b164ae..1bce95748e0 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
@@ -93,6 +93,7 @@ class DeviceHandlerTest {
"photos",
"contacts",
"calendar",
+ "callLog",
"motion",
)
for (key in expected) {
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
index d3825a5720e..334fe31cb7f 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
@@ -2,6 +2,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
+import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand
@@ -25,6 +26,7 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
+ OpenClawCapability.CallLog.rawValue,
)
private val optionalCapabilities =
@@ -50,6 +52,7 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
+ OpenClawCallLogCommand.Search.rawValue,
)
private val optionalCommands =
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
index 8dd844dee83..6069a2cc97c 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
@@ -34,6 +34,7 @@ class OpenClawProtocolConstantsTest {
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue)
+ assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
}
@Test
@@ -84,4 +85,9 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
+
+ @Test
+ fun callLogCommandsUseStableStrings() {
+ assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
+ }
}
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 7c087162c46..3de435dd59e 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -285,6 +285,7 @@ Available families:
- `photos.latest`
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
+- `callLog.search`
- `motion.activity`, `motion.pedometer`
Example invokes:
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index 6bd5effb361..bfe73ca4526 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -163,4 +163,5 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `photos.latest`
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
+ - `callLog.search`
- `motion.activity`, `motion.pedometer`
diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts
index f7adcbf512f..de7f5e81117 100644
--- a/src/gateway/gateway-misc.test.ts
+++ b/src/gateway/gateway-misc.test.ts
@@ -347,6 +347,7 @@ describe("resolveNodeCommandAllowlist", () => {
expect(allow.has("notifications.actions")).toBe(true);
expect(allow.has("device.permissions")).toBe(true);
expect(allow.has("device.health")).toBe(true);
+ expect(allow.has("callLog.search")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
});
diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts
index 5f6734f6f7f..7310dc4ec73 100644
--- a/src/gateway/node-command-policy.ts
+++ b/src/gateway/node-command-policy.ts
@@ -36,6 +36,8 @@ const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"];
const CALENDAR_COMMANDS = ["calendar.events"];
const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"];
+const CALL_LOG_COMMANDS = ["callLog.search"];
+
const REMINDERS_COMMANDS = ["reminders.list"];
const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"];
@@ -93,6 +95,7 @@ const PLATFORM_DEFAULTS: Record = {
...ANDROID_DEVICE_COMMANDS,
...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS,
+ ...CALL_LOG_COMMANDS,
...REMINDERS_COMMANDS,
...PHOTOS_COMMANDS,
...MOTION_COMMANDS,