diff --git a/apps/android/README.md b/apps/android/README.md
index 50704e63d0b..0a92e4c8ec5 100644
--- a/apps/android/README.md
+++ b/apps/android/README.md
@@ -211,7 +211,7 @@ What it does:
- Reads `node.describe` command list from the selected Android node.
- Invokes advertised non-interactive commands.
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
-- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
+- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`).
Common failure quick-fixes:
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 0507bdf8aa1..591627eca3f 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -25,7 +25,6 @@
-
@@ -76,9 +75,5 @@
-
-
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt b/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt
deleted file mode 100644
index 745ea11f96e..00000000000
--- a/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package ai.openclaw.app
-
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageInstaller
-import android.util.Log
-
-class InstallResultReceiver : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
- val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
-
- when (status) {
- PackageInstaller.STATUS_PENDING_USER_ACTION -> {
- // System needs user confirmation — launch the confirmation activity
- @Suppress("DEPRECATION")
- val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT)
- if (confirmIntent != null) {
- confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(confirmIntent)
- Log.w("openclaw", "app.update: user confirmation requested, launching install dialog")
- }
- }
- PackageInstaller.STATUS_SUCCESS -> {
- Log.w("openclaw", "app.update: install SUCCESS")
- }
- else -> {
- Log.e("openclaw", "app.update: install FAILED status=$status message=$message")
- }
- }
- }
-}
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 263a80fc076..fc821f9fa2e 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
@@ -77,11 +77,6 @@ class NodeRuntime(context: Context) {
identityStore = identityStore,
)
- private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler(
- appContext = appContext,
- connectedEndpoint = { connectedEndpoint },
- )
-
private val locationHandler: LocationHandler = LocationHandler(
appContext = appContext,
location = location,
@@ -163,7 +158,6 @@ class NodeRuntime(context: Context) {
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
- appUpdateHandler = appUpdateHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt
deleted file mode 100644
index f314d3330dc..00000000000
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt
+++ /dev/null
@@ -1,295 +0,0 @@
-package ai.openclaw.app.node
-
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import ai.openclaw.app.InstallResultReceiver
-import ai.openclaw.app.MainActivity
-import ai.openclaw.app.gateway.GatewayEndpoint
-import ai.openclaw.app.gateway.GatewaySession
-import java.io.File
-import java.net.URI
-import java.security.MessageDigest
-import java.util.Locale
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.buildJsonObject
-import kotlinx.serialization.json.jsonObject
-import kotlinx.serialization.json.jsonPrimitive
-import kotlinx.serialization.json.put
-
-private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$")
-
-internal data class AppUpdateRequest(
- val url: String,
- val expectedSha256: String,
-)
-
-internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest {
- val params =
- try {
- paramsJson?.let { Json.parseToJsonElement(it).jsonObject }
- } catch (_: Throwable) {
- throw IllegalArgumentException("params must be valid JSON")
- } ?: throw IllegalArgumentException("missing 'url' parameter")
-
- val urlRaw =
- params["url"]?.jsonPrimitive?.content?.trim().orEmpty()
- .ifEmpty { throw IllegalArgumentException("missing 'url' parameter") }
- val sha256Raw =
- params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty()
- .ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") }
- if (!SHA256_HEX.matches(sha256Raw)) {
- throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)")
- }
-
- val uri =
- try {
- URI(urlRaw)
- } catch (_: Throwable) {
- throw IllegalArgumentException("invalid 'url' parameter")
- }
- val scheme = uri.scheme?.lowercase(Locale.US).orEmpty()
- if (scheme != "https") {
- throw IllegalArgumentException("url must use https")
- }
- if (!uri.userInfo.isNullOrBlank()) {
- throw IllegalArgumentException("url must not include credentials")
- }
- val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required")
- val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty()
- if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) {
- throw IllegalArgumentException("url host must match connected gateway host")
- }
-
- return AppUpdateRequest(
- url = uri.toASCIIString(),
- expectedSha256 = sha256Raw.lowercase(Locale.US),
- )
-}
-
-internal fun sha256Hex(file: File): String {
- val digest = MessageDigest.getInstance("SHA-256")
- file.inputStream().use { input ->
- val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
- while (true) {
- val read = input.read(buffer)
- if (read < 0) break
- if (read == 0) continue
- digest.update(buffer, 0, read)
- }
- }
- val out = StringBuilder(64)
- for (byte in digest.digest()) {
- out.append(String.format(Locale.US, "%02x", byte))
- }
- return out.toString()
-}
-
-class AppUpdateHandler(
- private val appContext: Context,
- private val connectedEndpoint: () -> GatewayEndpoint?,
-) {
-
- fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult {
- try {
- val updateRequest =
- try {
- parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host)
- } catch (err: IllegalArgumentException) {
- return GatewaySession.InvokeResult.error(
- code = "INVALID_REQUEST",
- message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}",
- )
- }
- val url = updateRequest.url
- val expectedSha256 = updateRequest.expectedSha256
-
- android.util.Log.w("openclaw", "app.update: downloading from $url")
-
- val notifId = 9001
- val channelId = "app_update"
- val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
-
- // Create notification channel (required for Android 8+)
- val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW)
- notifManager.createNotificationChannel(channel)
-
- // PendingIntent to open the app when notification is tapped
- val launchIntent = Intent(appContext, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- }
- val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
-
- // Launch download async so the invoke returns immediately
- CoroutineScope(Dispatchers.IO).launch {
- try {
- val cacheDir = java.io.File(appContext.cacheDir, "updates")
- cacheDir.mkdirs()
- val file = java.io.File(cacheDir, "update.apk")
- if (file.exists()) file.delete()
-
- // Show initial progress notification
- fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification {
- return android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_sys_download)
- .setContentTitle("OpenClaw Update")
- .setContentText(text)
- .setProgress(max, progress, max == 0)
-
- .setContentIntent(launchPi)
- .setOngoing(true)
- .build()
- }
- notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting..."))
-
- val client = okhttp3.OkHttpClient.Builder()
- .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
- .readTimeout(300, java.util.concurrent.TimeUnit.SECONDS)
- .build()
- val request = okhttp3.Request.Builder().url(url).build()
- val response = client.newCall(request).execute()
- if (!response.isSuccessful) {
- notifManager.cancel(notifId)
- notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_notify_error)
- .setContentTitle("Update Failed")
-
- .setContentIntent(launchPi)
- .setContentText("HTTP ${response.code}")
- .build())
- return@launch
- }
-
- val contentLength = response.body?.contentLength() ?: -1L
- val body = response.body ?: run {
- notifManager.cancel(notifId)
- return@launch
- }
-
- // Download with progress tracking
- var totalBytes = 0L
- var lastNotifUpdate = 0L
- body.byteStream().use { input ->
- file.outputStream().use { output ->
- val buffer = ByteArray(8192)
- while (true) {
- val bytesRead = input.read(buffer)
- if (bytesRead == -1) break
- output.write(buffer, 0, bytesRead)
- totalBytes += bytesRead
-
- // Update notification at most every 500ms
- val now = System.currentTimeMillis()
- if (now - lastNotifUpdate > 500) {
- lastNotifUpdate = now
- if (contentLength > 0) {
- val pct = ((totalBytes * 100) / contentLength).toInt()
- val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
- val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0)
- notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
- } else {
- val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
- notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
- }
- }
- }
- }
- }
-
- android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes")
- val actualSha256 = sha256Hex(file)
- if (actualSha256 != expectedSha256) {
- android.util.Log.e(
- "openclaw",
- "app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256",
- )
- file.delete()
- notifManager.cancel(notifId)
- notifManager.notify(
- notifId,
- android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_notify_error)
- .setContentTitle("Update Failed")
- .setContentIntent(launchPi)
- .setContentText("SHA-256 mismatch")
- .build(),
- )
- return@launch
- }
-
- // Verify file is a valid APK (basic check: ZIP magic bytes)
- val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() }
- if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) {
- android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})")
- file.delete()
- notifManager.cancel(notifId)
- notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_notify_error)
- .setContentTitle("Update Failed")
-
- .setContentIntent(launchPi)
- .setContentText("Downloaded file is not a valid APK")
- .build())
- return@launch
- }
-
- // Use PackageInstaller session API — works from background on API 34+
- // The system handles showing the install confirmation dialog
- notifManager.cancel(notifId)
- notifManager.notify(
- notifId,
- android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_sys_download_done)
- .setContentTitle("Installing Update...")
- .setContentIntent(launchPi)
- .setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded")
- .build(),
- )
-
- val installer = appContext.packageManager.packageInstaller
- val params = android.content.pm.PackageInstaller.SessionParams(
- android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
- )
- params.setSize(file.length())
- val sessionId = installer.createSession(params)
- val session = installer.openSession(sessionId)
- session.openWrite("openclaw-update.apk", 0, file.length()).use { out ->
- file.inputStream().use { inp -> inp.copyTo(out) }
- session.fsync(out)
- }
- // Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status
- val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java)
- val pi = android.app.PendingIntent.getBroadcast(
- appContext, sessionId, callbackIntent,
- android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
- )
- session.commit(pi.intentSender)
- android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation")
- } catch (err: Throwable) {
- android.util.Log.e("openclaw", "app.update: async error", err)
- notifManager.cancel(notifId)
- notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
- .setSmallIcon(android.R.drawable.stat_notify_error)
- .setContentTitle("Update Failed")
-
- .setContentIntent(launchPi)
- .setContentText(err.message ?: "Unknown error")
- .build())
- }
- }
-
- // Return immediately — download happens in background
- return GatewaySession.InvokeResult.ok(buildJsonObject {
- put("status", "downloading")
- put("url", url)
- put("sha256", expectedSha256)
- }.toString())
- } catch (err: Throwable) {
- android.util.Log.e("openclaw", "app.update: error", err)
- return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed")
- }
- }
-}
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 9f7ee1a890a..adb9b6030bf 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
@@ -63,7 +63,6 @@ object InvokeCommandRegistry {
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
- NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.Camera.rawValue,
availability = NodeCapabilityAvailability.CameraEnabled,
@@ -202,7 +201,6 @@ object InvokeCommandRegistry {
name = "debug.ed25519",
availability = InvokeCommandAvailability.DebugBuild,
),
- InvokeCommandSpec(name = "app.update"),
)
private val byNameInternal: Map = all.associateBy { it.name }
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 dc6eed7438d..0e3fe458a7f 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
@@ -29,7 +29,6 @@ class InvokeDispatcher(
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
- private val appUpdateHandler: AppUpdateHandler,
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
@@ -170,10 +169,6 @@ class InvokeDispatcher(
// Debug commands
"debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs()
-
- // App update
- "app.update" -> appUpdateHandler.handleUpdate(paramsJson)
-
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
}
}
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 ef4c2d95c96..8c9a1f4c220 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
@@ -10,7 +10,6 @@ enum class OpenClawCapability(val rawValue: String) {
Device("device"),
Notifications("notifications"),
System("system"),
- AppUpdate("appUpdate"),
Photos("photos"),
Contacts("contacts"),
Calendar("calendar"),
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 417abd34e52..5db2a5e6d78 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
@@ -80,7 +80,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
-import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -118,7 +117,6 @@ private enum class PermissionToggle {
private enum class SpecialAccessToggle {
NotificationListener,
- AppUpdates,
}
private val onboardingBackgroundGradient =
@@ -274,10 +272,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable {
mutableStateOf(isNotificationListenerEnabled(context))
}
- var enableAppUpdates by
- rememberSaveable {
- mutableStateOf(canInstallUnknownApps(context))
- }
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
var enableCamera by rememberSaveable { mutableStateOf(false) }
var enablePhotos by rememberSaveable { mutableStateOf(false) }
@@ -342,7 +336,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
when (toggle) {
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
- SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled
}
}
@@ -352,7 +345,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableLocation,
enableNotifications,
enableNotificationListener,
- enableAppUpdates,
enableMicrophone,
enableCamera,
enablePhotos,
@@ -368,7 +360,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableLocation) enabled += "Location"
if (enableNotifications) enabled += "Notifications"
if (enableNotificationListener) enabled += "Notification listener"
- if (enableAppUpdates) enabled += "App updates"
if (enableMicrophone) enabled += "Microphone"
if (enableCamera) enabled += "Camera"
if (enablePhotos) enabled += "Photos"
@@ -385,10 +376,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
openNotificationListenerSettings(context)
openedSpecialSetup = true
}
- if (enableAppUpdates && !canInstallUnknownApps(context)) {
- openUnknownAppSourcesSettings(context)
- openedSpecialSetup = true
- }
if (openedSpecialSetup) {
return@proceed
}
@@ -431,7 +418,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val grantedNow =
when (toggle) {
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
- SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context)
}
if (grantedNow) {
setSpecialAccessToggleEnabled(toggle, true)
@@ -441,7 +427,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
pendingSpecialAccessToggle = toggle
when (toggle) {
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
- SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context)
}
}
@@ -459,13 +444,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
pendingSpecialAccessToggle = null
}
- SpecialAccessToggle.AppUpdates -> {
- setSpecialAccessToggleEnabled(
- SpecialAccessToggle.AppUpdates,
- canInstallUnknownApps(context),
- )
- pendingSpecialAccessToggle = null
- }
null -> Unit
}
}
@@ -606,7 +584,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableLocation = enableLocation,
enableNotifications = enableNotifications,
enableNotificationListener = enableNotificationListener,
- enableAppUpdates = enableAppUpdates,
enableMicrophone = enableMicrophone,
enableCamera = enableCamera,
enablePhotos = enablePhotos,
@@ -649,9 +626,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onNotificationListenerChange = { checked ->
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
},
- onAppUpdatesChange = { checked ->
- requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked)
- },
onMicrophoneChange = { checked ->
requestPermissionToggle(
PermissionToggle.Microphone,
@@ -1337,7 +1311,6 @@ private fun PermissionsStep(
enableLocation: Boolean,
enableNotifications: Boolean,
enableNotificationListener: Boolean,
- enableAppUpdates: Boolean,
enableMicrophone: Boolean,
enableCamera: Boolean,
enablePhotos: Boolean,
@@ -1353,7 +1326,6 @@ private fun PermissionsStep(
onLocationChange: (Boolean) -> Unit,
onNotificationsChange: (Boolean) -> Unit,
onNotificationListenerChange: (Boolean) -> Unit,
- onAppUpdatesChange: (Boolean) -> Unit,
onMicrophoneChange: (Boolean) -> Unit,
onCameraChange: (Boolean) -> Unit,
onPhotosChange: (Boolean) -> Unit,
@@ -1387,7 +1359,6 @@ private fun PermissionsStep(
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
}
val notificationListenerGranted = isNotificationListenerEnabled(context)
- val appUpdatesGranted = canInstallUnknownApps(context)
StepShell(title = "Permissions") {
Text(
@@ -1429,14 +1400,6 @@ private fun PermissionsStep(
onCheckedChange = onNotificationListenerChange,
)
InlineDivider()
- PermissionToggleRow(
- title = "App updates",
- subtitle = "app.update install confirmation (opens Android Settings)",
- checked = enableAppUpdates,
- granted = appUpdatesGranted,
- onCheckedChange = onAppUpdatesChange,
- )
- InlineDivider()
PermissionToggleRow(
title = "Microphone",
subtitle = "Voice tab transcription",
@@ -1635,10 +1598,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
return DeviceNotificationListenerService.isAccessEnabled(context)
}
-private fun canInstallUnknownApps(context: Context): Boolean {
- return context.packageManager.canRequestPackageInstalls()
-}
-
private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching {
@@ -1648,19 +1607,6 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
-private fun openUnknownAppSourcesSettings(context: Context) {
- val intent =
- Intent(
- Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
- "package:${context.packageName}".toUri(),
- ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- runCatching {
- context.startActivity(intent)
- }.getOrElse {
- openAppSettings(context)
- }
-}
-
private fun openAppSettings(context: Context) {
val intent =
Intent(
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 1be0e23b63f..a58d66f8531 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
@@ -62,7 +62,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
-import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -246,11 +245,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
motionPermissionGranted = granted
}
- var appUpdateInstallEnabled by
- remember {
- mutableStateOf(canInstallUnknownApps(context))
- }
-
var smsPermissionGranted by
remember {
mutableStateOf(
@@ -290,7 +284,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
!motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
PackageManager.PERMISSION_GRANTED
- appUpdateInstallEnabled = canInstallUnknownApps(context)
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
@@ -759,41 +752,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item { HorizontalDivider(color = mobileBorder) }
- // System
- item {
- Text(
- "SYSTEM",
- style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
- color = mobileAccent,
- )
- }
- item {
- ListItem(
- modifier = Modifier.settingsRowModifier(),
- colors = listItemColors,
- headlineContent = { Text("Install App Updates", style = mobileHeadline) },
- supportingContent = {
- Text(
- "Enable install access for `app.update` package installs.",
- style = mobileCallout,
- )
- },
- trailingContent = {
- Button(
- onClick = { openUnknownAppSourcesSettings(context) },
- colors = settingsPrimaryButtonColors(),
- shape = RoundedCornerShape(14.dp),
- ) {
- Text(
- if (appUpdateInstallEnabled) "Manage" else "Enable",
- style = mobileCallout.copy(fontWeight = FontWeight.Bold),
- )
- }
- },
- )
- }
- item { HorizontalDivider(color = mobileBorder) }
-
// Location
item {
Text(
@@ -865,7 +823,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
color = mobileTextSecondary,
)
}
-
item { HorizontalDivider(color = mobileBorder) }
// Screen
@@ -970,19 +927,6 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
-private fun openUnknownAppSourcesSettings(context: Context) {
- val intent =
- Intent(
- Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
- "package:${context.packageName}".toUri(),
- )
- runCatching {
- context.startActivity(intent)
- }.getOrElse {
- openAppSettings(context)
- }
-}
-
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -993,10 +937,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
return DeviceNotificationListenerService.isAccessEnabled(context)
}
-private fun canInstallUnknownApps(context: Context): Boolean {
- return context.packageManager.canRequestPackageInstalls()
-}
-
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt
deleted file mode 100644
index e0bad8e1fd1..00000000000
--- a/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package ai.openclaw.app.node
-
-import java.io.File
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertThrows
-import org.junit.Test
-
-class AppUpdateHandlerTest {
- @Test
- fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() {
- val req =
- parseAppUpdateRequest(
- paramsJson =
- """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
- connectedHost = "gw.example.com",
- )
-
- assertEquals("https://gw.example.com/releases/openclaw.apk", req.url)
- assertEquals("a".repeat(64), req.expectedSha256)
- }
-
- @Test
- fun parseAppUpdateRequest_rejectsNonHttps() {
- assertThrows(IllegalArgumentException::class.java) {
- parseAppUpdateRequest(
- paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
- connectedHost = "gw.example.com",
- )
- }
- }
-
- @Test
- fun parseAppUpdateRequest_rejectsHostMismatch() {
- assertThrows(IllegalArgumentException::class.java) {
- parseAppUpdateRequest(
- paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
- connectedHost = "gw.example.com",
- )
- }
- }
-
- @Test
- fun parseAppUpdateRequest_rejectsInvalidSha256() {
- assertThrows(IllegalArgumentException::class.java) {
- parseAppUpdateRequest(
- paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""",
- connectedHost = "gw.example.com",
- )
- }
- }
-
- @Test
- fun sha256Hex_computesExpectedDigest() {
- val tmp = File.createTempFile("openclaw-update-hash", ".bin")
- try {
- tmp.writeText("hello", Charsets.UTF_8)
- assertEquals(
- "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret
- sha256Hex(tmp),
- )
- } finally {
- tmp.delete()
- }
- }
-}
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 58c89f1cd52..374fe391420 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
@@ -23,7 +23,6 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Device.rawValue,
OpenClawCapability.Notifications.rawValue,
OpenClawCapability.System.rawValue,
- OpenClawCapability.AppUpdate.rawValue,
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
@@ -52,7 +51,6 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
- "app.update",
)
private val optionalCommands =
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 25eda3872e3..103c1334163 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
@@ -31,7 +31,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("device", OpenClawCapability.Device.rawValue)
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
assertEquals("system", OpenClawCapability.System.rawValue)
- assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
assertEquals("photos", OpenClawCapability.Photos.rawValue)
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
diff --git a/docs/concepts/features.md b/docs/concepts/features.md
index 55f0b2bcd12..1af14647707 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, SMS, and app update commands
+- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera/screen, 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/nodes/index.md b/docs/nodes/index.md
index 37bba45953d..d1a59d771d1 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -275,7 +275,6 @@ Available families:
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `motion.activity`, `motion.pedometer`
-- `app.update`
Example invokes:
@@ -288,7 +287,6 @@ openclaw nodes invoke --node --command photos.latest --params '{"
Notes:
- Motion commands are capability-gated by available sensors.
-- `app.update` is permission + policy gated by the node runtime.
## System commands (node host / mac node)
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index fe1683abdbf..8619fccdd49 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -166,4 +166,3 @@ Screen commands:
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `motion.activity`, `motion.pedometer`
- - `app.update`
diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts
index 6094f255748..d0ca9f07299 100644
--- a/src/gateway/android-node.capabilities.live.test.ts
+++ b/src/gateway/android-node.capabilities.live.test.ts
@@ -240,12 +240,6 @@ const COMMAND_PROFILES: Record = {
expect(readString(obj.diagnostics)).not.toBeNull();
},
},
- "app.update": {
- buildParams: () => ({}),
- timeoutMs: 20_000,
- outcome: "error",
- allowedErrorCodes: ["INVALID_REQUEST"],
- },
};
function resolveGatewayConnection() {