Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<ReminderListEntry> =
listOf(ReminderListEntry(id = listName, title = listName))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
fun SessionContext.itemSource(rawText: String? = null): ItemSource =
ItemSource(recordingFirestoreId, timeBase, toolCallId, rawText)
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>(),
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<String, String>(),
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),
)
}
}