diff --git a/experimental/src/androidMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.android.kt b/experimental/src/androidMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.android.kt index 4d5861ba4..22c8c960f 100644 --- a/experimental/src/androidMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.android.kt +++ b/experimental/src/androidMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.android.kt @@ -15,10 +15,9 @@ actual fun createTaskerReminderIntegration(): ReminderIntegration = TaskerRemind /** * Routes reminders to Tasker via [TaskerEndpoint]. The reminder text is sent as the payload with a - * `messageType=reminder` extra, plus the optional `deadline`, `notify_before_seconds` and `list` - * extras so the Tasker side can branch on them. Tasker has no native list concept, so - * [searchForList] echoes the requested name back and it is forwarded verbatim as the `list` extra; - * honouring the lead time is up to the user's Tasker profile. + * `messageType=reminder` extra, plus the [taskerReminderExtras] (due date, list, raw transcript). + * Tasker has no native list concept, so [searchForList] echoes the requested name back and it is + * forwarded verbatim as the `list` extra; honouring the lead time is up to the user's Tasker profile. */ class TaskerReminderIntegration : ReminderIntegration, KoinComponent { private val tokenStorage: IntegrationTokenStorage by inject() @@ -29,19 +28,11 @@ class TaskerReminderIntegration : ReminderIntegration, KoinComponent { listId: String?, notifyBefore: Duration?, source: ItemSource?, - ): String { - // The due date goes to Tasker as a UTC timestamp: kotlin.time.Instant.toString() renders - // the absolute due-time in ISO-8601 UTC (e.g. 2026-06-18T16:00:00Z), regardless of local - // timezone. The lead time (when set alongside a due date) is passed as whole seconds. - val extras = buildMap { - deadline?.let { - put("deadline", it.toString()) - notifyBefore?.let { lead -> put("notify_before_seconds", lead.inWholeSeconds.toString()) } - } - listId?.let { put("list", it) } - } - return TaskerEndpoint.send(title, messageType = "reminder", extras = extras) - } + ): String = TaskerEndpoint.send( + title, + messageType = "reminder", + extras = taskerReminderExtras(deadline, listId, notifyBefore, source?.rawText), + ) override suspend fun searchForList(listName: String): List = listOf(ReminderListEntry(id = listName, title = listName)) diff --git a/experimental/src/androidMain/kotlin/coredevices/ring/tasker/TaskerEndpoint.kt b/experimental/src/androidMain/kotlin/coredevices/ring/tasker/TaskerEndpoint.kt index d94a22dff..e0ac4889a 100644 --- a/experimental/src/androidMain/kotlin/coredevices/ring/tasker/TaskerEndpoint.kt +++ b/experimental/src/androidMain/kotlin/coredevices/ring/tasker/TaskerEndpoint.kt @@ -9,24 +9,27 @@ import org.koin.core.component.inject /** * Shared Android plumbing for routing Index content (notes, reminders) to - * [Tasker](https://tasker.joaoapps.com/) via an `ACTION_SEND` intent. Users pick the payload up with - * a "Received Share" event profile filtered to this app and fan it out wherever they like — for - * example, appending to an Obsidian vault. + * [Tasker](https://tasker.joaoapps.com/). Every item goes out two ways, and the user wires up + * whichever suits them (setting up both double-processes the item): * - * The content is sent as [Intent.EXTRA_TEXT]; `messageType`, `timestamp`, and any caller-supplied - * extras ride alongside so the Tasker side can branch on them. + * - an `ACTION_SEND` activity intent, picked up with a "Received Share" event profile — the content + * arrives in `%rs_text` and custom extras are dropped; + * - an [ACTION_INDEX_ITEM] broadcast, picked up with an "Intent Received" event profile — every + * extra (`text`, `message_type`, `timestamp`, and caller extras like `deadline`) arrives as its + * own Tasker variable. An explicit broadcast is also exempt from the background-activity-launch + * restrictions that make the activity path unreliable behind a locked screen. * * There is no remote auth — "connecting" simply records that the user opted in (see * [IntegrationTokenStorage] usage in the note/reminder clients), and Tasker is only treated as * available while its package is installed. - * - * Note: `startActivity` from a background context is subject to Android's background-activity-launch - * restrictions, so delivery while the screen is locked is not guaranteed. */ internal object TaskerEndpoint : KoinComponent { const val PACKAGE = "net.dinglisch.android.taskerm" const val TOKEN_STORAGE_KEY = "tasker" + /** Action of the broadcast that feeds a Tasker "Intent Received" profile; users type this in. */ + const val ACTION_INDEX_ITEM = "coredevices.coreapp.INDEX_ITEM" + private val context: Context by inject() private val logger = Logger.withTag("TaskerEndpoint") @@ -56,6 +59,19 @@ internal object TaskerEndpoint : KoinComponent { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) + + // "Received Share" (above) hides custom extras; this broadcast feeds "Intent Received", + // where every extra becomes a Tasker variable. + context.sendBroadcast( + Intent(ACTION_INDEX_ITEM).apply { + setPackage(PACKAGE) + putExtra("text", text) + putExtra("message_type", messageType) + putExtra("timestamp", timestamp) + extras.forEach { (key, value) -> putExtra(key, value) } + } + ) + logger.i { "Sent $messageType to Tasker (${text.length} chars)" } return timestamp } diff --git a/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ListTool.kt b/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ListTool.kt index a462b907f..9087cffc1 100644 --- a/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ListTool.kt +++ b/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ListTool.kt @@ -208,11 +208,12 @@ class ListTool: BuiltInMcpTool( return try { val integration = reminderIntegrationFactory.createReminderIntegration() val list = integration.searchForList(listItemArgs.list_name).firstOrNull() + val rawText = runCatching { context.userMessageText.await() }.getOrNull() val reminderId = integration.createReminder( listItemArgs.message, instant, listId = list?.id, - source = context.itemSource(), + source = context.itemSource(rawText), ) val resolvedListId = runCatching { resolveListIdByHint(listItemArgs.list_name) }.getOrNull() ToolCallResult( diff --git a/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ReminderTool.kt b/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ReminderTool.kt index b46058bf0..127527f77 100644 --- a/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ReminderTool.kt +++ b/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/ReminderTool.kt @@ -212,9 +212,16 @@ class ReminderTool: BuiltInMcpTool( remindArgs.notification_hours_before?.takeIf { hours -> hours > 0 }?.hours } + val rawText = runCatching { context.userMessageText.await() }.getOrNull() + return try { val reminderId = reminderIntegrationFactory.createReminderIntegration() - .createReminder(remindArgs.message, instant, notifyBefore = notifyBefore, source = context.itemSource()) + .createReminder( + remindArgs.message, + instant, + notifyBefore = notifyBefore, + source = context.itemSource(rawText), + ) ToolCallResult( JsonSnake.encodeToString(RemindResult(success = true, reminderId = reminderId)), SemanticResult.TaskCreation( diff --git a/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.kt b/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.kt index c1508cedc..d8aec062d 100644 --- a/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.kt +++ b/experimental/src/commonMain/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderIntegration.kt @@ -1,9 +1,32 @@ package coredevices.ring.agent.builtin_servlets.reminders import coredevices.ring.agent.integrations.ReminderIntegration +import kotlin.time.Duration +import kotlin.time.Instant /** * Tasker is Android-only. The Android implementation routes reminders to Tasker via an * `ACTION_SEND` intent; iOS returns a disabled stub so the provider never surfaces there. */ expect fun createTaskerReminderIntegration(): ReminderIntegration + +/** + * Intent extras describing a Tasker reminder. Each key surfaces as a Tasker variable on the + * "Intent Received" side (`%deadline`, `%notify_before_seconds`, `%list`, `%raw_text`). The due date + * is a UTC ISO-8601 instant; the lead time is whole seconds and only travels alongside a due date. + * [rawText] is the unaltered transcript, for recipes that would rather parse the spoken phrasing + * themselves (e.g. Todoist Quick Add) than the app's split-out fields. + */ +internal fun taskerReminderExtras( + deadline: Instant?, + listId: String?, + notifyBefore: Duration?, + rawText: String?, +): Map = buildMap { + rawText?.let { put("raw_text", it) } + deadline?.let { + put("deadline", it.toString()) + notifyBefore?.let { lead -> put("notify_before_seconds", lead.inWholeSeconds.toString()) } + } + listId?.let { put("list", it) } +} diff --git a/experimental/src/commonMain/kotlin/coredevices/ring/agent/integrations/Integration.kt b/experimental/src/commonMain/kotlin/coredevices/ring/agent/integrations/Integration.kt index 225f509ae..8fa4b4cc3 100644 --- a/experimental/src/commonMain/kotlin/coredevices/ring/agent/integrations/Integration.kt +++ b/experimental/src/commonMain/kotlin/coredevices/ring/agent/integrations/Integration.kt @@ -83,13 +83,15 @@ interface NoteIntegration : Integration { /** * Identifies the recording a tool-created object came from, so builtin integrations can stamp * the feed items they create (feed grouping and reminder deep links rely on it). External - * integrations ignore it. + * integrations ignore everything here except [rawText], the unaltered transcript, which the Tasker + * integration forwards for recipes that parse the spoken phrasing themselves. */ data class ItemSource( val recordingFirestoreId: String?, val createdAt: Instant?, val toolCallId: String?, + val rawText: String? = null, ) -fun SessionContext.itemSource(): ItemSource = - ItemSource(recordingFirestoreId, timeBase, toolCallId) \ No newline at end of file +fun SessionContext.itemSource(rawText: String? = null): ItemSource = + ItemSource(recordingFirestoreId, timeBase, toolCallId, rawText) \ No newline at end of file diff --git a/experimental/src/commonMain/kotlin/coredevices/ring/ui/screens/settings/AddIntegration.kt b/experimental/src/commonMain/kotlin/coredevices/ring/ui/screens/settings/AddIntegration.kt index 8ae0a983e..be64a479b 100644 --- a/experimental/src/commonMain/kotlin/coredevices/ring/ui/screens/settings/AddIntegration.kt +++ b/experimental/src/commonMain/kotlin/coredevices/ring/ui/screens/settings/AddIntegration.kt @@ -726,15 +726,23 @@ fun TaskerDialog( is SignInState.Idle -> { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - "Sends your notes and reminders to Tasker as a shared intent " + - "(text/plain), so you can route them to any app or action." + "Sends your notes and reminders to Tasker, so you can route them to any " + + "app or action." ) - Text("To receive them, in Tasker:", fontWeight = FontWeight.Bold) - Text("1. Add a profile, pick Event → Received Share.") - Text("2. Attach a task — your text arrives in the %rs_text variable.") Text( - "3. Optional: limit it to this app with %rs_package_name = " + - "coredevices.coreapp." + "Set up ONE of these profiles in Tasker (both together processes each " + + "item twice):", + fontWeight = FontWeight.Bold, + ) + Text( + "A. Event → Received Share. Your text arrives in %rs_text. Optionally " + + "limit it to this app with %rs_package_name = coredevices.coreapp." + ) + Text( + "B. Event → Intent Received, Action = coredevices.coreapp.INDEX_ITEM. " + + "Each detail is a separate variable: %text, %message_type, %timestamp, " + + "and for reminders %raw_text (the unaltered transcript), %deadline " + + "(ISO-8601 UTC), %notify_before_seconds, %list." ) } } diff --git a/experimental/src/commonTest/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderExtrasTest.kt b/experimental/src/commonTest/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderExtrasTest.kt new file mode 100644 index 000000000..d700074b0 --- /dev/null +++ b/experimental/src/commonTest/kotlin/coredevices/ring/agent/builtin_servlets/reminders/TaskerReminderExtrasTest.kt @@ -0,0 +1,77 @@ +@file:OptIn(ExperimentalTime::class) + +package coredevices.ring.agent.builtin_servlets.reminders + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class TaskerReminderExtrasTest { + private val deadline = Instant.parse("2026-09-02T14:00:00Z") + + @Test + fun `a plain reminder carries no extras`() { + assertEquals( + emptyMap(), + taskerReminderExtras(deadline = null, listId = null, notifyBefore = null, rawText = null), + ) + } + + @Test + fun `a due date travels as a UTC ISO-8601 instant`() { + assertEquals( + mapOf("deadline" to "2026-09-02T14:00:00Z"), + taskerReminderExtras(deadline, listId = null, notifyBefore = null, rawText = null), + ) + } + + @Test + fun `lead time travels as whole seconds alongside the due date`() { + assertEquals( + mapOf("deadline" to "2026-09-02T14:00:00Z", "notify_before_seconds" to "3600"), + taskerReminderExtras(deadline, listId = null, notifyBefore = 1.hours, rawText = null), + ) + } + + @Test + fun `lead time without a due date is dropped`() { + assertEquals( + emptyMap(), + taskerReminderExtras(deadline = null, listId = null, notifyBefore = 1.hours, rawText = null), + ) + } + + @Test + fun `a list name travels independently of the due date`() { + assertEquals( + mapOf("list" to "Groceries"), + taskerReminderExtras(deadline = null, listId = "Groceries", notifyBefore = null, rawText = null), + ) + } + + @Test + fun `the unaltered transcript travels as raw_text`() { + assertEquals( + mapOf( + "raw_text" to "remind me to call about my reservation at 2pm", + "deadline" to "2026-09-02T14:00:00Z", + ), + taskerReminderExtras( + deadline, + listId = null, + notifyBefore = null, + rawText = "remind me to call about my reservation at 2pm", + ), + ) + } + + @Test + fun `a missing transcript adds no raw_text key`() { + assertEquals( + mapOf("deadline" to "2026-09-02T14:00:00Z"), + taskerReminderExtras(deadline, listId = null, notifyBefore = null, rawText = null), + ) + } +}