Commit 7bac6065 authored by Lisa (AI Assistant)'s avatar Lisa (AI Assistant)

feat: build testable Android node APK

parent 1a6294f4
Pipeline #324 canceled with stages
# Android / Gradle # Android / Gradle
.gradle/ .gradle/
build/ build/
app/build/
local.properties local.properties
**/build/
*.apk
*.aab
*.iml *.iml
.idea/ .idea/
......
...@@ -2,92 +2,111 @@ ...@@ -2,92 +2,111 @@
Android node agent for the Hermes Node Protocol. Android node agent for the Hermes Node Protocol.
This project is the Android sibling of the Linux `hermes-node-agent`. The goal is **functional parity where Android permits it**, not a toy “status-only” agent. This project is the Android sibling of the Linux `hermes-node-agent`. It reuses existing Hermes Node Gateway capability names where Android can provide equivalent intent, instead of inventing a separate `phone_control` gateway tool.
The gateway should not need a new “phone_control” tool just because the node is Android. Where the existing capability names already describe the intent, Android should reuse them and implement Android-native semantics behind the same wire contract. Remote:
## Current status
Early Android/Kotlin scaffold with protocol/capability classes. The project already has a GitLab remote:
```text ```text
git@git.nexlab.net:lisa/hermes-node-android.git git@git.nexlab.net:lisa/hermes-node-android.git
``` ```
## Current status
Buildable first test APK.
Implemented enough to install on a phone and connect it to the Hermes Node Gateway:
- Android/Kotlin app project with Gradle wrapper.
- Config UI for gateway URL, token, node name, TLS mode, and advertised capabilities.
- Foreground service for persistent outbound WebSocket connection.
- Registration frame compatible with Hermes Node Gateway-style capability discovery.
- Reconnect loop and heartbeat frames.
- App-local config via `SharedPreferences`; no hardcoded token.
- Status display in the app.
- Debug APK build verified locally.
Baseline capability handlers:
- `computer_control`
- `launch_app`: implemented via package launch intent.
- `open_url`: implemented via `ACTION_VIEW`.
- `open_intent`: implemented for implicit Android intents.
- gesture/key/text actions return explicit unsupported until AccessibilityService backend is added.
- `desktop_observe`
- `screen_info`: implemented.
- cursor/mouse-position compatibility returns Android-specific note.
- screenshots/active-window/clipboard return explicit unsupported until Android permission backends are added.
- `audio_control`
- `get_audio_status`: implemented.
- other actions return explicit unsupported until media/mic backend is added.
- `camera_control`
- `list_cameras`: implemented via Camera2.
- `get_camera_status`: implemented.
- capture/injection actions return explicit unsupported until CameraX/MediaProjection UX exists.
- `exec`
- advertised only if enabled in UI, but backend intentionally returns unsupported for now.
## Capability naming decision ## Capability naming decision
Use existing gateway capability names where practical: Use existing gateway capability names where practical:
- `computer_control` = interactive device control. - `computer_control` = interactive device control.
- On Linux: mouse, keyboard, X11 desktop actions. - Linux: mouse, keyboard, X11 desktop actions.
- On Android: touch/input abstraction, launch apps, open intents, press Back/Home/Recents, type text, tap/swipe, optionally accessibility-backed UI actions. - Android: app launch, URL/intent dispatch, touch/input abstractions, future AccessibilityService gestures.
- `desktop_observe` = structured screen/device observation. - `desktop_observe` = structured screen/device observation.
- On Linux: active window, cursor, screen geometry, screenshots. - Linux: active window, cursor, screen geometry, screenshots.
- On Android: foreground app/activity where available, display metrics, orientation, screen state, screenshots where permission/API allows. - Android: display metrics, orientation, interactive state, future foreground app/screenshot backends.
- `audio_control` = device/media audio operations. - `audio_control` = device/media audio operations.
- On Android: media session controls, volume, audio route/status, playback of provided audio, mic capture only with permission and visible UX. - `camera_control` = camera read paths where supported.
- `camera_control` = camera read/write paths where supported. - `browser_control` = reserved for a future browser automation backend.
- On Android: list cameras, capture frame/video with CameraX/Camera2 and explicit permission/visible UX. Virtual camera injection is not an Android baseline feature. - `exec` = Android shell execution if ever explicitly designed; disabled by default.
- `browser_control` = browser automation if implemented later via Chrome DevTools/WebView/intent flows. For first pass, browser launching/open URL can live under `computer_control` actions.
- `exec` = Android shell execution if available, disabled by default and honest about non-root limitations.
This avoids gateway-side churn while still letting the gateway inspect `capability_info.platform == "android"` when it needs platform-specific hints.
## Design goals
- **Protocol-compatible** with existing Hermes Node Gateway JSON/WebSocket protocol.
- **Outbound-only** connection from Android to gateway: `wss://gateway:8765`.
- **Functional parity with Linux capabilities where Android permits it.**
- **No root requirement** for baseline operation.
- **Foreground service** for persistent connectivity.
- **Explicit permissions** for sensitive functions: notifications, accessibility, camera, mic, screen capture.
- **No hidden capture or spyware behavior.** If Android requires a visible permission prompt or foreground notification, we respect that.
- **No hardcoded tokens.** Config is app-local and user-supplied.
## First implementation targets
### `computer_control`
Android-native actions:
- `launch_app` by package name.
- `open_url` via browser intent.
- `open_intent` for explicit/implicit intents.
- `key_press` for Android navigation keys where permitted: Back, Home, Recents, Enter, Volume Up/Down.
- `type_text` through accessibility/IME path where available.
- `tap`, `swipe`, `long_press` through accessibility gesture dispatch where enabled.
- `get_active_window` mapped to foreground app/activity when available.
### `desktop_observe` The gateway can inspect `capability_info.platform == "android"` and `platform_semantics == "android_phone_control"` for platform-specific hints while keeping the public tool names stable.
Android-native observations: ## Build
- `screen_info`: display size, density, orientation, interactive/locked state where available. Prerequisites:
- `active_window`/`get_active_window`: foreground app/activity via usage stats or accessibility, depending on grants.
- `screenshot`: MediaProjection-based capture with explicit user consent.
- `clipboard_get`: only when Android version/API permits it for the foreground/default IME constraints.
### `audio_control` - JDK 17
- Android SDK with platform 35 available
- `list_audio_devices` Build debug APK:
- `get_audio_status`
- `play_audio`
- `capture_input` with runtime microphone permission and foreground UX
- `capture_output` only if Android API/app constraints allow; otherwise explicit unsupported result.
### `camera_control` ```bash
JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
ANDROID_HOME=/usr/lib/android-sdk \
ANDROID_SDK_ROOT=/usr/lib/android-sdk \
./gradlew --no-daemon assembleDebug
```
- `list_cameras` Output:
- `get_camera_status`
- `capture_frame`
- `capture_video`
- `inject_video`: return explicit unsupported on normal Android unless a later rooted/vendor-specific backend exists.
### `exec` ```text
app/build/outputs/apk/debug/app-debug.apk
```
- Optional Android shell command execution using app-accessible process execution. ## Install/test on Android phone
- Disabled by default.
- Must return honest permission/SELinux/root limitations. 1. Copy/install the debug APK:
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
2. Open **Hermes Node** on the phone.
3. Fill in:
- Gateway URL: `wss://<gateway-host>:8765`
- Node name: e.g. `phone`
- Gateway token: token from `~/.config/hermes-node-gateway/config.json`
- Leave “Allow self-signed/untrusted gateway TLS” enabled if using the current self-signed gateway cert setup.
4. Tap **Save & start node**.
5. Android should show a persistent foreground-service notification.
6. On Hermes, check `node_list` / gateway node status for the Android node name.
7. Smoke-test safe actions:
- `desktop_observe``screen_info`
- `audio_control``get_audio_status`
- `camera_control``list_cameras`
- `computer_control``open_url`
## Repository layout ## Repository layout
...@@ -97,32 +116,29 @@ Android-native observations: ...@@ -97,32 +116,29 @@ Android-native observations:
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml │ ├── AndroidManifest.xml
│ ├── java/net/nexlab/hermesnodeandroid/ │ ├── java/net/nexlab/hermesnodeandroid/
│ │ ├── MainActivity.kt │ │ ├── ConfigStore.kt
│ │ ├── HermesNodeService.kt
│ │ ├── GatewayClient.kt │ │ ├── GatewayClient.kt
│ │ ├── HermesNodeService.kt
│ │ ├── MainActivity.kt
│ │ ├── NodeAgent.kt │ │ ├── NodeAgent.kt
│ │ ├── ProtocolModels.kt │ │ ├── ProtocolModels.kt
│ │ ├── StatusStore.kt
│ │ └── capabilities/ │ │ └── capabilities/
│ └── res/ │ └── res/
├── docs/ ├── docs/
│ ├── ANDROID_CAPABILITIES.md ├── gradle/wrapper/
│ └── PROTOCOL_COMPATIBILITY.md
├── build.gradle.kts ├── build.gradle.kts
├── settings.gradle.kts ├── settings.gradle.kts
└── README.md └── README.md
``` ```
## Build prerequisites ## Design constraints
- Android Studio or Android SDK command-line tools
- JDK 17+
- Gradle/Android Gradle Plugin matching the checked-in build files
A Gradle wrapper is not checked in yet. Once the local Android tooling is confirmed, generate it with: - Outbound-only connection from Android to gateway.
- No root requirement for baseline operation.
```bash - Foreground service for persistent connectivity.
gradle wrapper - No hidden capture/spyware behavior: camera, mic, screen capture, and accessibility features must use explicit Android permission/UX flows.
``` - No hardcoded tokens.
## License ## License
......
...@@ -15,6 +15,11 @@ android { ...@@ -15,6 +15,11 @@ android {
versionName = "0.1.0" versionName = "0.1.0"
} }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
......
/*
* Hermes Node Android
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.nexlab.hermesnodeandroid
import android.content.Context
import android.os.Build
object ConfigStore {
private const val PREFS = "hermes_node_android"
fun load(context: Context): NodeConfig {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val defaultName = "android-${Build.MODEL.replace(Regex("\\s+"), "-").lowercase()}"
return NodeConfig(
gatewayUrl = prefs.getString("gateway_url", "wss://lisa.nexlab.net:8765") ?: "wss://lisa.nexlab.net:8765",
token = prefs.getString("token", "") ?: "",
nodeName = prefs.getString("node_name", defaultName) ?: defaultName,
enableExec = prefs.getBoolean("enable_exec", false),
enableComputerControl = prefs.getBoolean("enable_computer_control", true),
enableDesktopObserve = prefs.getBoolean("enable_desktop_observe", true),
enableAudioControl = prefs.getBoolean("enable_audio_control", true),
enableCameraControl = prefs.getBoolean("enable_camera_control", true),
enableBrowserControl = prefs.getBoolean("enable_browser_control", false),
insecureTls = prefs.getBoolean("insecure_tls", true),
reconnectIntervalSeconds = prefs.getLong("reconnect_interval_seconds", 5L),
heartbeatIntervalSeconds = prefs.getLong("heartbeat_interval_seconds", 30L),
)
}
fun save(context: Context, config: NodeConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("gateway_url", config.gatewayUrl.trim())
.putString("token", config.token.trim())
.putString("node_name", config.nodeName.trim())
.putBoolean("enable_exec", config.enableExec)
.putBoolean("enable_computer_control", config.enableComputerControl)
.putBoolean("enable_desktop_observe", config.enableDesktopObserve)
.putBoolean("enable_audio_control", config.enableAudioControl)
.putBoolean("enable_camera_control", config.enableCameraControl)
.putBoolean("enable_browser_control", config.enableBrowserControl)
.putBoolean("insecure_tls", config.insecureTls)
.putLong("reconnect_interval_seconds", config.reconnectIntervalSeconds)
.putLong("heartbeat_interval_seconds", config.heartbeatIntervalSeconds)
.apply()
}
}
...@@ -9,19 +9,23 @@ import okhttp3.OkHttpClient ...@@ -9,19 +9,23 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class GatewayClient( class GatewayClient(
private val gatewayUrl: String, private val config: NodeConfig,
private val token: String,
private val nodeName: String,
) { ) {
private val client = OkHttpClient() private val client = buildClient(config.insecureTls)
private var socket: WebSocket? = null private var socket: WebSocket? = null
fun connect(listener: WebSocketListener) { fun connect(listener: WebSocketListener) {
val request = Request.Builder() val request = Request.Builder()
.url(gatewayUrl) .url(config.gatewayUrl)
.addHeader("Authorization", "Bearer $token") .addHeader("Authorization", "Bearer ${config.token}")
.build() .build()
socket = client.newWebSocket(request, listener) socket = client.newWebSocket(request, listener)
} }
...@@ -32,4 +36,23 @@ class GatewayClient( ...@@ -32,4 +36,23 @@ class GatewayClient(
socket?.close(1000, "Hermes Node Android stopping") socket?.close(1000, "Hermes Node Android stopping")
socket = null socket = null
} }
private fun buildClient(insecureTls: Boolean): OkHttpClient {
val builder = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
if (insecureTls) {
val trustAll = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf<TrustManager>(trustAll), SecureRandom())
builder.sslSocketFactory(sslContext.socketFactory, trustAll)
builder.hostnameVerifier { _, _ -> true }
}
return builder.build()
}
} }
...@@ -5,15 +5,102 @@ ...@@ -5,15 +5,102 @@
*/ */
package net.nexlab.hermesnodeandroid package net.nexlab.hermesnodeandroid
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.widget.Toast
class HermesNodeService : Service() { class HermesNodeService : Service() {
private var agent: NodeAgent? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: start foreground notification, load config, connect GatewayClient. when (intent?.action) {
ACTION_STOP -> {
stopAgent()
stopSelf()
return START_NOT_STICKY
}
else -> startAgent()
}
return START_STICKY return START_STICKY
} }
override fun onDestroy() {
stopAgent()
super.onDestroy()
}
private fun startAgent() {
val config = ConfigStore.load(this)
val validation = config.validationError()
if (validation != null) {
StatusStore.set(this, "config_error", validation)
Toast.makeText(this, validation, Toast.LENGTH_LONG).show()
stopSelf()
return
}
startForeground(NOTIFICATION_ID, notification("Connecting ${config.nodeName}"))
agent?.stop()
agent = NodeAgent(applicationContext, config).also { it.start() }
}
private fun stopAgent() {
agent?.stop()
agent = null
}
private fun notification(text: String): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val pending = PendingIntent.getActivity(
this,
0,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = Intent(this, HermesNodeService::class.java).setAction(ACTION_STOP)
val stopPending = PendingIntent.getService(
this,
1,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Hermes Node Android")
.setContentText(text)
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
.setContentIntent(pending)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPending)
.setOngoing(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Hermes Node",
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
companion object {
const val ACTION_START = "net.nexlab.hermesnodeandroid.START"
const val ACTION_STOP = "net.nexlab.hermesnodeandroid.STOP"
private const val CHANNEL_ID = "hermes_node_android"
private const val NOTIFICATION_ID = 1001
}
} }
...@@ -5,20 +5,180 @@ ...@@ -5,20 +5,180 @@
*/ */
package net.nexlab.hermesnodeandroid package net.nexlab.hermesnodeandroid
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
class MainActivity : Activity() { class MainActivity : Activity() {
private lateinit var gatewayUrl: EditText
private lateinit var nodeName: EditText
private lateinit var token: EditText
private lateinit var insecureTls: CheckBox
private lateinit var computerControl: CheckBox
private lateinit var desktopObserve: CheckBox
private lateinit var audioControl: CheckBox
private lateinit var cameraControl: CheckBox
private lateinit var browserControl: CheckBox
private lateinit var execControl: CheckBox
private lateinit var statusView: TextView
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) = refreshStatus()
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val text = TextView(this).apply { buildUi()
text = "Hermes Node Android loadConfigIntoUi()
refreshStatus()
requestNotificationPermissionIfNeeded()
}
override fun onResume() {
super.onResume()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(statusReceiver, IntentFilter(StatusStore.ACTION_STATUS_CHANGED), RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(statusReceiver, IntentFilter(StatusStore.ACTION_STATUS_CHANGED))
}
refreshStatus()
}
override fun onPause() {
runCatching { unregisterReceiver(statusReceiver) }
super.onPause()
}
Scaffold ready. Configuration UI comes next." private fun buildUi() {
textSize = 18f val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(32, 32, 32, 32) setPadding(32, 32, 32, 32)
} }
setContentView(text) val title = TextView(this).apply {
text = "Hermes Node Android"
textSize = 24f
}
val subtitle = TextView(this).apply {
text = "Reverse-connect this Android phone to the Hermes Node Gateway."
textSize = 14f
}
gatewayUrl = edit("Gateway URL", "wss://lisa.nexlab.net:8765")
nodeName = edit("Node name", "android-phone")
token = edit("Gateway token", "")
insecureTls = checkbox("Allow self-signed/untrusted gateway TLS", true)
computerControl = checkbox("computer_control: launch apps / open URLs", true)
desktopObserve = checkbox("desktop_observe: screen/status info", true)
audioControl = checkbox("audio_control: audio status", true)
cameraControl = checkbox("camera_control: list cameras", true)
browserControl = checkbox("browser_control (reserved; off for now)", false)
execControl = checkbox("exec (dangerous; disabled backend)", false)
statusView = TextView(this).apply {
textSize = 14f
setPadding(0, 24, 0, 24)
}
val save = Button(this).apply {
text = "Save config"
setOnClickListener { saveConfig(showToast = true) }
}
val start = Button(this).apply {
text = "Save & start node"
setOnClickListener {
val config = saveConfig(showToast = false)
val error = config.validationError()
if (error != null) {
Toast.makeText(this@MainActivity, error, Toast.LENGTH_LONG).show()
} else {
startService(Intent(this@MainActivity, HermesNodeService::class.java).setAction(HermesNodeService.ACTION_START))
Toast.makeText(this@MainActivity, "Hermes node starting", Toast.LENGTH_SHORT).show()
}
}
}
val stop = Button(this).apply {
text = "Stop node"
setOnClickListener {
startService(Intent(this@MainActivity, HermesNodeService::class.java).setAction(HermesNodeService.ACTION_STOP))
Toast.makeText(this@MainActivity, "Hermes node stopping", Toast.LENGTH_SHORT).show()
}
}
val refresh = Button(this).apply {
text = "Refresh status"
setOnClickListener { refreshStatus() }
}
listOf<View>(
title, subtitle, gatewayUrl, nodeName, token, insecureTls,
computerControl, desktopObserve, audioControl, cameraControl,
browserControl, execControl, save, start, stop, refresh, statusView,
).forEach { root.addView(it) }
setContentView(ScrollView(this).apply { addView(root) })
}
private fun edit(hint: String, value: String): EditText = EditText(this).apply {
this.hint = hint
setText(value)
setSingleLine(true)
}
private fun checkbox(label: String, checked: Boolean): CheckBox = CheckBox(this).apply {
text = label
isChecked = checked
}
private fun loadConfigIntoUi() {
val config = ConfigStore.load(this)
gatewayUrl.setText(config.gatewayUrl)
nodeName.setText(config.nodeName)
token.setText(config.token)
insecureTls.isChecked = config.insecureTls
computerControl.isChecked = config.enableComputerControl
desktopObserve.isChecked = config.enableDesktopObserve
audioControl.isChecked = config.enableAudioControl
cameraControl.isChecked = config.enableCameraControl
browserControl.isChecked = config.enableBrowserControl
execControl.isChecked = config.enableExec
}
private fun saveConfig(showToast: Boolean): NodeConfig {
val config = NodeConfig(
gatewayUrl = gatewayUrl.text.toString(),
token = token.text.toString(),
nodeName = nodeName.text.toString(),
enableExec = execControl.isChecked,
enableComputerControl = computerControl.isChecked,
enableDesktopObserve = desktopObserve.isChecked,
enableAudioControl = audioControl.isChecked,
enableCameraControl = cameraControl.isChecked,
enableBrowserControl = browserControl.isChecked,
insecureTls = insecureTls.isChecked,
)
ConfigStore.save(this, config)
if (showToast) Toast.makeText(this, "Config saved", Toast.LENGTH_SHORT).show()
return config
}
private fun refreshStatus() {
statusView.text = "Status:\n${StatusStore.snapshot(this)}"
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100)
}
} }
} }
...@@ -6,6 +6,9 @@ ...@@ -6,6 +6,9 @@
package net.nexlab.hermesnodeandroid package net.nexlab.hermesnodeandroid
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import net.nexlab.hermesnodeandroid.capabilities.AndroidAudioControl import net.nexlab.hermesnodeandroid.capabilities.AndroidAudioControl
import net.nexlab.hermesnodeandroid.capabilities.AndroidCameraControl import net.nexlab.hermesnodeandroid.capabilities.AndroidCameraControl
import net.nexlab.hermesnodeandroid.capabilities.AndroidComputerControl import net.nexlab.hermesnodeandroid.capabilities.AndroidComputerControl
...@@ -15,6 +18,7 @@ import okhttp3.Response ...@@ -15,6 +18,7 @@ import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import org.json.JSONObject import org.json.JSONObject
import kotlin.math.min
class NodeAgent( class NodeAgent(
private val context: Context, private val context: Context,
...@@ -25,32 +29,57 @@ class NodeAgent( ...@@ -25,32 +29,57 @@ class NodeAgent(
private val audio = AndroidAudioControl(context) private val audio = AndroidAudioControl(context)
private val camera = AndroidCameraControl(context) private val camera = AndroidCameraControl(context)
private val exec = AndroidExecControl(context) private val exec = AndroidExecControl(context)
private val mainHandler = Handler(Looper.getMainLooper())
private var gatewayClient: GatewayClient? = null private var gatewayClient: GatewayClient? = null
private var stopped = false
private var reconnectAttempts = 0
private var heartbeatScheduled = false
fun start() { fun start() {
gatewayClient = GatewayClient(config.gatewayUrl, config.token, config.nodeName) stopped = false
gatewayClient?.connect(this) StatusStore.set(context, "connecting", "${config.nodeName} -> ${config.gatewayUrl}")
connectNow()
} }
fun stop() { fun stop() {
stopped = true
heartbeatScheduled = false
gatewayClient?.close() gatewayClient?.close()
gatewayClient = null gatewayClient = null
mainHandler.removeCallbacksAndMessages(null)
StatusStore.set(context, "stopped")
}
private fun connectNow() {
if (stopped) return
gatewayClient?.close()
gatewayClient = GatewayClient(config)
Log.i(TAG, "connect ${config.nodeName} -> ${config.gatewayUrl}")
gatewayClient?.connect(this)
} }
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
reconnectAttempts = 0
StatusStore.set(context, "connected", "registered as ${config.nodeName}")
Log.i(TAG, "connected; sending registration")
webSocket.send(registrationFrame()) webSocket.send(registrationFrame())
scheduleHeartbeat(webSocket)
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
val command = runCatching { GatewayCommand.fromJson(text) }.getOrElse { return } val command = runCatching { GatewayCommand.fromJson(text) }.getOrElse {
Log.w(TAG, "invalid command: ${it.message}")
return
}
val action = command.action ?: command.command val action = command.action ?: command.command
if (command.type == "heartbeat_ack" || command.type == "register_ack") return
Log.i(TAG, "tool call type=${command.type} action=$action id=${command.id}")
val result = when (command.type) { val result = when (command.type) {
"computer_control" -> computer.handle(action, command.params) "computer_control" -> computer.handle(action, command.params)
"desktop_observe" -> observe.handle(action, command.params) "desktop_observe" -> observe.handle(action, command.params)
"audio_control" -> audio.handle(action, command.params) "audio_control" -> audio.handle(action, command.params)
"camera_control" -> camera.handle(action, command.params) "camera_control" -> camera.handle(action, command.params)
"exec" -> exec.handle(command) "exec" -> exec.handle(command)
"heartbeat_ack", "register_ack" -> return
else -> CapabilityResult.unsupported("Unknown command type: ${command.type}") else -> CapabilityResult.unsupported("Unknown command type: ${command.type}")
} }
val resultType = when (command.type) { val resultType = when (command.type) {
...@@ -64,6 +93,44 @@ class NodeAgent( ...@@ -64,6 +93,44 @@ class NodeAgent(
webSocket.send(result.toWireResult(resultType, command.id, action)) webSocket.send(result.toWireResult(resultType, command.id, action))
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (stopped) return
Log.w(TAG, "connection failure: ${t.message}")
StatusStore.set(context, "disconnected", t.message ?: "connection failure")
scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (stopped) return
Log.i(TAG, "connection closed: $code $reason")
StatusStore.set(context, "disconnected", "closed: $code $reason")
scheduleReconnect()
}
private fun scheduleReconnect() {
if (stopped) return
reconnectAttempts += 1
val delaySeconds = min(config.reconnectIntervalSeconds * reconnectAttempts, 60L)
StatusStore.set(context, "reconnecting", "attempt $reconnectAttempts in ${delaySeconds}s")
mainHandler.postDelayed({ connectNow() }, delaySeconds * 1000L)
}
private fun scheduleHeartbeat(webSocket: WebSocket) {
if (heartbeatScheduled || stopped) return
heartbeatScheduled = true
fun tick() {
if (stopped) return
val heartbeat = JSONObject()
.put("type", "heartbeat")
.put("node_name", config.nodeName)
.put("timestamp", System.currentTimeMillis() / 1000L)
.toString()
webSocket.send(heartbeat)
mainHandler.postDelayed({ tick() }, config.heartbeatIntervalSeconds * 1000L)
}
mainHandler.postDelayed({ tick() }, config.heartbeatIntervalSeconds * 1000L)
}
fun registrationFrame(): String { fun registrationFrame(): String {
val tools = mutableListOf<String>() val tools = mutableListOf<String>()
if (config.enableExec) tools += "exec" if (config.enableExec) tools += "exec"
...@@ -78,12 +145,14 @@ class NodeAgent( ...@@ -78,12 +145,14 @@ class NodeAgent(
.put("node_name", config.nodeName) .put("node_name", config.nodeName)
.put("version", "0.1.0-android") .put("version", "0.1.0-android")
.put("tools", jsonArrayOf(tools)) .put("tools", jsonArrayOf(tools))
.put("capabilities", capabilityInfo()) .put("capabilities", jsonArrayOf(tools))
.put("capability_info", capabilityInfo())
.toString() .toString()
} }
private fun capabilityInfo(): JSONObject = JSONObject() private fun capabilityInfo(): JSONObject = JSONObject()
.put("platform", "android") .put("platform", "android")
.put("platform_semantics", "android_phone_control")
.put("enable_exec", config.enableExec) .put("enable_exec", config.enableExec)
.put("enable_computer_control", config.enableComputerControl) .put("enable_computer_control", config.enableComputerControl)
.put("enable_desktop_observe", config.enableDesktopObserve) .put("enable_desktop_observe", config.enableDesktopObserve)
...@@ -94,4 +163,9 @@ class NodeAgent( ...@@ -94,4 +163,9 @@ class NodeAgent(
.put("desktop_observe", observe.capabilityInfo()) .put("desktop_observe", observe.capabilityInfo())
.put("audio_control", audio.capabilityInfo()) .put("audio_control", audio.capabilityInfo())
.put("camera_control", camera.capabilityInfo()) .put("camera_control", camera.capabilityInfo())
.put("exec", exec.capabilityInfo())
companion object {
private const val TAG = "HermesNodeAgent"
}
} }
...@@ -18,7 +18,18 @@ data class NodeConfig( ...@@ -18,7 +18,18 @@ data class NodeConfig(
val enableAudioControl: Boolean = true, val enableAudioControl: Boolean = true,
val enableCameraControl: Boolean = true, val enableCameraControl: Boolean = true,
val enableBrowserControl: Boolean = false, val enableBrowserControl: Boolean = false,
) val insecureTls: Boolean = true,
val reconnectIntervalSeconds: Long = 5L,
val heartbeatIntervalSeconds: Long = 30L,
) {
fun validationError(): String? = when {
gatewayUrl.isBlank() -> "Gateway URL is required"
!(gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) -> "Gateway URL must start with ws:// or wss://"
token.isBlank() -> "Gateway token is required"
nodeName.isBlank() -> "Node name is required"
else -> null
}
}
data class GatewayCommand( data class GatewayCommand(
val id: String, val id: String,
......
/*
* Hermes Node Android
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.nexlab.hermesnodeandroid
import android.content.Context
object StatusStore {
const val ACTION_STATUS_CHANGED = "net.nexlab.hermesnodeandroid.STATUS_CHANGED"
private const val PREFS = "hermes_node_android_status"
fun set(context: Context, status: String, detail: String = "") {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("status", status)
.putString("detail", detail)
.putLong("updated_at", System.currentTimeMillis())
.apply()
context.sendBroadcast(android.content.Intent(ACTION_STATUS_CHANGED))
}
fun snapshot(context: Context): String {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val status = prefs.getString("status", "stopped") ?: "stopped"
val detail = prefs.getString("detail", "") ?: ""
val updated = prefs.getLong("updated_at", 0L)
return if (detail.isBlank()) "$status\nupdated: $updated" else "$status\n$detail\nupdated: $updated"
}
}
...@@ -8,15 +8,15 @@ The Android agent should expose **the same gateway capability names** as Linux w ...@@ -8,15 +8,15 @@ The Android agent should expose **the same gateway capability names** as Linux w
Meaning on Android: phone/device control. Meaning on Android: phone/device control.
Actions to support: Current first-test APK actions:
- `launch_app`: start app by package name. - `launch_app`: implemented; starts app by package name.
- `open_url`: open URL via Android intent. - `open_url`: implemented; opens URL via Android intent.
- `open_intent`: explicit/implicit Android intent bridge. - `open_intent`: implemented for implicit Android intent bridge.
- `key_press`: Back/Home/Recents/Enter/volume controls where available. - `key_press`: returns explicit unsupported until AccessibilityService/privileged input backend exists.
- `tap`, `swipe`, `long_press`: accessibility gesture dispatch. - `tap`, `swipe`, `long_press`: return explicit unsupported until AccessibilityService gesture dispatch exists.
- `type_text`: accessibility/IME-backed text entry. - `type_text`: returns explicit unsupported until accessibility/IME-backed text entry exists.
- `get_active_window`: foreground app/activity when permission grants exist. - `get_active_window`: route through `desktop_observe`; foreground app/activity needs UsageStats or Accessibility grant.
Keep the gateway name `computer_control` unless gateway-side UX later needs a friendly alias. Compatibility beats naming purity here. Keep the gateway name `computer_control` unless gateway-side UX later needs a friendly alias. Compatibility beats naming purity here.
...@@ -24,33 +24,33 @@ Keep the gateway name `computer_control` unless gateway-side UX later needs a fr ...@@ -24,33 +24,33 @@ Keep the gateway name `computer_control` unless gateway-side UX later needs a fr
Meaning on Android: screen/device observation. Meaning on Android: screen/device observation.
Actions to support: Current first-test APK actions:
- `screen_info`: display metrics, density, orientation, interactive state. - `screen_info`: implemented; display metrics, density, orientation, interactive state.
- `active_window` / `get_active_window`: foreground app/activity using UsageStats or AccessibilityService. - `active_window` / `get_active_window`: explicit unsupported until UsageStats or AccessibilityService grant flow exists.
- `screenshot` / `region_screenshot`: MediaProjection with explicit consent. - `screenshot` / `region_screenshot`: explicit unsupported until MediaProjection consent flow exists.
- `clipboard_get`: only where Android version and foreground restrictions permit it. - `clipboard_get`: explicit unsupported until Android foreground/API restrictions are handled.
- `cursor_position` / `mouse_position`: mostly compatibility shim; Android has touch focus, not a persistent cursor. - `cursor_position` / `mouse_position`: implemented as compatibility shim; Android has touch focus, not a persistent cursor.
### `audio_control` ### `audio_control`
Actions to support: Current first-test APK actions:
- `get_audio_status`: stream volume, route, ringer mode, mute state. - `get_audio_status`: implemented; stream volume, ringer mode, speakerphone flag, mic mute flag.
- `list_audio_devices`: AudioDeviceInfo enumeration. - `list_audio_devices`: explicit unsupported until AudioDeviceInfo enumeration is wired.
- `play_audio`: play gateway-provided audio through MediaPlayer/ExoPlayer. - `play_audio`: explicit unsupported until download/cache + MediaPlayer/ExoPlayer backend exists.
- `capture_input`: microphone recording with runtime permission and foreground UX. - `capture_input`: explicit unsupported until microphone permission and foreground recording UX exist.
- `capture_output`: return unsupported unless Android API/app role permits it. - `capture_output`: explicit unsupported; normal Android apps generally cannot capture arbitrary output audio.
### `camera_control` ### `camera_control`
Actions to support: Current first-test APK actions:
- `list_cameras`: Camera2/CameraX camera inventory. - `list_cameras`: implemented with Camera2 camera inventory.
- `get_camera_status`: backend/permission state. - `get_camera_status`: implemented with backend/status metadata.
- `capture_frame`: explicit camera capture. - `capture_frame`: explicit unsupported until CameraX/Camera2 capture session and permission UX exist.
- `capture_video`: explicit video capture. - `capture_video`: explicit unsupported until CameraX/Camera2 capture session and visible UX exist.
- `inject_video`: unsupported on normal Android; no fake parity. - `inject_video`: explicit unsupported on normal Android; no fake parity.
### `browser_control` ### `browser_control`
......
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# 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
;;
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"
which java >/dev/null 2>&1 || 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
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
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