Commit 3218aee2 authored by Lisa (AI Assistant)'s avatar Lisa (AI Assistant)

Add core features: screenshot, touch, notifications, settings

- ScreenshotService using MediaProjection API
- TouchService using AccessibilityService for gestures
- NotificationListener for notification mirroring
- SettingsRepository using DataStore
- NodeClient WebSocket implementation
- Domain models for commands/responses
- Hilt DI setup
- Improved UI with permission management
- Gradle wrapper for building
parent 1fee2c32
Pipeline #264 canceled with stages
...@@ -20,6 +20,10 @@ ...@@ -20,6 +20,10 @@
<!-- Wake lock for keeping connection alive --> <!-- Wake lock for keeping connection alive -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Query all packages for app listing -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application <application
android:name=".OpenClawApp" android:name=".OpenClawApp"
android:allowBackup="true" android:allowBackup="true"
...@@ -51,6 +55,30 @@ ...@@ -51,6 +55,30 @@
android:value="remote_control" /> android:value="remote_control" />
</service> </service>
<!-- Touch Accessibility Service -->
<service
android:name=".service.TouchService"
android:label="@string/app_name"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
<!-- Notification Listener Service -->
<service
android:name=".service.NotificationListener"
android:exported="false"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
package com.openclaw.node.data.remote
import com.openclaw.node.domain.model.NodeCapabilities
import com.openclaw.node.domain.model.NodeCommand
import com.openclaw.node.domain.model.NodeEvent
import com.openclaw.node.domain.model.NodeResponse
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import okhttp3.*
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NodeClient @Inject constructor() {
private var webSocket: WebSocket? = null
private val client = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.pingInterval(20, TimeUnit.SECONDS)
.build()
private val _connectionStatus = Channel<ConnectionStatus>(Channel.BUFFERED)
val connectionStatus: Flow<ConnectionStatus> = _connectionStatus.receiveAsFlow()
private val _incomingCommands = Channel<NodeCommand>(Channel.BUFFERED)
val incomingCommands: Flow<NodeCommand> = _incomingCommands.receiveAsFlow()
private var isConnected = false
private var currentNodeId: String = ""
data class ConnectionStatus(val connected: Boolean, val nodeId: String)
fun connect(gatewayUrl: String, nodeId: String, token: String, capabilities: NodeCapabilities) {
currentNodeId = nodeId
val urlWithParams = "$gatewayUrl?token=$token&node_id=$nodeId"
val request = Request.Builder()
.url(urlWithParams)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isConnected = true
_connectionStatus.trySend(ConnectionStatus(true, nodeId))
// Send registration
val register = JSONObject().apply {
put("type", "register")
put("node_id", nodeId)
put("capabilities", JSONObject().apply {
put("screenshot", capabilities.screenshot)
put("touch", capabilities.touch)
put("shell", capabilities.shell)
put("notifications", capabilities.notifications)
put("app_control", capabilities.appControl)
put("sensors", capabilities.sensors)
})
}
sendJson(register)
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val json = JSONObject(text)
val command = parseCommand(json)
command?.let { _incomingCommands.trySend(it) }
} catch (e: Exception) {
// Handle parsing error
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
_connectionStatus.trySend(ConnectionStatus(false, nodeId))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected = false
_connectionStatus.trySend(ConnectionStatus(false, nodeId))
}
})
}
fun disconnect() {
webSocket?.close(1000, "User disconnected")
webSocket = null
isConnected = false
}
fun sendResponse(response: NodeResponse) {
val json = when (response) {
is NodeResponse.Success -> JSONObject().apply {
put("type", "response")
put("id", response.id)
put("success", true)
put("data", JSONObject().apply {
response.data.forEach { (key, value) ->
put(key, value)
}
})
}
is NodeResponse.Error -> JSONObject().apply {
put("type", "error")
put("id", response.id)
put("error", response.error)
}
is NodeResponse.ScreenshotResponse -> JSONObject().apply {
put("type", "response")
put("id", response.id)
put("success", true)
put("data", JSONObject().apply {
put("screenshot", response.base64Image)
})
}
}
sendJson(json)
}
fun sendEvent(event: NodeEvent) {
val json = when (event) {
is NodeEvent.Notification -> JSONObject().apply {
put("type", "notification")
put("app", event.app)
put("title", event.title)
put("text", event.text)
put("timestamp", event.timestamp)
}
is NodeEvent.Battery -> JSONObject().apply {
put("type", "battery")
put("level", event.level)
put("charging", event.charging)
}
is NodeEvent.ForegroundAppChanged -> JSONObject().apply {
put("type", "foreground_app")
put("package", event.packageName)
put("app_name", event.appName)
}
is NodeEvent.ConnectionStatus -> JSONObject().apply {
put("type", "connection")
put("connected", event.connected)
put("node_id", event.nodeId)
}
}
sendJson(json)
}
private fun sendJson(json: JSONObject) {
webSocket?.send(json.toString())
}
private fun parseCommand(json: JSONObject): NodeCommand? {
val type = json.getString("type")
val id = json.optString("id", "")
return when (type) {
"screenshot" -> {
val quality = json.optJSONObject("params")?.optInt("quality", 80) ?: 80
NodeCommand.Screenshot(id, quality)
}
"tap" -> {
val params = json.getJSONObject("params")
NodeCommand.Tap(id, params.getInt("x"), params.getInt("y"))
}
"swipe" -> {
val params = json.getJSONObject("params")
NodeCommand.Swipe(
id,
params.getInt("x1"), params.getInt("y1"),
params.getInt("x2"), params.getInt("y2"),
params.optInt("duration", 300)
)
}
"long_press" -> {
val params = json.getJSONObject("params")
NodeCommand.LongPress(
id,
params.getInt("x"), params.getInt("y"),
params.optInt("duration", 500)
)
}
"launch_app" -> {
val params = json.getJSONObject("params")
NodeCommand.LaunchApp(id, params.getString("package"))
}
"shell" -> {
val params = json.getJSONObject("params")
NodeCommand.Shell(id, params.getString("command"))
}
"device_info" -> NodeCommand.DeviceInfo(id)
"list_apps" -> {
val systemApps = json.optJSONObject("params")?.optBoolean("system_apps", false) ?: false
NodeCommand.ListApps(id, systemApps)
}
"foreground_app" -> NodeCommand.GetForegroundApp(id)
"notification_listener" -> {
val enabled = json.optJSONObject("params")?.optBoolean("enabled", true) ?: true
NodeCommand.NotificationListener(id, enabled)
}
"key_event" -> {
val params = json.getJSONObject("params")
NodeCommand.KeyEvent(id, params.getInt("key_code"))
}
"ping" -> NodeCommand.Ping
else -> null
}
}
}
package com.openclaw.node.data.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "openclaw_node_settings")
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private val GATEWAY_URL = stringPreferencesKey("gateway_url")
private val NODE_TOKEN = stringPreferencesKey("node_token")
private val NODE_ID = stringPreferencesKey("node_id")
private val AUTO_CONNECT = booleanPreferencesKey("auto_connect")
private val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
private val SCREENSHOT_QUALITY = stringPreferencesKey("screenshot_quality")
}
val gatewayUrl: Flow<String> = context.dataStore.data.map { preferences ->
preferences[GATEWAY_URL] ?: ""
}
val nodeToken: Flow<String> = context.dataStore.data.map { preferences ->
preferences[NODE_TOKEN] ?: ""
}
val nodeId: Flow<String> = context.dataStore.data.map { preferences ->
preferences[NODE_ID] ?: "android_${android.os.Build.MODEL}"
}
val autoConnect: Flow<Boolean> = context.dataStore.data.map { preferences ->
preferences[AUTO_CONNECT] ?: false
}
val notificationsEnabled: Flow<Boolean> = context.dataStore.data.map { preferences ->
preferences[NOTIFICATIONS_ENABLED] ?: true
}
val screenshotQuality: Flow<Int> = context.dataStore.data.map { preferences ->
preferences[SCREENSHOT_QUALITY]?.toIntOrNull() ?: 80
}
suspend fun setGatewayUrl(url: String) {
context.dataStore.edit { preferences ->
preferences[GATEWAY_URL] = url
}
}
suspend fun setNodeToken(token: String) {
context.dataStore.edit { preferences ->
preferences[NODE_TOKEN] = token
}
}
suspend fun setNodeId(id: String) {
context.dataStore.edit { preferences ->
preferences[NODE_ID] = id
}
}
suspend fun setAutoConnect(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[AUTO_CONNECT] = enabled
}
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[NOTIFICATIONS_ENABLED] = enabled
}
}
suspend fun setScreenshotQuality(quality: Int) {
context.dataStore.edit { preferences ->
preferences[SCREENSHOT_QUALITY] = quality.toString()
}
}
suspend fun clearAll() {
context.dataStore.edit { preferences ->
preferences.clear()
}
}
}
package com.openclaw.node.di
import android.content.Context
import com.openclaw.node.data.remote.NodeClient
import com.openclaw.node.data.repository.SettingsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideNodeClient(): NodeClient {
return NodeClient()
}
@Provides
@Singleton
fun provideSettingsRepository(
@ApplicationContext context: Context
): SettingsRepository {
return SettingsRepository(context)
}
}
package com.openclaw.node.domain.model
/**
* Represents a command received from the OpenClaw gateway
*/
sealed class NodeCommand {
abstract val id: String
data class Screenshot(override val id: String, val quality: Int = 80) : NodeCommand()
data class Tap(override val id: String, val x: Int, val y: Int) : NodeCommand()
data class Swipe(override val id: String, val x1: Int, val y1: Int, val x2: Int, val y2: Int, val duration: Int = 300) : NodeCommand()
data class LongPress(override val id: String, val x: Int, val y: Int, val duration: Int = 500) : NodeCommand()
data class LaunchApp(override val id: String, val packageName: String) : NodeCommand()
data class Shell(override val id: String, val command: String) : NodeCommand()
data class DeviceInfo(override val id: String) : NodeCommand()
data class ListApps(override val id: String, val systemApps: Boolean = false) : NodeCommand()
data class GetForegroundApp(override val id: String) : NodeCommand()
data class NotificationListener(override val id: String, val enabled: Boolean) : NodeCommand()
data class KeyEvent(override val id: String, val keyCode: Int) : NodeCommand()
object Ping : NodeCommand() { override val id: String = "" }
}
/**
* Response sent back to the gateway
*/
sealed class NodeResponse {
abstract val id: String
data class Success(override val id: String, val data: Map<String, Any> = emptyMap()) : NodeResponse()
data class Error(override val id: String, val error: String) : NodeResponse()
data class ScreenshotResponse(override val id: String, val base64Image: String) : NodeResponse()
}
/**
* Events pushed to the gateway without request
*/
sealed class NodeEvent {
data class Notification(val app: String, val title: String, val text: String, val timestamp: Long) : NodeEvent()
data class Battery(val level: Int, val charging: Boolean) : NodeEvent()
data class ForegroundAppChanged(val packageName: String, val appName: String) : NodeEvent()
data class ConnectionStatus(val connected: Boolean, val nodeId: String) : NodeEvent()
}
/**
* Node capabilities advertised during registration
*/
data class NodeCapabilities(
val screenshot: Boolean = true,
val touch: Boolean = true,
val shell: Boolean = true,
val notifications: Boolean = true,
val appControl: Boolean = true,
val sensors: Boolean = true
)
/**
* Device information
*/
data class DeviceInfo(
val model: String,
val manufacturer: String,
val androidVersion: String,
val sdkInt: Int,
val batteryLevel: Int,
val charging: Boolean,
val storage: StorageInfo,
val network: NetworkInfo
)
data class StorageInfo(
val total: Long,
val available: Long
)
data class NetworkInfo(
val connected: Boolean,
val type: String, // wifi, mobile, ethernet
val ip: String?
)
...@@ -7,267 +7,315 @@ import android.app.PendingIntent ...@@ -7,267 +7,315 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.content.pm.PackageManager
import android.graphics.PixelFormat import android.os.BatteryManager
import android.hardware.display.DisplayManager
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.DisplayMetrics import android.os.PowerManager
import android.view.WindowManager import android.provider.Settings
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.openclaw.node.R import com.openclaw.node.R
import com.openclaw.node.data.remote.NodeClient
import com.openclaw.node.data.repository.SettingsRepository
import com.openclaw.node.domain.model.*
import com.openclaw.node.ui.MainActivity import com.openclaw.node.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.* import kotlinx.coroutines.flow.first
import org.json.JSONObject import javax.inject.Inject
import java.io.IOException
import java.util.concurrent.TimeUnit
@AndroidEntryPoint @AndroidEntryPoint
class NodeService : Service() { class NodeService : Service() {
private val CHANNEL_ID = "openclaw_node_channel" @Inject
private val NOTIFICATION_ID = 1 lateinit var nodeClient: NodeClient
private var webSocket: WebSocket? = null @Inject
private val client = OkHttpClient.Builder() lateinit var settingsRepository: SettingsRepository
.readTimeout(30, TimeUnit.SECONDS)
.pingInterval(15, TimeUnit.SECONDS)
.build()
private var gatewayUrl: String = "" private val CHANNEL_ID = "openclaw_node_channel"
private var nodeToken: String = "" private val NOTIFICATION_ID = 1
private var nodeId: String = ""
private var wakeLock: PowerManager.WakeLock? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mediaProjection: MediaProjection? = null private val screenshotService by lazy { ScreenshotService(this) }
private var imageReader: ImageReader? = null
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel() createNotificationChannel()
acquireWakeLock()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
"START" -> { "START" -> {
gatewayUrl = intent.getStringExtra("gateway_url") ?: ""
nodeToken = intent.getStringExtra("node_token") ?: ""
nodeId = intent.getStringExtra("node_id") ?: "android_${Build.MODEL}"
startForeground(NOTIFICATION_ID, createNotification()) startForeground(NOTIFICATION_ID, createNotification())
connectToGateway() scope.launch { startNode() }
} }
"STOP" -> { "STOP" -> {
disconnect() scope.launch { stopNode() }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} }
} }
return START_STICKY return START_STICKY
} }
private fun createNotificationChannel() { private suspend fun startNode() {
val channel = NotificationChannel( val gatewayUrl = settingsRepository.gatewayUrl.first()
CHANNEL_ID, val nodeToken = settingsRepository.nodeToken.first()
"OpenClaw Node", val nodeId = settingsRepository.nodeId.first()
NotificationManager.IMPORTANCE_LOW
).apply { if (gatewayUrl.isBlank() || nodeToken.isBlank()) {
description = "OpenClaw Android Node Service" return
} }
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel) // Setup notification forwarding
NotificationListener.onNotificationReceived = { app, title, text ->
nodeClient.sendEvent(
NodeEvent.Notification(app, title, text, System.currentTimeMillis())
)
} }
private fun createNotification(): Notification { // Connect to gateway
val intent = Intent(this, MainActivity::class.java) nodeClient.connect(
val pendingIntent = PendingIntent.getActivity( gatewayUrl = gatewayUrl,
this, 0, intent, nodeId = nodeId,
PendingIntent.FLAG_IMMUTABLE token = nodeToken,
capabilities = NodeCapabilities(
screenshot = true,
touch = true,
shell = true,
notifications = true,
appControl = true,
sensors = true
)
) )
return NotificationCompat.Builder(this, CHANNEL_ID) // Process incoming commands
.setContentTitle("OpenClaw Node") nodeClient.incomingCommands.collect { command ->
.setContentText("Connected and ready") handleCommand(command)
.setSmallIcon(android.R.drawable.ic_dialog_info) }
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
} }
private fun connectToGateway() { private suspend fun stopNode() {
val urlWithParams = "$gatewayUrl?token=$nodeToken&node_id=$nodeId" nodeClient.disconnect()
NotificationListener.onNotificationReceived = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
val request = Request.Builder() private fun handleCommand(command: NodeCommand) {
.url(urlWithParams) when (command) {
.build() is NodeCommand.Screenshot -> handleScreenshot(command)
is NodeCommand.Tap -> handleTap(command)
is NodeCommand.Swipe -> handleSwipe(command)
is NodeCommand.LongPress -> handleLongPress(command)
is NodeCommand.LaunchApp -> handleLaunchApp(command)
is NodeCommand.Shell -> handleShell(command)
is NodeCommand.DeviceInfo -> handleDeviceInfo(command)
is NodeCommand.ListApps -> handleListApps(command)
is NodeCommand.GetForegroundApp -> handleForegroundApp(command)
is NodeCommand.NotificationListener -> handleNotificationListener(command)
is NodeCommand.KeyEvent -> handleKeyEvent(command)
is NodeCommand.Ping -> nodeClient.sendResponse(NodeResponse.Success(command.id))
}
}
webSocket = client.newWebSocket(request, object : WebSocketListener() { private fun handleScreenshot(command: NodeCommand.Screenshot) {
override fun onOpen(webSocket: WebSocket, response: Response) { screenshotService.captureScreenshot(command.quality) { result ->
scope.launch { result.fold(
sendMessage(JSONObject().apply { onSuccess = { base64 ->
put("type", "register") nodeClient.sendResponse(NodeResponse.ScreenshotResponse(command.id, base64))
put("node_id", nodeId) },
put("capabilities", JSONObject().apply { onFailure = { error ->
put("screenshot", true) nodeClient.sendResponse(NodeResponse.Error(command.id, error.message ?: "Screenshot failed"))
put("touch", true) }
put("shell", true) )
put("notifications", true)
})
}.toString())
} }
} }
override fun onMessage(webSocket: WebSocket, text: String) { private fun handleTap(command: NodeCommand.Tap) {
handleCommand(JSONObject(text)) val success = TouchService.instance?.tap(command.x, command.y) ?: false
nodeClient.sendResponse(
if (success) NodeResponse.Success(command.id)
else NodeResponse.Error(command.id, "Touch service not available")
)
} }
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { private fun handleSwipe(command: NodeCommand.Swipe) {
webSocket.close(1000, null) val success = TouchService.instance?.swipe(
command.x1, command.y1, command.x2, command.y2, command.duration
) ?: false
nodeClient.sendResponse(
if (success) NodeResponse.Success(command.id)
else NodeResponse.Error(command.id, "Touch service not available")
)
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { private fun handleLongPress(command: NodeCommand.LongPress) {
// Reconnect after delay val success = TouchService.instance?.longPress(
scope.launch { command.x, command.y, command.duration
delay(5000) ) ?: false
connectToGateway() nodeClient.sendResponse(
if (success) NodeResponse.Success(command.id)
else NodeResponse.Error(command.id, "Touch service not available")
)
} }
private fun handleLaunchApp(command: NodeCommand.LaunchApp) {
try {
val intent = packageManager.getLaunchIntentForPackage(command.packageName)
if (intent != null) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
nodeClient.sendResponse(NodeResponse.Success(command.id))
} else {
nodeClient.sendResponse(NodeResponse.Error(command.id, "App not found"))
} }
}) } catch (e: Exception) {
nodeClient.sendResponse(NodeResponse.Error(command.id, e.message ?: "Failed to launch app"))
} }
}
private fun handleShell(command: NodeCommand.Shell) {
// Limited shell - security critical
val allowedCommands = listOf(
"ls", "pwd", "date", "id", "getprop", "settings get",
"am start", "input tap", "input swipe", "input text"
)
private fun handleCommand(command: JSONObject) { val isAllowed = allowedCommands.any { command.command.startsWith(it) }
val type = command.getString("type")
val id = command.optString("id", "")
when (type) { if (isAllowed) {
"screenshot" -> captureScreenshot(id) try {
"tap" -> performTap(command.getJSONObject("params"), id) val process = Runtime.getRuntime().exec(command.command)
"swipe" -> performSwipe(command.getJSONObject("params"), id) val output = process.inputStream.bufferedReader().readText()
"launch_app" -> launchApp(command.getJSONObject("params"), id) nodeClient.sendResponse(NodeResponse.Success(command.id, mapOf("output" to output)))
"shell" -> executeShell(command.getJSONObject("params"), id) } catch (e: Exception) {
"device_info" -> sendDeviceInfo(id) nodeClient.sendResponse(NodeResponse.Error(command.id, e.message ?: "Shell execution failed"))
"ping" -> sendMessage(JSONObject().apply { }
put("type", "pong") } else {
put("id", id) nodeClient.sendResponse(NodeResponse.Error(command.id, "Command not allowed"))
}.toString())
} }
} }
private fun captureScreenshot(id: String) { private fun handleDeviceInfo(command: NodeCommand.DeviceInfo) {
// Implementation would use MediaProjection val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
// This is a placeholder val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
sendMessage(JSONObject().apply { val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
put("type", "response") val battery = if (level >= 0 && scale > 0) (level * 100 / scale) else 0
put("id", id) val charging = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) == BatteryManager.BATTERY_STATUS_CHARGING
put("success", false)
put("error", "Screenshot not implemented yet") nodeClient.sendResponse(NodeResponse.Success(command.id, mapOf(
}.toString()) "model" to Build.MODEL,
"manufacturer" to Build.MANUFACTURER,
"android_version" to Build.VERSION.RELEASE,
"sdk_int" to Build.VERSION.SDK_INT,
"battery" to battery,
"charging" to charging
)))
} }
private fun performTap(params: JSONObject, id: String) { private fun handleListApps(command: NodeCommand.ListApps) {
val x = params.getInt("x") val pm = packageManager
val y = params.getInt("y") val packages = pm.getInstalledApplications(PackageManager.GET_META_DATA)
// Implementation would use AccessibilityService or shell input tap val apps = packages
sendMessage(JSONObject().apply { .filter { !command.systemApps || !isSystemApp(it) }
put("type", "response") .map {
put("id", id) mapOf(
put("success", true) "package" to it.packageName,
}.toString()) "name" to pm.getApplicationLabel(it).toString()
)
} }
private fun performSwipe(params: JSONObject, id: String) { nodeClient.sendResponse(NodeResponse.Success(command.id, mapOf("apps" to apps)))
val x1 = params.getInt("x1") }
val y1 = params.getInt("y1")
val x2 = params.getInt("x2")
val y2 = params.getInt("y2")
val duration = params.optInt("duration", 300)
sendMessage(JSONObject().apply { private fun isSystemApp(appInfo: android.content.pm.ApplicationInfo): Boolean {
put("type", "response") return (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
put("id", id)
put("success", true)
}.toString())
} }
private fun launchApp(params: JSONObject, id: String) { private fun handleForegroundApp(command: NodeCommand.GetForegroundApp) {
val packageName = params.getString("package") // Requires usage stats permission - placeholder
nodeClient.sendResponse(NodeResponse.Error(command.id, "Not implemented - requires Usage Stats permission"))
}
val intent = packageManager.getLaunchIntentForPackage(packageName) private fun handleNotificationListener(command: NodeCommand.NotificationListener) {
if (intent != null) { // Would enable/disable notification forwarding
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) scope.launch {
startActivity(intent) settingsRepository.setNotificationsEnabled(command.enabled)
sendMessage(JSONObject().apply { nodeClient.sendResponse(NodeResponse.Success(command.id))
put("type", "response")
put("id", id)
put("success", true)
}.toString())
} else {
sendMessage(JSONObject().apply {
put("type", "response")
put("id", id)
put("success", false)
put("error", "App not found: $packageName")
}.toString())
} }
} }
private fun executeShell(params: JSONObject, id: String) { private fun handleKeyEvent(command: NodeCommand.KeyEvent) {
// Limited shell execution - only certain commands try {
val command = params.getString("command") val intent = Intent(Intent.ACTION_MEDIA_BUTTON).apply {
putExtra(Intent.EXTRA_KEY_EVENT, android.view.KeyEvent(
// Security: whitelist allowed commands android.view.KeyEvent.ACTION_DOWN, command.keyCode
val allowedCommands = listOf("ls", "pwd", "date", "am", "input") ))
}
sendMessage(JSONObject().apply { sendOrderedBroadcast(intent, null)
put("type", "response") nodeClient.sendResponse(NodeResponse.Success(command.id))
put("id", id) } catch (e: Exception) {
put("success", true) nodeClient.sendResponse(NodeResponse.Error(command.id, e.message ?: "Key event failed"))
put("data", JSONObject().apply { }
put("output", "Shell execution limited for security")
})
}.toString())
} }
private fun sendDeviceInfo(id: String) { private fun createNotificationChannel() {
val info = JSONObject().apply { val channel = NotificationChannel(
put("model", Build.MODEL) CHANNEL_ID,
put("manufacturer", Build.MANUFACTURER) "OpenClaw Node",
put("android_version", Build.VERSION.RELEASE) NotificationManager.IMPORTANCE_LOW
put("sdk_int", Build.VERSION.SDK_INT) ).apply {
put("battery", 50) // Would get actual battery level description = "OpenClaw Android Node Service"
put("charging", false) }
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
} }
sendMessage(JSONObject().apply { private fun createNotification(): Notification {
put("type", "response") val intent = Intent(this, MainActivity::class.java)
put("id", id) val pendingIntent = PendingIntent.getActivity(
put("success", true) this, 0, intent,
put("data", info) PendingIntent.FLAG_IMMUTABLE
}.toString()) )
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("OpenClaw Node")
.setContentText("Connected and ready")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
} }
private fun sendMessage(message: String) { private fun acquireWakeLock() {
webSocket?.send(message) val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"OpenClaw:NodeWakeLock"
).apply {
acquire(10 * 60 * 60 * 1000L) // 10 hours max
}
} }
private fun disconnect() { private fun releaseWakeLock() {
webSocket?.close(1000, "User disconnected") wakeLock?.let {
webSocket = null if (it.isHeld) {
it.release()
}
}
wakeLock = null
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
disconnect()
scope.cancel() scope.cancel()
mediaProjection?.stop() nodeClient.disconnect()
releaseWakeLock()
} }
} }
package com.openclaw.node.service
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
/**
* Service for mirroring notifications to the OpenClaw gateway
*/
class NotificationListener : NotificationListenerService() {
companion object {
var instance: NotificationListener? = null
var onNotificationReceived: ((String, String, String) -> Unit)? = null
}
override fun onCreate() {
super.onCreate()
instance = this
}
override fun onDestroy() {
super.onDestroy()
instance = null
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
sbn ?: return
// Filter out our own notifications
if (sbn.packageName == "com.openclaw.node") {
return
}
val extras = sbn.notification.extras
val title = extras.getCharSequence("android.title")?.toString() ?: ""
val text = extras.getCharSequence("android.text")?.toString() ?: ""
onNotificationReceived?.invoke(sbn.packageName, title, text)
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
// Handle notification removal if needed
}
}
package com.openclaw.node.service
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.DisplayMetrics
import android.view.WindowManager
import java.io.ByteArrayOutputStream
/**
* Service for capturing screenshots using MediaProjection API
*/
class ScreenshotService(private val context: Context) {
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var isCapturing = false
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val mainHandler = Handler(Looper.getMainLooper())
private var callback: ((Result<String>) -> Unit)? = null
/**
* Initialize the screenshot service with a media projection
*/
fun initialize(projection: MediaProjection) {
this.mediaProjection = projection
setupVirtualDisplay()
}
/**
* Handle the activity result from requesting screen capture permission
*/
fun onActivityResult(resultCode: Int, data: Intent?, onReady: () -> Unit) {
if (resultCode != Activity.RESULT_OK || data == null) {
callback?.invoke(Result.failure(Exception("Screen capture permission denied")))
return
}
val projectionManager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaProjection = projectionManager.getMediaProjection(resultCode, data)
mediaProjection?.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
cleanup()
}
}, mainHandler)
setupVirtualDisplay()
onReady()
}
@SuppressLint("WrongConstant")
private fun setupVirtualDisplay() {
val metrics = DisplayMetrics()
windowManager.defaultDisplay.getRealMetrics(metrics)
val density = metrics.densityDpi
val width = metrics.widthPixels
val height = metrics.heightPixels
imageReader = ImageReader.newInstance(
width, height,
PixelFormat.RGBA_8888, 2
)
virtualDisplay = mediaProjection?.createVirtualDisplay(
"OpenClawScreenshot",
width, height, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null, mainHandler
)
isCapturing = true
}
/**
* Capture a single screenshot
*/
fun captureScreenshot(quality: Int = 80, onResult: (Result<String>) -> Unit) {
callback = onResult
if (!isCapturing || imageReader == null) {
onResult(Result.failure(Exception("Screenshot service not initialized")))
return
}
try {
val image = imageReader?.acquireLatestImage()
if (image == null) {
onResult(Result.failure(Exception("Failed to acquire image")))
return
}
val planes = image.planes
val buffer = planes[0].buffer
val pixelStride = planes[0].pixelStride
val rowStride = planes[0].rowStride
val rowPadding = rowStride - pixelStride * image.width
val bitmap = Bitmap.createBitmap(
image.width + rowPadding / pixelStride,
image.height,
Bitmap.Config.ARGB_8888
)
bitmap.copyPixelsFromBuffer(buffer)
// Crop to actual screen size
val croppedBitmap = Bitmap.createBitmap(
bitmap, 0, 0,
image.width, image.height
)
val outputStream = ByteArrayOutputStream()
croppedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
bitmap.recycle()
croppedBitmap.recycle()
image.close()
onResult(Result.success(base64))
} catch (e: Exception) {
onResult(Result.failure(e))
}
}
/**
* Start continuous screenshot streaming
*/
fun startStream(fps: Int = 5, onFrame: (String) -> Unit) {
// Implementation for streaming mode
// Would use a coroutine to capture at regular intervals
}
/**
* Stop screenshot capture
*/
fun stop() {
isCapturing = false
cleanup()
}
private fun cleanup() {
virtualDisplay?.release()
virtualDisplay = null
imageReader?.close()
imageReader = null
mediaProjection?.stop()
mediaProjection = null
}
companion object {
const val REQUEST_CODE = 1001
}
}
package com.openclaw.node.service
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.content.Intent
import android.graphics.Path
import android.graphics.PixelFormat
import android.os.Build
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
/**
* AccessibilityService for performing touch gestures
*/
class TouchService : AccessibilityService() {
companion object {
var instance: TouchService? = null
private set
var isServiceRunning = false
private set
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
isServiceRunning = true
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Could forward accessibility events to gateway here
}
override fun onInterrupt() {
// Handle interruption
}
override fun onDestroy() {
super.onDestroy()
instance = null
isServiceRunning = false
}
override fun onUnbind(intent: Intent?): Boolean {
instance = null
isServiceRunning = false
return super.onUnbind(intent)
}
/**
* Perform a tap gesture at coordinates
*/
fun tap(x: Int, y: Int, callback: ((Boolean) -> Unit)? = null): Boolean {
if (!isServiceRunning) {
callback?.invoke(false)
return false
}
val path = Path().apply {
moveTo(x.toFloat(), y.toFloat())
}
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, 100))
.build()
return dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
callback?.invoke(true)
}
override fun onCancelled(gestureDescription: GestureDescription?) {
callback?.invoke(false)
}
}, null)
}
/**
* Perform a swipe gesture from (x1,y1) to (x2,y2)
*/
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, duration: Int = 300, callback: ((Boolean) -> Unit)? = null): Boolean {
if (!isServiceRunning) {
callback?.invoke(false)
return false
}
val path = Path().apply {
moveTo(x1.toFloat(), y1.toFloat())
lineTo(x2.toFloat(), y2.toFloat())
}
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, duration.toLong()))
.build()
return dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
callback?.invoke(true)
}
override fun onCancelled(gestureDescription: GestureDescription?) {
callback?.invoke(false)
}
}, null)
}
/**
* Perform a long press gesture
*/
fun longPress(x: Int, y: Int, duration: Int = 500, callback: ((Boolean) -> Unit)? = null): Boolean {
if (!isServiceRunning) {
callback?.invoke(false)
return false
}
val path = Path().apply {
moveTo(x.toFloat(), y.toFloat())
}
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, duration.toLong()))
.build()
return dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
callback?.invoke(true)
}
override fun onCancelled(gestureDescription: GestureDescription?) {
callback?.invoke(false)
}
}, null)
}
/**
* Perform a pinch zoom gesture (stretch goal)
*/
fun pinchZoom(centerX: Int, centerY: Int, scale: Float, duration: Int = 500): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return false
}
// Simplified pinch - just a two-finger gesture
val offset = 100
val path1 = Path().apply {
moveTo((centerX - offset).toFloat(), centerY.toFloat())
lineTo((centerX - offset - (offset * scale)).toFloat(), centerY.toFloat())
}
val path2 = Path().apply {
moveTo((centerX + offset).toFloat(), centerY.toFloat())
lineTo((centerX + offset + (offset * scale)).toFloat(), centerY.toFloat())
}
val builder = GestureDescription.Builder()
builder.addStroke(GestureDescription.StrokeDescription(path1, 0, duration.toLong()))
builder.addStroke(GestureDescription.StrokeDescription(path2, 0, duration.toLong()))
return dispatchGesture(builder.build(), null, null)
}
/**
* Perform a double tap gesture
*/
fun doubleTap(x: Int, y: Int, callback: ((Boolean) -> Unit)? = null): Boolean {
// Double tap is two quick taps
val path = Path().apply {
moveTo(x.toFloat(), y.toFloat())
}
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, 50))
.addStroke(GestureDescription.StrokeDescription(path, 100, 50))
.build()
return dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
callback?.invoke(true)
}
override fun onCancelled(gestureDescription: GestureDescription?) {
callback?.invoke(false)
}
}, null)
}
}
package com.openclaw.node.ui package com.openclaw.node.ui
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.openclaw.node.data.repository.SettingsRepository
import com.openclaw.node.service.NodeService
import com.openclaw.node.service.TouchService
import com.openclaw.node.ui.theme.OpenClawAndroidNodeTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
MaterialTheme { OpenClawAndroidNodeTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
MainScreen() MainScreen(
onStartService = { startNodeService() },
onStopService = { stopNodeService() },
onOpenAccessibilitySettings = { openAccessibilitySettings() },
onOpenNotificationSettings = { openNotificationSettings() }
)
}
} }
} }
} }
private fun startNodeService() {
val intent = Intent(this, NodeService::class.java).apply {
action = "START"
}
startForegroundService(intent)
}
private fun stopNodeService() {
val intent = Intent(this, NodeService::class.java).apply {
action = "STOP"
}
startService(intent)
}
private fun openAccessibilitySettings() {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
private fun openNotificationSettings() {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen() { fun MainScreen(
onStartService: () -> Unit,
onStopService: () -> Unit,
onOpenAccessibilitySettings: () -> Unit,
onOpenNotificationSettings: () -> Unit
) {
var gatewayUrl by remember { mutableStateOf("wss://") } var gatewayUrl by remember { mutableStateOf("wss://") }
var nodeToken by remember { mutableStateOf("") } var nodeToken by remember { mutableStateOf("") }
var nodeId by remember { mutableStateOf("") }
var isConnected by remember { mutableStateOf(false) } var isConnected by remember { mutableStateOf(false) }
var statusMessage by remember { mutableStateOf("Not connected") } var statusMessage by remember { mutableStateOf("Not connected") }
val context = LocalContext.current
// Check accessibility status
val accessibilityEnabled = remember {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val service = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_GENERIC)
service.any { it.id.contains("com.openclaw.node") }
}
LaunchedEffect(Unit) {
// Load saved settings
// Note: In real app, use ViewModel + StateFlow
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = "OpenClaw Android Node", text = "OpenClaw Android Node",
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium
) )
Text(
text = "Control your Android device remotely",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// Connection Card
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Gateway Connection",
style = MaterialTheme.typography.titleMedium
)
OutlinedTextField( OutlinedTextField(
value = gatewayUrl, value = gatewayUrl,
onValueChange = { gatewayUrl = it }, onValueChange = { gatewayUrl = it },
label = { Text("Gateway URL") }, label = { Text("Gateway URL") },
placeholder = { Text("wss://gateway.example.com/node") }, placeholder = { Text("wss://gateway.example.com/node") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !isConnected enabled = !isConnected,
singleLine = true
) )
OutlinedTextField( OutlinedTextField(
...@@ -63,20 +157,31 @@ fun MainScreen() { ...@@ -63,20 +157,31 @@ fun MainScreen() {
label = { Text("Node Token") }, label = { Text("Node Token") },
placeholder = { Text("Enter your auth token") }, placeholder = { Text("Enter your auth token") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !isConnected enabled = !isConnected,
singleLine = true
)
OutlinedTextField(
value = nodeId,
onValueChange = { nodeId = it },
label = { Text("Node ID (optional)") },
placeholder = { Text("android_device_1") },
modifier = Modifier.fillMaxWidth(),
enabled = !isConnected,
singleLine = true
) )
Button( Button(
onClick = { onClick = {
if (isConnected) { if (isConnected) {
// Disconnect onStopService()
isConnected = false isConnected = false
statusMessage = "Disconnected" statusMessage = "Disconnected"
} else { } else {
// Connect
if (gatewayUrl.isNotBlank() && nodeToken.isNotBlank()) { if (gatewayUrl.isNotBlank() && nodeToken.isNotBlank()) {
onStartService()
isConnected = true isConnected = true
statusMessage = "Connected to gateway" statusMessage = "Connecting..."
} else { } else {
statusMessage = "Please enter gateway URL and token" statusMessage = "Please enter gateway URL and token"
} }
...@@ -87,39 +192,121 @@ fun MainScreen() { ...@@ -87,39 +192,121 @@ fun MainScreen() {
Text(if (isConnected) "Disconnect" else "Connect") Text(if (isConnected) "Disconnect" else "Connect")
} }
Text(
text = statusMessage,
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Permissions Card
Card( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = "Status", text = "Required Permissions",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(modifier = Modifier.height(8.dp))
Text( PermissionRow(
text = statusMessage, name = "Accessibility",
style = MaterialTheme.typography.bodyMedium description = "For touch gestures (tap, swipe)",
isEnabled = accessibilityEnabled,
onClick = onOpenAccessibilitySettings
)
HorizontalDivider()
PermissionRow(
name = "Notification Access",
description = "To mirror notifications",
isEnabled = false, // Would check notification listener
onClick = onOpenNotificationSettings
) )
} }
} }
// Features Card
Card( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
text = "Required Permissions", text = "Features",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(modifier = Modifier.height(8.dp))
Text("• Accessibility (for touch control)") FeatureItem("📸 Screenshot capture")
Text("• Notification access") FeatureItem("👆 Touch control (tap, swipe, long press)")
Text("• Overlay (for screenshot)") FeatureItem("🚀 Launch apps")
FeatureItem("🔔 Notification mirroring")
FeatureItem("📱 Device info")
} }
} }
// Help text
Text(
text = "Configure gateway URL and token from your OpenClaw dashboard, then tap Connect. Grant permissions when prompted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun PermissionRow(
name: String,
description: String,
isEnabled: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = if (isEnabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.secondary
)
) {
Text(if (isEnabled) "Enabled" else "Enable")
}
}
}
@Composable
fun FeatureItem(text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
} }
} }
package com.openclaw.node.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val Purple80 = Color(0xFFD0BCFF)
private val PurpleGrey80 = Color(0xFFCCC2DC)
private val Pink80 = Color(0xFFEFB8C8)
private val Purple40 = Color(0xFF6650a4)
private val PurpleGrey40 = Color(0xFF625b71)
private val Pink40 = Color(0xFF7D5260)
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun OpenClawAndroidNodeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">OpenClaw Node</string> <string name="app_name">OpenClaw Node</string>
<string name="accessibility_service_description">OpenClaw Touch Control - Allows the app to perform touch gestures (tap, swipe, scroll) on your device. Required for remote control functionality.</string>
</resources> </resources>
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRequestTouchExplorationMode"
android:canPerformGestures="true"
android:canRetrieveWindowContent="false"
android:description="@string/accessibility_service_description"
android:notificationTimeout="100"
android:settingsActivity="com.openclaw.node.ui.MainActivity" />
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment