feat(android): add Google Calendar sync via CalendarContract — read events, convert to timers, deterministic IDs
This commit is contained in:
parent
0848c9a041
commit
31d1668ce8
@ -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" />
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user