mirror of
https://github.com/avinal/nikki.git
synced 2026-07-03 21:40:09 +05:30
Add CalendarContract integration for reliable reminders
CalendarReminderManager: - Creates hidden "Nikki Tasks" calendar via CalendarContract - Syncs tasks with due dates as calendar events with reminders - Uses SYNC_DATA1 for task ID deduplication (sync adapter URI) - Cleans up events for completed/deleted tasks - Handles SecurityException gracefully if permission denied Wired into both triggerReminderCheck() and TaskCheckWorker. AlarmManager kept for p1 tasks that bypass DND. Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com> Co-Authored-By: Claude Opus 4.6 (1M context)
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
package com.avinal.memos.notifications
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import com.avinal.memos.domain.Memo
|
||||
import com.avinal.memos.domain.ReminderUnit
|
||||
import com.avinal.memos.parser.TaskParser
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.todayIn
|
||||
import kotlinx.datetime.toInstant
|
||||
|
||||
object CalendarReminderManager {
|
||||
|
||||
private const val CALENDAR_NAME = "nikki_tasks"
|
||||
private const val ACCOUNT_NAME = "nikki"
|
||||
private const val ACCOUNT_TYPE = CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
|
||||
private fun syncAdapterUri(uri: android.net.Uri): android.net.Uri =
|
||||
uri.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE)
|
||||
.build()
|
||||
|
||||
fun syncTaskReminders(context: Context, memos: List<Memo>) {
|
||||
try {
|
||||
val cr = context.contentResolver
|
||||
val calId = getOrCreateCalendar(context) ?: return
|
||||
val defaultTime = readDefaultNotifyTime(context)
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val today = kotlin.time.Clock.System.todayIn(tz)
|
||||
|
||||
val allTasks = memos.flatMap { memo ->
|
||||
TaskParser.extractTasks(memo.id, memo.content, memo.tags)
|
||||
}
|
||||
|
||||
val activeTaskIds = mutableSetOf<String>()
|
||||
|
||||
allTasks.forEach { task ->
|
||||
if (task.isCompleted) return@forEach
|
||||
val effectiveDate = task.dueDate ?: if (task.dueTime != null) today else return@forEach
|
||||
val effectiveTime = task.dueTime ?: defaultTime
|
||||
|
||||
val startMs = effectiveDate.atTime(effectiveTime).toInstant(tz).toEpochMilliseconds()
|
||||
val taskSyncId = task.id
|
||||
|
||||
activeTaskIds.add(taskSyncId)
|
||||
|
||||
val eventValues = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calId)
|
||||
put(CalendarContract.Events.TITLE, task.text)
|
||||
put(CalendarContract.Events.DTSTART, startMs)
|
||||
put(CalendarContract.Events.DTEND, startMs + 1800_000)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, tz.id)
|
||||
put(CalendarContract.Events.SYNC_DATA1, taskSyncId)
|
||||
task.priority?.let { put(CalendarContract.Events.SYNC_DATA2, it.toString()) }
|
||||
}
|
||||
|
||||
val existingId = findEventBySyncId(context, calId, taskSyncId)
|
||||
val eventId = if (existingId != null) {
|
||||
cr.update(
|
||||
syncAdapterUri(ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, existingId)),
|
||||
eventValues, null, null,
|
||||
)
|
||||
existingId
|
||||
} else {
|
||||
val uri = cr.insert(syncAdapterUri(CalendarContract.Events.CONTENT_URI), eventValues) ?: return@forEach
|
||||
ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
cr.delete(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
"${CalendarContract.Reminders.EVENT_ID} = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
)
|
||||
|
||||
val reminderMinutes = if (task.reminder != null) {
|
||||
when (task.reminder.unit) {
|
||||
ReminderUnit.MIN -> task.reminder.value
|
||||
ReminderUnit.HR -> task.reminder.value * 60
|
||||
ReminderUnit.DAY -> task.reminder.value * 1440
|
||||
ReminderUnit.WEEK -> task.reminder.value * 10080
|
||||
}
|
||||
} else 0
|
||||
|
||||
cr.insert(CalendarContract.Reminders.CONTENT_URI, ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, reminderMinutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
})
|
||||
}
|
||||
|
||||
cleanupStaleEvents(context, calId, activeTaskIds)
|
||||
} catch (_: SecurityException) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateCalendar(context: Context): Long? {
|
||||
val cr = context.contentResolver
|
||||
|
||||
cr.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Calendars._ID),
|
||||
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND ${CalendarContract.Calendars.ACCOUNT_TYPE} = ?",
|
||||
arrayOf(ACCOUNT_NAME, ACCOUNT_TYPE),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE)
|
||||
put(CalendarContract.Calendars.NAME, CALENDAR_NAME)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, "Nikki Tasks")
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFFEE67A4.toInt())
|
||||
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.VISIBLE, 0)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
put(CalendarContract.Calendars.CALENDAR_TIME_ZONE, TimeZone.currentSystemDefault().id)
|
||||
}
|
||||
|
||||
val uri = cr.insert(
|
||||
CalendarContract.Calendars.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE)
|
||||
.build(),
|
||||
values,
|
||||
)
|
||||
return uri?.let { ContentUris.parseId(it) }
|
||||
}
|
||||
|
||||
private fun findEventBySyncId(context: Context, calId: Long, syncId: String): Long? {
|
||||
context.contentResolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.SYNC_DATA1} = ?",
|
||||
arrayOf(calId.toString(), syncId),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) return cursor.getLong(0)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cleanupStaleEvents(context: Context, calId: Long, activeIds: Set<String>) {
|
||||
val cr = context.contentResolver
|
||||
cr.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.SYNC_DATA1),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ?",
|
||||
arrayOf(calId.toString()),
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
val idIdx = cursor.getColumnIndex(CalendarContract.Events._ID)
|
||||
val syncIdx = cursor.getColumnIndex(CalendarContract.Events.SYNC_DATA1)
|
||||
while (cursor.moveToNext()) {
|
||||
val syncId = cursor.getString(syncIdx) ?: continue
|
||||
if (syncId !in activeIds) {
|
||||
cr.delete(
|
||||
syncAdapterUri(ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, cursor.getLong(idIdx))),
|
||||
null, null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ class TaskCheckWorker(
|
||||
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority)
|
||||
}
|
||||
|
||||
CalendarReminderManager.syncTaskReminders(appContext, memos)
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ actual fun triggerReminderCheck() {
|
||||
val memos = liveMemosProvider?.invoke()
|
||||
if (memos != null && memos.isNotEmpty()) {
|
||||
DirectAlarmScheduler.scheduleFromMemos(ctx, memos)
|
||||
com.avinal.memos.notifications.CalendarReminderManager.syncTaskReminders(ctx, memos)
|
||||
} else {
|
||||
runTaskCheckNow(ctx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user