Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />

<!-- Android tool extension permissions -->
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.WRITE_SETTINGS"
tools:ignore="ProtectedPermissions" />

<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
Expand Down Expand Up @@ -85,6 +96,14 @@
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

<activity
android:name=".CalendarPickerActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

<service
android:name=".assistant.AssistantService"
android:exported="false"
Expand Down
159 changes: 159 additions & 0 deletions android/app/src/main/java/io/clawdroid/CalendarPickerActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package io.clawdroid

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.ClawDroidTheme
import io.clawdroid.core.ui.theme.GlassBorder
import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary
import io.clawdroid.core.ui.theme.DarkCard

class CalendarPickerActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val calendars = queryWritableCalendars()
when {
calendars.isEmpty() -> {
broadcastResult(cancelled = true)
finish()
}
calendars.size == 1 -> {
val (id, name) = calendars.first()
broadcastResult(calendarId = id, calendarName = name)
finish()
}
else -> setContent {
ClawDroidTheme {
CalendarPickerDialog(
calendars = calendars,
onSelect = { cal ->
broadcastResult(calendarId = cal.id, calendarName = cal.displayName)
finish()
},
onCancel = {
broadcastResult(cancelled = true)
finish()
}
)
}
}
}
}

private data class CalendarInfo(val id: Long, val displayName: String)

private fun queryWritableCalendars(): List<CalendarInfo> {
val projection = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
)
val selection =
"${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ${CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR}"
val cursor = contentResolver.query(
CalendarContract.Calendars.CONTENT_URI, projection, selection, null, null
) ?: return emptyList()

return cursor.use {
val result = mutableListOf<CalendarInfo>()
while (it.moveToNext()) {
result += CalendarInfo(it.getLong(0), it.getString(1) ?: "")
}
result
}
}

@Composable
private fun CalendarPickerDialog(
calendars: List<CalendarInfo>,
onSelect: (CalendarInfo) -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
containerColor = DarkCard,
title = {
Text("Select Calendar", color = NeonCyan)
},
text = {
Surface(
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, GlassBorder),
color = DarkCard,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
calendars.forEachIndexed { index, cal ->
Text(
text = cal.displayName,
color = TextPrimary,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(cal) }
.padding(horizontal = 16.dp, vertical = 14.dp)
)
if (index < calendars.lastIndex) {
HorizontalDivider(color = GlassBorder)
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onCancel) {
Text("Cancel", color = TextSecondary)
}
},
)
}

private fun broadcastResult(
calendarId: Long = -1,
calendarName: String = "",
cancelled: Boolean = false,
) {
sendBroadcast(
Intent(ACTION_RESULT)
.setPackage(packageName)
.putExtra(EXTRA_CALENDAR_ID, calendarId.toString())
.putExtra(EXTRA_CALENDAR_NAME, calendarName)
.putExtra(EXTRA_CANCELLED, cancelled)
)
}

companion object {
const val ACTION_RESULT = "io.clawdroid.CALENDAR_PICKER_RESULT"
const val EXTRA_CALENDAR_ID = "calendar_id"
const val EXTRA_CALENDAR_NAME = "calendar_name"
const val EXTRA_CANCELLED = "cancelled"

fun intent(context: Context): Intent =
Intent(context, CalendarPickerActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.clawdroid.assistant

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import io.clawdroid.PermissionRequestActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume

class PermissionRequester(private val context: Context) {

suspend fun request(permission: String): Boolean {
if (ContextCompat.checkSelfPermission(context, permission)
== PackageManager.PERMISSION_GRANTED
) {
return true
}

return withTimeoutOrNull(12_000L) {
suspendCancellableCoroutine { cont ->
val unregistered = AtomicBoolean(false)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val perm = intent.getStringExtra(PermissionRequestActivity.EXTRA_PERMISSION)
if (perm == permission) {
if (!unregistered.compareAndSet(false, true)) return
context.unregisterReceiver(this)
val granted = intent.getBooleanExtra(
PermissionRequestActivity.EXTRA_GRANTED, false
)
if (cont.isActive) cont.resume(granted)
}
}
}

val filter = IntentFilter(PermissionRequestActivity.ACTION_RESULT)
ContextCompat.registerReceiver(
context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED
)

cont.invokeOnCancellation {
if (unregistered.compareAndSet(false, true)) {
try {
context.unregisterReceiver(receiver)
} catch (_: IllegalArgumentException) {
// already unregistered
}
}
}

context.startActivity(
PermissionRequestActivity.intent(context, permission)
)
}
} == true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import io.clawdroid.assistant.actions.*
import java.io.ByteArrayOutputStream

class ToolRequestHandler(
Expand All @@ -34,9 +35,29 @@ class ToolRequestHandler(
private val onAccessibilityNeeded: () -> Unit
) {

private val actionHandlers: List<ActionHandler> = listOf(
AlarmActionHandler(),
CalendarActionHandler(),
ContactsActionHandler(),
CommunicationActionHandler(),
MediaActionHandler(),
NavigationActionHandler(),
DeviceControlActionHandler(),
SettingsActionHandler(),
WebActionHandler(),
ClipboardActionHandler(),
)

private val handlerMap: Map<String, ActionHandler> = actionHandlers
.flatMap { handler -> handler.supportedActions.map { it to handler } }
.toMap()

private val permissionRequester = PermissionRequester(context)

suspend fun handle(request: ToolRequest): ToolResponse {
return try {
when (request.action) {
// Core actions handled directly
"search_apps" -> handleSearchApps(request)
"app_info" -> handleAppInfo(request)
"launch_app" -> handleLaunchApp(request)
Expand All @@ -48,11 +69,17 @@ class ToolRequestHandler(
"keyevent" -> handleKeyEvent(request)
"broadcast" -> handleBroadcast(request)
"intent" -> handleIntent(request)
else -> ToolResponse(
requestId = request.requestId,
success = false,
error = "Unknown action: ${request.action}"
)
// Delegate to category handlers
else -> {
val handler = handlerMap[request.action]
?: return ToolResponse(
requestId = request.requestId,
success = false,
error = "Unknown action: ${request.action}"
)
ensurePermissions(request, handler)?.let { return it }
handler.handle(request, context)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error handling tool request: ${request.action}", e)
Expand All @@ -64,6 +91,42 @@ class ToolRequestHandler(
}
}

private suspend fun ensurePermissions(
request: ToolRequest,
handler: ActionHandler
): ToolResponse? {
val requirements = handler.requiredPermissions(request.action)
if (requirements.isEmpty()) return null

for (req in requirements) {
when (req) {
is PermissionRequirement.Runtime -> {
val granted = permissionRequester.request(req.permission)
if (!granted) {
return ToolResponse(
requestId = request.requestId,
success = false,
error = "Permission denied: ${req.description}. Please grant the permission and try again."
)
}
}
is PermissionRequirement.Special -> {
if (!req.check(context)) {
val intent = req.settingsIntent
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
return ToolResponse(
requestId = request.requestId,
success = false,
error = "${req.description} is not granted. Settings screen has been opened. Please grant the permission and try again."
)
}
}
}
}
return null
}

private fun requireAccessibility(request: ToolRequest): ToolResponse? {
if (!deviceController.isAvailable) {
onAccessibilityNeeded()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.clawdroid.assistant.actions

import android.content.Context
import android.content.Intent
import io.clawdroid.core.data.remote.dto.ToolRequest
import io.clawdroid.core.data.remote.dto.ToolResponse
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive

interface ActionHandler {
val supportedActions: Set<String>
suspend fun handle(request: ToolRequest, context: Context): ToolResponse
fun requiredPermissions(action: String): List<PermissionRequirement> = emptyList()
}

fun ToolRequest.stringParam(name: String): String? =
params?.get(name)?.jsonPrimitive?.contentOrNull

fun ToolRequest.intParam(name: String): Int? =
params?.get(name)?.jsonPrimitive?.intOrNull

fun ToolRequest.boolParam(name: String): Boolean? =
params?.get(name)?.jsonPrimitive?.booleanOrNull

fun ToolRequest.doubleParam(name: String): Double? =
params?.get(name)?.jsonPrimitive?.doubleOrNull

fun launchActivity(request: ToolRequest, context: Context, intent: Intent, successMessage: String): ToolResponse {
return try {
context.startActivity(intent)
ToolResponse(request.requestId, true, result = successMessage)
} catch (e: Exception) {
ToolResponse(request.requestId, false, error = e.message ?: "Unknown error")
}
}
Loading