mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(android): remove self-update install flow
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
@@ -76,9 +75,5 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".InstallResultReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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>(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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Device("device"),
|
||||
Notifications("notifications"),
|
||||
System("system"),
|
||||
AppUpdate("appUpdate"),
|
||||
Photos("photos"),
|
||||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
<Note>
|
||||
Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only
|
||||
|
||||
@@ -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 <idOrNameOrIp> --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)
|
||||
|
||||
|
||||
@@ -166,4 +166,3 @@ Screen commands:
|
||||
- `contacts.search`, `contacts.add`
|
||||
- `calendar.events`, `calendar.add`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
- `app.update`
|
||||
|
||||
@@ -240,12 +240,6 @@ const COMMAND_PROFILES: Record<string, CommandProfile> = {
|
||||
expect(readString(obj.diagnostics)).not.toBeNull();
|
||||
},
|
||||
},
|
||||
"app.update": {
|
||||
buildParams: () => ({}),
|
||||
timeoutMs: 20_000,
|
||||
outcome: "error",
|
||||
allowedErrorCodes: ["INVALID_REQUEST"],
|
||||
},
|
||||
};
|
||||
|
||||
function resolveGatewayConnection() {
|
||||
|
||||
Reference in New Issue
Block a user