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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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+) -->
|
<!-- Exact alarm scheduling (Android 12+) -->
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.USE_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