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.
|
- Reads `node.describe` command list from the selected Android node.
|
||||||
- Invokes advertised non-interactive commands.
|
- Invokes advertised non-interactive commands.
|
||||||
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
|
- 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:
|
Common failure quick-fixes:
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
@@ -76,9 +75,5 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".InstallResultReceiver"
|
|
||||||
android:exported="false" />
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</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,
|
identityStore = identityStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler(
|
|
||||||
appContext = appContext,
|
|
||||||
connectedEndpoint = { connectedEndpoint },
|
|
||||||
)
|
|
||||||
|
|
||||||
private val locationHandler: LocationHandler = LocationHandler(
|
private val locationHandler: LocationHandler = LocationHandler(
|
||||||
appContext = appContext,
|
appContext = appContext,
|
||||||
location = location,
|
location = location,
|
||||||
@@ -163,7 +158,6 @@ class NodeRuntime(context: Context) {
|
|||||||
smsHandler = smsHandlerImpl,
|
smsHandler = smsHandlerImpl,
|
||||||
a2uiHandler = a2uiHandler,
|
a2uiHandler = a2uiHandler,
|
||||||
debugHandler = debugHandler,
|
debugHandler = debugHandler,
|
||||||
appUpdateHandler = appUpdateHandler,
|
|
||||||
isForeground = { _isForeground.value },
|
isForeground = { _isForeground.value },
|
||||||
cameraEnabled = { cameraEnabled.value },
|
cameraEnabled = { cameraEnabled.value },
|
||||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
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.Device.rawValue),
|
||||||
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
|
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
|
||||||
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
|
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
|
||||||
NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
|
|
||||||
NodeCapabilitySpec(
|
NodeCapabilitySpec(
|
||||||
name = OpenClawCapability.Camera.rawValue,
|
name = OpenClawCapability.Camera.rawValue,
|
||||||
availability = NodeCapabilityAvailability.CameraEnabled,
|
availability = NodeCapabilityAvailability.CameraEnabled,
|
||||||
@@ -202,7 +201,6 @@ object InvokeCommandRegistry {
|
|||||||
name = "debug.ed25519",
|
name = "debug.ed25519",
|
||||||
availability = InvokeCommandAvailability.DebugBuild,
|
availability = InvokeCommandAvailability.DebugBuild,
|
||||||
),
|
),
|
||||||
InvokeCommandSpec(name = "app.update"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class InvokeDispatcher(
|
|||||||
private val smsHandler: SmsHandler,
|
private val smsHandler: SmsHandler,
|
||||||
private val a2uiHandler: A2UIHandler,
|
private val a2uiHandler: A2UIHandler,
|
||||||
private val debugHandler: DebugHandler,
|
private val debugHandler: DebugHandler,
|
||||||
private val appUpdateHandler: AppUpdateHandler,
|
|
||||||
private val isForeground: () -> Boolean,
|
private val isForeground: () -> Boolean,
|
||||||
private val cameraEnabled: () -> Boolean,
|
private val cameraEnabled: () -> Boolean,
|
||||||
private val locationEnabled: () -> Boolean,
|
private val locationEnabled: () -> Boolean,
|
||||||
@@ -170,10 +169,6 @@ class InvokeDispatcher(
|
|||||||
// Debug commands
|
// Debug commands
|
||||||
"debug.ed25519" -> debugHandler.handleEd25519()
|
"debug.ed25519" -> debugHandler.handleEd25519()
|
||||||
"debug.logs" -> debugHandler.handleLogs()
|
"debug.logs" -> debugHandler.handleLogs()
|
||||||
|
|
||||||
// App update
|
|
||||||
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
|
|
||||||
|
|
||||||
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
|
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ enum class OpenClawCapability(val rawValue: String) {
|
|||||||
Device("device"),
|
Device("device"),
|
||||||
Notifications("notifications"),
|
Notifications("notifications"),
|
||||||
System("system"),
|
System("system"),
|
||||||
AppUpdate("appUpdate"),
|
|
||||||
Photos("photos"),
|
Photos("photos"),
|
||||||
Contacts("contacts"),
|
Contacts("contacts"),
|
||||||
Calendar("calendar"),
|
Calendar("calendar"),
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -118,7 +117,6 @@ private enum class PermissionToggle {
|
|||||||
|
|
||||||
private enum class SpecialAccessToggle {
|
private enum class SpecialAccessToggle {
|
||||||
NotificationListener,
|
NotificationListener,
|
||||||
AppUpdates,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val onboardingBackgroundGradient =
|
private val onboardingBackgroundGradient =
|
||||||
@@ -274,10 +272,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
rememberSaveable {
|
rememberSaveable {
|
||||||
mutableStateOf(isNotificationListenerEnabled(context))
|
mutableStateOf(isNotificationListenerEnabled(context))
|
||||||
}
|
}
|
||||||
var enableAppUpdates by
|
|
||||||
rememberSaveable {
|
|
||||||
mutableStateOf(canInstallUnknownApps(context))
|
|
||||||
}
|
|
||||||
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
|
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
|
||||||
var enableCamera by rememberSaveable { mutableStateOf(false) }
|
var enableCamera by rememberSaveable { mutableStateOf(false) }
|
||||||
var enablePhotos 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) {
|
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||||
when (toggle) {
|
when (toggle) {
|
||||||
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
|
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
|
||||||
SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +345,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
enableLocation,
|
enableLocation,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
enableNotificationListener,
|
enableNotificationListener,
|
||||||
enableAppUpdates,
|
|
||||||
enableMicrophone,
|
enableMicrophone,
|
||||||
enableCamera,
|
enableCamera,
|
||||||
enablePhotos,
|
enablePhotos,
|
||||||
@@ -368,7 +360,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
if (enableLocation) enabled += "Location"
|
if (enableLocation) enabled += "Location"
|
||||||
if (enableNotifications) enabled += "Notifications"
|
if (enableNotifications) enabled += "Notifications"
|
||||||
if (enableNotificationListener) enabled += "Notification listener"
|
if (enableNotificationListener) enabled += "Notification listener"
|
||||||
if (enableAppUpdates) enabled += "App updates"
|
|
||||||
if (enableMicrophone) enabled += "Microphone"
|
if (enableMicrophone) enabled += "Microphone"
|
||||||
if (enableCamera) enabled += "Camera"
|
if (enableCamera) enabled += "Camera"
|
||||||
if (enablePhotos) enabled += "Photos"
|
if (enablePhotos) enabled += "Photos"
|
||||||
@@ -385,10 +376,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
openNotificationListenerSettings(context)
|
openNotificationListenerSettings(context)
|
||||||
openedSpecialSetup = true
|
openedSpecialSetup = true
|
||||||
}
|
}
|
||||||
if (enableAppUpdates && !canInstallUnknownApps(context)) {
|
|
||||||
openUnknownAppSourcesSettings(context)
|
|
||||||
openedSpecialSetup = true
|
|
||||||
}
|
|
||||||
if (openedSpecialSetup) {
|
if (openedSpecialSetup) {
|
||||||
return@proceed
|
return@proceed
|
||||||
}
|
}
|
||||||
@@ -431,7 +418,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
val grantedNow =
|
val grantedNow =
|
||||||
when (toggle) {
|
when (toggle) {
|
||||||
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
|
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
|
||||||
SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context)
|
|
||||||
}
|
}
|
||||||
if (grantedNow) {
|
if (grantedNow) {
|
||||||
setSpecialAccessToggleEnabled(toggle, true)
|
setSpecialAccessToggleEnabled(toggle, true)
|
||||||
@@ -441,7 +427,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
pendingSpecialAccessToggle = toggle
|
pendingSpecialAccessToggle = toggle
|
||||||
when (toggle) {
|
when (toggle) {
|
||||||
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
|
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
|
||||||
SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,13 +444,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
pendingSpecialAccessToggle = null
|
pendingSpecialAccessToggle = null
|
||||||
}
|
}
|
||||||
SpecialAccessToggle.AppUpdates -> {
|
|
||||||
setSpecialAccessToggleEnabled(
|
|
||||||
SpecialAccessToggle.AppUpdates,
|
|
||||||
canInstallUnknownApps(context),
|
|
||||||
)
|
|
||||||
pendingSpecialAccessToggle = null
|
|
||||||
}
|
|
||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,7 +584,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
enableLocation = enableLocation,
|
enableLocation = enableLocation,
|
||||||
enableNotifications = enableNotifications,
|
enableNotifications = enableNotifications,
|
||||||
enableNotificationListener = enableNotificationListener,
|
enableNotificationListener = enableNotificationListener,
|
||||||
enableAppUpdates = enableAppUpdates,
|
|
||||||
enableMicrophone = enableMicrophone,
|
enableMicrophone = enableMicrophone,
|
||||||
enableCamera = enableCamera,
|
enableCamera = enableCamera,
|
||||||
enablePhotos = enablePhotos,
|
enablePhotos = enablePhotos,
|
||||||
@@ -649,9 +626,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
onNotificationListenerChange = { checked ->
|
onNotificationListenerChange = { checked ->
|
||||||
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
|
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
|
||||||
},
|
},
|
||||||
onAppUpdatesChange = { checked ->
|
|
||||||
requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked)
|
|
||||||
},
|
|
||||||
onMicrophoneChange = { checked ->
|
onMicrophoneChange = { checked ->
|
||||||
requestPermissionToggle(
|
requestPermissionToggle(
|
||||||
PermissionToggle.Microphone,
|
PermissionToggle.Microphone,
|
||||||
@@ -1337,7 +1311,6 @@ private fun PermissionsStep(
|
|||||||
enableLocation: Boolean,
|
enableLocation: Boolean,
|
||||||
enableNotifications: Boolean,
|
enableNotifications: Boolean,
|
||||||
enableNotificationListener: Boolean,
|
enableNotificationListener: Boolean,
|
||||||
enableAppUpdates: Boolean,
|
|
||||||
enableMicrophone: Boolean,
|
enableMicrophone: Boolean,
|
||||||
enableCamera: Boolean,
|
enableCamera: Boolean,
|
||||||
enablePhotos: Boolean,
|
enablePhotos: Boolean,
|
||||||
@@ -1353,7 +1326,6 @@ private fun PermissionsStep(
|
|||||||
onLocationChange: (Boolean) -> Unit,
|
onLocationChange: (Boolean) -> Unit,
|
||||||
onNotificationsChange: (Boolean) -> Unit,
|
onNotificationsChange: (Boolean) -> Unit,
|
||||||
onNotificationListenerChange: (Boolean) -> Unit,
|
onNotificationListenerChange: (Boolean) -> Unit,
|
||||||
onAppUpdatesChange: (Boolean) -> Unit,
|
|
||||||
onMicrophoneChange: (Boolean) -> Unit,
|
onMicrophoneChange: (Boolean) -> Unit,
|
||||||
onCameraChange: (Boolean) -> Unit,
|
onCameraChange: (Boolean) -> Unit,
|
||||||
onPhotosChange: (Boolean) -> Unit,
|
onPhotosChange: (Boolean) -> Unit,
|
||||||
@@ -1387,7 +1359,6 @@ private fun PermissionsStep(
|
|||||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||||
}
|
}
|
||||||
val notificationListenerGranted = isNotificationListenerEnabled(context)
|
val notificationListenerGranted = isNotificationListenerEnabled(context)
|
||||||
val appUpdatesGranted = canInstallUnknownApps(context)
|
|
||||||
|
|
||||||
StepShell(title = "Permissions") {
|
StepShell(title = "Permissions") {
|
||||||
Text(
|
Text(
|
||||||
@@ -1429,14 +1400,6 @@ private fun PermissionsStep(
|
|||||||
onCheckedChange = onNotificationListenerChange,
|
onCheckedChange = onNotificationListenerChange,
|
||||||
)
|
)
|
||||||
InlineDivider()
|
InlineDivider()
|
||||||
PermissionToggleRow(
|
|
||||||
title = "App updates",
|
|
||||||
subtitle = "app.update install confirmation (opens Android Settings)",
|
|
||||||
checked = enableAppUpdates,
|
|
||||||
granted = appUpdatesGranted,
|
|
||||||
onCheckedChange = onAppUpdatesChange,
|
|
||||||
)
|
|
||||||
InlineDivider()
|
|
||||||
PermissionToggleRow(
|
PermissionToggleRow(
|
||||||
title = "Microphone",
|
title = "Microphone",
|
||||||
subtitle = "Voice tab transcription",
|
subtitle = "Voice tab transcription",
|
||||||
@@ -1635,10 +1598,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
|
|||||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canInstallUnknownApps(context: Context): Boolean {
|
|
||||||
return context.packageManager.canRequestPackageInstalls()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openNotificationListenerSettings(context: Context) {
|
private fun openNotificationListenerSettings(context: Context) {
|
||||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
runCatching {
|
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) {
|
private fun openAppSettings(context: Context) {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(
|
Intent(
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -246,11 +245,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
motionPermissionGranted = granted
|
motionPermissionGranted = granted
|
||||||
}
|
}
|
||||||
|
|
||||||
var appUpdateInstallEnabled by
|
|
||||||
remember {
|
|
||||||
mutableStateOf(canInstallUnknownApps(context))
|
|
||||||
}
|
|
||||||
|
|
||||||
var smsPermissionGranted by
|
var smsPermissionGranted by
|
||||||
remember {
|
remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -290,7 +284,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
!motionPermissionRequired ||
|
!motionPermissionRequired ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
appUpdateInstallEnabled = canInstallUnknownApps(context)
|
|
||||||
smsPermissionGranted =
|
smsPermissionGranted =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
@@ -759,41 +752,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
item { HorizontalDivider(color = mobileBorder) }
|
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
|
// Location
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
@@ -865,7 +823,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
color = mobileTextSecondary,
|
color = mobileTextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item { HorizontalDivider(color = mobileBorder) }
|
item { HorizontalDivider(color = mobileBorder) }
|
||||||
|
|
||||||
// Screen
|
// 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 {
|
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||||
if (Build.VERSION.SDK_INT < 33) return true
|
if (Build.VERSION.SDK_INT < 33) return true
|
||||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
@@ -993,10 +937,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
|
|||||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canInstallUnknownApps(context: Context): Boolean {
|
|
||||||
return context.packageManager.canRequestPackageInstalls()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
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.Device.rawValue,
|
||||||
OpenClawCapability.Notifications.rawValue,
|
OpenClawCapability.Notifications.rawValue,
|
||||||
OpenClawCapability.System.rawValue,
|
OpenClawCapability.System.rawValue,
|
||||||
OpenClawCapability.AppUpdate.rawValue,
|
|
||||||
OpenClawCapability.Photos.rawValue,
|
OpenClawCapability.Photos.rawValue,
|
||||||
OpenClawCapability.Contacts.rawValue,
|
OpenClawCapability.Contacts.rawValue,
|
||||||
OpenClawCapability.Calendar.rawValue,
|
OpenClawCapability.Calendar.rawValue,
|
||||||
@@ -52,7 +51,6 @@ class InvokeCommandRegistryTest {
|
|||||||
OpenClawContactsCommand.Add.rawValue,
|
OpenClawContactsCommand.Add.rawValue,
|
||||||
OpenClawCalendarCommand.Events.rawValue,
|
OpenClawCalendarCommand.Events.rawValue,
|
||||||
OpenClawCalendarCommand.Add.rawValue,
|
OpenClawCalendarCommand.Add.rawValue,
|
||||||
"app.update",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val optionalCommands =
|
private val optionalCommands =
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class OpenClawProtocolConstantsTest {
|
|||||||
assertEquals("device", OpenClawCapability.Device.rawValue)
|
assertEquals("device", OpenClawCapability.Device.rawValue)
|
||||||
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
|
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
|
||||||
assertEquals("system", OpenClawCapability.System.rawValue)
|
assertEquals("system", OpenClawCapability.System.rawValue)
|
||||||
assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
|
|
||||||
assertEquals("photos", OpenClawCapability.Photos.rawValue)
|
assertEquals("photos", OpenClawCapability.Photos.rawValue)
|
||||||
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
|
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
|
||||||
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
|
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ title: "Features"
|
|||||||
- Optional voice note transcription hook
|
- Optional voice note transcription hook
|
||||||
- WebChat and macOS menu bar app
|
- WebChat and macOS menu bar app
|
||||||
- iOS node with pairing, Canvas, camera, screen recording, location, and voice features
|
- 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>
|
<Note>
|
||||||
Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only
|
Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ Available families:
|
|||||||
- `contacts.search`, `contacts.add`
|
- `contacts.search`, `contacts.add`
|
||||||
- `calendar.events`, `calendar.add`
|
- `calendar.events`, `calendar.add`
|
||||||
- `motion.activity`, `motion.pedometer`
|
- `motion.activity`, `motion.pedometer`
|
||||||
- `app.update`
|
|
||||||
|
|
||||||
Example invokes:
|
Example invokes:
|
||||||
|
|
||||||
@@ -288,7 +287,6 @@ openclaw nodes invoke --node <idOrNameOrIp> --command photos.latest --params '{"
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Motion commands are capability-gated by available sensors.
|
- Motion commands are capability-gated by available sensors.
|
||||||
- `app.update` is permission + policy gated by the node runtime.
|
|
||||||
|
|
||||||
## System commands (node host / mac node)
|
## System commands (node host / mac node)
|
||||||
|
|
||||||
|
|||||||
@@ -166,4 +166,3 @@ Screen commands:
|
|||||||
- `contacts.search`, `contacts.add`
|
- `contacts.search`, `contacts.add`
|
||||||
- `calendar.events`, `calendar.add`
|
- `calendar.events`, `calendar.add`
|
||||||
- `motion.activity`, `motion.pedometer`
|
- `motion.activity`, `motion.pedometer`
|
||||||
- `app.update`
|
|
||||||
|
|||||||
@@ -240,12 +240,6 @@ const COMMAND_PROFILES: Record<string, CommandProfile> = {
|
|||||||
expect(readString(obj.diagnostics)).not.toBeNull();
|
expect(readString(obj.diagnostics)).not.toBeNull();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"app.update": {
|
|
||||||
buildParams: () => ({}),
|
|
||||||
timeoutMs: 20_000,
|
|
||||||
outcome: "error",
|
|
||||||
allowedErrorCodes: ["INVALID_REQUEST"],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveGatewayConnection() {
|
function resolveGatewayConnection() {
|
||||||
|
|||||||
Reference in New Issue
Block a user