mirror of
https://github.com/frido22/cellclaw
synced 2026-05-10 22:43:50 +00:00
Add Quick Settings tile for one-tap screenshot & explain
- New CellClawTileService with @EntryPoint for Hilt, tile state reflecting AgentState (ACTIVE/INACTIVE labels), and onClick screenshot+explain flow posting result to CHANNEL_ALERTS notification (ID 200) - New ic_qs_cellclaw.xml 24dp monochrome vector for QS tile icon - Register CellClawTileService in manifest with BIND_QUICK_SETTINGS_TILE permission, QS_TILE intent-filter, and ACTIVE_TILE metadata Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -103,6 +103,21 @@
|
||||
android:resource="@xml/accessibility_config" />
|
||||
</service>
|
||||
|
||||
<!-- Quick Settings Tile -->
|
||||
<service
|
||||
android:name=".service.CellClawTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_qs_cellclaw"
|
||||
android:label="CellClaw"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<!-- Notification Listener Service -->
|
||||
<service
|
||||
android:name=".service.CellClawNotificationListener"
|
||||
|
||||
177
app/src/main/kotlin/com/cellclaw/service/CellClawTileService.kt
Normal file
177
app/src/main/kotlin/com/cellclaw/service/CellClawTileService.kt
Normal file
@@ -0,0 +1,177 @@
|
||||
package com.cellclaw.service
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.cellclaw.CellClawApp
|
||||
import com.cellclaw.R
|
||||
import com.cellclaw.agent.AgentLoop
|
||||
import com.cellclaw.agent.AgentState
|
||||
import com.cellclaw.tools.ScreenCaptureTool
|
||||
import com.cellclaw.tools.VisionAnalyzeTool
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class CellClawTileService : TileService() {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface TileEntryPoint {
|
||||
fun agentLoop(): AgentLoop
|
||||
fun screenCaptureTool(): ScreenCaptureTool
|
||||
fun visionAnalyzeTool(): VisionAnalyzeTool
|
||||
}
|
||||
|
||||
private val tileScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private fun getEntryPoint(): TileEntryPoint =
|
||||
EntryPointAccessors.fromApplication(applicationContext, TileEntryPoint::class.java)
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
updateTileState()
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
|
||||
val tile = qsTile ?: return
|
||||
val entryPoint = getEntryPoint()
|
||||
val agentLoop = entryPoint.agentLoop()
|
||||
|
||||
// Don't capture if agent isn't running
|
||||
if (agentLoop.state.value == AgentState.ERROR) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set tile to capturing state
|
||||
tile.state = Tile.STATE_UNAVAILABLE
|
||||
tile.label = "Capturing..."
|
||||
tile.updateTile()
|
||||
|
||||
tileScope.launch {
|
||||
try {
|
||||
val screenCapture = entryPoint.screenCaptureTool()
|
||||
val visionAnalyze = entryPoint.visionAnalyzeTool()
|
||||
|
||||
val captureResult = screenCapture.execute(
|
||||
buildJsonObject { put("include_base64", false) }
|
||||
)
|
||||
if (!captureResult.success) {
|
||||
postResultNotification("Screenshot failed: ${captureResult.error}")
|
||||
restoreTile()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val filePath = captureResult.data?.let { data ->
|
||||
(data as? JsonObject)?.get("file_path")
|
||||
?.let { (it as? JsonPrimitive)?.content }
|
||||
}
|
||||
if (filePath == null) {
|
||||
postResultNotification("Screenshot failed: no file path")
|
||||
restoreTile()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val analyzeResult = visionAnalyze.execute(buildJsonObject {
|
||||
put("file_path", filePath)
|
||||
put("question", "Describe what you see on the screen. What app is open? What is the user looking at?")
|
||||
})
|
||||
|
||||
if (analyzeResult.success) {
|
||||
val analysis = analyzeResult.data?.let { data ->
|
||||
(data as? JsonObject)?.get("analysis")
|
||||
?.let { (it as? JsonPrimitive)?.content }
|
||||
} ?: "Analysis complete"
|
||||
postResultNotification(analysis)
|
||||
} else {
|
||||
postResultNotification("Analysis failed: ${analyzeResult.error}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Tile screenshot+explain failed: ${e.message}")
|
||||
postResultNotification("Error: ${e.message}")
|
||||
} finally {
|
||||
restoreTile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
tileScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun updateTileState() {
|
||||
val tile = qsTile ?: return
|
||||
try {
|
||||
val agentLoop = getEntryPoint().agentLoop()
|
||||
when (agentLoop.state.value) {
|
||||
AgentState.IDLE -> {
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = "CellClaw"
|
||||
}
|
||||
AgentState.THINKING -> {
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = "Thinking..."
|
||||
}
|
||||
AgentState.EXECUTING_TOOLS -> {
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = "Working..."
|
||||
}
|
||||
AgentState.WAITING_APPROVAL -> {
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = "Approval"
|
||||
}
|
||||
AgentState.PAUSED -> {
|
||||
tile.state = Tile.STATE_INACTIVE
|
||||
tile.label = "Paused"
|
||||
}
|
||||
AgentState.ERROR -> {
|
||||
tile.state = Tile.STATE_INACTIVE
|
||||
tile.label = "Error"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tile.state = Tile.STATE_INACTIVE
|
||||
tile.label = "CellClaw"
|
||||
}
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
private fun restoreTile() {
|
||||
try {
|
||||
updateTileState()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun postResultNotification(text: String) {
|
||||
val notification = NotificationCompat.Builder(this, CellClawApp.CHANNEL_ALERTS)
|
||||
.setContentTitle("Screen Analysis")
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(TILE_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CellClawTile"
|
||||
private const val TILE_NOTIFICATION_ID = 200
|
||||
}
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_qs_cellclaw.xml
Normal file
11
app/src/main/res/drawable/ic_qs_cellclaw.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
|
||||
</vector>
|
||||
Reference in New Issue
Block a user