mirror of https://github.com/openclaw/openclaw.git
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:
parent
4bb8a65edd
commit
d7ac16788e
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,7 @@ class DeviceHandlerTest {
|
||||||
"photos",
|
"photos",
|
||||||
"contacts",
|
"contacts",
|
||||||
"calendar",
|
"calendar",
|
||||||
|
"callLog",
|
||||||
"motion",
|
"motion",
|
||||||
)
|
)
|
||||||
for (key in expected) {
|
for (key in expected) {
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue