Commit 1bee91f1 authored by Lisa (AI Assistant)'s avatar Lisa (AI Assistant)

Add permission system with user confirmation

- PermissionAction model with sensitivity levels (LOW/MEDIUM/HIGH)
- PermissionManager with allowlist and remember choice
- PermissionDialog UI with 'remember this choice' option
- Updated SPEC.md and README with pending features
- Added reminder for donation system (Paypal, BTC, ETH)
parent 99422324
Pipeline #266 canceled with stages
...@@ -13,6 +13,15 @@ An Android app that turns any Android device into an OpenClaw node, enabling rem ...@@ -13,6 +13,15 @@ An Android app that turns any Android device into an OpenClaw node, enabling rem
-**Notification Mirroring** - Forward notifications to gateway -**Notification Mirroring** - Forward notifications to gateway
-**Settings UI** - Configure gateway URL, token, node ID -**Settings UI** - Configure gateway URL, token, node ID
## Upcoming Features
- 🔐 **Permission System** - Allowlist + confirmation for sensitive actions (camera, mic)
- 📷 **Camera Access** - Capture photos/video
- 🎤 **Microphone Access** - Record audio
- 🔊 **Audio Playback** - Play audio files
- 💬 **Chat Interface** - Text + voice messages with AI
-**Donations** - PayPal, BTC, ETH, credit card (details TBD)
## Requirements ## Requirements
- Android 8.0+ (API 26+) - Android 8.0+ (API 26+)
......
...@@ -78,6 +78,40 @@ This Android application connects to an OpenClaw gateway as a node, allowing the ...@@ -78,6 +78,40 @@ This Android application connects to an OpenClaw gateway as a node, allowing the
- Tasker integration - Tasker integration
- QR code pairing - QR code pairing
---
## 📋 PENDING FEATURES (To Be Implemented)
### Permission System
- [ ] **Allowlist actions** - Actions permitted without user interaction (screenshot, touch, app launch, etc.)
- [ ] **Confirmation actions** - Sensitive actions requiring user confirmation (camera, microphone)
- [ ] **Remember choice** - Option to save preference permanently or ask every time
- [ ] **Per-action settings** - Granular control over each sensitive action
### Media Capabilities
- [ ] **Camera access** - Capture photos, record video
- [ ] **Microphone access** - Record audio
- [ ] **Audio playback** - Play audio files on device speaker
### Chat Interface
- [ ] **Text chat** - In-app chat with AI assistant
- [ ] **File upload** - Send files/images to gateway
- [ ] **Voice messages** - Record and send audio messages
- [ ] **Real-time calls** (Future) - Voice/video call capability
### Background Service
- [ ] **Persistent connection** - Keep WebSocket connected to gateway
- [ ] **No root required** - Work within Android's permission system
- [ ] **Battery optimization** - Handle Doze mode properly
### Donation System
- [ ] **PayPal** - PayPal.me or donation link
- [ ] **Cryptocurrency** - BTC and ETH addresses
- [ ] **Credit card** - Stripe or similar integration
- [ ] **In-app UI** - Clean donation menu in app
> ⚠️ **TODO**: Implement donation menu details - research PayPal SDK, crypto QR codes, Stripe integration
## Technical Stack ## Technical Stack
- **Language**: Kotlin - **Language**: Kotlin
......
package com.openclaw.node.domain.model
/**
* Represents a permission action that can be requested
*/
sealed class PermissionAction {
abstract val id: String
abstract val displayName: String
abstract val description: String
// Categorize sensitivity level
abstract val sensitivity: Sensitivity
enum class Sensitivity {
LOW, // No confirmation needed
MEDIUM, // Confirm once, remember option
HIGH // Always confirm
}
// Built-in actions
data class Screenshot(override val id: String = "screenshot") : PermissionAction() {
override val displayName = "Screenshot"
override val description = "Capture screen"
override val sensitivity = Sensitivity.LOW
}
data class Touch(override val id: String = "touch") : PermissionAction() {
override val displayName = "Touch Control"
override val description = "Tap, swipe, gestures"
override val sensitivity = Sensitivity.LOW
}
data class LaunchApp(override val id: String = "launch_app") : PermissionAction() {
override val displayName = "Launch Apps"
override val description = "Open applications"
override val sensitivity = Sensitivity.LOW
}
data class Shell(override val id: String = "shell") : PermissionAction() {
override val displayName = "Shell Commands"
override val description = "Execute shell commands"
override val sensitivity = Sensitivity.LOW
}
data class Notifications(override val id: String = "notifications") : PermissionAction() {
override val displayName = "Notifications"
override val description = "Read notifications"
override val sensitivity = Sensitivity.MEDIUM
}
data class Camera(override val id: String = "camera") : PermissionAction() {
override val displayName = "Camera"
override val description = "Take photos, record video"
override val sensitivity = Sensitivity.HIGH
}
data class Microphone(override val id: String = "microphone") : PermissionAction() {
override val displayName = "Microphone"
override val description = "Record audio"
override val sensitivity = Sensitivity.HIGH
}
data class AudioPlayback(override val id: String = "audio_playback") : PermissionAction() {
override val displayName = "Audio Playback"
override val description = "Play audio files"
override val sensitivity = Sensitivity.MEDIUM
}
data class Clipboard(override val id: String = "clipboard") : PermissionAction() {
override val displayName = "Clipboard"
override val description = "Read/write clipboard"
override val sensitivity = Sensitivity.MEDIUM
}
data class Location(override val id: String = "location") : PermissionAction() {
override val displayName = "Location"
override val description = "GPS location"
override val sensitivity = Sensitivity.HIGH
}
data class Contacts(override val id: String = "contacts") : PermissionAction() {
override val displayName = "Contacts"
override val description = "Read contacts"
override val sensitivity = Sensitivity.HIGH
}
data class Storage(override val id: String = "storage") : PermissionAction() {
override val displayName = "Storage"
override val description = "Read/write files"
override val sensitivity = Sensitivity.MEDIUM
}
data class Phone(override val id: String = "phone") : PermissionAction() {
override val displayName = "Phone"
override val description = "Make calls, read phone state"
override val sensitivity = Sensitivity.HIGH
}
data class SMS(override val id: String = "sms") : PermissionAction() {
override val displayName = "SMS"
override val description = "Read/send SMS"
override val sensitivity = Sensitivity.HIGH
}
companion object {
fun fromId(id: String): PermissionAction? = when (id) {
"screenshot" -> Screenshot()
"touch" -> Touch()
"launch_app" -> LaunchApp()
"shell" -> Shell()
"notifications" -> Notifications()
"camera" -> Camera()
"microphone" -> Microphone()
"audio_playback" -> AudioPlayback()
"clipboard" -> Clipboard()
"location" -> Location()
"contacts" -> Contacts()
"storage" -> Storage()
"phone" -> Phone()
"sms" -> SMS()
else -> null
}
fun all(): List<PermissionAction> = listOf(
Screenshot(),
Touch(),
LaunchApp(),
Shell(),
Notifications(),
Camera(),
Microphone(),
AudioPlayback(),
Clipboard(),
Location(),
Contacts(),
Storage(),
Phone(),
SMS()
)
}
}
/**
* User's decision on a permission
*/
data class PermissionDecision(
val actionId: String,
val allowed: Boolean,
val rememberChoice: Boolean = false, // Save this decision permanently
val timestamp: Long = System.currentTimeMillis()
)
/**
* Result of a permission check
*/
sealed class PermissionResult {
object Allowed : PermissionResult()
object Denied : PermissionResult()
object PendingConfirmation : PermissionResult()
data class NeedsSystemPermission(val action: PermissionAction) : PermissionResult()
}
package com.openclaw.node.service
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import com.openclaw.node.domain.model.PermissionAction
import com.openclaw.node.domain.model.PermissionDecision
import com.openclaw.node.domain.model.PermissionResult
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.permissionDataStore: DataStore<Preferences> by preferencesDataStore(name = "permissions")
@Singleton
class PermissionManager @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
// Keys for remembering decisions
private fun rememberKey(actionId: String) = booleanPreferencesKey("remember_${actionId}")
private fun allowedKey(actionId: String) = booleanPreferencesKey("allowed_${actionId}")
}
// Default allowlist - actions that don't need confirmation
private val defaultAllowlist = setOf(
"screenshot",
"touch",
"launch_app",
"shell",
"notifications"
)
// High sensitivity actions that always need confirmation
private val highSensitivityActions = setOf(
"camera",
"microphone",
"location",
"contacts",
"phone",
"sms"
)
/**
* Check if an action is allowed
* Returns the appropriate PermissionResult
*/
suspend fun checkPermission(action: PermissionAction): PermissionResult {
// Check if user has permanently remembered a decision
val remembered = isDecisionRemembered(action.id)
if (remembered) {
val allowed = isAllowed(action.id)
return if (allowed) PermissionResult.Allowed else PermissionResult.Denied
}
// Check system permissions for sensitive actions
val systemResult = checkSystemPermission(action)
if (systemResult != null) {
return systemResult
}
// Check sensitivity level
return when (action.sensitivity) {
PermissionAction.Sensitivity.LOW -> {
// Low sensitivity - auto-allow
PermissionResult.Allowed
}
PermissionAction.Sensitivity.MEDIUM -> {
// Medium sensitivity - check allowlist
if (isInAllowlist(action.id)) {
PermissionResult.Allowed
} else {
PermissionResult.PendingConfirmation
}
}
PermissionAction.Sensitivity.HIGH -> {
// High sensitivity - always confirm
PermissionResult.PendingConfirmation
}
}
}
/**
* Process a user's decision on a permission
*/
suspend fun decide(
action: PermissionAction,
allowed: Boolean,
rememberChoice: Boolean
) {
// Store the decision
context.permissionDataStore.edit { preferences ->
preferences[allowedKey(action.id)] = allowed
if (rememberChoice) {
preferences[rememberKey(action.id)] = true
}
}
}
/**
* Clear a remembered decision
*/
suspend fun clearDecision(actionId: String) {
context.permissionDataStore.edit { preferences ->
preferences.remove(rememberKey(actionId))
preferences.remove(allowedKey(actionId))
}
}
/**
* Clear all remembered decisions
*/
suspend fun clearAllDecisions() {
context.permissionDataStore.edit { preferences ->
preferences.clear()
}
}
/**
* Add action to allowlist (no confirmation needed)
*/
suspend fun addToAllowlist(actionId: String) {
context.permissionDataStore.edit { preferences ->
preferences[booleanPreferencesKey("allowlist_$actionId")] = true
}
}
/**
* Remove action from allowlist
*/
suspend fun removeFromAllowlist(actionId: String) {
context.permissionDataStore.edit { preferences ->
preferences.remove(booleanPreferencesKey("allowlist_$actionId"))
}
}
/**
* Get all actions in allowlist
*/
fun getAllowlist(): Flow<Set<String>> = context.permissionDataStore.data.map { preferences ->
preferences.asMap()
.filter { (key, value) ->
key.name.startsWith("allowlist_") && value == true
}
.map { it.key.name.removePrefix("allowlist_") }
.toSet()
}
/**
* Get all remembered decisions
*/
fun getAllDecisions(): Flow<Map<String, PermissionDecision>> = context.permissionDataStore.data.map { preferences ->
preferences.asMap()
.filter { it.key.name.startsWith("allowed_") }
.mapNotNull { (key, value) ->
val actionId = key.name.removePrefix("allowed_")
val rememberKey = rememberKey(actionId)
val remembered = preferences[rememberKey] ?: false
val allowed = value as? Boolean ?: return@mapNotNull null
actionId to PermissionDecision(
actionId = actionId,
allowed = allowed,
rememberChoice = remembered
)
}
.toMap()
}
// Private helpers
private suspend fun isDecisionRemembered(actionId: String): Boolean {
return context.permissionDataStore.data.first()[rememberKey(actionId)] ?: false
}
private suspend fun isAllowed(actionId: String): Boolean {
return context.permissionDataStore.data.first()[allowedKey(actionId)] ?: false
}
private suspend fun isInAllowlist(actionId: String): Boolean {
return context.permissionDataStore.data.first()[booleanPreferencesKey("allowlist_$actionId")]
?: defaultAllowlist.contains(actionId)
}
private fun checkSystemPermission(action: PermissionAction): PermissionResult? {
// Check Android runtime permissions
val permission = when (action.id) {
"camera" -> Manifest.permission.CAMERA
"microphone" -> Manifest.permission.RECORD_AUDIO
"location" -> Manifest.permission.ACCESS_FINE_LOCATION
"contacts" -> Manifest.permission.READ_CONTACTS
"phone" -> Manifest.permission.READ_PHONE_STATE
"sms" -> Manifest.permission.READ_SMS
"storage" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
else -> null
}
if (permission != null) {
val granted = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
return if (!granted) {
PermissionResult.NeedsSystemPermission(action)
} else {
null // System permission granted, continue with app-level check
}
}
return null
}
}
/**
* Extension to get all permission decisions as a list
*/
suspend fun PermissionManager.getPermissionStatus(): Map<String, PermissionDecision> {
return getAllDecisions().first()
}
package com.openclaw.node.ui.permission
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.openclaw.node.domain.model.PermissionAction
/**
* Dialog for requesting user confirmation on sensitive actions
*/
@Composable
fun PermissionDialog(
action: PermissionAction,
onAllow: (rememberChoice: Boolean) -> Unit,
onDeny: (rememberChoice: Boolean) -> Unit,
onDismiss: () -> Unit
) {
var rememberChoice by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.large
) {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Icon based on sensitivity
val emoji = when (action.sensitivity) {
PermissionAction.Sensitivity.LOW -> "✅"
PermissionAction.Sensitivity.MEDIUM -> "⚠️"
PermissionAction.Sensitivity.HIGH -> "🔴"
}
Text(
text = emoji,
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Permission Request",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Allow ${action.displayName}?",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = action.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// Sensitivity warning
if (action.sensitivity == PermissionAction.Sensitivity.HIGH) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = "⚠️ This is a sensitive permission",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// Remember choice checkbox
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = rememberChoice,
onCheckedChange = { rememberChoice = it }
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "Remember this choice",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = if (rememberChoice) "Won't ask again" else "Ask every time",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = { onDeny(rememberChoice) },
modifier = Modifier.weight(1f)
) {
Text("Deny")
}
Button(
onClick = { onAllow(rememberChoice) },
modifier = Modifier.weight(1f)
) {
Text("Allow")
}
}
}
}
}
}
/**
* Compact permission status item for settings screen
*/
@Composable
fun PermissionStatusItem(
action: PermissionAction,
isAllowed: Boolean?,
isRemembered: Boolean,
onClearDecision: () -> Unit,
onToggleAllowlist: (Boolean) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = action.displayName,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = action.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Status chip
val statusText = when {
isRemembered && isAllowed == true -> "✅ Always allowed"
isRemembered && isAllowed == false -> "❌ Always denied"
isAllowed == true -> "Allowed"
isAllowed == false -> "Denied"
action.sensitivity == PermissionAction.Sensitivity.LOW -> "Auto"
else -> "Ask"
}
AssistChip(
onClick = { showDialog = true },
label = { Text(statusText) }
)
}
if (showDialog) {
// Simple toggle dialog
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text(action.displayName) },
text = { Text(action.description) },
confirmButton = {
TextButton(onClick = {
onClearDecision()
showDialog = false
}) {
Text("Clear Decision")
}
},
dismissButton = {
TextButton(onClick = { showDialog = false }) {
Text("Close")
}
}
)
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment