Skip to content

Commit

Permalink
allow changing event status from timeline
Browse files Browse the repository at this point in the history
  • Loading branch information
crc-32 committed Aug 2, 2024
1 parent 86a714e commit b4ebae1
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import com.benasher44.uuid.Uuid
import io.rebble.cobble.shared.AndroidPlatformContext
import io.rebble.cobble.shared.PlatformContext
import io.rebble.cobble.shared.datastore.createDataStore
import io.rebble.cobble.shared.domain.calendar.PlatformCalendarActionExecutor
import io.rebble.cobble.shared.domain.notifications.PlatformNotificationActionExecutor
import io.rebble.cobble.shared.domain.notifications.AndroidNotificationActionExecutor
import io.rebble.cobble.shared.domain.calendar.AndroidCalendarActionExecutor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koin.dsl.module
Expand All @@ -25,4 +27,5 @@ val androidModule = module {
MutableStateFlow<Map<Uuid, StatusBarNotification>>(emptyMap())
} bind StateFlow::class
singleOf<PlatformNotificationActionExecutor>(::AndroidNotificationActionExecutor)
singleOf<PlatformCalendarActionExecutor>(::AndroidCalendarActionExecutor)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package io.rebble.cobble.shared.domain.calendar

import android.content.ContentValues
import android.provider.CalendarContract
import io.rebble.cobble.shared.AndroidPlatformContext
import io.rebble.cobble.shared.Logging
import io.rebble.cobble.shared.PlatformContext
import io.rebble.cobble.shared.data.toCompositeBackingId
import io.rebble.cobble.shared.database.dao.CalendarDao
import io.rebble.cobble.shared.database.entity.TimelinePin
import io.rebble.cobble.shared.domain.timeline.WatchTimelineSyncer
import io.rebble.libpebblecommon.packets.blobdb.TimelineIcon
import io.rebble.libpebblecommon.services.blobdb.TimelineService
import io.rebble.libpebblecommon.util.TimelineAttributeFactory
import kotlinx.datetime.Clock
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.time.Duration.Companion.days

class AndroidCalendarActionExecutor: PlatformCalendarActionExecutor, KoinComponent {
private val platformContext: PlatformContext by inject()
private val calendarDao: CalendarDao by inject()
private val watchTimelineSyncer: WatchTimelineSyncer by inject()

override suspend fun handlePlatformAction(action: CalendarAction, pin: TimelinePin): TimelineService.ActionResponse {
val instanceId = pin.backingId ?: run {
Logging.e("No backing ID for calendar pin")
return TimelineService.ActionResponse(success = false)
}
val event = getCalendarInstanceById(platformContext, instanceId.toCompositeBackingId().eventId.toString(), Clock.System.now()-1.days, Clock.System.now()+30.days) ?: run {
Logging.e("No calendar event found for ID $instanceId")
return TimelineService.ActionResponse(success = false)
}
val eventId = event.baseEventId
val calendar = calendarDao.get(event.calendarId) ?: run {
Logging.e("No calendar found for ID ${event.calendarId}")
return TimelineService.ActionResponse(success = false)
}

return when (action) {
CalendarAction.Accept -> {
if (updateAttendeeStatus(eventId.toString(), CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED, calendar.ownerId)) {
TimelineService.ActionResponse(
success = true,
attributes = listOf(
TimelineAttributeFactory.subtitle("Accepted"),
TimelineAttributeFactory.largeIcon(TimelineIcon.ResultSent),
)
)
} else {
TimelineService.ActionResponse(success = false)
}
}
CalendarAction.Maybe -> {
if (updateAttendeeStatus(eventId.toString(), CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE, calendar.ownerId)) {
TimelineService.ActionResponse(
success = true,
attributes = listOf(
TimelineAttributeFactory.subtitle("Sent Maybe"),
TimelineAttributeFactory.largeIcon(TimelineIcon.ResultSent),
)
)
} else {
TimelineService.ActionResponse(success = false)
}
}
CalendarAction.Decline -> {
if (updateAttendeeStatus(eventId.toString(), CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, calendar.ownerId)) {
TimelineService.ActionResponse(
success = true,
attributes = listOf(
TimelineAttributeFactory.subtitle("Declined"),
TimelineAttributeFactory.largeIcon(TimelineIcon.ResultSent),
)
)
} else {
TimelineService.ActionResponse(success = false)
}
}
CalendarAction.Remove -> {
watchTimelineSyncer.deleteThenIgnore(pin)
TimelineService.ActionResponse(
success = true,
attributes = listOf(
TimelineAttributeFactory.subtitle("Removed from timeline"),
TimelineAttributeFactory.largeIcon(TimelineIcon.ResultDeleted),
)
)
}

CalendarAction.Mute -> {
TimelineService.ActionResponse(
success = true,
attributes = listOf(
TimelineAttributeFactory.subtitle("TODO"),
TimelineAttributeFactory.largeIcon(TimelineIcon.Settings),
)
)
}
}
}

private fun updateAttendeeStatus(eventId: String, eventStatus: Int, attendee: String): Boolean {
val contentResolver = (platformContext as AndroidPlatformContext).applicationContext.contentResolver
val uri = CalendarContract.Attendees.CONTENT_URI
val values = ContentValues().apply {
put(CalendarContract.Attendees.ATTENDEE_STATUS, eventStatus)
}
val select = "${CalendarContract.Attendees.EVENT_ID} = ? AND ${CalendarContract.Attendees.ATTENDEE_EMAIL} = ?"
val updated = contentResolver.update(uri, values, select, arrayOf(eventId, attendee))
return if (updated == 0) {
Logging.e("Failed to update attendee status")
false
} else {
true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import com.philjay.RRule
import io.rebble.cobble.shared.AndroidPlatformContext
import io.rebble.cobble.shared.Logging
import io.rebble.cobble.shared.PlatformContext
import io.rebble.cobble.shared.data.CalendarEvent
import io.rebble.cobble.shared.data.EventAttendee
import io.rebble.cobble.shared.data.EventRecurrenceRule
import io.rebble.cobble.shared.data.EventReminder
import io.rebble.cobble.shared.data.*
import io.rebble.cobble.shared.database.entity.Calendar
import kotlinx.datetime.*

Expand All @@ -26,8 +23,8 @@ private val calendarProjection = arrayOf(
CalendarContract.Calendars.CALENDAR_COLOR
)

private val eventUri: Uri = CalendarContract.Instances.CONTENT_URI
private val eventProjection = arrayOf(
private val instanceUri: Uri = CalendarContract.Instances.CONTENT_URI
private val instanceProjection = arrayOf(
CalendarContract.Instances._ID,
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
Expand All @@ -43,6 +40,22 @@ private val eventProjection = arrayOf(
CalendarContract.Instances.RDATE
)

private val eventUri: Uri = CalendarContract.Events.CONTENT_URI
private val eventProjection = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.STATUS,
CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE
)

private val attendeeUri: Uri = CalendarContract.Attendees.CONTENT_URI
private val attendeeProjection = arrayOf(
CalendarContract.Attendees._ID,
Expand Down Expand Up @@ -114,12 +127,12 @@ actual suspend fun getCalendarEvents(
platformContext as AndroidPlatformContext

val contentResolver = platformContext.applicationContext.contentResolver
val uriBuilder = eventUri.buildUpon()
val uriBuilder = instanceUri.buildUpon()
ContentUris.appendId(uriBuilder, startDate.toEpochMilliseconds())
ContentUris.appendId(uriBuilder, endDate.toEpochMilliseconds())
val builtUri = uriBuilder.build()

val result = contentResolver.query(builtUri, eventProjection,
val result = contentResolver.query(builtUri, instanceProjection,
"${CalendarContract.Instances.CALENDAR_ID} = ?"
+ " AND IFNULL(" + CalendarContract.Instances.STATUS + ", " + CalendarContract.Instances.STATUS_TENTATIVE + ") != " + CalendarContract.Instances.STATUS_CANCELED
+ " AND IFNULL(" + CalendarContract.Instances.SELF_ATTENDEE_STATUS + ", " + CalendarContract.Attendees.ATTENDEE_STATUS_NONE + ") != " + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
Expand All @@ -128,7 +141,7 @@ platformContext as AndroidPlatformContext
Logging.d("Found ${cursor.count} events for calendar ${calendar.name}")
val list = mutableListOf<CalendarEvent>()
while (cursor.moveToNext()) {
val event = resolveCalendarEvent(contentResolver, cursor, calendar.ownerId)
val event = resolveCalendarInstance(contentResolver, cursor, calendar.ownerId)
if (event != null) {
list.add(event)
}
Expand All @@ -138,7 +151,7 @@ platformContext as AndroidPlatformContext
return result
}

private fun resolveCalendarEvent(contentResolver: ContentResolver, cursor: Cursor, ownerEmail: String): CalendarEvent? {
private fun resolveCalendarInstance(contentResolver: ContentResolver, cursor: Cursor, ownerEmail: String): CalendarEvent? {
val id = cursor.getNullableColumnIndex(CalendarContract.Instances._ID)
?.let { cursor.getLong(it) } ?: return null
val eventId = cursor.getNullableColumnIndex(CalendarContract.Instances.EVENT_ID)
Expand Down Expand Up @@ -187,7 +200,61 @@ private fun resolveCalendarEvent(contentResolver: ContentResolver, cursor: Curso
CalendarContract.Instances.STATUS_CANCELED -> CalendarEvent.Status.Cancelled
CalendarContract.Instances.STATUS_TENTATIVE -> CalendarEvent.Status.Tentative
else -> CalendarEvent.Status.None
}
},
baseEventId = eventId
)
}

private fun resolveCalendarEvent(contentResolver: ContentResolver, cursor: Cursor, ownerEmail: String): CalendarEvent? {
val id = cursor.getNullableColumnIndex(CalendarContract.Events._ID)
?.let { cursor.getLong(it) } ?: return null
val eventId = id
val calendarId = cursor.getNullableColumnIndex(CalendarContract.Events.CALENDAR_ID)
?.let { cursor.getLong(it) } ?: return null
val title = cursor.getNullableColumnIndex(CalendarContract.Events.TITLE)
?.let { cursor.getString(it) } ?: "Untitled event"
val description = cursor.getNullableColumnIndex(CalendarContract.Events.DESCRIPTION)
?.let { cursor.getString(it) } ?: ""
val allDay = cursor.getNullableColumnIndex(CalendarContract.Events.ALL_DAY)
?.let { cursor.getInt(it) } ?: false
val location = cursor.getNullableColumnIndex(CalendarContract.Events.EVENT_LOCATION)
?.let { cursor.getString(it) }
val availability = cursor.getNullableColumnIndex(CalendarContract.Events.AVAILABILITY)
?.let { cursor.getInt(it) } ?: return null
val status = cursor.getNullableColumnIndex(CalendarContract.Events.STATUS)
?.let { cursor.getInt(it) } ?: return null
val recurrenceRule = cursor.getNullableColumnIndex(CalendarContract.Events.RRULE)
?.let { cursor.getString(it) }
val start = cursor.getNullableColumnIndex(CalendarContract.Events.DTSTART)
?.let { cursor.getLong(it) } ?: return null
val end = cursor.getNullableColumnIndex(CalendarContract.Events.DTEND)
?.let { cursor.getLong(it) } ?: return null

return CalendarEvent(
id = id,
calendarId = calendarId,
title = title,
description = description,
location = location,
startTime = Instant.fromEpochMilliseconds(start),
endTime = Instant.fromEpochMilliseconds(end),
allDay = allDay != 0,
attendees = resolveAttendees(eventId, ownerEmail, contentResolver),
recurrenceRule = recurrenceRule?.let { resolveRecurrenceRule(it, Instant.fromEpochMilliseconds(start)) },
reminders = resolveReminders(eventId, contentResolver),
availability = when (availability) {
CalendarContract.Instances.AVAILABILITY_BUSY -> CalendarEvent.Availability.Busy
CalendarContract.Instances.AVAILABILITY_FREE -> CalendarEvent.Availability.Free
CalendarContract.Instances.AVAILABILITY_TENTATIVE -> CalendarEvent.Availability.Tentative
else -> CalendarEvent.Availability.Unavailable
},
status = when (status) {
CalendarContract.Instances.STATUS_CONFIRMED -> CalendarEvent.Status.Confirmed
CalendarContract.Instances.STATUS_CANCELED -> CalendarEvent.Status.Cancelled
CalendarContract.Instances.STATUS_TENTATIVE -> CalendarEvent.Status.Tentative
else -> CalendarEvent.Status.None
},
baseEventId = eventId
)
}

Expand Down Expand Up @@ -292,4 +359,53 @@ private fun resolveReminders(eventId: Long, contentResolver: ContentResolver): L
}
}.toList()
} ?: listOf()
}

suspend fun getCalendarEventById(
platformContext: PlatformContext,
eventId: String
): CalendarEvent? {
platformContext as AndroidPlatformContext

val contentResolver = platformContext.applicationContext.contentResolver
return contentResolver.query(eventUri, eventProjection,
"${CalendarContract.Events._ID} = ?",
arrayOf(eventId), "DTSTART ASC"
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idCol = cursor.getNullableColumnIndex(CalendarContract.Events.CALENDAR_ID)
val calendarId = idCol?.let { cursor.getLong(it) } ?: return null
val calendar = getCalendars(platformContext).find { it.platformId == calendarId.toString() } ?: return null
resolveCalendarEvent(contentResolver, cursor, calendar.ownerId)
} else {
null
}
}
}

suspend fun getCalendarInstanceById(
platformContext: PlatformContext,
instanceId: String,
startDate: Instant,
endDate: Instant
): CalendarEvent? {
platformContext as AndroidPlatformContext

val contentResolver = platformContext.applicationContext.contentResolver
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(uri, startDate.toEpochMilliseconds())
ContentUris.appendId(uri, endDate.toEpochMilliseconds())
return contentResolver.query(uri.build(), instanceProjection,
"Instances._id = ?",
arrayOf(instanceId), "BEGIN ASC"
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idCol = cursor.getNullableColumnIndex(CalendarContract.Instances.CALENDAR_ID)
val calendarId = idCol?.let { cursor.getLong(it) } ?: return null
val calendar = getCalendars(platformContext).find { it.platformId == calendarId.toString() } ?: return null
resolveCalendarInstance(contentResolver, cursor, calendar.ownerId)
} else {
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import io.rebble.cobble.shared.domain.timeline.TimelineIcon
import io.rebble.libpebblecommon.packets.blobdb.TimelineItem
import io.rebble.libpebblecommon.util.trimWithEllipsis
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.format.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.time.DurationUnit
Expand All @@ -29,6 +31,7 @@ data class CalendarEvent(
val reminders: List<EventReminder>,
val availability: Availability,
val status: Status,
val baseEventId: Long,
) {
enum class Availability {
Free,
Expand Down Expand Up @@ -176,4 +179,19 @@ fun CalendarEvent.toTimelinePin(calendar: Calendar): TimelinePin {
}


private fun CalendarEvent.generateCompositeBackingId() = "${calendarId}T${id}T${startTime}"
private fun CalendarEvent.generateCompositeBackingId() = "${calendarId}T${id}T${startTime}"

data class CompositeBackingId(
val calendarId: Long,
val eventId: Long,
val startTime: String
)

fun String.toCompositeBackingId(): CompositeBackingId {
val parts = split("T")
return CompositeBackingId(
calendarId = parts[0].toLong(),
eventId = parts[1].toLong(),
startTime = parts[2]
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ interface CalendarDao {

@Query("SELECT * FROM Calendar")
fun getFlow(): Flow<List<Calendar>>

@Query("SELECT * FROM Calendar WHERE id = :calendarId")
suspend fun get(calendarId: Long): Calendar?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.rebble.cobble.shared.domain.calendar

import io.rebble.cobble.shared.database.entity.TimelinePin
import io.rebble.libpebblecommon.services.blobdb.TimelineService

interface PlatformCalendarActionExecutor {
suspend fun handlePlatformAction(action: CalendarAction, pin: TimelinePin): TimelineService.ActionResponse
}
Loading

0 comments on commit b4ebae1

Please sign in to comment.