Compare commits
4 Commits
main
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3f9a5934 | ||
|
|
0372457ba8 | ||
|
|
f28a03c407 | ||
|
|
f5abc8e9c9 |
@ -23,6 +23,7 @@
|
|||||||
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
|
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
|
||||||
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
|
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
|
||||||
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
|
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
|
||||||
|
- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
|
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
|
||||||
|
|||||||
@ -14,9 +14,13 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<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" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.telephony"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NodeApp"
|
android:name=".NodeApp"
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||||
|
viewModel.sms.attachPermissionRequester(permissionRequester)
|
||||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||||
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.clawdis.android.chat.OutgoingAttachment
|
|||||||
import com.clawdis.android.node.CameraCaptureManager
|
import com.clawdis.android.node.CameraCaptureManager
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdis.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdis.android.node.ScreenRecordManager
|
||||||
|
import com.clawdis.android.node.SmsManager
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val canvas: CanvasController = runtime.canvas
|
val canvas: CanvasController = runtime.canvas
|
||||||
val camera: CameraCaptureManager = runtime.camera
|
val camera: CameraCaptureManager = runtime.camera
|
||||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||||
|
val sms: SmsManager = runtime.sms
|
||||||
|
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||||
@ -116,6 +118,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setTalkEnabled(enabled)
|
runtime.setTalkEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshBridgeHello() {
|
||||||
|
runtime.refreshBridgeHello()
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,15 @@ import com.clawdis.android.node.LocationCaptureManager
|
|||||||
import com.clawdis.android.BuildConfig
|
import com.clawdis.android.BuildConfig
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdis.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdis.android.node.ScreenRecordManager
|
||||||
|
import com.clawdis.android.node.SmsManager
|
||||||
import com.clawdis.android.protocol.ClawdisCapability
|
import com.clawdis.android.protocol.ClawdisCapability
|
||||||
import com.clawdis.android.protocol.ClawdisCameraCommand
|
import com.clawdis.android.protocol.ClawdisCameraCommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||||
import com.clawdis.android.protocol.ClawdisLocationCommand
|
|
||||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
import com.clawdis.android.protocol.ClawdisScreenCommand
|
||||||
|
import com.clawdis.android.protocol.ClawdisLocationCommand
|
||||||
|
import com.clawdis.android.protocol.ClawdisSmsCommand
|
||||||
import com.clawdis.android.voice.TalkModeManager
|
import com.clawdis.android.voice.TalkModeManager
|
||||||
import com.clawdis.android.voice.VoiceWakeManager
|
import com.clawdis.android.voice.VoiceWakeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -61,6 +63,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
val location = LocationCaptureManager(appContext)
|
||||||
val screenRecorder = ScreenRecordManager(appContext)
|
val screenRecorder = ScreenRecordManager(appContext)
|
||||||
|
val sms = SmsManager(appContext)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||||
@ -364,69 +367,112 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setTalkEnabled(value)
|
prefs.setTalkEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildInvokeCommands(): List<String> =
|
||||||
|
buildList {
|
||||||
|
add(ClawdisCanvasCommand.Present.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||||
|
add(ClawdisScreenCommand.Record.rawValue)
|
||||||
|
if (cameraEnabled.value) {
|
||||||
|
add(ClawdisCameraCommand.Snap.rawValue)
|
||||||
|
add(ClawdisCameraCommand.Clip.rawValue)
|
||||||
|
}
|
||||||
|
if (locationMode.value != LocationMode.Off) {
|
||||||
|
add(ClawdisLocationCommand.Get.rawValue)
|
||||||
|
}
|
||||||
|
if (sms.canSendSms()) {
|
||||||
|
add(ClawdisSmsCommand.Send.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCapabilities(): List<String> =
|
||||||
|
buildList {
|
||||||
|
add(ClawdisCapability.Canvas.rawValue)
|
||||||
|
add(ClawdisCapability.Screen.rawValue)
|
||||||
|
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||||
|
if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue)
|
||||||
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
if (locationMode.value != LocationMode.Off) {
|
||||||
|
add(ClawdisCapability.Location.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
|
||||||
|
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
.ifEmpty { null }
|
||||||
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
|
val advertisedVersion =
|
||||||
|
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
|
"$versionName-dev"
|
||||||
|
} else {
|
||||||
|
versionName
|
||||||
|
}
|
||||||
|
return BridgePairingClient.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = token,
|
||||||
|
platform = "Android",
|
||||||
|
version = advertisedVersion,
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = modelIdentifier,
|
||||||
|
caps = buildCapabilities(),
|
||||||
|
commands = buildInvokeCommands(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSessionHello(token: String?): BridgeSession.Hello {
|
||||||
|
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
.ifEmpty { null }
|
||||||
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
|
val advertisedVersion =
|
||||||
|
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
|
"$versionName-dev"
|
||||||
|
} else {
|
||||||
|
versionName
|
||||||
|
}
|
||||||
|
return BridgeSession.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = token,
|
||||||
|
platform = "Android",
|
||||||
|
version = advertisedVersion,
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = modelIdentifier,
|
||||||
|
caps = buildCapabilities(),
|
||||||
|
commands = buildInvokeCommands(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshBridgeHello() {
|
||||||
|
scope.launch {
|
||||||
|
if (!_isConnected.value) return@launch
|
||||||
|
val token = prefs.loadBridgeToken()
|
||||||
|
if (token.isNullOrBlank()) return@launch
|
||||||
|
session.updateHello(buildSessionHello(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
val storedToken = prefs.loadBridgeToken()
|
val storedToken = prefs.loadBridgeToken()
|
||||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
.ifEmpty { null }
|
|
||||||
|
|
||||||
val invokeCommands =
|
|
||||||
buildList {
|
|
||||||
add(ClawdisCanvasCommand.Present.rawValue)
|
|
||||||
add(ClawdisCanvasCommand.Hide.rawValue)
|
|
||||||
add(ClawdisCanvasCommand.Navigate.rawValue)
|
|
||||||
add(ClawdisCanvasCommand.Eval.rawValue)
|
|
||||||
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
|
||||||
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
|
||||||
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
|
||||||
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
|
||||||
add(ClawdisScreenCommand.Record.rawValue)
|
|
||||||
if (cameraEnabled.value) {
|
|
||||||
add(ClawdisCameraCommand.Snap.rawValue)
|
|
||||||
add(ClawdisCameraCommand.Clip.rawValue)
|
|
||||||
}
|
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdisLocationCommand.Get.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val resolved =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
_statusText.value = "Pairing…"
|
_statusText.value = "Pairing…"
|
||||||
val caps = buildList {
|
|
||||||
add(ClawdisCapability.Canvas.rawValue)
|
|
||||||
add(ClawdisCapability.Screen.rawValue)
|
|
||||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
|
||||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
|
||||||
}
|
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdisCapability.Location.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
|
||||||
val advertisedVersion =
|
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
|
||||||
"$versionName-dev"
|
|
||||||
} else {
|
|
||||||
versionName
|
|
||||||
}
|
|
||||||
BridgePairingClient().pairAndHello(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello =
|
hello = buildPairingHello(token = null),
|
||||||
BridgePairingClient.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = null,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps = caps,
|
|
||||||
commands = invokeCommands,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
@ -440,38 +486,9 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
val authToken = requireNotNull(resolved.token).trim()
|
val authToken = requireNotNull(resolved.token).trim()
|
||||||
prefs.saveBridgeToken(authToken)
|
prefs.saveBridgeToken(authToken)
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
|
||||||
val advertisedVersion =
|
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
|
||||||
"$versionName-dev"
|
|
||||||
} else {
|
|
||||||
versionName
|
|
||||||
}
|
|
||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello =
|
hello = buildSessionHello(token = authToken),
|
||||||
BridgeSession.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = authToken,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps =
|
|
||||||
buildList {
|
|
||||||
add(ClawdisCapability.Canvas.rawValue)
|
|
||||||
add(ClawdisCapability.Screen.rawValue)
|
|
||||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
|
||||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
|
||||||
}
|
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdisCapability.Location.rawValue)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
commands = invokeCommands,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -911,6 +928,17 @@ class NodeRuntime(context: Context) {
|
|||||||
_screenRecordActive.value = false
|
_screenRecordActive.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ClawdisSmsCommand.Send.rawValue -> {
|
||||||
|
val res = sms.send(paramsJson)
|
||||||
|
if (res.ok) {
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
} else {
|
||||||
|
val error = res.error ?: "SMS_SEND_FAILED"
|
||||||
|
val idx = error.indexOf(':')
|
||||||
|
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
||||||
|
BridgeSession.InvokeResult.error(code = code, message = error)
|
||||||
|
}
|
||||||
|
}
|
||||||
else ->
|
else ->
|
||||||
BridgeSession.InvokeResult.error(
|
BridgeSession.InvokeResult.error(
|
||||||
code = "INVALID_REQUEST",
|
code = "INVALID_REQUEST",
|
||||||
|
|||||||
@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
|
|
||||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||||
val labels = permissions.map { permissionLabel(it) }
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
return "Clawdis needs ${labels.joinToString(", ")} permissions to continue."
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||||
@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
when (permission) {
|
when (permission) {
|
||||||
Manifest.permission.CAMERA -> "Camera"
|
Manifest.permission.CAMERA -> "Camera"
|
||||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||||
|
Manifest.permission.SEND_SMS -> "SMS"
|
||||||
else -> permission
|
else -> permission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,13 @@ class BridgeSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateHello(hello: Hello) {
|
||||||
|
val target = desired ?: return
|
||||||
|
desired = target.first to hello
|
||||||
|
val conn = currentConnection ?: return
|
||||||
|
conn.sendJson(buildHelloJson(hello))
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
desired = null
|
desired = null
|
||||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||||
@ -196,20 +203,7 @@ class BridgeSession(
|
|||||||
currentConnection = conn
|
currentConnection = conn
|
||||||
|
|
||||||
try {
|
try {
|
||||||
conn.sendJson(
|
conn.sendJson(buildHelloJson(hello))
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("hello"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
@ -307,6 +301,20 @@ class BridgeSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
||||||
val trimmed = raw?.trim().orEmpty()
|
val trimmed = raw?.trim().orEmpty()
|
||||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
||||||
|
|||||||
@ -0,0 +1,230 @@
|
|||||||
|
package com.clawdis.android.node
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.telephony.SmsManager as AndroidSmsManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import com.clawdis.android.PermissionRequester
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends SMS messages via the Android SMS API.
|
||||||
|
* Requires SEND_SMS permission to be granted.
|
||||||
|
*/
|
||||||
|
class SmsManager(private val context: Context) {
|
||||||
|
|
||||||
|
private val json = JsonConfig
|
||||||
|
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||||
|
|
||||||
|
data class SendResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val to: String,
|
||||||
|
val message: String?,
|
||||||
|
val error: String? = null,
|
||||||
|
val payloadJson: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ParsedParams(
|
||||||
|
val to: String,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed class ParseResult {
|
||||||
|
data class Ok(val params: ParsedParams) : ParseResult()
|
||||||
|
data class Error(
|
||||||
|
val error: String,
|
||||||
|
val to: String = "",
|
||||||
|
val message: String? = null,
|
||||||
|
) : ParseResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class SendPlan(
|
||||||
|
val parts: List<String>,
|
||||||
|
val useMultipart: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
||||||
|
val params = paramsJson?.trim().orEmpty()
|
||||||
|
if (params.isEmpty()) {
|
||||||
|
return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val obj = try {
|
||||||
|
json.parseToJsonElement(params).jsonObject
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj == null) {
|
||||||
|
return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object")
|
||||||
|
}
|
||||||
|
|
||||||
|
val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
|
val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty()
|
||||||
|
|
||||||
|
if (to.isEmpty()) {
|
||||||
|
return ParseResult.Error(
|
||||||
|
error = "INVALID_REQUEST: 'to' phone number required",
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
return ParseResult.Error(
|
||||||
|
error = "INVALID_REQUEST: 'message' text required",
|
||||||
|
to = to,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildSendPlan(
|
||||||
|
message: String,
|
||||||
|
divider: (String) -> List<String>,
|
||||||
|
): SendPlan {
|
||||||
|
val parts = divider(message).ifEmpty { listOf(message) }
|
||||||
|
return SendPlan(parts = parts, useMultipart = parts.size > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildPayloadJson(
|
||||||
|
json: Json = JsonConfig,
|
||||||
|
ok: Boolean,
|
||||||
|
to: String,
|
||||||
|
error: String?,
|
||||||
|
): String {
|
||||||
|
val payload =
|
||||||
|
mutableMapOf<String, JsonElement>(
|
||||||
|
"ok" to JsonPrimitive(ok),
|
||||||
|
"to" to JsonPrimitive(to),
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED")
|
||||||
|
}
|
||||||
|
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasSmsPermission(): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.SEND_SMS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canSendSms(): Boolean {
|
||||||
|
return hasSmsPermission() && hasTelephonyFeature()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasTelephonyFeature(): Boolean {
|
||||||
|
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||||
|
permissionRequester = requester
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an SMS message.
|
||||||
|
*
|
||||||
|
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||||
|
* @return SendResult indicating success or failure
|
||||||
|
*/
|
||||||
|
suspend fun send(paramsJson: String?): SendResult {
|
||||||
|
if (!hasTelephonyFeature()) {
|
||||||
|
return errorResult(
|
||||||
|
error = "SMS_UNAVAILABLE: telephony not available",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureSmsPermission()) {
|
||||||
|
return errorResult(
|
||||||
|
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parseResult = parseParams(paramsJson, json)
|
||||||
|
if (parseResult is ParseResult.Error) {
|
||||||
|
return errorResult(
|
||||||
|
error = parseResult.error,
|
||||||
|
to = parseResult.to,
|
||||||
|
message = parseResult.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val params = (parseResult as ParseResult.Ok).params
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val smsManager = context.getSystemService(AndroidSmsManager::class.java)
|
||||||
|
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
|
||||||
|
|
||||||
|
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
|
||||||
|
if (plan.useMultipart) {
|
||||||
|
smsManager.sendMultipartTextMessage(
|
||||||
|
params.to, // destination
|
||||||
|
null, // service center (null = default)
|
||||||
|
ArrayList(plan.parts), // message parts
|
||||||
|
null, // sent intents
|
||||||
|
null, // delivery intents
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
smsManager.sendTextMessage(
|
||||||
|
params.to, // destination
|
||||||
|
null, // service center (null = default)
|
||||||
|
params.message,// message
|
||||||
|
null, // sent intent
|
||||||
|
null, // delivery intent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
okResult(to = params.to, message = params.message)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
errorResult(
|
||||||
|
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
|
||||||
|
to = params.to,
|
||||||
|
message = params.message,
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
errorResult(
|
||||||
|
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
|
||||||
|
to = params.to,
|
||||||
|
message = params.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureSmsPermission(): Boolean {
|
||||||
|
if (hasSmsPermission()) return true
|
||||||
|
val requester = permissionRequester ?: return false
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS))
|
||||||
|
return results[Manifest.permission.SEND_SMS] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun okResult(to: String, message: String): SendResult {
|
||||||
|
return SendResult(
|
||||||
|
ok = true,
|
||||||
|
to = to,
|
||||||
|
message = message,
|
||||||
|
error = null,
|
||||||
|
payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun errorResult(error: String, to: String = "", message: String? = null): SendResult {
|
||||||
|
return SendResult(
|
||||||
|
ok = false,
|
||||||
|
to = to,
|
||||||
|
message = message,
|
||||||
|
error = error,
|
||||||
|
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ enum class ClawdisCapability(val rawValue: String) {
|
|||||||
Canvas("canvas"),
|
Canvas("canvas"),
|
||||||
Camera("camera"),
|
Camera("camera"),
|
||||||
Screen("screen"),
|
Screen("screen"),
|
||||||
|
Sms("sms"),
|
||||||
VoiceWake("voiceWake"),
|
VoiceWake("voiceWake"),
|
||||||
Location("location"),
|
Location("location"),
|
||||||
}
|
}
|
||||||
@ -51,6 +52,15 @@ enum class ClawdisScreenCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class ClawdisSmsCommand(val rawValue: String) {
|
||||||
|
Send("sms.send"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NamespacePrefix: String = "sms."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class ClawdisLocationCommand(val rawValue: String) {
|
enum class ClawdisLocationCommand(val rawValue: String) {
|
||||||
Get("location.get"),
|
Get("location.get"),
|
||||||
;
|
;
|
||||||
|
|||||||
@ -149,6 +149,23 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
// Status text is handled by NodeRuntime.
|
// Status text is handled by NodeRuntime.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val smsPermissionAvailable =
|
||||||
|
remember {
|
||||||
|
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||||
|
}
|
||||||
|
var smsPermissionGranted by
|
||||||
|
remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val smsPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
smsPermissionGranted = granted
|
||||||
|
viewModel.refreshBridgeHello()
|
||||||
|
}
|
||||||
|
|
||||||
fun setCameraEnabledChecked(checked: Boolean) {
|
fun setCameraEnabledChecked(checked: Boolean) {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
viewModel.setCameraEnabled(false)
|
viewModel.setCameraEnabled(false)
|
||||||
@ -233,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
// Order parity: Node → Bridge → Voice → Camera → Location → Screen.
|
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
|
||||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@ -507,6 +524,46 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
// Messaging
|
||||||
|
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
|
||||||
|
item {
|
||||||
|
val buttonLabel =
|
||||||
|
when {
|
||||||
|
!smsPermissionAvailable -> "Unavailable"
|
||||||
|
smsPermissionGranted -> "Manage"
|
||||||
|
else -> "Grant"
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("SMS Permission") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
if (smsPermissionAvailable) {
|
||||||
|
"Allow the bridge to send SMS from this device."
|
||||||
|
} else {
|
||||||
|
"SMS requires a device with telephony hardware."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (!smsPermissionAvailable) return@Button
|
||||||
|
if (smsPermissionGranted) {
|
||||||
|
openAppSettings(context)
|
||||||
|
} else {
|
||||||
|
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = smsPermissionAvailable,
|
||||||
|
) {
|
||||||
|
Text(buttonLabel)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
package com.clawdis.android.node
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SmsManagerTest {
|
||||||
|
private val json = SmsManager.JsonConfig
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseParamsRejectsEmptyPayload() {
|
||||||
|
val result = SmsManager.parseParams("", json)
|
||||||
|
assertTrue(result is SmsManager.ParseResult.Error)
|
||||||
|
val error = result as SmsManager.ParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: paramsJSON required", error.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseParamsRejectsInvalidJson() {
|
||||||
|
val result = SmsManager.parseParams("not-json", json)
|
||||||
|
assertTrue(result is SmsManager.ParseResult.Error)
|
||||||
|
val error = result as SmsManager.ParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseParamsRejectsNonObjectJson() {
|
||||||
|
val result = SmsManager.parseParams("[]", json)
|
||||||
|
assertTrue(result is SmsManager.ParseResult.Error)
|
||||||
|
val error = result as SmsManager.ParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseParamsRejectsMissingTo() {
|
||||||
|
val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json)
|
||||||
|
assertTrue(result is SmsManager.ParseResult.Error)
|
||||||
|
val error = result as SmsManager.ParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: 'to' phone number required", error.error)
|
||||||
|
assertEquals("Hi", error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseParamsRejectsMissingMessage() {
|
||||||
|
val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json)
|
||||||
|
assertTrue(result is SmsManager.ParseResult.Error)
|
||||||
|
val error = result as SmsManager.ParseResult.Error
|
||||||
|
assertEquals("INVALID_REQUEST: 'message' text required", error.error)
|
||||||
|
assertEquals("+1234", error.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseParamsTrimsToField() {
|
||||||
|
val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json)
|
||||||
|
assertTrue(result is SmsManager.ParseResult.Ok)
|
||||||
|
val ok = result as SmsManager.ParseResult.Ok
|
||||||
|
assertEquals("+1555", ok.params.to)
|
||||||
|
assertEquals("Hello", ok.params.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildPayloadJsonEscapesFields() {
|
||||||
|
val payload = SmsManager.buildPayloadJson(
|
||||||
|
json = json,
|
||||||
|
ok = false,
|
||||||
|
to = "+1\"23",
|
||||||
|
error = "SMS_SEND_FAILED: \"nope\"",
|
||||||
|
)
|
||||||
|
val parsed = json.parseToJsonElement(payload).jsonObject
|
||||||
|
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSendPlanUsesMultipartWhenMultipleParts() {
|
||||||
|
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
|
||||||
|
assertTrue(plan.useMultipart)
|
||||||
|
assertEquals(listOf("a", "b"), plan.parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() {
|
||||||
|
val plan = SmsManager.buildSendPlan("hello") { emptyList() }
|
||||||
|
assertFalse(plan.useMultipart)
|
||||||
|
assertEquals(listOf("hello"), plan.parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -110,6 +110,20 @@ Notes:
|
|||||||
- “Always” requires system permission; background fetch is best-effort.
|
- “Always” requires system permission; background fetch is best-effort.
|
||||||
- The response includes lat/lon, accuracy (meters), and timestamp.
|
- The response includes lat/lon, accuracy (meters), and timestamp.
|
||||||
|
|
||||||
|
## SMS (Android nodes)
|
||||||
|
|
||||||
|
Android nodes can expose `sms.send` when the user grants **SMS** permission and the device supports telephony.
|
||||||
|
|
||||||
|
Low-level invoke:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdis nodes invoke --node <idOrNameOrIp> --command sms.send --params '{"to":"+15555550123","message":"Hello from Clawdis"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
||||||
|
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
||||||
|
|
||||||
## System commands (mac node)
|
## System commands (mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run` and `system.notify`.
|
The macOS node exposes `system.run` and `system.notify`.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user