diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 436873c2bfa..f9bf03b1a3d 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -3,8 +3,6 @@
-
-
+ android:foregroundServiceType="dataSync" />
= runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText
val camera: CameraCaptureManager = runtime.camera
- val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val gateways: StateFlow> = runtime.gateways
@@ -38,7 +36,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val cameraHud: StateFlow = runtime.cameraHud
val cameraFlashToken: StateFlow = runtime.cameraFlashToken
- val screenRecordActive: StateFlow = runtime.screenRecordActive
val instanceId: StateFlow = runtime.instanceId
val displayName: StateFlow = runtime.displayName
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
index 684849b3e86..5761567ebcc 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
@@ -5,13 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.PendingIntent
-import android.Manifest
import android.content.Context
import android.content.Intent
-import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
-import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -23,14 +20,13 @@ import kotlinx.coroutines.launch
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
- private var lastRequiresMic = false
private var didStartForeground = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
- startForegroundWithTypes(notification = initial, requiresMic = false)
+ startForegroundWithTypes(notification = initial)
val runtime = (application as NodeApp).runtime
notificationJob =
@@ -53,11 +49,8 @@ class NodeForegroundService : Service() {
}
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
- val requiresMic =
- micEnabled && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
- requiresMic = requiresMic,
)
}
}
@@ -135,30 +128,15 @@ class NodeForegroundService : Service() {
mgr.notify(NOTIFICATION_ID, notification)
}
- private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
- if (didStartForeground && requiresMic == lastRequiresMic) {
+ private fun startForegroundWithTypes(notification: Notification) {
+ if (didStartForeground) {
updateNotification(notification)
return
}
-
- lastRequiresMic = requiresMic
- val types =
- if (requiresMic) {
- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
- } else {
- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
- }
- startForeground(NOTIFICATION_ID, notification, types)
+ startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
didStartForeground = true
}
- private fun hasRecordAudioPermission(): Boolean {
- return (
- ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
- PackageManager.PERMISSION_GRANTED
- )
- }
-
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
index bd9f21c8ea7..c4e5f6a5b1d 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt
@@ -50,7 +50,6 @@ class NodeRuntime(context: Context) {
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
- val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
@@ -113,12 +112,6 @@ class NodeRuntime(context: Context) {
appContext = appContext,
)
- private val screenHandler: ScreenHandler = ScreenHandler(
- screenRecorder = screenRecorder,
- setScreenRecordActive = { _screenRecordActive.value = it },
- invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
- )
-
private val smsHandlerImpl: SmsHandler = SmsHandler(
sms = sms,
)
@@ -153,7 +146,6 @@ class NodeRuntime(context: Context) {
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
- screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
@@ -199,9 +191,6 @@ class NodeRuntime(context: Context) {
private val _cameraFlashToken = MutableStateFlow(0L)
val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow()
- private val _screenRecordActive = MutableStateFlow(false)
- val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow()
-
private val _canvasA2uiHydrated = MutableStateFlow(false)
val canvasA2uiHydrated: StateFlow = _canvasA2uiHydrated.asStateFlow()
private val _canvasRehydratePending = MutableStateFlow(false)
@@ -616,6 +605,9 @@ class NodeRuntime(context: Context) {
fun setForeground(value: Boolean) {
_isForeground.value = value
+ if (!value) {
+ stopActiveVoiceSession()
+ }
}
fun setDisplayName(value: String) {
@@ -660,11 +652,7 @@ class NodeRuntime(context: Context) {
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
- // User left voice screen — stop mic and TTS
- talkMode.ttsOnAllResponses = false
- talkMode.stopTts()
- micCapture.setMicEnabled(false)
- prefs.setTalkEnabled(false)
+ stopActiveVoiceSession()
}
// Don't re-enable on active=true; mic toggle drives that
}
@@ -693,6 +681,14 @@ class NodeRuntime(context: Context) {
talkMode.setPlaybackEnabled(value)
}
+ private fun stopActiveVoiceSession() {
+ talkMode.ttsOnAllResponses = false
+ talkMode.stopTts()
+ micCapture.setMicEnabled(false)
+ prefs.setTalkEnabled(false)
+ externalAudioCaptureActive.value = false
+ }
+
fun refreshGatewayConnection() {
val endpoint =
connectedEndpoint ?: run {
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt
deleted file mode 100644
index 77711f27ca7..00000000000
--- a/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package ai.openclaw.app
-
-import android.app.Activity
-import android.content.Context
-import android.content.Intent
-import android.media.projection.MediaProjectionManager
-import androidx.activity.ComponentActivity
-import androidx.activity.result.ActivityResultLauncher
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AlertDialog
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeout
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlin.coroutines.resume
-
-class ScreenCaptureRequester(private val activity: ComponentActivity) {
- data class CaptureResult(val resultCode: Int, val data: Intent)
-
- private val mutex = Mutex()
- private var pending: CompletableDeferred? = null
-
- private val launcher: ActivityResultLauncher =
- activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- val p = pending
- pending = null
- val data = result.data
- if (result.resultCode == Activity.RESULT_OK && data != null) {
- p?.complete(CaptureResult(result.resultCode, data))
- } else {
- p?.complete(null)
- }
- }
-
- suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
- mutex.withLock {
- val proceed = showRationaleDialog()
- if (!proceed) return null
-
- val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
- val intent = mgr.createScreenCaptureIntent()
-
- val deferred = CompletableDeferred()
- pending = deferred
- withContext(Dispatchers.Main) { launcher.launch(intent) }
-
- withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
- }
-
- private suspend fun showRationaleDialog(): Boolean =
- withContext(Dispatchers.Main) {
- suspendCancellableCoroutine { cont ->
- AlertDialog.Builder(activity)
- .setTitle("Screen recording required")
- .setMessage("OpenClaw needs to record the screen for this command.")
- .setPositiveButton("Continue") { _, _ -> cont.resume(true) }
- .setNegativeButton("Not now") { _, _ -> cont.resume(false) }
- .setOnCancelListener { cont.resume(false) }
- .show()
- }
- }
-}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt
index 71c23102c40..de3b24df193 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt
@@ -219,14 +219,6 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
- // Screen capture on Android is interactive per-capture consent, not a sticky app permission.
- put(
- "screenCapture",
- permissionStateJson(
- granted = false,
- promptableWhenDenied = true,
- ),
- )
},
)
}.toString()
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
index adb9b6030bf..5ce86340965 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
@@ -11,7 +11,6 @@ import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
-import ai.openclaw.app.protocol.OpenClawScreenCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
@@ -59,7 +58,6 @@ object InvokeCommandRegistry {
val capabilityManifest: List =
listOf(
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
- NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
@@ -122,10 +120,6 @@ object InvokeCommandRegistry {
name = OpenClawCanvasA2UICommand.Reset.rawValue,
requiresForeground = true,
),
- InvokeCommandSpec(
- name = OpenClawScreenCommand.Record.rawValue,
- requiresForeground = true,
- ),
InvokeCommandSpec(
name = OpenClawSystemCommand.Notify.rawValue,
),
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
index 0e3fe458a7f..f2b79159009 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
@@ -10,7 +10,6 @@ import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawNotificationsCommand
-import ai.openclaw.app.protocol.OpenClawScreenCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
@@ -25,7 +24,6 @@ class InvokeDispatcher(
private val contactsHandler: ContactsHandler,
private val calendarHandler: CalendarHandler,
private val motionHandler: MotionHandler,
- private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
@@ -160,9 +158,6 @@ class InvokeDispatcher(
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
- // Screen command
- OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
-
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt
deleted file mode 100644
index ebbe6f415d6..00000000000
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package ai.openclaw.app.node
-
-import ai.openclaw.app.gateway.GatewaySession
-
-class ScreenHandler(
- private val screenRecorder: ScreenRecordManager,
- private val setScreenRecordActive: (Boolean) -> Unit,
- private val invokeErrorFromThrowable: (Throwable) -> Pair,
-) {
- suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult {
- setScreenRecordActive(true)
- try {
- val res =
- try {
- screenRecorder.record(paramsJson)
- } catch (err: Throwable) {
- val (code, message) = invokeErrorFromThrowable(err)
- return GatewaySession.InvokeResult.error(code = code, message = message)
- }
- return GatewaySession.InvokeResult.ok(res.payloadJson)
- } finally {
- setScreenRecordActive(false)
- }
- }
-}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt
deleted file mode 100644
index bae5587c4cc..00000000000
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt
+++ /dev/null
@@ -1,165 +0,0 @@
-package ai.openclaw.app.node
-
-import android.content.Context
-import android.hardware.display.DisplayManager
-import android.media.MediaRecorder
-import android.media.projection.MediaProjectionManager
-import android.os.Build
-import android.util.Base64
-import ai.openclaw.app.ScreenCaptureRequester
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.json.JsonObject
-import java.io.File
-import kotlin.math.roundToInt
-
-class ScreenRecordManager(private val context: Context) {
- data class Payload(val payloadJson: String)
-
- @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
- @Volatile private var permissionRequester: ai.openclaw.app.PermissionRequester? = null
-
- fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
- screenCaptureRequester = requester
- }
-
- fun attachPermissionRequester(requester: ai.openclaw.app.PermissionRequester) {
- permissionRequester = requester
- }
-
- suspend fun record(paramsJson: String?): Payload =
- withContext(Dispatchers.Default) {
- val requester =
- screenCaptureRequester
- ?: throw IllegalStateException(
- "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
- )
-
- val params = parseJsonParamsObject(paramsJson)
- val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
- val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
- val fpsInt = fps.roundToInt().coerceIn(1, 60)
- val screenIndex = parseScreenIndex(params)
- val includeAudio = parseIncludeAudio(params) ?: true
- val format = parseString(params, key = "format")
- if (format != null && format.lowercase() != "mp4") {
- throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
- }
- if (screenIndex != null && screenIndex != 0) {
- throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android")
- }
-
- val capture = requester.requestCapture()
- ?: throw IllegalStateException(
- "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
- )
-
- val mgr =
- context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
- val projection = mgr.getMediaProjection(capture.resultCode, capture.data)
- ?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable")
-
- val metrics = context.resources.displayMetrics
- val width = metrics.widthPixels
- val height = metrics.heightPixels
- val densityDpi = metrics.densityDpi
-
- val file = File.createTempFile("openclaw-screen-", ".mp4")
- if (includeAudio) ensureMicPermission()
-
- val recorder = createMediaRecorder()
- var virtualDisplay: android.hardware.display.VirtualDisplay? = null
- try {
- if (includeAudio) {
- recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
- }
- recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
- recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
- recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
- if (includeAudio) {
- recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
- recorder.setAudioChannels(1)
- recorder.setAudioSamplingRate(44_100)
- recorder.setAudioEncodingBitRate(96_000)
- }
- recorder.setVideoSize(width, height)
- recorder.setVideoFrameRate(fpsInt)
- recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt))
- recorder.setOutputFile(file.absolutePath)
- recorder.prepare()
-
- val surface = recorder.surface
- virtualDisplay =
- projection.createVirtualDisplay(
- "openclaw-screen",
- width,
- height,
- densityDpi,
- DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
- surface,
- null,
- null,
- )
-
- recorder.start()
- delay(durationMs.toLong())
- } finally {
- try {
- recorder.stop()
- } catch (_: Throwable) {
- // ignore
- }
- recorder.reset()
- recorder.release()
- virtualDisplay?.release()
- projection.stop()
- }
-
- val bytes = withContext(Dispatchers.IO) { file.readBytes() }
- file.delete()
- val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
- Payload(
- """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""",
- )
- }
-
- private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
-
- private suspend fun ensureMicPermission() {
- val granted =
- androidx.core.content.ContextCompat.checkSelfPermission(
- context,
- android.Manifest.permission.RECORD_AUDIO,
- ) == android.content.pm.PackageManager.PERMISSION_GRANTED
- if (granted) return
-
- val requester =
- permissionRequester
- ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
- val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO))
- if (results[android.Manifest.permission.RECORD_AUDIO] != true) {
- throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
- }
- }
-
- private fun parseDurationMs(params: JsonObject?): Int? =
- parseJsonInt(params, "durationMs")
-
- private fun parseFps(params: JsonObject?): Double? =
- parseJsonDouble(params, "fps")
-
- private fun parseScreenIndex(params: JsonObject?): Int? =
- parseJsonInt(params, "screenIndex")
-
- private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
-
- private fun parseString(params: JsonObject?, key: String): String? =
- parseJsonString(params, key)
-
- private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
- val pixels = width.toLong() * height.toLong()
- val raw = (pixels * fps.toLong() * 2L).toInt()
- return raw.coerceIn(1_000_000, 12_000_000)
- }
-}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
index 8c9a1f4c220..95ba2912b09 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
@@ -3,7 +3,6 @@ package ai.openclaw.app.protocol
enum class OpenClawCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
- Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
@@ -51,15 +50,6 @@ enum class OpenClawCameraCommand(val rawValue: String) {
}
}
-enum class OpenClawScreenCommand(val rawValue: String) {
- Record("screen.record"),
- ;
-
- companion object {
- const val NamespacePrefix: String = "screen."
- }
-}
-
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
;
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
index 738ab4a4919..8810ea93fcb 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
@@ -1402,7 +1402,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Microphone",
- subtitle = "Voice tab transcription",
+ subtitle = "Foreground Voice tab transcription",
checked = enableMicrophone,
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
onCheckedChange = onMicrophoneChange,
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
index 8b50f210103..a3f7868fa90 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
@@ -402,9 +402,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
supportingContent = {
Text(
if (micPermissionGranted) {
- "Granted. Use the Voice tab mic button to capture transcript."
+ "Granted. Use the Voice tab mic button to capture transcript while the app is open."
} else {
- "Required for Voice tab transcription."
+ "Required for foreground Voice tab transcription."
},
style = mobileCallout,
)
@@ -431,7 +431,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
Text(
- "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.",
+ "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.",
style = mobileCallout,
color = mobileTextSecondary,
)
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt
index ab92fb5f300..e40e2b164ae 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt
@@ -94,7 +94,6 @@ class DeviceHandlerTest {
"contacts",
"calendar",
"motion",
- "screenCapture",
)
for (key in expected) {
val state = permissions.getValue(key).jsonObject
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
index 374fe391420..d3825a5720e 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
@@ -19,7 +19,6 @@ class InvokeCommandRegistryTest {
private val coreCapabilities =
setOf(
OpenClawCapability.Canvas.rawValue,
- OpenClawCapability.Screen.rawValue,
OpenClawCapability.Device.rawValue,
OpenClawCapability.Notifications.rawValue,
OpenClawCapability.System.rawValue,
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
index 103c1334163..8dd844dee83 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
@@ -24,7 +24,6 @@ class OpenClawProtocolConstantsTest {
fun capabilitiesUseStableStrings() {
assertEquals("canvas", OpenClawCapability.Canvas.rawValue)
assertEquals("camera", OpenClawCapability.Camera.rawValue)
- assertEquals("screen", OpenClawCapability.Screen.rawValue)
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
@@ -44,11 +43,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
}
- @Test
- fun screenCommandsUseStableStrings() {
- assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
- }
-
@Test
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
diff --git a/docs/concepts/features.md b/docs/concepts/features.md
index 1af14647707..1d04af9187d 100644
--- a/docs/concepts/features.md
+++ b/docs/concepts/features.md
@@ -45,7 +45,7 @@ title: "Features"
- Optional voice note transcription hook
- WebChat and macOS menu bar app
- iOS node with pairing, Canvas, camera, screen recording, location, and voice features
-- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera/screen, plus device, notifications, contacts/calendar, motion, photos, and SMS commands
+- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera, plus device, notifications, contacts/calendar, motion, photos, and SMS commands
Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only
diff --git a/docs/index.md b/docs/index.md
index 2821cb1c84f..f838ebf4cab 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -89,7 +89,7 @@ The Gateway is the single source of truth for sessions, routing, and channel con
Browser dashboard for chat, config, sessions, and nodes.
- Pair iOS and Android nodes for Canvas, camera/screen, and voice-enabled workflows.
+ Pair iOS and Android nodes for Canvas, camera, and voice-enabled workflows.
@@ -164,7 +164,7 @@ Example:
Channel-specific setup for WhatsApp, Telegram, Discord, and more.
- iOS and Android nodes with pairing, Canvas, camera/screen, and device actions.
+ iOS and Android nodes with pairing, Canvas, camera, and device actions.
Common fixes and troubleshooting entry point.
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index d1a59d771d1..1b9b2bfaea2 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -216,7 +216,7 @@ Notes:
## Screen recordings (nodes)
-Nodes expose `screen.record` (mp4). Example:
+Supported nodes expose `screen.record` (mp4). Example:
```bash
openclaw nodes screen record --node --duration 10s --fps 10
@@ -225,10 +225,9 @@ openclaw nodes screen record --node --duration 10s --fps 10 --no-
Notes:
-- `screen.record` requires the node app to be foregrounded.
-- Android will show the system screen-capture prompt before recording.
+- `screen.record` availability depends on node platform.
- Screen recordings are clamped to `<= 60s`.
-- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio).
+- `--no-audio` disables microphone capture on supported platforms.
- Use `--screen ` to select a display when multiple screens are available.
## Location (nodes)
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index 8619fccdd49..4df71b83e73 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -118,7 +118,7 @@ The Android Chat tab supports session selection (default `main`, plus other exis
- Send: `chat.send`
- Push updates (best-effort): `chat.subscribe` → `event:"chat"`
-### 7) Canvas + screen + camera
+### 7) Canvas + camera
#### Gateway Canvas Host (recommended for web content)
@@ -151,13 +151,9 @@ Camera commands (foreground only; permission-gated):
See [Camera node](/nodes/camera) for parameters and CLI helpers.
-Screen commands:
-
-- `screen.record` (mp4; foreground only)
-
### 8) Voice + expanded Android command surface
-- Voice: Android uses a single mic on/off flow in the Voice tab with transcript capture and TTS playback (ElevenLabs when configured, system TTS fallback).
+- Voice: Android uses a single mic on/off flow in the Voice tab with transcript capture and TTS playback (ElevenLabs when configured, system TTS fallback). Voice stops when the app leaves the foreground.
- Voice wake/talk-mode toggles are currently removed from Android UX/runtime.
- Additional Android command families (availability depends on device + permissions):
- `device.status`, `device.info`, `device.permissions`, `device.health`
diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts
index d0ca9f07299..80b4c8ae687 100644
--- a/src/gateway/android-node.capabilities.live.test.ts
+++ b/src/gateway/android-node.capabilities.live.test.ts
@@ -12,7 +12,7 @@ import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
const LIVE_ANDROID_NODE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ANDROID_NODE);
const describeLive = LIVE && LIVE_ANDROID_NODE ? describe : describe.skip;
-const SKIPPED_INTERACTIVE_COMMANDS = new Set(["screen.record"]);
+const SKIPPED_INTERACTIVE_COMMANDS = new Set();
type CommandOutcome = "success" | "error";
@@ -120,15 +120,6 @@ const COMMAND_PROFILES: Record = {
timeoutMs: 30_000,
outcome: "success",
},
- "screen.record": {
- buildParams: () => ({ durationMs: 1500, fps: 8, includeAudio: false }),
- timeoutMs: 60_000,
- outcome: "success",
- onSuccess: (payload) => {
- const obj = assertObjectPayload("screen.record", payload);
- expect(readString(obj.base64)).not.toBeNull();
- },
- },
"camera.list": {
buildParams: () => ({}),
timeoutMs: 20_000,