Skip to content

Commit f753859

Browse files
committed
feat(android): add calendar capability handlers
1 parent 81ebe7d commit f753859

2 files changed

Lines changed: 500 additions & 0 deletions

File tree

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
package ai.openclaw.android.node
2+
3+
import android.Manifest
4+
import android.content.ContentResolver
5+
import android.content.ContentUris
6+
import android.content.ContentValues
7+
import android.content.Context
8+
import android.provider.CalendarContract
9+
import androidx.core.content.ContextCompat
10+
import ai.openclaw.android.gateway.GatewaySession
11+
import java.time.Instant
12+
import java.time.temporal.ChronoUnit
13+
import java.util.TimeZone
14+
import kotlinx.serialization.json.Json
15+
import kotlinx.serialization.json.JsonObject
16+
import kotlinx.serialization.json.JsonPrimitive
17+
import kotlinx.serialization.json.buildJsonArray
18+
import kotlinx.serialization.json.buildJsonObject
19+
import kotlinx.serialization.json.put
20+
21+
private const val DEFAULT_CALENDAR_LIMIT = 50
22+
23+
internal data class CalendarEventsRequest(
24+
val startMs: Long,
25+
val endMs: Long,
26+
val limit: Int,
27+
)
28+
29+
internal data class CalendarAddRequest(
30+
val title: String,
31+
val startMs: Long,
32+
val endMs: Long,
33+
val isAllDay: Boolean,
34+
val location: String?,
35+
val notes: String?,
36+
val calendarId: Long?,
37+
val calendarTitle: String?,
38+
)
39+
40+
internal data class CalendarEventRecord(
41+
val identifier: String,
42+
val title: String,
43+
val startISO: String,
44+
val endISO: String,
45+
val isAllDay: Boolean,
46+
val location: String?,
47+
val calendarTitle: String?,
48+
)
49+
50+
internal interface CalendarDataSource {
51+
fun hasReadPermission(context: Context): Boolean
52+
53+
fun hasWritePermission(context: Context): Boolean
54+
55+
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
56+
57+
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
58+
}
59+
60+
private object SystemCalendarDataSource : CalendarDataSource {
61+
override fun hasReadPermission(context: Context): Boolean {
62+
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
63+
android.content.pm.PackageManager.PERMISSION_GRANTED
64+
}
65+
66+
override fun hasWritePermission(context: Context): Boolean {
67+
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
68+
android.content.pm.PackageManager.PERMISSION_GRANTED
69+
}
70+
71+
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
72+
val resolver = context.contentResolver
73+
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
74+
ContentUris.appendId(builder, request.startMs)
75+
ContentUris.appendId(builder, request.endMs)
76+
val projection =
77+
arrayOf(
78+
CalendarContract.Instances.EVENT_ID,
79+
CalendarContract.Instances.TITLE,
80+
CalendarContract.Instances.BEGIN,
81+
CalendarContract.Instances.END,
82+
CalendarContract.Instances.ALL_DAY,
83+
CalendarContract.Instances.EVENT_LOCATION,
84+
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
85+
)
86+
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
87+
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
88+
if (cursor == null) return emptyList()
89+
val out = mutableListOf<CalendarEventRecord>()
90+
while (cursor.moveToNext() && out.size < request.limit) {
91+
val id = cursor.getLong(0)
92+
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
93+
val beginMs = cursor.getLong(2)
94+
val endMs = cursor.getLong(3)
95+
val isAllDay = cursor.getInt(4) == 1
96+
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
97+
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
98+
out +=
99+
CalendarEventRecord(
100+
identifier = id.toString(),
101+
title = title,
102+
startISO = Instant.ofEpochMilli(beginMs).toString(),
103+
endISO = Instant.ofEpochMilli(endMs).toString(),
104+
isAllDay = isAllDay,
105+
location = location,
106+
calendarTitle = calendarTitle,
107+
)
108+
}
109+
return out
110+
}
111+
}
112+
113+
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
114+
val resolver = context.contentResolver
115+
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
116+
val values =
117+
ContentValues().apply {
118+
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
119+
put(CalendarContract.Events.TITLE, request.title)
120+
put(CalendarContract.Events.DTSTART, request.startMs)
121+
put(CalendarContract.Events.DTEND, request.endMs)
122+
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
123+
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
124+
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
125+
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
126+
}
127+
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
128+
?: throw IllegalStateException("calendar insert failed")
129+
val eventId = uri.lastPathSegment?.toLongOrNull()
130+
?: throw IllegalStateException("calendar insert failed")
131+
return loadEventById(resolver, eventId)
132+
?: throw IllegalStateException("calendar insert failed")
133+
}
134+
135+
private fun resolveCalendarId(
136+
resolver: ContentResolver,
137+
calendarId: Long?,
138+
calendarTitle: String?,
139+
): Long {
140+
if (calendarId != null) {
141+
if (calendarExists(resolver, calendarId)) return calendarId
142+
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
143+
}
144+
if (!calendarTitle.isNullOrEmpty()) {
145+
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
146+
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
147+
}
148+
findDefaultCalendarId(resolver)?.let { return it }
149+
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
150+
}
151+
152+
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
153+
val projection = arrayOf(CalendarContract.Calendars._ID)
154+
resolver.query(
155+
CalendarContract.Calendars.CONTENT_URI,
156+
projection,
157+
"${CalendarContract.Calendars._ID}=?",
158+
arrayOf(id.toString()),
159+
null,
160+
).use { cursor ->
161+
return cursor != null && cursor.moveToFirst()
162+
}
163+
}
164+
165+
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
166+
val projection = arrayOf(CalendarContract.Calendars._ID)
167+
resolver.query(
168+
CalendarContract.Calendars.CONTENT_URI,
169+
projection,
170+
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
171+
arrayOf(title),
172+
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
173+
).use { cursor ->
174+
if (cursor == null || !cursor.moveToFirst()) return null
175+
return cursor.getLong(0)
176+
}
177+
}
178+
179+
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
180+
val projection = arrayOf(CalendarContract.Calendars._ID)
181+
resolver.query(
182+
CalendarContract.Calendars.CONTENT_URI,
183+
projection,
184+
"${CalendarContract.Calendars.VISIBLE}=1",
185+
null,
186+
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
187+
).use { cursor ->
188+
if (cursor == null || !cursor.moveToFirst()) return null
189+
return cursor.getLong(0)
190+
}
191+
}
192+
193+
private fun loadEventById(
194+
resolver: ContentResolver,
195+
eventId: Long,
196+
): CalendarEventRecord? {
197+
val projection =
198+
arrayOf(
199+
CalendarContract.Events._ID,
200+
CalendarContract.Events.TITLE,
201+
CalendarContract.Events.DTSTART,
202+
CalendarContract.Events.DTEND,
203+
CalendarContract.Events.ALL_DAY,
204+
CalendarContract.Events.EVENT_LOCATION,
205+
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
206+
)
207+
resolver.query(
208+
CalendarContract.Events.CONTENT_URI,
209+
projection,
210+
"${CalendarContract.Events._ID}=?",
211+
arrayOf(eventId.toString()),
212+
null,
213+
).use { cursor ->
214+
if (cursor == null || !cursor.moveToFirst()) return null
215+
return CalendarEventRecord(
216+
identifier = cursor.getLong(0).toString(),
217+
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
218+
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
219+
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
220+
isAllDay = cursor.getInt(4) == 1,
221+
location = cursor.getString(5)?.trim()?.ifEmpty { null },
222+
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
223+
)
224+
}
225+
}
226+
}
227+
228+
class CalendarHandler private constructor(
229+
private val appContext: Context,
230+
private val dataSource: CalendarDataSource,
231+
) {
232+
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
233+
234+
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
235+
if (!dataSource.hasReadPermission(appContext)) {
236+
return GatewaySession.InvokeResult.error(
237+
code = "CALENDAR_PERMISSION_REQUIRED",
238+
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
239+
)
240+
}
241+
val request =
242+
parseEventsRequest(paramsJson)
243+
?: return GatewaySession.InvokeResult.error(
244+
code = "INVALID_REQUEST",
245+
message = "INVALID_REQUEST: expected JSON object",
246+
)
247+
return try {
248+
val events = dataSource.events(appContext, request)
249+
GatewaySession.InvokeResult.ok(
250+
buildJsonObject {
251+
put(
252+
"events",
253+
buildJsonArray { events.forEach { add(eventJson(it)) } },
254+
)
255+
}.toString(),
256+
)
257+
} catch (err: Throwable) {
258+
GatewaySession.InvokeResult.error(
259+
code = "CALENDAR_UNAVAILABLE",
260+
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
261+
)
262+
}
263+
}
264+
265+
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
266+
if (!dataSource.hasWritePermission(appContext)) {
267+
return GatewaySession.InvokeResult.error(
268+
code = "CALENDAR_PERMISSION_REQUIRED",
269+
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
270+
)
271+
}
272+
val request =
273+
parseAddRequest(paramsJson)
274+
?: return GatewaySession.InvokeResult.error(
275+
code = "INVALID_REQUEST",
276+
message = "INVALID_REQUEST: expected JSON object",
277+
)
278+
if (request.title.isEmpty()) {
279+
return GatewaySession.InvokeResult.error(
280+
code = "CALENDAR_INVALID",
281+
message = "CALENDAR_INVALID: title required",
282+
)
283+
}
284+
if (request.endMs <= request.startMs) {
285+
return GatewaySession.InvokeResult.error(
286+
code = "CALENDAR_INVALID",
287+
message = "CALENDAR_INVALID: endISO must be after startISO",
288+
)
289+
}
290+
return try {
291+
val event = dataSource.add(appContext, request)
292+
GatewaySession.InvokeResult.ok(
293+
buildJsonObject {
294+
put("event", eventJson(event))
295+
}.toString(),
296+
)
297+
} catch (err: IllegalArgumentException) {
298+
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
299+
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
300+
GatewaySession.InvokeResult.error(code = code, message = msg)
301+
} catch (err: Throwable) {
302+
GatewaySession.InvokeResult.error(
303+
code = "CALENDAR_UNAVAILABLE",
304+
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
305+
)
306+
}
307+
}
308+
309+
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
310+
if (paramsJson.isNullOrBlank()) {
311+
val start = Instant.now()
312+
val end = start.plus(7, ChronoUnit.DAYS)
313+
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
314+
}
315+
val params =
316+
try {
317+
Json.parseToJsonElement(paramsJson).asObjectOrNull()
318+
} catch (_: Throwable) {
319+
null
320+
} ?: return null
321+
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
322+
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
323+
val resolvedStart = start ?: Instant.now()
324+
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
325+
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
326+
return CalendarEventsRequest(
327+
startMs = resolvedStart.toEpochMilli(),
328+
endMs = resolvedEnd.toEpochMilli(),
329+
limit = limit,
330+
)
331+
}
332+
333+
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
334+
val params =
335+
try {
336+
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
337+
} catch (_: Throwable) {
338+
null
339+
} ?: return null
340+
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
341+
?: return null
342+
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
343+
?: return null
344+
return CalendarAddRequest(
345+
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
346+
startMs = start.toEpochMilli(),
347+
endMs = end.toEpochMilli(),
348+
isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false,
349+
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
350+
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
351+
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
352+
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
353+
)
354+
}
355+
356+
private fun parseISO(raw: String?): Instant? {
357+
val value = raw?.trim().orEmpty()
358+
if (value.isEmpty()) return null
359+
return try {
360+
Instant.parse(value)
361+
} catch (_: Throwable) {
362+
null
363+
}
364+
}
365+
366+
private fun eventJson(event: CalendarEventRecord): JsonObject {
367+
return buildJsonObject {
368+
put("identifier", JsonPrimitive(event.identifier))
369+
put("title", JsonPrimitive(event.title))
370+
put("startISO", JsonPrimitive(event.startISO))
371+
put("endISO", JsonPrimitive(event.endISO))
372+
put("isAllDay", JsonPrimitive(event.isAllDay))
373+
event.location?.let { put("location", JsonPrimitive(it)) }
374+
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
375+
}
376+
}
377+
378+
companion object {
379+
internal fun forTesting(
380+
appContext: Context,
381+
dataSource: CalendarDataSource,
382+
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
383+
}
384+
}

0 commit comments

Comments
 (0)