feat(android): add Google Calendar sync via CalendarContract — read events, convert to timers, deterministic IDs

This commit is contained in:
saravanakumardb1 2026-02-27 23:17:50 -08:00
parent 0848c9a041
commit 31d1668ce8
2 changed files with 169 additions and 0 deletions

View File

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Calendar sync -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<!-- Exact alarm scheduling (Android 12+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />

View File

@ -0,0 +1,166 @@
package com.chronomind.app.calendar
import android.Manifest
import android.content.ContentResolver
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import com.chronomind.app.engine.*
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
// ── Calendar Event ────────────────────────────────────────────
data class CalendarEvent(
val eventId: Long,
val title: String,
val description: String?,
val dtStart: Long,
val dtEnd: Long,
val calendarDisplayName: String?,
val calendarColor: Int?,
val allDay: Boolean
)
// ── Calendar Sync Manager ─────────────────────────────────────
@Singleton
class CalendarSyncManager @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val SYNC_PREFIX = "cal-sync-"
}
fun hasCalendarPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
}
/**
* Fetch calendar events for the next [days] days from the device calendar.
*/
fun fetchEvents(days: Int = 7): List<CalendarEvent> {
if (!hasCalendarPermission()) return emptyList()
val now = System.currentTimeMillis()
val endTime = now + days * 24 * 60 * 60 * 1000L
val projection = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.ALL_DAY
)
val selection = "(${CalendarContract.Events.DTSTART} >= ?) AND (${CalendarContract.Events.DTSTART} <= ?)"
val selectionArgs = arrayOf(now.toString(), endTime.toString())
val sortOrder = "${CalendarContract.Events.DTSTART} ASC"
val events = mutableListOf<CalendarEvent>()
val resolver: ContentResolver = context.contentResolver
var cursor: Cursor? = null
try {
cursor = resolver.query(
CalendarContract.Events.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)
cursor?.let {
while (it.moveToNext()) {
val eventId = it.getLong(0)
val title = it.getString(1) ?: "Untitled"
val description = it.getString(2)
val dtStart = it.getLong(3)
val dtEnd = it.getLong(4)
val calName = it.getString(5)
val calColor = if (!it.isNull(6)) it.getInt(6) else null
val allDay = it.getInt(7) == 1
if (!allDay && title.isNotBlank()) {
events.add(
CalendarEvent(
eventId = eventId,
title = title,
description = description,
dtStart = dtStart,
dtEnd = dtEnd,
calendarDisplayName = calName,
calendarColor = calColor,
allDay = allDay
)
)
}
}
}
} finally {
cursor?.close()
}
return events
}
/**
* Convert calendar events to CMTimers with deterministic IDs.
* Uses SYNC_PREFIX + eventId for stable identity across syncs.
*/
fun convertToTimers(events: List<CalendarEvent>): List<CMTimer> {
return events.mapNotNull { event ->
val targetTime = Date(event.dtStart)
if (targetTime.before(Date())) return@mapNotNull null
val durationSeconds = (event.dtEnd - event.dtStart) / 1000.0
CMTimer(
id = "$SYNC_PREFIX${event.eventId}",
label = event.title,
description = event.description,
type = CMTimerType.ALARM,
state = CMTimerState.ACTIVE,
urgency = UrgencyLevel.STANDARD,
duration = durationSeconds,
targetTime = targetTime,
createdAt = Date(),
startedAt = Date(),
cascade = CascadeConfig(CascadePreset.STANDARD, emptyList()),
warnings = calculateCascadeWarnings(
targetTime,
CascadePreset.STANDARD.defaultIntervals,
Date()
).toMutableList(),
isCalendarSync = true,
calendarEventId = event.eventId.toString(),
category = event.calendarDisplayName
)
}
}
/**
* Perform a full sync: fetch events and convert to timers.
*/
fun sync(days: Int = 7): List<CMTimer> {
val events = fetchEvents(days)
return convertToTimers(events)
}
/**
* Check if a timer ID is a calendar-synced timer.
*/
fun isCalendarSyncTimer(timerId: String): Boolean {
return timerId.startsWith(SYNC_PREFIX)
}
}