From 86765cee2789920907c28046b7fddc32ecaae08d Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Fri, 5 Jun 2026 15:05:07 +0530 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../notifications/CalendarReminderManager.kt | 173 ++++++++++++++++++ .../memos/notifications/TaskCheckWorker.kt | 2 + .../util/PlatformNotification.android.kt | 1 + 3 files changed, 176 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt new file mode 100644 index 0000000..430f476 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/CalendarReminderManager.kt @@ -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) { + 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() + + 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) { + 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, + ) + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt index e00c1e0..aab9461 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt @@ -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() } diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt index 803f081..a7046a3 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt @@ -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) }