diff --git a/README.md b/README.md
index c651f50..fdd9ab5 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Requires Android 8 or above.
- Microphone mute switch
- Wake/preannounce sounds
- Timers
+- [Tasker integration](TASKER.md)
# Todo
- Improved Assist feedback on screen
diff --git a/TASKER.md b/TASKER.md
new file mode 100644
index 0000000..ff4c711
--- /dev/null
+++ b/TASKER.md
@@ -0,0 +1,27 @@
+# State of the Tasker integration
+
+Ava integrates with the paid [Tasker](https://tasker.joaoapps.com/) app to support automations.
+We will be tracking use cases and progress on [this issue](https://github.com/brownard/Ava/issues/49).
+
+You can also use the [Home Assistant Plug-In for Tasker](https://github.com/MarkAdamson/home-assistant-plugin-for-tasker)
+to query the voice assistant state via the Home Assistant server.
+
+> [!NOTE]
+> The project will not provide general support for Tasker. You should check out their website and
+> the `r/tasker` subreddit for support.
+
+## State Condition: Ava Activity
+
+The `Ava Activity` state can be used to trigger tasks when the voice satellite enters or exits
+specific states:
+
+- A conversation is happening (listening, thinking, replying)
+- Voice timers are active (ringing, running, paused)
+
+This [example profile](taskerprofile://H4sIAAAAAAAA/71YXZOiRhR9Hn8FRVU2Lwmfik6WoQpddsuKqxPASVJ5oHqlYUmwsZrW7Pz7dNPgguIow1TmYcRzb597+tIf92r6IP8H4g+AACHHD6IohIfkQVRFgRweREMyJE0RrcGd+YizKElh4bSjz5ooHOCDqDHjnbkJAYGWOh7rqj5R7sfDe92UOcjMsG42JsZ4rJoyPJqjFMS5NTFl/sCgJLQ0U6b/2ZdtEioWJSw+S0C1hgWgFgDaQss+AMHekOSQkGdTZgizeIRGKVRvMqTURVPVCFCvpyzZwNpIjnKHLKSyNUMdK8pQG1EbAwrTdI/CMh0AxwpnvDOfQJoX4AGkJcZothL5N4syTJ6jbI/BVkqzDUihlCACEZHgN4KBNF2s3ak1y9AB4jxB8S8CwXs48JMtxIJLgTNsj9ARY+I6hHmFtJ/J8w5af4MDkFKAYskjmEbvFpdzHINXU7WqGRyBM5fT6NMsSyFA9UENcgSJFFIwTfLNVwmgEGdJKJFiuUuus3Ce7KUfPNnu3J4uHM96l5L3fEY2xuCZ74Z3MXnPDAFgWNCRU2HDf4AYDxyMMyzM6OoZzCOBfIUY/pgLAAmQGX4S6BwISNB3SGArjUWWe4R+vXK1Ur7N41L8Z5jnIL5Z/5a795iCWk1Brr0WhtHN3fHdvm5J8OX01+J0xXcXcPvCLLZKLtkzf75aBvPl49oPZgvb8yy2y+A3sN3RfcUElQPoqVcdXXO025Mr2i7z91J36Wjow9ZdkLteLh335nyx4xPiTiIbEfoJfIOUndN1kOT84bt28LvtBbPV8uP809p1PgRT5+PKdcrTuC/L24i5eO6/CW8Hja7z29rxfErgzz87q7VvGQr9u03I+eA+kU9zMqf3bHzrUr7A1kFPda4FrvO4sGdO8Kvzp2fdFr19bN/gfTbTC4S3yMr3X74UtWB4y7apeXcjf902OB1fxiSsfAx2YJ9THREtWansBtbid1HAuVNjNOaFa5mcJtbmeCVOw6s5nlfDzUAl1uZ4LVDdixf3Mqvuefkv8/qff6GrrWoEVN5h6OLp/WOyKubMXbvkfryuyruKdgVREtcurxof3f0Vn075QMr6N7nofuSi/WH9m1w2cOyZ9XzFCBZEb+ngVGWkTpSLHZyqj+61Sb2Do42afmzXWOvlbTCESFih750YU54hrnRDylZsXLVirLGaKEa9xarNSynnNVKUamacroVZbWNWtSvMJW/Dpr5g00qbcabHlFl+z/I8bMvzyNDG95fzPNFHmnKS52FrnqOoS6J1pUc21JfS0WbTX7ANm7b/9bWq1+fR8lr5J//JxBr8B+9E9JtAEQAA)
+turns the screen on when conversing and when timers are running or ringing (but ignores paused timers).
+
+> [!IMPORTANT]
+> Tasker only runs tasks when the desired state is achieved, but not when the reason for this state
+> change (for example timer running -> ringing). For this, you have to setup several profiles with
+> different conditions.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bc1267e..c6952bd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -76,6 +76,7 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.documentfile)
implementation(libs.timber)
+ implementation(libs.taskerpluginlibrary)
ksp(libs.hilt.android.compiler)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c7c72b..034536d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,6 +36,16 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt
index 9aa8fad..19aa11f 100644
--- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt
+++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt
@@ -28,10 +28,14 @@ import com.example.ava.settings.MicrophoneSettingsStore
import com.example.ava.settings.PlayerSettingsStore
import com.example.ava.settings.VoiceSatelliteSettings
import com.example.ava.settings.VoiceSatelliteSettingsStore
+import com.example.ava.tasker.ActivityConfigAvaActivity
+import com.example.ava.tasker.AvaActivityRunner
import com.example.ava.utils.translate
import com.example.ava.wakelocks.WifiWakeLock
+import com.joaomgcd.taskerpluginlibrary.extensions.requestQuery
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
@@ -93,6 +97,7 @@ class VoiceSatelliteService() : LifecycleService() {
createVoiceSatelliteServiceNotificationChannel(this)
updateNotificationOnStateChanges()
startSettingsWatcher()
+ startTaskerStateObserver()
}
class VoiceSatelliteBinder(val service: VoiceSatelliteService) : Binder()
@@ -149,6 +154,13 @@ class VoiceSatelliteService() : LifecycleService() {
}.launchIn(lifecycleScope)
}
+ private fun startTaskerStateObserver() {
+ combine(voiceSatelliteState, voiceTimers) { state, timers ->
+ AvaActivityRunner.updateState(state, timers)
+ ActivityConfigAvaActivity::class.java.requestQuery(this)
+ }.launchIn(lifecycleScope)
+ }
+
private suspend fun createVoiceSatellite(satelliteSettings: VoiceSatelliteSettings): VoiceSatellite {
val microphoneSettings = microphoneSettingsStore.get()
val audioInput = VoiceSatelliteAudioInputImpl(
diff --git a/app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt b/app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt
new file mode 100644
index 0000000..242ffbd
--- /dev/null
+++ b/app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt
@@ -0,0 +1,124 @@
+package com.example.ava.tasker
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.example.ava.R
+import com.example.ava.ui.screens.settings.components.SwitchSetting
+import com.example.ava.ui.theme.AvaTheme
+import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig
+import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelper
+import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
+
+class AvaActivityHelper(config: TaskerPluginConfig) :
+ TaskerPluginConfigHelper(config) {
+ override val runnerClass = AvaActivityRunner::class.java
+ override val inputClass = AvaActivityInput::class.java
+ override val outputClass = Unit::class.java
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+class ActivityConfigAvaActivity : ComponentActivity(),
+ TaskerPluginConfig {
+ override val context get() = applicationContext
+ private val taskerHelper by lazy { AvaActivityHelper(this) }
+
+ private var inputState = AvaActivityInput()
+
+ override val inputForTasker: TaskerInput
+ get() = TaskerInput(inputState)
+
+ override fun assignFromInput(input: TaskerInput) {
+ inputState = input.regular
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ AvaTheme {
+ var state by remember { mutableStateOf(inputState) }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ colors = topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ title = {
+ Text(stringResource(R.string.tasker_condition_ava_activity))
+ },
+ actions = {
+ TextButton(
+ onClick = {
+ inputState = state
+ taskerHelper.finishForTasker()
+ }
+ ) {
+ Text(stringResource(R.string.label_save))
+ }
+ }
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ ) {
+ Text(
+ text = stringResource(R.string.tasker_condition_description),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(16.dp)
+ )
+ SwitchSetting(
+ name = stringResource(R.string.tasker_filter_conversing),
+ description = stringResource(R.string.tasker_filter_conversing_description),
+ value = state.conversing,
+ onCheckedChange = { state = state.copy(conversing = it) }
+ )
+ SwitchSetting(
+ name = stringResource(R.string.tasker_filter_timer_ringing),
+ description = stringResource(R.string.tasker_filter_timer_ringing_description),
+ value = state.timerRinging,
+ onCheckedChange = { state = state.copy(timerRinging = it) }
+ )
+ SwitchSetting(
+ name = stringResource(R.string.tasker_filter_timer_running),
+ description = stringResource(R.string.tasker_filter_timer_running_description),
+ value = state.timerRunning,
+ onCheckedChange = { state = state.copy(timerRunning = it) }
+ )
+ SwitchSetting(
+ name = stringResource(R.string.tasker_filter_timer_paused),
+ description = stringResource(R.string.tasker_filter_timer_paused_description),
+ value = state.timerPaused,
+ onCheckedChange = { state = state.copy(timerPaused = it) }
+ )
+ }
+ }
+ }
+ }
+ taskerHelper.onCreate()
+ }
+}
diff --git a/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt b/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt
new file mode 100644
index 0000000..ca2a1e4
--- /dev/null
+++ b/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt
@@ -0,0 +1,94 @@
+package com.example.ava.tasker
+
+import android.content.Context
+import com.example.ava.esphome.EspHomeState
+import com.example.ava.esphome.voicesatellite.Listening
+import com.example.ava.esphome.voicesatellite.Processing
+import com.example.ava.esphome.voicesatellite.Responding
+import com.example.ava.esphome.voicesatellite.VoiceTimer
+import com.joaomgcd.taskerpluginlibrary.condition.TaskerPluginRunnerConditionState
+import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
+import com.joaomgcd.taskerpluginlibrary.input.TaskerInputField
+import com.joaomgcd.taskerpluginlibrary.input.TaskerInputRoot
+import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultCondition
+import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionSatisfied
+import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionUnsatisfied
+import timber.log.Timber
+
+/**
+ * Holds the tasker configuration for evaluating the state condition.
+ *
+ * Because of limitations in Tasker, it cannot be a data class, so toString and copy
+ * are implemented manually.
+ */
+@TaskerInputRoot
+class AvaActivityInput @JvmOverloads constructor(
+ @field:TaskerInputField("conversing", labelResIdName = "tasker_filter_conversing")
+ val conversing: Boolean = true,
+ @field:TaskerInputField("timer_ringing", labelResIdName = "tasker_filter_timer_ringing")
+ val timerRinging: Boolean = true,
+ @field:TaskerInputField("timer_running", labelResIdName = "tasker_filter_timer_running")
+ val timerRunning: Boolean = false,
+ @field:TaskerInputField("timer_paused", labelResIdName = "tasker_filter_timer_paused")
+ val timerPaused: Boolean = false
+) {
+ override fun toString(): String =
+ "AvaActivityInput(conversing=$conversing, timerRinging=$timerRinging, timerRunning=$timerRunning, timerPaused=$timerPaused)"
+
+ fun copy(
+ conversing: Boolean = this.conversing,
+ timerRinging: Boolean = this.timerRinging,
+ timerRunning: Boolean = this.timerRunning,
+ timerPaused: Boolean = this.timerPaused
+ ): AvaActivityInput = AvaActivityInput(conversing, timerRinging, timerRunning, timerPaused)
+}
+
+val conversingStates = setOf(Listening, Processing, Responding)
+
+/**
+ * Holds the condition evaluation logic: Tasker calls getSatisfiedCondition when
+ * we signal a change (updateState is called by VoiceSatelliteService).
+ *
+ * Because of limitations in Tasker, we cannot hold a reactive state, so we get
+ * state pushed in through a companion object.
+ */
+class AvaActivityRunner :
+ TaskerPluginRunnerConditionState() {
+ override fun getSatisfiedCondition(
+ context: Context,
+ input: TaskerInput,
+ update: Unit?
+ ): TaskerPluginResultCondition {
+ val filter = input.regular
+ Timber.d("Evaluating tasker condition: $filter - $currentState - $currentTimers")
+
+ val timers = currentTimers ?: return TaskerPluginResultConditionUnsatisfied()
+ val satisfied = when {
+ filter.conversing && currentState in conversingStates -> true
+ filter.timerRinging && timers.any { it is VoiceTimer.Ringing } -> true
+ filter.timerRunning && timers.any { it is VoiceTimer.Running } -> true
+ filter.timerPaused && timers.any { it is VoiceTimer.Paused } -> true
+ else -> false
+ }
+
+ Timber.d("Evaluated to $satisfied")
+ return if (satisfied) {
+ TaskerPluginResultConditionSatisfied(context)
+ } else {
+ TaskerPluginResultConditionUnsatisfied()
+ }
+ }
+
+ companion object {
+ var currentState: EspHomeState? = null
+ private set
+ var currentTimers: List? = null
+ private set
+
+ fun updateState(state: EspHomeState, timers: List) {
+ Timber.d("Tasker state updating: state=$state, timers=${timers.size}")
+ currentState = state
+ currentTimers = timers
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fbc365c..eecbf4e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3,6 +3,7 @@
Start
Stop
OK
+ Save
Cancel
Disabled
Settings
@@ -33,4 +34,15 @@
Processing
Responding
Server Error: %1$s
+
+ Ava Activity
+ Condition is satisfied if any enabled filter matches
+ Conversing
+ Listening or replying to a voice command
+ Timer Ringing
+ A voice timer is ringing
+ Timer Running
+ At least one timer is counting down
+ Timer Paused
+ At least one paused timer remains
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index f3f6344..41f1287 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,4 @@
-
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1d761e7..0aa5688 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -27,6 +27,7 @@ media3CommonKtx = "1.9.2"
media3Exoplayer = "1.9.2"
material3 = "1.4.0"
documentfile = "1.1.0"
+taskerpluginlibrary = "0.4.10"
timber = "5.0.1"
[libraries]
@@ -63,6 +64,7 @@ androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplaye
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3Exoplayer" }
material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
+taskerpluginlibrary = { module = "com.joaomgcd:taskerpluginlibrary", version.ref = "taskerpluginlibrary" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
[plugins]