revert: remove workflow automation, keep overlay and stop_goal
Remove all workflow-related code from PR #6 (input classifier, workflow parser, notification listener, workflow CRUD handlers). Keep the overlay, stop_goal, AbortSignal threading, and OkHttp engine switch. Add v2 design doc for safer workflow implementation.
This commit is contained in:
@@ -53,14 +53,6 @@
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".workflow.WorkflowNotificationService"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -2,17 +2,13 @@ package com.thisux.droidclaw
|
||||
|
||||
import android.app.Application
|
||||
import com.thisux.droidclaw.data.SettingsStore
|
||||
import com.thisux.droidclaw.data.WorkflowStore
|
||||
|
||||
class DroidClawApp : Application() {
|
||||
lateinit var settingsStore: SettingsStore
|
||||
private set
|
||||
lateinit var workflowStore: WorkflowStore
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settingsStore = SettingsStore(this)
|
||||
workflowStore = WorkflowStore(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,14 @@ import com.thisux.droidclaw.model.PongMessage
|
||||
import com.thisux.droidclaw.model.ResultResponse
|
||||
import com.thisux.droidclaw.model.ScreenResponse
|
||||
import com.thisux.droidclaw.model.ServerMessage
|
||||
import com.thisux.droidclaw.model.Workflow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class CommandRouter(
|
||||
private val webSocket: ReliableWebSocket,
|
||||
private val captureManager: ScreenCaptureManager?,
|
||||
private val onWorkflowSync: (suspend (List<Workflow>) -> Unit)? = null,
|
||||
private val onWorkflowCreated: (suspend (Workflow) -> Unit)? = null,
|
||||
private val onWorkflowDeleted: (suspend (String) -> Unit)? = null
|
||||
private val captureManager: ScreenCaptureManager?
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "CommandRouter"
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
}
|
||||
|
||||
val currentGoalStatus = MutableStateFlow(GoalStatus.Idle)
|
||||
@@ -77,38 +71,6 @@ class CommandRouter(
|
||||
Log.i(TAG, "Goal failed: ${msg.message}")
|
||||
}
|
||||
|
||||
"workflow_created" -> {
|
||||
val wfJson = msg.workflowJson ?: return
|
||||
try {
|
||||
val wf = json.decodeFromString<Workflow>(wfJson)
|
||||
onWorkflowCreated?.invoke(wf)
|
||||
Log.i(TAG, "Workflow created: ${wf.name}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse workflow_created: ${e.message}")
|
||||
}
|
||||
}
|
||||
"workflow_synced" -> {
|
||||
val wfsJson = msg.workflowsJson ?: return
|
||||
try {
|
||||
val wfs = json.decodeFromString<List<Workflow>>(wfsJson)
|
||||
onWorkflowSync?.invoke(wfs)
|
||||
Log.i(TAG, "Workflows synced: ${wfs.size} workflows")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse workflow_synced: ${e.message}")
|
||||
}
|
||||
}
|
||||
"workflow_deleted" -> {
|
||||
val id = msg.workflowId ?: return
|
||||
onWorkflowDeleted?.invoke(id)
|
||||
Log.i(TAG, "Workflow deleted: $id")
|
||||
}
|
||||
"workflow_goal" -> {
|
||||
val goal = msg.goal ?: return
|
||||
Log.i(TAG, "Workflow-triggered goal: $goal")
|
||||
// Submit as a regular goal via the WebSocket
|
||||
webSocket.sendTyped(com.thisux.droidclaw.model.GoalMessage(text = goal))
|
||||
}
|
||||
|
||||
else -> Log.w(TAG, "Unknown message type: ${msg.type}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,6 @@ import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import com.thisux.droidclaw.model.StopGoalMessage
|
||||
import com.thisux.droidclaw.model.WorkflowCreateMessage
|
||||
import com.thisux.droidclaw.model.WorkflowDeleteMessage
|
||||
import com.thisux.droidclaw.model.WorkflowSyncMessage
|
||||
import com.thisux.droidclaw.model.WorkflowTriggerMessage
|
||||
import com.thisux.droidclaw.model.WorkflowUpdateMessage
|
||||
import com.thisux.droidclaw.overlay.AgentOverlay
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -127,12 +122,7 @@ class ConnectionService : LifecycleService() {
|
||||
}
|
||||
webSocket = ws
|
||||
|
||||
val router = CommandRouter(
|
||||
ws, captureManager,
|
||||
onWorkflowSync = { workflows -> app.workflowStore.replaceAll(workflows) },
|
||||
onWorkflowCreated = { workflow -> app.workflowStore.save(workflow) },
|
||||
onWorkflowDeleted = { id -> app.workflowStore.delete(id) }
|
||||
)
|
||||
val router = CommandRouter(ws, captureManager)
|
||||
commandRouter = router
|
||||
|
||||
launch {
|
||||
@@ -154,8 +144,6 @@ class ConnectionService : LifecycleService() {
|
||||
val apps = getInstalledApps()
|
||||
webSocket?.sendTyped(AppsMessage(apps = apps))
|
||||
Log.i(TAG, "Sent ${apps.size} installed apps to server")
|
||||
// Sync workflows from server
|
||||
webSocket?.sendTyped(WorkflowSyncMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,26 +181,6 @@ class ConnectionService : LifecycleService() {
|
||||
webSocket?.sendTyped(StopGoalMessage())
|
||||
}
|
||||
|
||||
fun sendWorkflowCreate(description: String) {
|
||||
webSocket?.sendTyped(WorkflowCreateMessage(description = description))
|
||||
}
|
||||
|
||||
fun sendWorkflowUpdate(workflowId: String, enabled: Boolean?) {
|
||||
webSocket?.sendTyped(WorkflowUpdateMessage(workflowId = workflowId, enabled = enabled))
|
||||
}
|
||||
|
||||
fun sendWorkflowDelete(workflowId: String) {
|
||||
webSocket?.sendTyped(WorkflowDeleteMessage(workflowId = workflowId))
|
||||
}
|
||||
|
||||
fun sendWorkflowSync() {
|
||||
webSocket?.sendTyped(WorkflowSyncMessage())
|
||||
}
|
||||
|
||||
fun sendWorkflowTrigger(msg: WorkflowTriggerMessage) {
|
||||
webSocket?.sendTyped(msg)
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
overlay?.hide()
|
||||
webSocket?.disconnect()
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.thisux.droidclaw.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.thisux.droidclaw.model.Workflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val WORKFLOWS_KEY = stringPreferencesKey("workflows_json")
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
class WorkflowStore(private val context: Context) {
|
||||
|
||||
val workflows: Flow<List<Workflow>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[WORKFLOWS_KEY] ?: "[]"
|
||||
try { json.decodeFromString<List<Workflow>>(raw) } catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
suspend fun save(workflow: Workflow) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val list = currentList(prefs).toMutableList()
|
||||
val idx = list.indexOfFirst { it.id == workflow.id }
|
||||
if (idx >= 0) list[idx] = workflow else list.add(workflow)
|
||||
prefs[WORKFLOWS_KEY] = json.encodeToString(list)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(workflowId: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val list = currentList(prefs).filter { it.id != workflowId }
|
||||
prefs[WORKFLOWS_KEY] = json.encodeToString(list)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceAll(workflows: List<Workflow>) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[WORKFLOWS_KEY] = json.encodeToString(workflows)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentList(prefs: androidx.datastore.preferences.core.Preferences): List<Workflow> {
|
||||
val raw = prefs[WORKFLOWS_KEY] ?: "[]"
|
||||
return try { json.decodeFromString<List<Workflow>>(raw) } catch (_: Exception) { emptyList() }
|
||||
}
|
||||
}
|
||||
@@ -76,39 +76,6 @@ data class StopGoalMessage(
|
||||
val type: String = "stop_goal"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowCreateMessage(
|
||||
val type: String = "workflow_create",
|
||||
val description: String // natural-language workflow description
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowUpdateMessage(
|
||||
val type: String = "workflow_update",
|
||||
val workflowId: String,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowDeleteMessage(
|
||||
val type: String = "workflow_delete",
|
||||
val workflowId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowSyncMessage(
|
||||
val type: String = "workflow_sync"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowTriggerMessage(
|
||||
val type: String = "workflow_trigger",
|
||||
val workflowId: String,
|
||||
val notificationApp: String? = null,
|
||||
val notificationTitle: String? = null,
|
||||
val notificationText: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerMessage(
|
||||
val type: String,
|
||||
@@ -139,9 +106,5 @@ data class ServerMessage(
|
||||
val intentUri: String? = null,
|
||||
val intentType: String? = null,
|
||||
val intentExtras: Map<String, String>? = null,
|
||||
val setting: String? = null,
|
||||
// Workflow fields
|
||||
val workflowId: String? = null,
|
||||
val workflowJson: String? = null, // single workflow as JSON
|
||||
val workflowsJson: String? = null // array of workflows as JSON (for sync)
|
||||
val setting: String? = null
|
||||
)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.thisux.droidclaw.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class TriggerType {
|
||||
notification
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MatchMode {
|
||||
contains, exact, regex
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TriggerCondition(
|
||||
val field: String, // "app_package", "title", "text"
|
||||
val matchMode: MatchMode,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Workflow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String, // original natural-language input
|
||||
val triggerType: TriggerType = TriggerType.notification,
|
||||
val conditions: List<TriggerCondition> = emptyList(),
|
||||
val goalTemplate: String, // sent to agent as a goal
|
||||
val enabled: Boolean = true,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.thisux.droidclaw.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,49 +15,35 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.thisux.droidclaw.DroidClawApp
|
||||
import com.thisux.droidclaw.connection.ConnectionService
|
||||
import com.thisux.droidclaw.model.ConnectionState
|
||||
import com.thisux.droidclaw.model.GoalStatus
|
||||
import com.thisux.droidclaw.model.Workflow
|
||||
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as DroidClawApp
|
||||
val connectionState by ConnectionService.connectionState.collectAsState()
|
||||
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
|
||||
val steps by ConnectionService.currentSteps.collectAsState()
|
||||
val currentGoal by ConnectionService.currentGoal.collectAsState()
|
||||
val errorMessage by ConnectionService.errorMessage.collectAsState()
|
||||
val workflows by app.workflowStore.workflows.collectAsState(initial = emptyList())
|
||||
|
||||
var goalInput by remember { mutableStateOf("") }
|
||||
|
||||
@@ -124,7 +108,7 @@ fun HomeScreen() {
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Goal Input — same field for goals and workflows
|
||||
// Goal Input
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -132,7 +116,7 @@ fun HomeScreen() {
|
||||
OutlinedTextField(
|
||||
value = goalInput,
|
||||
onValueChange = { goalInput = it },
|
||||
label = { Text("Goal or workflow...") },
|
||||
label = { Text("Enter a goal...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = connectionState == ConnectionState.Connected && goalStatus != GoalStatus.Running,
|
||||
singleLine = true
|
||||
@@ -209,102 +193,5 @@ fun HomeScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
// Saved Workflows section
|
||||
if (workflows.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
WorkflowsSection(workflows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkflowsSection(workflows: List<Workflow>) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Saved Workflows (${workflows.size})",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
workflows.forEach { wf ->
|
||||
WorkflowChip(wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkflowChip(workflow: Workflow) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (workflow.enabled) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = workflow.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = workflow.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = workflow.enabled,
|
||||
onCheckedChange = { enabled ->
|
||||
ConnectionService.instance?.sendWorkflowUpdate(workflow.id, enabled)
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
ConnectionService.instance?.sendWorkflowDelete(workflow.id)
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import com.thisux.droidclaw.DroidClawApp
|
||||
import com.thisux.droidclaw.accessibility.DroidClawAccessibilityService
|
||||
import com.thisux.droidclaw.capture.ScreenCaptureManager
|
||||
import com.thisux.droidclaw.util.BatteryOptimization
|
||||
import com.thisux.droidclaw.workflow.WorkflowNotificationService
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -77,9 +76,6 @@ fun SettingsScreen() {
|
||||
}
|
||||
var isBatteryExempt by remember { mutableStateOf(BatteryOptimization.isIgnoringBatteryOptimizations(context)) }
|
||||
var hasOverlayPermission by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
var isNotificationListenerEnabled by remember {
|
||||
mutableStateOf(WorkflowNotificationService.isEnabled(context))
|
||||
}
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
@@ -90,7 +86,6 @@ fun SettingsScreen() {
|
||||
hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent()
|
||||
isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(context)
|
||||
hasOverlayPermission = Settings.canDrawOverlays(context)
|
||||
isNotificationListenerEnabled = WorkflowNotificationService.isEnabled(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
@@ -205,16 +200,6 @@ fun SettingsScreen() {
|
||||
}
|
||||
)
|
||||
|
||||
ChecklistItem(
|
||||
label = "Notification listener (for workflows)",
|
||||
isOk = isNotificationListenerEnabled,
|
||||
actionLabel = "Enable",
|
||||
onAction = {
|
||||
context.startActivity(
|
||||
Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.thisux.droidclaw.workflow
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.thisux.droidclaw.DroidClawApp
|
||||
import com.thisux.droidclaw.connection.ConnectionService
|
||||
import com.thisux.droidclaw.model.ConnectionState
|
||||
import com.thisux.droidclaw.model.MatchMode
|
||||
import com.thisux.droidclaw.model.TriggerCondition
|
||||
import com.thisux.droidclaw.model.Workflow
|
||||
import com.thisux.droidclaw.model.WorkflowTriggerMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class WorkflowNotificationService : NotificationListenerService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WorkflowNotifSvc"
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val flat = Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
"enabled_notification_listeners"
|
||||
) ?: return false
|
||||
val ourComponent = ComponentName(context, WorkflowNotificationService::class.java)
|
||||
return flat.contains(ourComponent.flattenToString())
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
sbn ?: return
|
||||
val pkg = sbn.packageName ?: return
|
||||
// Ignore our own notifications
|
||||
if (pkg == packageName) return
|
||||
|
||||
val extras = sbn.notification?.extras ?: return
|
||||
val title = extras.getCharSequence("android.title")?.toString() ?: ""
|
||||
val text = extras.getCharSequence("android.text")?.toString() ?: ""
|
||||
|
||||
Log.d(TAG, "Notification from=$pkg title=$title text=$text")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val app = application as DroidClawApp
|
||||
val workflows = app.workflowStore.workflows.first()
|
||||
val enabled = workflows.filter { it.enabled }
|
||||
|
||||
for (wf in enabled) {
|
||||
if (matchesWorkflow(wf, pkg, title, text)) {
|
||||
Log.i(TAG, "Workflow '${wf.name}' matched notification from $pkg")
|
||||
triggerWorkflow(wf, pkg, title, text)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process notification for workflows: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesWorkflow(
|
||||
wf: Workflow,
|
||||
pkg: String,
|
||||
title: String,
|
||||
text: String
|
||||
): Boolean {
|
||||
if (wf.conditions.isEmpty()) return false
|
||||
return wf.conditions.all { cond -> matchesCondition(cond, pkg, title, text) }
|
||||
}
|
||||
|
||||
private fun matchesCondition(
|
||||
cond: TriggerCondition,
|
||||
pkg: String,
|
||||
title: String,
|
||||
text: String
|
||||
): Boolean {
|
||||
val actual = when (cond.field) {
|
||||
"app_package" -> pkg
|
||||
"title" -> title
|
||||
"text" -> text
|
||||
else -> return false
|
||||
}
|
||||
return when (cond.matchMode) {
|
||||
MatchMode.contains -> actual.contains(cond.value, ignoreCase = true)
|
||||
MatchMode.exact -> actual.equals(cond.value, ignoreCase = true)
|
||||
MatchMode.regex -> try {
|
||||
Regex(cond.value, RegexOption.IGNORE_CASE).containsMatchIn(actual)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerWorkflow(wf: Workflow, pkg: String, title: String, text: String) {
|
||||
val svc = ConnectionService.instance ?: return
|
||||
if (ConnectionService.connectionState.value != ConnectionState.Connected) {
|
||||
Log.w(TAG, "Cannot trigger workflow '${wf.name}': not connected")
|
||||
return
|
||||
}
|
||||
svc.sendWorkflowTrigger(
|
||||
WorkflowTriggerMessage(
|
||||
workflowId = wf.id,
|
||||
notificationApp = pkg,
|
||||
notificationTitle = title,
|
||||
notificationText = text
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user