feat(android): add calendar capability handlers

This commit is contained in:
Ayaan Zaidi
2026-02-28 09:14:31 +05:30
committed by Ayaan Zaidi
parent 81ebe7de46
commit f75385981a
2 changed files with 500 additions and 0 deletions

View File

@@ -0,0 +1,384 @@
package ai.openclaw.android.node
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import ai.openclaw.android.gateway.GatewaySession
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
private const val DEFAULT_CALENDAR_LIMIT = 50
internal data class CalendarEventsRequest(
val startMs: Long,
val endMs: Long,
val limit: Int,
)
internal data class CalendarAddRequest(
val title: String,
val startMs: Long,
val endMs: Long,
val isAllDay: Boolean,
val location: String?,
val notes: String?,
val calendarId: Long?,
val calendarTitle: String?,
)
internal data class CalendarEventRecord(
val identifier: String,
val title: String,
val startISO: String,
val endISO: String,
val isAllDay: Boolean,
val location: String?,
val calendarTitle: String?,
)
internal interface CalendarDataSource {
fun hasReadPermission(context: Context): Boolean
fun hasWritePermission(context: Context): Boolean
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
}
private object SystemCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun hasWritePermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, request.startMs)
ContentUris.appendId(builder, request.endMs)
val projection =
arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
)
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
if (cursor == null) return emptyList()
val out = mutableListOf<CalendarEventRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val id = cursor.getLong(0)
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
val beginMs = cursor.getLong(2)
val endMs = cursor.getLong(3)
val isAllDay = cursor.getInt(4) == 1
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
out +=
CalendarEventRecord(
identifier = id.toString(),
title = title,
startISO = Instant.ofEpochMilli(beginMs).toString(),
endISO = Instant.ofEpochMilli(endMs).toString(),
isAllDay = isAllDay,
location = location,
calendarTitle = calendarTitle,
)
}
return out
}
}
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
val resolver = context.contentResolver
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
val values =
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
put(CalendarContract.Events.TITLE, request.title)
put(CalendarContract.Events.DTSTART, request.startMs)
put(CalendarContract.Events.DTEND, request.endMs)
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId = uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
return loadEventById(resolver, eventId)
?: throw IllegalStateException("calendar insert failed")
}
private fun resolveCalendarId(
resolver: ContentResolver,
calendarId: Long?,
calendarTitle: String?,
): Long {
if (calendarId != null) {
if (calendarExists(resolver, calendarId)) return calendarId
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
}
if (!calendarTitle.isNullOrEmpty()) {
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
}
findDefaultCalendarId(resolver)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
}
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
}
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun loadEventById(
resolver: ContentResolver,
eventId: Long,
): CalendarEventRecord? {
val projection =
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
)
resolver.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
}
}
class CalendarHandler private constructor(
private val appContext: Context,
private val dataSource: CalendarDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_PERMISSION_REQUIRED",
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
)
}
val request =
parseEventsRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val events = dataSource.events(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"events",
buildJsonArray { events.forEach { add(eventJson(it)) } },
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALENDAR_UNAVAILABLE",
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
)
}
}
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasWritePermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_PERMISSION_REQUIRED",
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
)
}
val request =
parseAddRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
if (request.title.isEmpty()) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_INVALID",
message = "CALENDAR_INVALID: title required",
)
}
if (request.endMs <= request.startMs) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_INVALID",
message = "CALENDAR_INVALID: endISO must be after startISO",
)
}
return try {
val event = dataSource.add(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put("event", eventJson(event))
}.toString(),
)
} catch (err: IllegalArgumentException) {
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
GatewaySession.InvokeResult.error(code = code, message = msg)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALENDAR_UNAVAILABLE",
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
)
}
}
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
if (paramsJson.isNullOrBlank()) {
val start = Instant.now()
val end = start.plus(7, ChronoUnit.DAYS)
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
val resolvedStart = start ?: Instant.now()
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
return CalendarEventsRequest(
startMs = resolvedStart.toEpochMilli(),
endMs = resolvedEnd.toEpochMilli(),
limit = limit,
)
}
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
val params =
try {
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
} catch (_: Throwable) {
null
} ?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
return CalendarAddRequest(
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
startMs = start.toEpochMilli(),
endMs = end.toEpochMilli(),
isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false,
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
)
}
private fun parseISO(raw: String?): Instant? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
return try {
Instant.parse(value)
} catch (_: Throwable) {
null
}
}
private fun eventJson(event: CalendarEventRecord): JsonObject {
return buildJsonObject {
put("identifier", JsonPrimitive(event.identifier))
put("title", JsonPrimitive(event.title))
put("startISO", JsonPrimitive(event.startISO))
put("endISO", JsonPrimitive(event.endISO))
put("isAllDay", JsonPrimitive(event.isAllDay))
event.location?.let { put("location", JsonPrimitive(it)) }
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
}
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: CalendarDataSource,
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@@ -0,0 +1,116 @@
package ai.openclaw.android.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class CalendarHandlerTest {
@Test
fun handleCalendarEvents_requiresPermission() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
val result = handler.handleCalendarEvents(null)
assertFalse(result.ok)
assertEquals("CALENDAR_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleCalendarAdd_rejectsEndBeforeStart() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = true, canWrite = true))
val result =
handler.handleCalendarAdd(
"""{"title":"Standup","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T09:00:00Z"}""",
)
assertFalse(result.ok)
assertEquals("CALENDAR_INVALID", result.error?.code)
}
@Test
fun handleCalendarEvents_returnsEvents() {
val event =
CalendarEventRecord(
identifier = "101",
title = "Sprint Planning",
startISO = "2026-02-28T10:00:00Z",
endISO = "2026-02-28T11:00:00Z",
isAllDay = false,
location = "Room 1",
calendarTitle = "Work",
)
val handler =
CalendarHandler.forTesting(
appContext(),
FakeCalendarDataSource(canRead = true, events = listOf(event)),
)
val result = handler.handleCalendarEvents("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val events = payload.getValue("events").jsonArray
assertEquals(1, events.size)
assertEquals("Sprint Planning", events.first().jsonObject.getValue("title").jsonPrimitive.content)
}
@Test
fun handleCalendarAdd_mapsNotFoundErrorCode() {
val source =
FakeCalendarDataSource(
canRead = true,
canWrite = true,
addError = IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar"),
)
val handler = CalendarHandler.forTesting(appContext(), source)
val result =
handler.handleCalendarAdd(
"""{"title":"Call","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T11:00:00Z"}""",
)
assertFalse(result.ok)
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeCalendarDataSource(
private val canRead: Boolean,
private val canWrite: Boolean = false,
private val events: List<CalendarEventRecord> = emptyList(),
private val addResult: CalendarEventRecord =
CalendarEventRecord(
identifier = "0",
title = "Default",
startISO = "2026-01-01T00:00:00Z",
endISO = "2026-01-01T01:00:00Z",
isAllDay = false,
location = null,
calendarTitle = null,
),
private val addError: Throwable? = null,
) : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean = canRead
override fun hasWritePermission(context: Context): Boolean = canWrite
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> = events
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
addError?.let { throw it }
return addResult
}
}