fix(android): support android node `calllog.search` (#44073)

* fix(android): support android node  `calllog.search`

* fix(android): support android node calllog.search

* fix(android): wire callLog through shared surfaces

* fix: land Android callLog support (#44073) (thanks @lxk7280)

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ace Lee 2026-03-15 17:24:32 +08:00 committed by GitHub
parent 4bb8a65edd
commit d7ac16788e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 560 additions and 2 deletions

View File

@ -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/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. - 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. - 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 ### Fixes

View File

@ -19,6 +19,7 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" /> <uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" /> <uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />

View File

@ -110,6 +110,10 @@ class NodeRuntime(context: Context) {
appContext = appContext, appContext = appContext,
) )
private val callLogHandler: CallLogHandler = CallLogHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler( private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext, appContext = appContext,
) )
@ -151,6 +155,7 @@ class NodeRuntime(context: Context) {
smsHandler = smsHandlerImpl, smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler, a2uiHandler = a2uiHandler,
debugHandler = debugHandler, debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value }, isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value }, cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off }, locationEnabled = { locationMode.value != LocationMode.Off },

View File

@ -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<CallLogRecord>
}
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<CallLogRecord> {
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<String>()
val selectionArgs = mutableListOf<String>()
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<CallLogRecord>()
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)
}
}

View File

@ -212,6 +212,13 @@ class DeviceHandler(
promptableWhenDenied = true, promptableWhenDenied = true,
), ),
) )
put(
"callLog",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = true,
),
)
put( put(
"motion", "motion",
permissionStateJson( permissionStateJson(

View File

@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawLocationCommand
@ -84,6 +85,7 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue, name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable, availability = NodeCapabilityAvailability.MotionAvailable,
), ),
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
) )
val all: List<InvokeCommandSpec> = val all: List<InvokeCommandSpec> =
@ -187,6 +189,9 @@ object InvokeCommandRegistry {
name = OpenClawSmsCommand.Send.rawValue, name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable, availability = InvokeCommandAvailability.SmsAvailable,
), ),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
),
InvokeCommandSpec( InvokeCommandSpec(
name = "debug.logs", name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild, availability = InvokeCommandAvailability.DebugBuild,

View File

@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawLocationCommand
@ -27,6 +28,7 @@ class InvokeDispatcher(
private val smsHandler: SmsHandler, private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler, private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler, private val debugHandler: DebugHandler,
private val callLogHandler: CallLogHandler,
private val isForeground: () -> Boolean, private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean, private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean, private val locationEnabled: () -> Boolean,
@ -161,6 +163,9 @@ class InvokeDispatcher(
// SMS command // SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson) OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
// Debug commands // Debug commands
"debug.ed25519" -> debugHandler.handleEd25519() "debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs() "debug.logs" -> debugHandler.handleLogs()

View File

@ -13,6 +13,7 @@ enum class OpenClawCapability(val rawValue: String) {
Contacts("contacts"), Contacts("contacts"),
Calendar("calendar"), Calendar("calendar"),
Motion("motion"), Motion("motion"),
CallLog("callLog"),
} }
enum class OpenClawCanvasCommand(val rawValue: String) { enum class OpenClawCanvasCommand(val rawValue: String) {
@ -137,3 +138,12 @@ enum class OpenClawMotionCommand(val rawValue: String) {
const val NamespacePrefix: String = "motion." const val NamespacePrefix: String = "motion."
} }
} }
enum class OpenClawCallLogCommand(val rawValue: String) {
Search("callLog.search"),
;
companion object {
const val NamespacePrefix: String = "callLog."
}
}

View File

@ -121,6 +121,7 @@ private enum class PermissionToggle {
Calendar, Calendar,
Motion, Motion,
Sms, Sms,
CallLog,
} }
private enum class SpecialAccessToggle { private enum class SpecialAccessToggle {
@ -288,6 +289,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable { rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS)) 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<PermissionToggle?>(null) } var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) } var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
@ -304,6 +309,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable 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) isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms -> PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS) !smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
} }
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) { fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@ -352,6 +359,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableCalendar, enableCalendar,
enableMotion, enableMotion,
enableSms, enableSms,
enableCallLog,
smsAvailable, smsAvailable,
motionAvailable, motionAvailable,
) { ) {
@ -367,6 +375,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar" if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion" if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS" if (smsAvailable && enableSms) enabled += "SMS"
if (enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
} }
@ -595,6 +604,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired, motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms, enableSms = enableSms,
smsAvailable = smsAvailable, smsAvailable = smsAvailable,
enableCallLog = enableCallLog,
context = context, context = context,
onDiscoveryChange = { checked -> onDiscoveryChange = { checked ->
requestPermissionToggle( 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 -> OnboardingStep.FinalCheck ->
FinalStep( FinalStep(
@ -1282,6 +1299,7 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean, motionPermissionRequired: Boolean,
enableSms: Boolean, enableSms: Boolean,
smsAvailable: Boolean, smsAvailable: Boolean,
enableCallLog: Boolean,
context: Context, context: Context,
onDiscoveryChange: (Boolean) -> Unit, onDiscoveryChange: (Boolean) -> Unit,
onLocationChange: (Boolean) -> Unit, onLocationChange: (Boolean) -> Unit,
@ -1294,6 +1312,7 @@ private fun PermissionsStep(
onCalendarChange: (Boolean) -> Unit, onCalendarChange: (Boolean) -> Unit,
onMotionChange: (Boolean) -> Unit, onMotionChange: (Boolean) -> Unit,
onSmsChange: (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 discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
val locationGranted = val locationGranted =
@ -1424,6 +1443,15 @@ private fun PermissionsStep(
onCheckedChange = onSmsChange, 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)
} }
} }

View File

@ -218,6 +218,18 @@ fun SettingsSheet(viewModel: MainViewModel) {
calendarPermissionGranted = readOk && writeOk 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 var motionPermissionGranted by
remember { remember {
mutableStateOf( mutableStateOf(
@ -266,6 +278,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED && PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) == ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
callLogPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED
motionPermissionGranted = motionPermissionGranted =
!motionPermissionRequired || !motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == 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) { if (motionAvailable) {
HorizontalDivider(color = mobileBorder) HorizontalDivider(color = mobileBorder)
ListItem( ListItem(
@ -782,7 +822,7 @@ private fun openNotificationListenerSettings(context: Context) {
private fun hasNotificationsPermission(context: Context): Boolean { private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
} }
private fun isNotificationListenerEnabled(context: Context): Boolean { private fun isNotificationListenerEnabled(context: Context): Boolean {
@ -792,5 +832,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
private fun hasMotionCapabilities(context: Context): Boolean { private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
} }

View File

@ -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<CallLogRecord> = emptyList(),
) : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean = canRead
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
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()
}
}
}

View File

@ -93,6 +93,7 @@ class DeviceHandlerTest {
"photos", "photos",
"contacts", "contacts",
"calendar", "calendar",
"callLog",
"motion", "motion",
) )
for (key in expected) { for (key in expected) {

View File

@ -2,6 +2,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCapability import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawContactsCommand import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawDeviceCommand
@ -25,6 +26,7 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue, OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue, OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue, OpenClawCapability.Calendar.rawValue,
OpenClawCapability.CallLog.rawValue,
) )
private val optionalCapabilities = private val optionalCapabilities =
@ -50,6 +52,7 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue, OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue, OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue, OpenClawCalendarCommand.Add.rawValue,
OpenClawCallLogCommand.Search.rawValue,
) )
private val optionalCommands = private val optionalCommands =

View File

@ -34,6 +34,7 @@ class OpenClawProtocolConstantsTest {
assertEquals("contacts", OpenClawCapability.Contacts.rawValue) assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue) assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue) assertEquals("motion", OpenClawCapability.Motion.rawValue)
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
} }
@Test @Test
@ -84,4 +85,9 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue) assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue) assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
} }
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
} }

View File

@ -285,6 +285,7 @@ Available families:
- `photos.latest` - `photos.latest`
- `contacts.search`, `contacts.add` - `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add` - `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer` - `motion.activity`, `motion.pedometer`
Example invokes: Example invokes:

View File

@ -163,4 +163,5 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `photos.latest` - `photos.latest`
- `contacts.search`, `contacts.add` - `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add` - `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer` - `motion.activity`, `motion.pedometer`

View File

@ -347,6 +347,7 @@ describe("resolveNodeCommandAllowlist", () => {
expect(allow.has("notifications.actions")).toBe(true); expect(allow.has("notifications.actions")).toBe(true);
expect(allow.has("device.permissions")).toBe(true); expect(allow.has("device.permissions")).toBe(true);
expect(allow.has("device.health")).toBe(true); expect(allow.has("device.health")).toBe(true);
expect(allow.has("callLog.search")).toBe(true);
expect(allow.has("system.notify")).toBe(true); expect(allow.has("system.notify")).toBe(true);
}); });

View File

@ -36,6 +36,8 @@ const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"];
const CALENDAR_COMMANDS = ["calendar.events"]; const CALENDAR_COMMANDS = ["calendar.events"];
const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"]; const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"];
const CALL_LOG_COMMANDS = ["callLog.search"];
const REMINDERS_COMMANDS = ["reminders.list"]; const REMINDERS_COMMANDS = ["reminders.list"];
const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"]; const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"];
@ -93,6 +95,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...ANDROID_DEVICE_COMMANDS, ...ANDROID_DEVICE_COMMANDS,
...CONTACTS_COMMANDS, ...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS, ...CALENDAR_COMMANDS,
...CALL_LOG_COMMANDS,
...REMINDERS_COMMANDS, ...REMINDERS_COMMANDS,
...PHOTOS_COMMANDS, ...PHOTOS_COMMANDS,
...MOTION_COMMANDS, ...MOTION_COMMANDS,