mirror of
https://github.com/yuga-hashimoto/openclaw-assistant.git
synced 2026-08-14 07:02:02 +00:00
Add missing Android node command families (#189)
* Add missing Android node command families (parity gap) - Implement Notifications, System, Photos, Contacts, Calendar, and Motion command families. - Add OpenClawNotificationListenerService and NotificationManager for notification support. - Implement robust runtime permission checks and structured error handling in all handlers. - Wire new handlers into NodeRuntime, InvokeDispatcher, and ConnectionManager. - Conditionally advertise capabilities to the gateway based on granted permissions. - Add unit tests for command routing and handler logic. - Update AndroidManifest.xml with necessary permissions and service declarations. Co-authored-by: yuga-hashimoto <74749461+yuga-hashimoto@users.noreply.github.com> * Refine Android node command families based on PR feedback - Implement actual Notification actions (dismiss, open) using NotificationListenerService. - Correct permission gating for Notifications (use Listener Service check instead of POST_NOTIFICATIONS). - Add runtime permission check for system.notify on Android 13+. - Improve Calendar event insertion by querying for a writable calendar instead of hardcoding ID 1. - Update capability advertisement in ConnectionManager to reflect true availability. - Enhance PhotosHandler and MotionHandler with more complete implementations. - Update unit tests to match refined permission and action logic. Co-authored-by: yuga-hashimoto <74749461+yuga-hashimoto@users.noreply.github.com> * Finalize Android node command families with refined notification support - Implement dismiss/open notification actions using NotificationListenerService. - Return NOT_IMPLEMENTED for reply notification action as requested. - Fix permission gating for notifications (use listener service status). - Add runtime permission check for system notifications on Android 13+. - Improve calendar event creation with dynamic writable calendar selection. - Refine capability advertising in ConnectionManager. - Clean up unused imports and duplicated logic. - Update unit tests to match refined behavior. Co-authored-by: yuga-hashimoto <74749461+yuga-hashimoto@users.noreply.github.com> * fix: address review issues in node command families PR - MotionHandler: add close() to unregister SensorEventListener on disconnect - NodeRuntime: call motionHandler.close() in disconnect() for proper cleanup - OpenClawNotificationListenerService: add @Volatile to companion manager field - SystemHandlerTest: replace unconditional SDK_INT branch with asserting tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add on-demand permission requests to new node command handlers Contacts, Calendar, Photos, and Motion handlers now use PermissionRequester to show the system permission dialog when a command is first invoked, matching the existing SMS/Camera handler pattern. - ContactsHandler, CalendarHandler, PhotosHandler, MotionHandler: add attachPermissionRequester() + ensureXPermission() suspend helpers, convert handle methods to suspend fun - PermissionRequester: add human-readable labels for new permissions - NodeRuntime: expose attachPermissionRequester() delegating to all handlers - MainActivity: wire permissionRequester to new handlers on startup - InvokeDispatcherTest: update every -> coEvery for suspend handler mocks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: guide user to notification listener settings via dialog When notifications.list or notifications.actions is invoked without notification access, show an AlertDialog explaining the requirement and open ACTION_NOTIFICATION_LISTENER_SETTINGS on confirmation. - PermissionRequester: add requestNotificationAccess() suspend fun - NotificationsHandler: add PermissionRequester wiring, make handle methods suspend, call requestNotificationAccess() before error return - NodeRuntime.attachPermissionRequester: include notificationsHandler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * add funding --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
parent
997b3e5966
commit
f8eb868e17
@@ -1 +1,4 @@
|
||||
github: yuga-hashimoto
|
||||
ko_fi: R5R51S97C4
|
||||
custom:
|
||||
- https://paypal.me/yuga620
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Git ワークフロー
|
||||
|
||||
### PR作成
|
||||
GitHub CLI (`gh` コマンド) を使用してPRを作成すること。
|
||||
|
||||
```bash
|
||||
# 変更をコミット
|
||||
git add <files>
|
||||
git commit -m "commit message"
|
||||
|
||||
# ブランチをpush
|
||||
git push origin <branch-name>
|
||||
|
||||
# ghコマンドでPR作成
|
||||
gh pr create --title "PRタイトル" --body "PR本文" --base main
|
||||
```
|
||||
|
||||
**注意:** `git` コマンドだけでなく、必ず `gh` コマンドを使ってPRを作成すること。
|
||||
@@ -41,6 +41,22 @@
|
||||
<!-- SMS -->
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
|
||||
<!-- Contacts -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
|
||||
<!-- Calendar -->
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
|
||||
<!-- Photos / Media -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<!-- Motion / Activity Recognition -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
|
||||
<!-- App update (install APK) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
@@ -153,6 +169,17 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
|
||||
<!-- Notification Listener Service -->
|
||||
<service
|
||||
android:name="com.openclaw.assistant.service.OpenClawNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- FileProvider for APK installs -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -169,6 +169,7 @@ class MainActivity : ComponentActivity(), TextToSpeech.OnInitListener {
|
||||
val runtime = (applicationContext as OpenClawApplication).nodeRuntime
|
||||
runtime.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||
runtime.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||
runtime.attachPermissionRequester(permissionRequester)
|
||||
|
||||
initializeTTS()
|
||||
// Removed checkPermissions() from onCreate to allow SetupGuideScreen to handle it
|
||||
|
||||
@@ -97,6 +97,25 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestNotificationAccess() =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Notification Access Required")
|
||||
.setMessage(
|
||||
"OpenClaw needs notification access to read and manage your notifications. " +
|
||||
"Tap \"Open Settings\", enable OpenClaw Assistant, then return to the app."
|
||||
)
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
activity.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
cont.resume(Unit)
|
||||
}
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(Unit) }
|
||||
.setOnCancelListener { cont.resume(Unit) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSettingsDialog(permissions: List<String>) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
@@ -128,6 +147,13 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
Manifest.permission.CAMERA -> "Camera"
|
||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||
Manifest.permission.SEND_SMS -> "SMS"
|
||||
Manifest.permission.READ_CONTACTS -> "Contacts (read)"
|
||||
Manifest.permission.WRITE_CONTACTS -> "Contacts (write)"
|
||||
Manifest.permission.READ_CALENDAR -> "Calendar (read)"
|
||||
Manifest.permission.WRITE_CALENDAR -> "Calendar (write)"
|
||||
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE -> "Storage"
|
||||
Manifest.permission.ACTIVITY_RECOGNITION -> "Activity Recognition"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.PermissionRequester
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.util.TimeZone
|
||||
|
||||
class CalendarHandler(private val appContext: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
private fun hasReadPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.READ_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun hasWritePermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.WRITE_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun getFirstWritableCalendarId(): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
val selection = "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?"
|
||||
val selectionArgs = arrayOf(CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString())
|
||||
val cursor = appContext.contentResolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)
|
||||
return cursor?.use {
|
||||
if (it.moveToFirst()) it.getLong(0) else null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureReadPermission(): Boolean {
|
||||
if (hasReadPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CALENDAR))
|
||||
return results[Manifest.permission.READ_CALENDAR] == true
|
||||
}
|
||||
|
||||
private suspend fun ensureWritePermission(): Boolean {
|
||||
if (hasWritePermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.WRITE_CALENDAR))
|
||||
return results[Manifest.permission.WRITE_CALENDAR] == true
|
||||
}
|
||||
|
||||
suspend fun handleEvents(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!ensureReadPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_READ_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_READ_PERMISSION_REQUIRED: grant Calendar read permission"
|
||||
)
|
||||
}
|
||||
|
||||
val params = paramsJson?.let {
|
||||
try {
|
||||
json.parseToJsonElement(it).jsonObject
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Expected JSON object")
|
||||
|
||||
val startTime = (params["startTime"] as? JsonPrimitive)?.content?.toLongOrNull() ?: System.currentTimeMillis()
|
||||
val endTime = (params["endTime"] as? JsonPrimitive)?.content?.toLongOrNull() ?: (startTime + 86400000)
|
||||
|
||||
val events = mutableListOf<JsonObject>()
|
||||
val projection = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND
|
||||
)
|
||||
val selection = "(${CalendarContract.Events.DTSTART} >= ?) AND (${CalendarContract.Events.DTSTART} <= ?)"
|
||||
val selectionArgs = arrayOf(startTime.toString(), endTime.toString())
|
||||
val cursor = try {
|
||||
appContext.contentResolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_READ_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_READ_PERMISSION_REQUIRED: ${e.message}"
|
||||
)
|
||||
}
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndexOrThrow(CalendarContract.Events._ID)
|
||||
val titleIndex = it.getColumnIndexOrThrow(CalendarContract.Events.TITLE)
|
||||
val startIndex = it.getColumnIndexOrThrow(CalendarContract.Events.DTSTART)
|
||||
val endIndex = it.getColumnIndexOrThrow(CalendarContract.Events.DTEND)
|
||||
var count = 0
|
||||
while (it.moveToNext() && count < 10) {
|
||||
val id = it.getLong(idIndex)
|
||||
val title = it.getString(titleIndex)
|
||||
val start = it.getLong(startIndex)
|
||||
val end = it.getLong(endIndex)
|
||||
events.add(buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("title", JsonPrimitive(title))
|
||||
put("startTime", JsonPrimitive(start))
|
||||
put("endTime", JsonPrimitive(end))
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("events", buildJsonArray {
|
||||
events.forEach { add(it) }
|
||||
})
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(payload.toString())
|
||||
}
|
||||
|
||||
suspend fun handleAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!ensureWritePermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_WRITE_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_WRITE_PERMISSION_REQUIRED: grant Calendar write permission"
|
||||
)
|
||||
}
|
||||
|
||||
val calendarId = getFirstWritableCalendarId() ?: return GatewaySession.InvokeResult.error("CALENDAR_NOT_FOUND", "No writable calendar found")
|
||||
|
||||
val params = paramsJson?.let {
|
||||
try {
|
||||
json.parseToJsonElement(it).jsonObject
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Expected JSON object")
|
||||
|
||||
val title = (params["title"] as? JsonPrimitive)?.content ?: ""
|
||||
val startTime = (params["startTime"] as? JsonPrimitive)?.content?.toLongOrNull() ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "startTime is required")
|
||||
val endTime = (params["endTime"] as? JsonPrimitive)?.content?.toLongOrNull() ?: (startTime + 3600000)
|
||||
|
||||
if (title.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Title is required")
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.DTSTART, startTime)
|
||||
put(CalendarContract.Events.DTEND, endTime)
|
||||
put(CalendarContract.Events.TITLE, title)
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
|
||||
}
|
||||
|
||||
return try {
|
||||
val uri = appContext.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
if (uri != null) {
|
||||
GatewaySession.InvokeResult.ok("""{"ok":true,"id":${uri.lastPathSegment}}""")
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error("CALENDAR_ADD_FAILED", "CALENDAR_ADD_FAILED: insert returned null")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GatewaySession.InvokeResult.error("CALENDAR_ADD_FAILED", "CALENDAR_ADD_FAILED: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.BuildConfig
|
||||
import com.openclaw.assistant.SecurePrefs
|
||||
import com.openclaw.assistant.gateway.GatewayClientInfo
|
||||
@@ -14,12 +18,20 @@ import com.openclaw.assistant.protocol.OpenClawDeviceCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawLocationCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawScreenCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawSmsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawNotificationsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawSystemCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawPhotosCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawContactsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawCalendarCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawMotionCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawCapability
|
||||
import com.openclaw.assistant.LocationMode
|
||||
import com.openclaw.assistant.VoiceWakeMode
|
||||
import android.provider.Settings
|
||||
|
||||
class ConnectionManager(
|
||||
private val prefs: SecurePrefs,
|
||||
private val appContext: Context,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationMode: () -> LocationMode,
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
@@ -81,6 +93,15 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(): Boolean {
|
||||
val enabledPackages = Settings.Secure.getString(appContext.contentResolver, "enabled_notification_listeners")
|
||||
return enabledPackages?.contains(appContext.packageName) == true
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
@@ -104,6 +125,48 @@ class ConnectionManager(
|
||||
if (smsAvailable()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
|
||||
// Notifications
|
||||
if (isNotificationListenerEnabled()) {
|
||||
add(OpenClawNotificationsCommand.List.rawValue)
|
||||
add(OpenClawNotificationsCommand.Actions.rawValue)
|
||||
}
|
||||
|
||||
// System
|
||||
add(OpenClawSystemCommand.Notify.rawValue)
|
||||
|
||||
// Photos
|
||||
val photosPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
if (hasPermission(photosPermission)) {
|
||||
add(OpenClawPhotosCommand.Latest.rawValue)
|
||||
}
|
||||
|
||||
// Contacts
|
||||
if (hasPermission(Manifest.permission.READ_CONTACTS)) {
|
||||
add(OpenClawContactsCommand.Search.rawValue)
|
||||
}
|
||||
if (hasPermission(Manifest.permission.WRITE_CONTACTS)) {
|
||||
add(OpenClawContactsCommand.Add.rawValue)
|
||||
}
|
||||
|
||||
// Calendar
|
||||
if (hasPermission(Manifest.permission.READ_CALENDAR)) {
|
||||
add(OpenClawCalendarCommand.Events.rawValue)
|
||||
}
|
||||
if (hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
add(OpenClawCalendarCommand.Add.rawValue)
|
||||
}
|
||||
|
||||
// Motion
|
||||
if (hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)) {
|
||||
add(OpenClawMotionCommand.Activity.rawValue)
|
||||
add(OpenClawMotionCommand.Pedometer.rawValue)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
add("debug.logs")
|
||||
add("debug.ed25519")
|
||||
@@ -115,6 +178,33 @@ class ConnectionManager(
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
add(OpenClawCapability.System.rawValue)
|
||||
|
||||
if (isNotificationListenerEnabled()) {
|
||||
add(OpenClawCapability.Notifications.rawValue)
|
||||
}
|
||||
|
||||
val photosPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
if (hasPermission(photosPermission)) {
|
||||
add(OpenClawCapability.Photos.rawValue)
|
||||
}
|
||||
|
||||
if (hasPermission(Manifest.permission.READ_CONTACTS) || hasPermission(Manifest.permission.WRITE_CONTACTS)) {
|
||||
add(OpenClawCapability.Contacts.rawValue)
|
||||
}
|
||||
|
||||
if (hasPermission(Manifest.permission.READ_CALENDAR) || hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
add(OpenClawCapability.Calendar.rawValue)
|
||||
}
|
||||
|
||||
if (hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)) {
|
||||
add(OpenClawCapability.Motion.rawValue)
|
||||
}
|
||||
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.PermissionRequester
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
class ContactsHandler(private val appContext: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
private fun hasReadPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun hasWritePermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.WRITE_CONTACTS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private suspend fun ensureReadPermission(): Boolean {
|
||||
if (hasReadPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
|
||||
return results[Manifest.permission.READ_CONTACTS] == true
|
||||
}
|
||||
|
||||
private suspend fun ensureWritePermission(): Boolean {
|
||||
if (hasWritePermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.WRITE_CONTACTS))
|
||||
return results[Manifest.permission.WRITE_CONTACTS] == true
|
||||
}
|
||||
|
||||
suspend fun handleSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!ensureReadPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_READ_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_READ_PERMISSION_REQUIRED: grant Contacts read permission"
|
||||
)
|
||||
}
|
||||
|
||||
val params = paramsJson?.let {
|
||||
try {
|
||||
json.parseToJsonElement(it).jsonObject
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Expected JSON object")
|
||||
|
||||
val query = (params["query"] as? JsonPrimitive)?.content ?: ""
|
||||
|
||||
if (query.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Query is required")
|
||||
}
|
||||
|
||||
val contacts = mutableListOf<JsonObject>()
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.CommonDataKinds.Phone.CONTACT_ID
|
||||
)
|
||||
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
|
||||
val selectionArgs = arrayOf("%$query%")
|
||||
val cursor = try {
|
||||
appContext.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_READ_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_READ_PERMISSION_REQUIRED: ${e.message}"
|
||||
)
|
||||
}
|
||||
|
||||
cursor?.use {
|
||||
val nameIndex = it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||
val numberIndex = it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
val idIndex = it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
|
||||
var count = 0
|
||||
while (it.moveToNext() && count < 10) {
|
||||
val name = it.getString(nameIndex)
|
||||
val number = it.getString(numberIndex)
|
||||
val id = it.getLong(idIndex)
|
||||
contacts.add(buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("name", JsonPrimitive(name))
|
||||
put("number", JsonPrimitive(number))
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("contacts", buildJsonArray {
|
||||
contacts.forEach { add(it) }
|
||||
})
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(payload.toString())
|
||||
}
|
||||
|
||||
suspend fun handleAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!ensureWritePermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_WRITE_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_WRITE_PERMISSION_REQUIRED: grant Contacts write permission"
|
||||
)
|
||||
}
|
||||
|
||||
val params = paramsJson?.let {
|
||||
try {
|
||||
json.parseToJsonElement(it).jsonObject
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Expected JSON object")
|
||||
|
||||
val name = (params["name"] as? JsonPrimitive)?.content ?: ""
|
||||
val number = (params["number"] as? JsonPrimitive)?.content ?: ""
|
||||
|
||||
if (name.isEmpty() || number.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Name and number are required")
|
||||
}
|
||||
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
|
||||
.build())
|
||||
|
||||
ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, name)
|
||||
.build())
|
||||
|
||||
ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
|
||||
.build())
|
||||
|
||||
return try {
|
||||
appContext.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||
GatewaySession.InvokeResult.ok("""{"ok":true}""")
|
||||
} catch (e: Exception) {
|
||||
GatewaySession.InvokeResult.error("CONTACTS_ADD_FAILED", "CONTACTS_ADD_FAILED: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import com.openclaw.assistant.protocol.OpenClawDeviceCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawLocationCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawScreenCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawSmsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawNotificationsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawSystemCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawPhotosCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawContactsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawCalendarCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawMotionCommand
|
||||
|
||||
class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
@@ -15,6 +21,12 @@ class InvokeDispatcher(
|
||||
private val locationHandler: LocationHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val notificationsHandler: NotificationsHandler,
|
||||
private val systemHandler: SystemHandler,
|
||||
private val photosHandler: PhotosHandler,
|
||||
private val contactsHandler: ContactsHandler,
|
||||
private val calendarHandler: CalendarHandler,
|
||||
private val motionHandler: MotionHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
private val debugHandler: DebugHandler,
|
||||
private val appUpdateHandler: AppUpdateHandler,
|
||||
@@ -162,6 +174,28 @@ class InvokeDispatcher(
|
||||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
|
||||
// Notifications commands
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleList()
|
||||
OpenClawNotificationsCommand.Actions.rawValue -> notificationsHandler.handleActions(paramsJson)
|
||||
|
||||
// System command
|
||||
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleNotify(paramsJson)
|
||||
|
||||
// Photos command
|
||||
OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handleLatest()
|
||||
|
||||
// Contacts commands
|
||||
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleSearch(paramsJson)
|
||||
OpenClawContactsCommand.Add.rawValue -> contactsHandler.handleAdd(paramsJson)
|
||||
|
||||
// Calendar commands
|
||||
OpenClawCalendarCommand.Events.rawValue -> calendarHandler.handleEvents(paramsJson)
|
||||
OpenClawCalendarCommand.Add.rawValue -> calendarHandler.handleAdd(paramsJson)
|
||||
|
||||
// Motion commands
|
||||
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleActivity()
|
||||
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handlePedometer()
|
||||
|
||||
// Device commands
|
||||
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleStatus()
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleInfo()
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.PermissionRequester
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
class MotionHandler(private val appContext: Context) : SensorEventListener {
|
||||
|
||||
private val sensorManager = appContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private var stepCounterSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
|
||||
private var currentSteps: Float = 0f
|
||||
|
||||
init {
|
||||
stepCounterSensor?.let {
|
||||
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
private fun hasPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.ACTIVITY_RECOGNITION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private suspend fun ensurePermission(): Boolean {
|
||||
if (hasPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.ACTIVITY_RECOGNITION))
|
||||
return results[Manifest.permission.ACTIVITY_RECOGNITION] == true
|
||||
}
|
||||
|
||||
suspend fun handleActivity(): GatewaySession.InvokeResult {
|
||||
if (!ensurePermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Activity Recognition permission"
|
||||
)
|
||||
}
|
||||
val payload = buildJsonObject {
|
||||
put("activity", JsonPrimitive("still")) // Activity Recognition API placeholder
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(payload.toString())
|
||||
}
|
||||
|
||||
suspend fun handlePedometer(): GatewaySession.InvokeResult {
|
||||
if (!ensurePermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Activity Recognition permission"
|
||||
)
|
||||
}
|
||||
val payload = buildJsonObject {
|
||||
put("steps", JsonPrimitive(currentSteps.toInt()))
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(payload.toString())
|
||||
}
|
||||
|
||||
fun close() {
|
||||
sensorManager.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (event?.sensor?.type == Sensor.TYPE_STEP_COUNTER) {
|
||||
currentSteps = event.values[0]
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package com.openclaw.assistant.node
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.CameraHudKind
|
||||
import com.openclaw.assistant.CameraHudState
|
||||
import com.openclaw.assistant.LocationMode
|
||||
import com.openclaw.assistant.PermissionRequester
|
||||
import com.openclaw.assistant.SecurePrefs
|
||||
import com.openclaw.assistant.VoiceWakeMode
|
||||
import com.openclaw.assistant.chat.ChatController
|
||||
@@ -27,6 +29,7 @@ import com.openclaw.assistant.gateway.GatewaySession
|
||||
import com.openclaw.assistant.gateway.probeGatewayTlsFingerprint
|
||||
import com.openclaw.assistant.protocol.OpenClawCanvasA2UIAction
|
||||
import com.openclaw.assistant.R
|
||||
import com.openclaw.assistant.service.OpenClawNotificationListenerService
|
||||
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -121,6 +124,35 @@ class NodeRuntime(context: Context) {
|
||||
sms = sms,
|
||||
)
|
||||
|
||||
private val notificationManager: NotificationManager = NotificationManager().also {
|
||||
OpenClawNotificationListenerService.manager = it
|
||||
}
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
context = appContext,
|
||||
notificationManager = notificationManager,
|
||||
)
|
||||
|
||||
private val systemHandler: SystemHandler = SystemHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val photosHandler: PhotosHandler = PhotosHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val contactsHandler: ContactsHandler = ContactsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val calendarHandler: CalendarHandler = CalendarHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val a2uiHandler: A2UIHandler = A2UIHandler(
|
||||
canvas = canvas,
|
||||
json = json,
|
||||
@@ -130,6 +162,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private val connectionManager: ConnectionManager = ConnectionManager(
|
||||
prefs = prefs,
|
||||
appContext = appContext,
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationMode = { locationMode.value },
|
||||
voiceWakeMode = { voiceWakeMode.value },
|
||||
@@ -145,6 +178,12 @@ class NodeRuntime(context: Context) {
|
||||
locationHandler = locationHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
notificationsHandler = notificationsHandler,
|
||||
systemHandler = systemHandler,
|
||||
photosHandler = photosHandler,
|
||||
contactsHandler = contactsHandler,
|
||||
calendarHandler = calendarHandler,
|
||||
motionHandler = motionHandler,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
appUpdateHandler = appUpdateHandler,
|
||||
@@ -656,11 +695,20 @@ class NodeRuntime(context: Context) {
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
notificationsHandler.attachPermissionRequester(requester)
|
||||
contactsHandler.attachPermissionRequester(requester)
|
||||
calendarHandler.attachPermissionRequester(requester)
|
||||
photosHandler.attachPermissionRequester(requester)
|
||||
motionHandler.attachPermissionRequester(requester)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connectedEndpoint = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
motionHandler.close()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.service.notification.StatusBarNotification
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class NotificationManager {
|
||||
private val activeNotifications = ConcurrentHashMap<String, StatusBarNotification>()
|
||||
|
||||
fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
val key = sbn.key
|
||||
activeNotifications[key] = sbn
|
||||
}
|
||||
|
||||
fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||
val key = sbn.key
|
||||
activeNotifications.remove(key)
|
||||
}
|
||||
|
||||
fun getActiveNotifications(): List<StatusBarNotification> {
|
||||
return activeNotifications.values.toList()
|
||||
}
|
||||
|
||||
fun getNotification(key: String): StatusBarNotification? {
|
||||
return activeNotifications[key]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import com.openclaw.assistant.PermissionRequester
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import com.openclaw.assistant.service.OpenClawNotificationListenerService
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
class NotificationsHandler(
|
||||
private val context: Context,
|
||||
private val notificationManager: NotificationManager
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
fun isServiceEnabled(): Boolean {
|
||||
val enabledPackages = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
|
||||
return enabledPackages?.contains(context.packageName) == true
|
||||
}
|
||||
|
||||
suspend fun handleList(): GatewaySession.InvokeResult {
|
||||
if (!isServiceEnabled()) {
|
||||
permissionRequester?.requestNotificationAccess()
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NOTIFICATIONS_PERMISSION_REQUIRED",
|
||||
message = "NOTIFICATIONS_PERMISSION_REQUIRED: enable notification access in Settings > Notification Access, then try again"
|
||||
)
|
||||
}
|
||||
|
||||
val notifications = notificationManager.getActiveNotifications()
|
||||
val payload = buildJsonObject {
|
||||
put("notifications", buildJsonArray {
|
||||
notifications.forEach { sbn ->
|
||||
add(buildJsonObject {
|
||||
put("key", JsonPrimitive(sbn.key))
|
||||
put("packageName", JsonPrimitive(sbn.packageName))
|
||||
put("title", JsonPrimitive(sbn.notification.extras.getCharSequence("android.title")?.toString().orEmpty()))
|
||||
put("text", JsonPrimitive(sbn.notification.extras.getCharSequence("android.text")?.toString().orEmpty()))
|
||||
put("postTime", JsonPrimitive(sbn.postTime))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(payload.toString())
|
||||
}
|
||||
|
||||
suspend fun handleActions(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!isServiceEnabled()) {
|
||||
permissionRequester?.requestNotificationAccess()
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NOTIFICATIONS_PERMISSION_REQUIRED",
|
||||
message = "NOTIFICATIONS_PERMISSION_REQUIRED: enable notification access in Settings > Notification Access, then try again"
|
||||
)
|
||||
}
|
||||
|
||||
val service = OpenClawNotificationListenerService.instance
|
||||
?: return GatewaySession.InvokeResult.error("SERVICE_UNAVAILABLE", "Notification Listener Service not running")
|
||||
|
||||
val params = paramsJson?.let {
|
||||
try {
|
||||
json.parseToJsonElement(it).jsonObject
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Expected JSON object")
|
||||
|
||||
val key = (params["key"] as? JsonPrimitive)?.content ?: ""
|
||||
val action = (params["action"] as? JsonPrimitive)?.content ?: ""
|
||||
|
||||
if (key.isEmpty() || action.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Key and action are required")
|
||||
}
|
||||
|
||||
return when (action.lowercase()) {
|
||||
"dismiss" -> {
|
||||
try {
|
||||
service.cancelNotification(key)
|
||||
GatewaySession.InvokeResult.ok("""{"ok":true}""")
|
||||
} catch (e: Exception) {
|
||||
GatewaySession.InvokeResult.error("ACTION_FAILED", "Failed to dismiss: ${e.message}")
|
||||
}
|
||||
}
|
||||
"open" -> {
|
||||
val sbn = notificationManager.getNotification(key)
|
||||
if (sbn != null) {
|
||||
try {
|
||||
sbn.notification.contentIntent.send()
|
||||
GatewaySession.InvokeResult.ok("""{"ok":true}""")
|
||||
} catch (e: Exception) {
|
||||
GatewaySession.InvokeResult.error("ACTION_FAILED", "Failed to open: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error("NOT_FOUND", "Notification not found")
|
||||
}
|
||||
}
|
||||
"reply" -> GatewaySession.InvokeResult.error("NOT_IMPLEMENTED", "Reply action not yet implemented")
|
||||
else -> GatewaySession.InvokeResult.error("INVALID_REQUEST", "Unsupported action: $action")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.PermissionRequester
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
class PhotosHandler(private val appContext: Context) {
|
||||
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
private fun hasPermission(): Boolean {
|
||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
return ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private suspend fun ensurePermission(): Boolean {
|
||||
if (hasPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
val results = requester.requestIfMissing(listOf(permission))
|
||||
return results[permission] == true
|
||||
}
|
||||
|
||||
suspend fun handleLatest(): GatewaySession.InvokeResult {
|
||||
if (!ensurePermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_PERMISSION_REQUIRED",
|
||||
message = "PHOTOS_PERMISSION_REQUIRED: grant Photos/Storage permission"
|
||||
)
|
||||
}
|
||||
|
||||
val photos = mutableListOf<JsonObject>()
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.DATE_TAKEN,
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MediaStore.Images.Media.SIZE
|
||||
)
|
||||
val cursor = try {
|
||||
appContext.contentResolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
"${MediaStore.Images.Media.DATE_TAKEN} DESC"
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_PERMISSION_REQUIRED",
|
||||
message = "PHOTOS_PERMISSION_REQUIRED: ${e.message}"
|
||||
)
|
||||
}
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
val nameIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
val dateIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
|
||||
val mimeIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
|
||||
val sizeIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
|
||||
var count = 0
|
||||
while (it.moveToNext() && count < 5) {
|
||||
photos.add(buildJsonObject {
|
||||
put("id", JsonPrimitive(it.getLong(idIndex)))
|
||||
put("name", JsonPrimitive(it.getString(nameIndex)))
|
||||
put("dateTaken", JsonPrimitive(it.getLong(dateIndex)))
|
||||
put("mimeType", JsonPrimitive(it.getString(mimeIndex)))
|
||||
put("size", JsonPrimitive(it.getLong(sizeIndex)))
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("photos", buildJsonArray {
|
||||
photos.forEach { add(it) }
|
||||
})
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(payload.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.openclaw.assistant.R
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
class SystemHandler(private val appContext: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun handleNotify(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return GatewaySession.InvokeResult.error("PERMISSION_REQUIRED", "POST_NOTIFICATIONS permission required")
|
||||
}
|
||||
}
|
||||
|
||||
val params = paramsJson?.let {
|
||||
try {
|
||||
json.parseToJsonElement(it).jsonObject
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Expected JSON object")
|
||||
|
||||
val title = (params["title"] as? JsonPrimitive)?.content ?: "OpenClaw Assistant"
|
||||
val message = (params["message"] as? JsonPrimitive)?.content ?: ""
|
||||
|
||||
if (message.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error("INVALID_REQUEST", "Message is required")
|
||||
}
|
||||
|
||||
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channelId = "openclaw_system"
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(channelId, "OpenClaw System Notifications", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
|
||||
|
||||
return GatewaySession.InvokeResult.ok("""{"ok":true}""")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
Notifications("notifications"),
|
||||
System("system"),
|
||||
Photos("photos"),
|
||||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
Motion("motion"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -82,3 +88,61 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
Actions("notifications.actions"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawSystemCommand(val rawValue: String) {
|
||||
Notify("system.notify"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "system."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawPhotosCommand(val rawValue: String) {
|
||||
Latest("photos.latest"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "photos."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawContactsCommand(val rawValue: String) {
|
||||
Search("contacts.search"),
|
||||
Add("contacts.add"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "contacts."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCalendarCommand(val rawValue: String) {
|
||||
Events("calendar.events"),
|
||||
Add("calendar.add"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "calendar."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawMotionCommand(val rawValue: String) {
|
||||
Activity("motion.activity"),
|
||||
Pedometer("motion.pedometer"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "motion."
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.openclaw.assistant.service
|
||||
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.openclaw.assistant.node.NotificationManager
|
||||
|
||||
/**
|
||||
* Captures notifications for the OpenClaw system.
|
||||
* Requires BIND_NOTIFICATION_LISTENER_SERVICE permission and user to enable it in Settings.
|
||||
*/
|
||||
class OpenClawNotificationListenerService : NotificationListenerService() {
|
||||
|
||||
companion object {
|
||||
@Volatile var manager: NotificationManager? = null
|
||||
@Volatile var instance: OpenClawNotificationListenerService? = null
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (instance == this) instance = null
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
sbn?.let { manager?.onNotificationPosted(it) }
|
||||
Log.d("OpenClawNotification", "Notification posted from ${sbn?.packageName}")
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
sbn?.let { manager?.onNotificationRemoved(it) }
|
||||
Log.d("OpenClawNotification", "Notification removed from ${sbn?.packageName}")
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
instance = this
|
||||
activeNotifications?.forEach { sbn ->
|
||||
manager?.onNotificationPosted(sbn)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
super.onListenerDisconnected()
|
||||
if (instance == this) instance = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.pm.PackageManager
|
||||
import android.Manifest
|
||||
import android.provider.CalendarContract
|
||||
import android.database.MatrixCursor
|
||||
import android.content.ContentResolver
|
||||
|
||||
class CalendarHandlerTest {
|
||||
private val context = mockk<Context>()
|
||||
private val contentResolver = mockk<ContentResolver>()
|
||||
private val handler = CalendarHandler(context)
|
||||
|
||||
@Test
|
||||
fun `handleEvents returns error when permission missing`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) } returns PackageManager.PERMISSION_DENIED
|
||||
|
||||
val result = handler.handleEvents("""{"startTime":"123"}""")
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("CALENDAR_READ_PERMISSION_REQUIRED", result.error?.code)
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleEvents returns events when permission granted`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) } returns PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val cursor = MatrixCursor(arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND
|
||||
))
|
||||
cursor.addRow(arrayOf(1L, "Event", 1000L, 2000L))
|
||||
|
||||
every { contentResolver.query(any(), any(), any(), any(), any()) } returns cursor
|
||||
|
||||
val result = handler.handleEvents("""{"startTime":"100"}""")
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals(true, result.payloadJson?.contains("Event"))
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.pm.PackageManager
|
||||
import android.Manifest
|
||||
import android.provider.ContactsContract
|
||||
import android.database.MatrixCursor
|
||||
import android.content.ContentResolver
|
||||
|
||||
class ContactsHandlerTest {
|
||||
private val context = mockk<Context>()
|
||||
private val contentResolver = mockk<ContentResolver>()
|
||||
private val handler = ContactsHandler(context)
|
||||
|
||||
@Test
|
||||
fun `handleSearch returns error when permission missing`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) } returns PackageManager.PERMISSION_DENIED
|
||||
|
||||
val result = handler.handleSearch("""{"query":"test"}""")
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("CONTACTS_READ_PERMISSION_REQUIRED", result.error?.code)
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleSearch returns contacts when permission granted`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) } returns PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val cursor = MatrixCursor(arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
ContactsContract.CommonDataKinds.Phone.CONTACT_ID
|
||||
))
|
||||
cursor.addRow(arrayOf("John Doe", "123456", 1L))
|
||||
|
||||
every { contentResolver.query(any(), any(), any(), any(), any()) } returns cursor
|
||||
|
||||
val result = handler.handleSearch("""{"query":"John"}""")
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals(true, result.payloadJson?.contains("John Doe"))
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,14 @@ package com.openclaw.assistant.node
|
||||
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import com.openclaw.assistant.protocol.OpenClawCameraCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawNotificationsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawSystemCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawPhotosCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawContactsCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawCalendarCommand
|
||||
import com.openclaw.assistant.protocol.OpenClawMotionCommand
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -14,6 +21,12 @@ class InvokeDispatcherTest {
|
||||
private val locationHandler = mockk<LocationHandler>()
|
||||
private val screenHandler = mockk<ScreenHandler>()
|
||||
private val smsHandler = mockk<SmsHandler>()
|
||||
private val notificationsHandler = mockk<NotificationsHandler>()
|
||||
private val systemHandler = mockk<SystemHandler>()
|
||||
private val photosHandler = mockk<PhotosHandler>()
|
||||
private val contactsHandler = mockk<ContactsHandler>()
|
||||
private val calendarHandler = mockk<CalendarHandler>()
|
||||
private val motionHandler = mockk<MotionHandler>()
|
||||
private val a2uiHandler = mockk<A2UIHandler>()
|
||||
private val debugHandler = mockk<DebugHandler>()
|
||||
private val appUpdateHandler = mockk<AppUpdateHandler>()
|
||||
@@ -29,6 +42,12 @@ class InvokeDispatcherTest {
|
||||
locationHandler = locationHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
systemHandler = systemHandler,
|
||||
photosHandler = photosHandler,
|
||||
contactsHandler = contactsHandler,
|
||||
calendarHandler = calendarHandler,
|
||||
motionHandler = motionHandler,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
appUpdateHandler = appUpdateHandler,
|
||||
@@ -68,4 +87,73 @@ class InvokeDispatcherTest {
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("NODE_BACKGROUND_UNAVAILABLE", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notifications list is dispatched to handler`() = runTest {
|
||||
val dispatcher = createDispatcher()
|
||||
coEvery { notificationsHandler.handleList() } returns GatewaySession.InvokeResult.ok("""{"notifications":[]}""")
|
||||
|
||||
val result = dispatcher.handleInvoke(OpenClawNotificationsCommand.List.rawValue, null)
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals("""{"notifications":[]}""", result.payloadJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system notify is dispatched to handler`() = runTest {
|
||||
val dispatcher = createDispatcher()
|
||||
val params = """{"message":"test"}"""
|
||||
coEvery { systemHandler.handleNotify(params) } returns GatewaySession.InvokeResult.ok("""{"ok":true}""")
|
||||
|
||||
val result = dispatcher.handleInvoke(OpenClawSystemCommand.Notify.rawValue, params)
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals("""{"ok":true}""", result.payloadJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photos latest is dispatched to handler`() = runTest {
|
||||
val dispatcher = createDispatcher()
|
||||
coEvery { photosHandler.handleLatest() } returns GatewaySession.InvokeResult.ok("""{"photos":[]}""")
|
||||
|
||||
val result = dispatcher.handleInvoke(OpenClawPhotosCommand.Latest.rawValue, null)
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals("""{"photos":[]}""", result.payloadJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contacts search is dispatched to handler`() = runTest {
|
||||
val dispatcher = createDispatcher()
|
||||
val params = """{"query":"test"}"""
|
||||
coEvery { contactsHandler.handleSearch(params) } returns GatewaySession.InvokeResult.ok("""{"contacts":[]}""")
|
||||
|
||||
val result = dispatcher.handleInvoke(OpenClawContactsCommand.Search.rawValue, params)
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals("""{"contacts":[]}""", result.payloadJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar events is dispatched to handler`() = runTest {
|
||||
val dispatcher = createDispatcher()
|
||||
val params = """{"startTime":"123"}"""
|
||||
coEvery { calendarHandler.handleEvents(params) } returns GatewaySession.InvokeResult.ok("""{"events":[]}""")
|
||||
|
||||
val result = dispatcher.handleInvoke(OpenClawCalendarCommand.Events.rawValue, params)
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals("""{"events":[]}""", result.payloadJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `motion activity is dispatched to handler`() = runTest {
|
||||
val dispatcher = createDispatcher()
|
||||
coEvery { motionHandler.handleActivity() } returns GatewaySession.InvokeResult.ok("""{"activity":"still"}""")
|
||||
|
||||
val result = dispatcher.handleInvoke(OpenClawMotionCommand.Activity.rawValue, null)
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals("""{"activity":"still"}""", result.payloadJson)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.pm.PackageManager
|
||||
import android.Manifest
|
||||
import android.hardware.SensorManager
|
||||
|
||||
class MotionHandlerTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val sensorManager = mockk<SensorManager>(relaxed = true)
|
||||
|
||||
init {
|
||||
every { context.getSystemService(Context.SENSOR_SERVICE) } returns sensorManager
|
||||
}
|
||||
|
||||
private val handler by lazy { MotionHandler(context) }
|
||||
|
||||
@Test
|
||||
fun `handleActivity returns error when permission missing`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) } returns PackageManager.PERMISSION_DENIED
|
||||
|
||||
val result = handler.handleActivity()
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("MOTION_PERMISSION_REQUIRED", result.error?.code)
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleActivity returns activity when permission granted`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) } returns PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val result = handler.handleActivity()
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals(true, result.payloadJson?.contains("still"))
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import android.service.notification.StatusBarNotification
|
||||
import io.mockk.unmockkStatic
|
||||
import android.provider.Settings
|
||||
import android.content.ContentResolver
|
||||
|
||||
class NotificationsHandlerTest {
|
||||
private val context = mockk<Context>()
|
||||
private val contentResolver = mockk<ContentResolver>()
|
||||
private val notificationManager = mockk<NotificationManager>()
|
||||
private val handler = NotificationsHandler(context, notificationManager)
|
||||
|
||||
@Test
|
||||
fun `handleList returns error when service disabled`() {
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { context.packageName } returns "com.openclaw.assistant"
|
||||
mockkStatic(Settings.Secure::class)
|
||||
every { Settings.Secure.getString(contentResolver, "enabled_notification_listeners") } returns ""
|
||||
|
||||
val result = handler.handleList()
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("NOTIFICATIONS_PERMISSION_REQUIRED", result.error?.code)
|
||||
unmockkStatic(Settings.Secure::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleList returns notifications when service enabled`() {
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { context.packageName } returns "com.openclaw.assistant"
|
||||
mockkStatic(Settings.Secure::class)
|
||||
every { Settings.Secure.getString(contentResolver, "enabled_notification_listeners") } returns "com.openclaw.assistant"
|
||||
|
||||
val sbn = mockk<StatusBarNotification>()
|
||||
every { sbn.key } returns "test_key"
|
||||
every { sbn.packageName } returns "com.test"
|
||||
every { sbn.postTime } returns 12345L
|
||||
every { sbn.notification.extras.getCharSequence("android.title") } returns "Title"
|
||||
every { sbn.notification.extras.getCharSequence("android.text") } returns "Text"
|
||||
|
||||
every { notificationManager.getActiveNotifications() } returns listOf(sbn)
|
||||
|
||||
val result = handler.handleList()
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
val json = result.payloadJson ?: ""
|
||||
assertEquals(true, json.contains("test_key"))
|
||||
unmockkStatic(Settings.Secure::class)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import com.openclaw.assistant.gateway.GatewaySession
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.pm.PackageManager
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.database.MatrixCursor
|
||||
import android.content.ContentResolver
|
||||
|
||||
class PhotosHandlerTest {
|
||||
private val context = mockk<Context>()
|
||||
private val contentResolver = mockk<ContentResolver>()
|
||||
private val handler = PhotosHandler(context)
|
||||
|
||||
@Test
|
||||
fun `handleLatest returns error when permission missing`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
// Assume API < 33
|
||||
every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) } returns PackageManager.PERMISSION_DENIED
|
||||
|
||||
val result = handler.handleLatest()
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("PHOTOS_PERMISSION_REQUIRED", result.error?.code)
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleLatest returns photos when permission granted`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every { context.contentResolver } returns contentResolver
|
||||
every { ContextCompat.checkSelfPermission(context, any()) } returns PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val cursor = MatrixCursor(arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.DATE_TAKEN,
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MediaStore.Images.Media.SIZE
|
||||
))
|
||||
cursor.addRow(arrayOf(1L, "photo.jpg", 1000L, "image/jpeg", 500L))
|
||||
|
||||
every { contentResolver.query(any(), any(), any(), any(), any()) } returns cursor
|
||||
|
||||
val result = handler.handleLatest()
|
||||
|
||||
assertEquals(true, result.ok)
|
||||
assertEquals(true, result.payloadJson?.contains("photo.jpg"))
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.openclaw.assistant.node
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.every
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.pm.PackageManager
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
|
||||
class SystemHandlerTest {
|
||||
private val context = mockk<Context>()
|
||||
private val handler = SystemHandler(context)
|
||||
|
||||
@Test
|
||||
fun `handleNotify returns INVALID_REQUEST when params are null`() {
|
||||
val result = handler.handleNotify(null)
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleNotify returns INVALID_REQUEST when message is empty`() {
|
||||
mockkStatic(ContextCompat::class)
|
||||
every {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
} returns PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val result = handler.handleNotify("""{"title":"test"}""")
|
||||
|
||||
// message field is absent -> INVALID_REQUEST regardless of SDK version
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleNotify returns PERMISSION_REQUIRED on Android 13+ when POST_NOTIFICATIONS denied`() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// Permission check is only enforced on API 33+; skip on lower SDK environments
|
||||
return
|
||||
}
|
||||
mockkStatic(ContextCompat::class)
|
||||
every {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
} returns PackageManager.PERMISSION_DENIED
|
||||
|
||||
val result = handler.handleNotify("""{"message":"test"}""")
|
||||
|
||||
assertEquals(false, result.ok)
|
||||
assertEquals("PERMISSION_REQUIRED", result.error?.code)
|
||||
unmockkStatic(ContextCompat::class)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user