diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 72c1ccb..49a3faf 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,4 @@ +github: yuga-hashimoto ko_fi: R5R51S97C4 +custom: + - https://paypal.me/yuga620 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c07744b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# AGENTS.md + +## Git ワークフロー + +### PR作成 +GitHub CLI (`gh` コマンド) を使用してPRを作成すること。 + +```bash +# 変更をコミット +git add +git commit -m "commit message" + +# ブランチをpush +git push origin + +# ghコマンドでPR作成 +gh pr create --title "PRタイトル" --body "PR本文" --base main +``` + +**注意:** `git` コマンドだけでなく、必ず `gh` コマンドを使ってPRを作成すること。 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4530d8c..737cc77 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,22 @@ + + + + + + + + + + + + + + + + @@ -153,6 +169,17 @@ android:exported="false" android:foregroundServiceType="dataSync|microphone|mediaProjection" /> + + + + + + + { 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) { 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 } } diff --git a/app/src/main/java/com/openclaw/assistant/node/CalendarHandler.kt b/app/src/main/java/com/openclaw/assistant/node/CalendarHandler.kt new file mode 100644 index 0000000..511ab3b --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/CalendarHandler.kt @@ -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() + 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}") + } + } +} diff --git a/app/src/main/java/com/openclaw/assistant/node/ConnectionManager.kt b/app/src/main/java/com/openclaw/assistant/node/ConnectionManager.kt index 804ef1c..3b4eb21 100644 --- a/app/src/main/java/com/openclaw/assistant/node/ConnectionManager.kt +++ b/app/src/main/java/com/openclaw/assistant/node/ConnectionManager.kt @@ -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 = 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()) { diff --git a/app/src/main/java/com/openclaw/assistant/node/ContactsHandler.kt b/app/src/main/java/com/openclaw/assistant/node/ContactsHandler.kt new file mode 100644 index 0000000..f6159c2 --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/ContactsHandler.kt @@ -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() + 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() + 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}") + } + } +} diff --git a/app/src/main/java/com/openclaw/assistant/node/InvokeDispatcher.kt b/app/src/main/java/com/openclaw/assistant/node/InvokeDispatcher.kt index 46b3b22..99d62b1 100644 --- a/app/src/main/java/com/openclaw/assistant/node/InvokeDispatcher.kt +++ b/app/src/main/java/com/openclaw/assistant/node/InvokeDispatcher.kt @@ -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() diff --git a/app/src/main/java/com/openclaw/assistant/node/MotionHandler.kt b/app/src/main/java/com/openclaw/assistant/node/MotionHandler.kt new file mode 100644 index 0000000..34759a8 --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/MotionHandler.kt @@ -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) {} +} diff --git a/app/src/main/java/com/openclaw/assistant/node/NodeRuntime.kt b/app/src/main/java/com/openclaw/assistant/node/NodeRuntime.kt index 146aefe..e950fcc 100644 --- a/app/src/main/java/com/openclaw/assistant/node/NodeRuntime.kt +++ b/app/src/main/java/com/openclaw/assistant/node/NodeRuntime.kt @@ -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) { diff --git a/app/src/main/java/com/openclaw/assistant/node/NotificationManager.kt b/app/src/main/java/com/openclaw/assistant/node/NotificationManager.kt new file mode 100644 index 0000000..a7742eb --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/NotificationManager.kt @@ -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() + + 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 { + return activeNotifications.values.toList() + } + + fun getNotification(key: String): StatusBarNotification? { + return activeNotifications[key] + } +} diff --git a/app/src/main/java/com/openclaw/assistant/node/NotificationsHandler.kt b/app/src/main/java/com/openclaw/assistant/node/NotificationsHandler.kt new file mode 100644 index 0000000..205dc32 --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/NotificationsHandler.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/openclaw/assistant/node/PhotosHandler.kt b/app/src/main/java/com/openclaw/assistant/node/PhotosHandler.kt new file mode 100644 index 0000000..d36e5b4 --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/PhotosHandler.kt @@ -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() + 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()) + } +} diff --git a/app/src/main/java/com/openclaw/assistant/node/SystemHandler.kt b/app/src/main/java/com/openclaw/assistant/node/SystemHandler.kt new file mode 100644 index 0000000..89d891b --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/node/SystemHandler.kt @@ -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}""") + } +} diff --git a/app/src/main/java/com/openclaw/assistant/protocol/OpenClawProtocolConstants.kt b/app/src/main/java/com/openclaw/assistant/protocol/OpenClawProtocolConstants.kt index 17fd691..7afc1b9 100644 --- a/app/src/main/java/com/openclaw/assistant/protocol/OpenClawProtocolConstants.kt +++ b/app/src/main/java/com/openclaw/assistant/protocol/OpenClawProtocolConstants.kt @@ -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." + } +} diff --git a/app/src/main/java/com/openclaw/assistant/service/OpenClawNotificationListenerService.kt b/app/src/main/java/com/openclaw/assistant/service/OpenClawNotificationListenerService.kt new file mode 100644 index 0000000..312eff4 --- /dev/null +++ b/app/src/main/java/com/openclaw/assistant/service/OpenClawNotificationListenerService.kt @@ -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 + } +} diff --git a/app/src/test/java/com/openclaw/assistant/node/CalendarHandlerTest.kt b/app/src/test/java/com/openclaw/assistant/node/CalendarHandlerTest.kt new file mode 100644 index 0000000..99acf65 --- /dev/null +++ b/app/src/test/java/com/openclaw/assistant/node/CalendarHandlerTest.kt @@ -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() + private val contentResolver = mockk() + 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) + } +} diff --git a/app/src/test/java/com/openclaw/assistant/node/ContactsHandlerTest.kt b/app/src/test/java/com/openclaw/assistant/node/ContactsHandlerTest.kt new file mode 100644 index 0000000..d928e9c --- /dev/null +++ b/app/src/test/java/com/openclaw/assistant/node/ContactsHandlerTest.kt @@ -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() + private val contentResolver = mockk() + 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) + } +} diff --git a/app/src/test/java/com/openclaw/assistant/node/InvokeDispatcherTest.kt b/app/src/test/java/com/openclaw/assistant/node/InvokeDispatcherTest.kt index ec69d0f..7fba544 100644 --- a/app/src/test/java/com/openclaw/assistant/node/InvokeDispatcherTest.kt +++ b/app/src/test/java/com/openclaw/assistant/node/InvokeDispatcherTest.kt @@ -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() private val screenHandler = mockk() private val smsHandler = mockk() + private val notificationsHandler = mockk() + private val systemHandler = mockk() + private val photosHandler = mockk() + private val contactsHandler = mockk() + private val calendarHandler = mockk() + private val motionHandler = mockk() private val a2uiHandler = mockk() private val debugHandler = mockk() private val appUpdateHandler = mockk() @@ -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) + } } diff --git a/app/src/test/java/com/openclaw/assistant/node/MotionHandlerTest.kt b/app/src/test/java/com/openclaw/assistant/node/MotionHandlerTest.kt new file mode 100644 index 0000000..a017f4c --- /dev/null +++ b/app/src/test/java/com/openclaw/assistant/node/MotionHandlerTest.kt @@ -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(relaxed = true) + private val sensorManager = mockk(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) + } +} diff --git a/app/src/test/java/com/openclaw/assistant/node/NotificationsHandlerTest.kt b/app/src/test/java/com/openclaw/assistant/node/NotificationsHandlerTest.kt new file mode 100644 index 0000000..5d91699 --- /dev/null +++ b/app/src/test/java/com/openclaw/assistant/node/NotificationsHandlerTest.kt @@ -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() + private val contentResolver = mockk() + private val notificationManager = mockk() + 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() + 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) + } +} diff --git a/app/src/test/java/com/openclaw/assistant/node/PhotosHandlerTest.kt b/app/src/test/java/com/openclaw/assistant/node/PhotosHandlerTest.kt new file mode 100644 index 0000000..a0c505b --- /dev/null +++ b/app/src/test/java/com/openclaw/assistant/node/PhotosHandlerTest.kt @@ -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() + private val contentResolver = mockk() + 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) + } +} diff --git a/app/src/test/java/com/openclaw/assistant/node/SystemHandlerTest.kt b/app/src/test/java/com/openclaw/assistant/node/SystemHandlerTest.kt new file mode 100644 index 0000000..2e893ca --- /dev/null +++ b/app/src/test/java/com/openclaw/assistant/node/SystemHandlerTest.kt @@ -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() + 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) + } +}