diff --git a/apps/android/README.md b/apps/android/README.md index cc812f976972..8699707f208b 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -218,6 +218,7 @@ Current OpenClaw Android implication: - Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception. - The repo now ships this split as Android product flavors: - `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities. + - Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`. - `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality. Policy links: diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 7ec397f31b98..26fae6a23b54 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -148,6 +148,7 @@ class MainViewModel( val gatewayBootstrapToken: StateFlow = prefs.gatewayBootstrapToken val onboardingCompleted: StateFlow = prefs.onboardingCompleted val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val installedAppsSharingEnabled: StateFlow = prefs.installedAppsSharingEnabled val speakerEnabled: StateFlow = prefs.speakerEnabled val voiceCaptureMode: StateFlow = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode } val micEnabled: StateFlow = runtimeState(initial = false) { it.micEnabled } @@ -299,6 +300,10 @@ class MainViewModel( prefs.setCanvasDebugStatusEnabled(value) } + fun setInstalledAppsSharingEnabled(value: Boolean) { + ensureRuntime().setInstalledAppsSharingEnabled(value) + } + fun setNotificationForwardingEnabled(value: Boolean) { ensureRuntime().setNotificationForwardingEnabled(value) } 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 9d7d0c008bd2..672471a9856b 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 @@ -207,6 +207,7 @@ class NodeRuntime( callLogAvailable = { SensitiveFeatureConfig.callLogEnabled }, photosAvailable = { SensitiveFeatureConfig.photosEnabled }, hasRecordAudioPermission = { hasRecordAudioPermission() }, + installedAppsSharingEnabled = { installedAppsSharingEnabled.value }, manualTls = { manualTls.value }, ) @@ -245,6 +246,7 @@ class NodeRuntime( smsTelephonyAvailable = { sms.hasTelephonyFeature() }, callLogAvailable = { SensitiveFeatureConfig.callLogEnabled }, photosAvailable = { SensitiveFeatureConfig.photosEnabled }, + installedAppsSharingEnabled = { installedAppsSharingEnabled.value }, debugBuild = { BuildConfig.DEBUG }, onCanvasA2uiPush = { _canvasA2uiHydrated.value = true @@ -866,6 +868,7 @@ class NodeRuntime( val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val installedAppsSharingEnabled: StateFlow = prefs.installedAppsSharingEnabled val notificationForwardingEnabled: StateFlow = prefs.notificationForwardingEnabled val notificationForwardingMode: StateFlow = prefs.notificationForwardingMode @@ -1077,6 +1080,12 @@ class NodeRuntime( prefs.setCanvasDebugStatusEnabled(value) } + fun setInstalledAppsSharingEnabled(value: Boolean) { + if (prefs.installedAppsSharingEnabled.value == value) return + prefs.setInstalledAppsSharingEnabled(value) + refreshNodeSurfaceAfterSharingChange() + } + fun setNotificationForwardingEnabled(value: Boolean) { prefs.setNotificationForwardingEnabled(value) } @@ -1414,6 +1423,11 @@ class NodeRuntime( connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true) } + private fun refreshNodeSurfaceAfterSharingChange() { + val endpoint = connectedEndpoint ?: return + connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true) + } + private fun connectWithAuth( endpoint: GatewayEndpoint, auth: GatewayConnectAuth, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index 361722641564..77fc08793ced 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -40,11 +40,13 @@ class SecurePrefs( private const val notificationsForwardingMaxEventsPerMinuteKey = "notifications.forwarding.maxEventsPerMinute" private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey" + private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled" private const val voiceMicEnabledKey = "voice.micEnabled" } private val appContext = context.applicationContext private val json = Json { ignoreUnknownKeys = true } + // Non-secret UI/runtime preferences stay readable for migration and backup behavior. private val plainPrefs: SharedPreferences = appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE) @@ -114,6 +116,10 @@ class SecurePrefs( MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false)) val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled + private val _installedAppsSharingEnabled = + MutableStateFlow(plainPrefs.getBoolean(installedAppsSharingEnabledKey, false)) + val installedAppsSharingEnabled: StateFlow = _installedAppsSharingEnabled + private val _notificationForwardingEnabled = MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled)) val notificationForwardingEnabled: StateFlow = _notificationForwardingEnabled @@ -252,6 +258,11 @@ class SecurePrefs( _canvasDebugStatusEnabled.value = value } + fun setInstalledAppsSharingEnabled(value: Boolean) { + plainPrefs.edit { putBoolean(installedAppsSharingEnabledKey, value) } + _installedAppsSharingEnabled.value = value + } + internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy { val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null) val mode = NotificationPackageFilterMode.fromRawValue(modeRaw) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 79b74fc78d34..f6b5e4734fe2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -28,6 +28,7 @@ class ConnectionManager( private val callLogAvailable: () -> Boolean, private val photosAvailable: () -> Boolean, private val hasRecordAudioPermission: () -> Boolean, + private val installedAppsSharingEnabled: () -> Boolean, private val manualTls: () -> Boolean, ) { companion object { @@ -115,6 +116,7 @@ class ConnectionManager( voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(), motionActivityAvailable = motionActivityAvailable(), motionPedometerAvailable = motionPedometerAvailable(), + installedAppsSharingEnabled = installedAppsSharingEnabled(), debugBuild = BuildConfig.DEBUG, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index 06d97b692521..259b7e21e930 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -8,6 +8,7 @@ import android.app.ActivityManager import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities @@ -24,16 +25,116 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.util.Locale +private const val DEFAULT_DEVICE_APPS_LIMIT = 100 +private const val MAX_DEVICE_APPS_LIMIT = 200 + +internal data class DeviceAppEntry( + val label: String, + val packageName: String, + val system: Boolean, + val enabled: Boolean, + val launchable: Boolean, +) + +internal interface DeviceAppSource { + fun listApps(includeNonLaunchable: Boolean): List +} + +private class AndroidDeviceAppSource( + private val appContext: Context, +) : DeviceAppSource { + override fun listApps(includeNonLaunchable: Boolean): List { + val packageManager = appContext.packageManager + val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } + val launchablePackages = + packageManager + .queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL) + .asSequence() + .mapNotNull { + it.activityInfo + ?.packageName + ?.trim() + ?.takeIf(String::isNotEmpty) + }.toSet() + + val appInfos = + if (includeNonLaunchable) { + packageManager.getInstalledApplications(PackageManager.MATCH_ALL) + } else { + launchablePackages.mapNotNull { packageName -> + runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull() + } + } + + return appInfos + .asSequence() + .mapNotNull { appInfo -> + appInfo.packageName + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { packageName -> + val label = packageManager.getApplicationLabel(appInfo).toString().trim() + DeviceAppEntry( + label = label.ifEmpty { packageName }, + packageName = packageName, + system = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0, + enabled = appInfo.enabled, + launchable = packageName in launchablePackages, + ) + } + }.distinctBy { it.packageName } + .sortedWith(compareBy { it.label.lowercase() }.thenBy { it.packageName }) + .toList() + } +} + +private data class DeviceAppsRequest( + val includeSystem: Boolean, + val includeDisabled: Boolean, + val includeNonLaunchable: Boolean, + val query: String?, + val limit: Int, +) + /** * Gateway device command adapter for Android status, info, permission, and health snapshots. */ -class DeviceHandler( +class DeviceHandler private constructor( private val appContext: Context, private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, + private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext), ) { + constructor( + appContext: Context, + smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, + callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, + photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, + ) : this( + appContext = appContext, + smsEnabled = smsEnabled, + callLogEnabled = callLogEnabled, + photosEnabled = photosEnabled, + appSource = AndroidDeviceAppSource(appContext), + ) + companion object { + internal fun forTesting( + appContext: Context, + appSource: DeviceAppSource, + smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, + callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, + photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, + ): DeviceHandler = + DeviceHandler( + appContext = appContext, + smsEnabled = smsEnabled, + callLogEnabled = callLogEnabled, + photosEnabled = photosEnabled, + appSource = appSource, + ) + /** * SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align. */ @@ -74,6 +175,49 @@ class DeviceHandler( /** Returns coarse device health for memory, power, thermal, battery, and security patch state. */ fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson()) + /** Returns apps visible to the Android node without requesting broad package visibility. */ + fun handleDeviceApps(paramsJson: String?): GatewaySession.InvokeResult { + val request = parseDeviceAppsRequest(paramsJson) + val matchingApps = + appSource + .listApps(includeNonLaunchable = request.includeNonLaunchable) + .asSequence() + .filter { request.includeSystem || !it.system } + .filter { request.includeDisabled || it.enabled } + .filter { app -> + val query = request.query ?: return@filter true + app.label.contains(query, ignoreCase = true) || app.packageName.contains(query, ignoreCase = true) + }.toList() + val limitedApps = matchingApps.take(request.limit) + + return GatewaySession.InvokeResult.ok( + buildJsonObject { + put("count", JsonPrimitive(limitedApps.size)) + put("totalMatched", JsonPrimitive(matchingApps.size)) + put("truncated", JsonPrimitive(matchingApps.size > limitedApps.size)) + put("visibility", JsonPrimitive(if (request.includeNonLaunchable) "android-visible" else "launcher")) + put("includeSystem", JsonPrimitive(request.includeSystem)) + put("includeDisabled", JsonPrimitive(request.includeDisabled)) + put( + "apps", + buildJsonArray { + for (app in limitedApps) { + add( + buildJsonObject { + put("label", JsonPrimitive(app.label)) + put("packageName", JsonPrimitive(app.packageName)) + put("system", JsonPrimitive(app.system)) + put("enabled", JsonPrimitive(app.enabled)) + put("launchable", JsonPrimitive(app.launchable)) + }, + ) + } + }, + ) + }.toString(), + ) + } + private fun statusPayloadJson(): String { val battery = readBatterySnapshot() val powerManager = appContext.getSystemService(PowerManager::class.java) @@ -365,6 +509,24 @@ class DeviceHandler( }.toString() } + private fun parseDeviceAppsRequest(paramsJson: String?): DeviceAppsRequest { + val params = parseJsonParamsObject(paramsJson) + val includeSystem = parseJsonBooleanFlag(params, "includeSystem") ?: false + val includeDisabled = parseJsonBooleanFlag(params, "includeDisabled") ?: false + val includeNonLaunchable = parseJsonBooleanFlag(params, "includeNonLaunchable") ?: false + val query = parseJsonString(params, "query")?.trim()?.takeIf { it.isNotEmpty() } + val limit = + (parseJsonInt(params, "limit") ?: DEFAULT_DEVICE_APPS_LIMIT) + .coerceIn(1, MAX_DEVICE_APPS_LIMIT) + return DeviceAppsRequest( + includeSystem = includeSystem, + includeDisabled = includeDisabled, + includeNonLaunchable = includeNonLaunchable, + query = query, + limit = limit, + ) + } + private fun readBatterySnapshot(): BatterySnapshot { // ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot. val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) 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 b2cc8d6ae494..2581d1de6de9 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 @@ -28,6 +28,7 @@ data class NodeRuntimeFlags( val voiceWakeEnabled: Boolean, val motionActivityAvailable: Boolean, val motionPedometerAvailable: Boolean, + val installedAppsSharingEnabled: Boolean, val debugBuild: Boolean, ) @@ -43,6 +44,7 @@ enum class InvokeCommandAvailability { PhotosAvailable, MotionActivityAvailable, MotionPedometerAvailable, + InstalledAppsSharingEnabled, DebugBuild, } @@ -193,6 +195,10 @@ object InvokeCommandRegistry { InvokeCommandSpec( name = OpenClawDeviceCommand.Health.rawValue, ), + InvokeCommandSpec( + name = OpenClawDeviceCommand.Apps.rawValue, + availability = InvokeCommandAvailability.InstalledAppsSharingEnabled, + ), InvokeCommandSpec( name = OpenClawNotificationsCommand.List.rawValue, ), @@ -281,6 +287,7 @@ object InvokeCommandRegistry { InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable + InvokeCommandAvailability.InstalledAppsSharingEnabled -> flags.installedAppsSharingEnabled InvokeCommandAvailability.DebugBuild -> flags.debugBuild } }.map { 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 15569f4f364b..76b532c8cba2 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 @@ -85,6 +85,7 @@ class InvokeDispatcher( private val smsTelephonyAvailable: () -> Boolean, private val callLogAvailable: () -> Boolean, private val photosAvailable: () -> Boolean, + private val installedAppsSharingEnabled: () -> Boolean, private val debugBuild: () -> Boolean, private val onCanvasA2uiPush: () -> Unit, private val onCanvasA2uiReset: () -> Unit, @@ -193,6 +194,7 @@ class InvokeDispatcher( OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson) OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson) OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson) + OpenClawDeviceCommand.Apps.rawValue -> deviceHandler.handleDeviceApps(paramsJson) // Notifications command OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson) @@ -348,6 +350,15 @@ class InvokeDispatcher( message = "PHOTOS_UNAVAILABLE: photos not available on this build", ) } + InvokeCommandAvailability.InstalledAppsSharingEnabled -> + if (installedAppsSharingEnabled()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "INSTALLED_APPS_SHARING_DISABLED", + message = "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings", + ) + } InvokeCommandAvailability.DebugBuild -> if (debugBuild()) { null 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 fcc53b1ac884..3072d5522c9d 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 @@ -112,6 +112,7 @@ enum class OpenClawDeviceCommand( Info("device.info"), Permissions("device.permissions"), Health("device.health"), + Apps("device.apps"), ; companion object { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index 7fa7720b4332..a343c6c9e490 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -714,6 +714,7 @@ private fun PhoneCapabilitiesScreen( val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() val preventSleep by viewModel.preventSleep.collectAsState() val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() + val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState() val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> viewModel.setCameraEnabled(granted) @@ -768,6 +769,13 @@ private fun PhoneCapabilitiesScreen( listOf( SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess), SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation), + SettingsToggleRow( + "Installed Apps", + if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.", + Icons.Default.Storage, + installedAppsSharingEnabled, + viewModel::setInstalledAppsSharingEnabled, + ), SettingsToggleRow("Keep Awake", "Keep the node available during active work.", Icons.Default.Bolt, preventSleep, viewModel::setPreventSleep), SettingsToggleRow("Canvas Status", "Show screen-sharing debug state.", Icons.AutoMirrored.Filled.ScreenShare, canvasDebugStatusEnabled, viewModel::setCanvasDebugStatusEnabled), ), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt index ce50c572f01b..a8cb0ae0808b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -62,6 +62,21 @@ class SecurePrefsTest { assertFalse(plainPrefs.getBoolean("talk.enabled", false)) } + @Test + fun installedAppsSharing_defaultsOffAndPersistsOptIn() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = SecurePrefs(context) + + assertFalse(prefs.installedAppsSharingEnabled.value) + + prefs.setInstalledAppsSharingEnabled(true) + + assertTrue(prefs.installedAppsSharingEnabled.value) + assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false)) + } + @Test fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() { val context = RuntimeEnvironment.getApplication() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index 0dbaffb85c46..03ba8c9bb585 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -9,6 +9,7 @@ import ai.openclaw.app.gateway.isLoopbackGatewayHost import ai.openclaw.app.protocol.OpenClawCallLogCommand import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCapability +import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawMotionCommand import ai.openclaw.app.protocol.OpenClawPhotosCommand @@ -475,6 +476,15 @@ class ConnectionManagerTest { assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue)) } + @Test + fun buildNodeConnectOptions_advertisesDeviceAppsOnlyWhenUserOptedIn() { + val disabled = newManager(installedAppsSharingEnabled = false).buildNodeConnectOptions() + val enabled = newManager(installedAppsSharingEnabled = true).buildNodeConnectOptions() + + assertFalse(disabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue)) + assertTrue(enabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue)) + } + @Test fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() { val options = @@ -546,6 +556,7 @@ class ConnectionManagerTest { callLogAvailable: Boolean = false, photosAvailable: Boolean = false, hasRecordAudioPermission: Boolean = false, + installedAppsSharingEnabled: Boolean = false, ): ConnectionManager { val context = RuntimeEnvironment.getApplication() val prefs = @@ -567,6 +578,7 @@ class ConnectionManagerTest { callLogAvailable = { callLogAvailable }, photosAvailable = { photosAvailable }, hasRecordAudioPermission = { hasRecordAudioPermission }, + installedAppsSharingEnabled = { installedAppsSharingEnabled }, manualTls = { false }, ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt index a278710ade6a..0c41dad79c5b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -320,6 +320,98 @@ class DeviceHandlerTest { system["securityPatchLevel"]?.jsonPrimitive?.content } + @Test + fun handleDeviceApps_filtersAndLimitsVisibleApps() { + val handler = + DeviceHandler.forTesting( + appContext = appContext(), + appSource = + FakeDeviceAppSource( + listOf( + DeviceAppEntry( + label = "Calendar", + packageName = "com.google.android.calendar", + system = false, + enabled = true, + launchable = true, + ), + DeviceAppEntry( + label = "Android System", + packageName = "android", + system = true, + enabled = true, + launchable = false, + ), + DeviceAppEntry( + label = "Disabled App", + packageName = "com.example.disabled", + system = false, + enabled = false, + launchable = true, + ), + DeviceAppEntry( + label = "Gmail", + packageName = "com.google.android.gm", + system = false, + enabled = true, + launchable = true, + ), + ), + ), + ) + + val result = handler.handleDeviceApps("""{"query":"google","limit":1}""") + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + assertEquals("1", payload.getValue("count").jsonPrimitive.content) + assertEquals("2", payload.getValue("totalMatched").jsonPrimitive.content) + assertTrue(payload.getValue("truncated").jsonPrimitive.boolean) + assertEquals("launcher", payload.getValue("visibility").jsonPrimitive.content) + val apps = payload.getValue("apps").jsonArray + assertEquals(1, apps.size) + val app = apps.first().jsonObject + assertEquals("Calendar", app.getValue("label").jsonPrimitive.content) + assertEquals("com.google.android.calendar", app.getValue("packageName").jsonPrimitive.content) + assertTrue(!app.getValue("system").jsonPrimitive.boolean) + assertTrue(app.getValue("enabled").jsonPrimitive.boolean) + assertTrue(app.getValue("launchable").jsonPrimitive.boolean) + } + + @Test + fun handleDeviceApps_canIncludeSystemAndNonLaunchableApps() { + val source = + FakeDeviceAppSource( + listOf( + DeviceAppEntry( + label = "Android System", + packageName = "android", + system = true, + enabled = true, + launchable = false, + ), + ), + ) + val handler = DeviceHandler.forTesting(appContext = appContext(), appSource = source) + + val result = handler.handleDeviceApps("""{"includeSystem":true,"includeNonLaunchable":true}""") + + assertTrue(result.ok) + val payload = parsePayload(result.payloadJson) + assertEquals("android-visible", payload.getValue("visibility").jsonPrimitive.content) + assertTrue(payload.getValue("includeSystem").jsonPrimitive.boolean) + val app = + payload + .getValue("apps") + .jsonArray + .first() + .jsonObject + assertEquals("android", app.getValue("packageName").jsonPrimitive.content) + assertTrue(app.getValue("system").jsonPrimitive.boolean) + assertTrue(!app.getValue("launchable").jsonPrimitive.boolean) + assertTrue(source.includeNonLaunchableRequests.single()) + } + private fun appContext(): Context = RuntimeEnvironment.getApplication() private fun parsePayload(payloadJson: String?): JsonObject { @@ -327,3 +419,14 @@ class DeviceHandlerTest { return Json.parseToJsonElement(jsonString).jsonObject } } + +private class FakeDeviceAppSource( + private val apps: List, +) : DeviceAppSource { + val includeNonLaunchableRequests = mutableListOf() + + override fun listApps(includeNonLaunchable: Boolean): List { + includeNonLaunchableRequests += includeNonLaunchable + return apps + } +} 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 d147b6ea1cb4..6273d80ca335 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 @@ -115,6 +115,15 @@ class InvokeCommandRegistryTest { assertMissingAll(commands, optionalCommands + debugCommands) } + @Test + fun advertisedCommands_includesDeviceAppsOnlyWhenUserOptedIn() { + val disabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = false)) + val enabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = true)) + + assertFalse(disabled.contains(OpenClawDeviceCommand.Apps.rawValue)) + assertTrue(enabled.contains(OpenClawDeviceCommand.Apps.rawValue)) + } + @Test fun advertisedCommands_includesFeatureCommandsWhenEnabled() { val commands = @@ -151,6 +160,7 @@ class InvokeCommandRegistryTest { voiceWakeEnabled = false, motionActivityAvailable = true, motionPedometerAvailable = false, + installedAppsSharingEnabled = false, debugBuild = false, ), ) @@ -262,6 +272,7 @@ class InvokeCommandRegistryTest { voiceWakeEnabled: Boolean = false, motionActivityAvailable: Boolean = false, motionPedometerAvailable: Boolean = false, + installedAppsSharingEnabled: Boolean = false, debugBuild: Boolean = false, ): NodeRuntimeFlags = NodeRuntimeFlags( @@ -275,6 +286,7 @@ class InvokeCommandRegistryTest { voiceWakeEnabled = voiceWakeEnabled, motionActivityAvailable = motionActivityAvailable, motionPedometerAvailable = motionPedometerAvailable, + installedAppsSharingEnabled = installedAppsSharingEnabled, debugBuild = debugBuild, ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt index c7446a96e6b5..b8b1833d7e53 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeDispatcherTest.kt @@ -4,6 +4,7 @@ import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.protocol.OpenClawCallLogCommand import ai.openclaw.app.protocol.OpenClawCameraCommand +import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawMotionCommand import ai.openclaw.app.protocol.OpenClawPhotosCommand @@ -170,6 +171,20 @@ class InvokeDispatcherTest { assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message) } + @Test + fun handleInvoke_blocksDeviceAppsWhenSharingDisabled() = + runTest { + val result = + newDispatcher(installedAppsSharingEnabled = false) + .handleInvoke(OpenClawDeviceCommand.Apps.rawValue, """{"limit":1}""") + + assertEquals("INSTALLED_APPS_SHARING_DISABLED", result.error?.code) + assertEquals( + "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings", + result.error?.message, + ) + } + @Test fun handleInvoke_blocksMotionActivityWhenUnavailable() = runTest { @@ -250,6 +265,7 @@ class InvokeDispatcherTest { smsTelephonyAvailable: Boolean = true, callLogAvailable: Boolean = false, photosAvailable: Boolean = true, + installedAppsSharingEnabled: Boolean = true, debugBuild: Boolean = false, motionActivityAvailable: Boolean = false, motionPedometerAvailable: Boolean = false, @@ -297,6 +313,7 @@ class InvokeDispatcherTest { smsTelephonyAvailable = { smsTelephonyAvailable }, callLogAvailable = { callLogAvailable }, photosAvailable = { photosAvailable }, + installedAppsSharingEnabled = { installedAppsSharingEnabled }, debugBuild = { debugBuild }, onCanvasA2uiPush = {}, onCanvasA2uiReset = {}, 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 069f51603c2a..db3f03ee052b 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 @@ -57,6 +57,7 @@ class OpenClawProtocolConstantsTest { assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue) assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue) assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue) + assertEquals("device.apps", OpenClawDeviceCommand.Apps.rawValue) } @Test diff --git a/docs/platforms/android.md b/docs/platforms/android.md index f65d20149eab..3574d056fa53 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -219,8 +219,9 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers. - By default, Android Talk uses native speech recognition, Gateway chat, and `talk.speak` through the configured gateway Talk provider. Local system TTS is used only when `talk.speak` is unavailable. - Android Talk uses realtime Gateway relay only when `talk.realtime.mode` is `realtime` and `talk.realtime.transport` is `gateway-relay`. - Voice wake remains disabled in the Android UX/runtime. -- Additional Android command families (availability depends on device + permissions): +- Additional Android command families (availability depends on device, permissions, and user settings): - `device.status`, `device.info`, `device.permissions`, `device.health` + - `device.apps` only when **Settings > Phone Capabilities > Installed Apps** is enabled; it lists launcher-visible apps by default. - `notifications.list`, `notifications.actions` (see [Notification forwarding](#notification-forwarding) below) - `photos.latest` - `contacts.search`, `contacts.add` diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index ae728309ffa4..a2ff98b01ee5 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -220,6 +220,15 @@ const COMMAND_PROFILES: Record = { expectRecord(obj.memory, "device.health memory payload"); }, }, + "device.apps": { + buildParams: () => ({ query: "calendar", includeSystem: true, limit: 5 }), + timeoutMs: 20_000, + outcome: "success", + onSuccess: (payload) => { + const obj = assertObjectPayload("device.apps", payload); + expect(Array.isArray(obj.apps)).toBe(true); + }, + }, "notifications.list": { buildParams: () => ({}), timeoutMs: 20_000, diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index f472e69a43b2..fc1f5f50bd1c 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -824,6 +824,7 @@ describe("resolveNodeCommandAllowlist", () => { "notifications.actions", "device.permissions", "device.health", + "device.apps", "callLog.search", "system.notify", ]); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 5cc2fccc20e3..d18be5ffb9f8 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -21,7 +21,12 @@ const NOTIFICATION_COMMANDS = ["notifications.list"]; const ANDROID_NOTIFICATION_COMMANDS = [...NOTIFICATION_COMMANDS, "notifications.actions"]; const DEVICE_COMMANDS = ["device.info", "device.status"]; -const ANDROID_DEVICE_COMMANDS = [...DEVICE_COMMANDS, "device.permissions", "device.health"]; +const ANDROID_DEVICE_COMMANDS = [ + ...DEVICE_COMMANDS, + "device.permissions", + "device.health", + "device.apps", +]; const CONTACTS_COMMANDS = ["contacts.search"]; const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"];