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:
JordanTheJet
2026-02-20 16:41:36 -05:00
parent c0a4adc22b
commit 529504e89b
3 changed files with 203 additions and 0 deletions

View File

@@ -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"

View 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
}
}

View 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>