Add calendar plugins

This commit is contained in:
MM20
2024-08-04 17:40:46 +02:00
parent c7987aabcd
commit 7479c7f401
36 changed files with 1260 additions and 351 deletions
+1
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
}
android {
@@ -1,42 +1,51 @@
package de.mm20.launcher2.calendar
import android.content.ContentUris
import android.content.Context
import android.provider.CalendarContract
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.calendar.providers.AndroidCalendarProvider
import de.mm20.launcher2.calendar.providers.CalendarList
import de.mm20.launcher2.calendar.providers.CalendarProvider
import de.mm20.launcher2.calendar.providers.PluginCalendarProvider
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SearchableRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.Calendar
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlin.time.Duration.Companion.days
interface CalendarRepository : SearchableRepository<CalendarEvent> {
fun findMany(
from: Long = System.currentTimeMillis(),
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
excludeCalendars: List<Long> = emptyList(),
excludeCalendars: List<String> = emptyList(),
excludeAllDayEvents: Boolean = false,
limit: Int = 999,
): Flow<ImmutableList<CalendarEvent>>
suspend fun getCalendars(): List<UserCalendar>
fun getCalendars(providerId: String? = null): Flow<List<CalendarList>>
}
internal class CalendarRepositoryImpl(
private val context: Context,
private val permissionsManager: PermissionsManager,
private val pluginRepository: PluginRepository,
private val settings: CalendarSearchSettings,
) : CalendarRepository {
@@ -48,48 +57,62 @@ internal class CalendarRepositoryImpl(
}
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val enabled = settings.enabled
val providerIds = settings.providers
return hasPermission.combine(enabled) { a, b -> a && b }
.map {
if (it) {
val now = System.currentTimeMillis()
queryCalendarEvents(
query,
intervalStart = now,
intervalEnd = now + 14 * 24 * 60 * 60 * 1000L,
).toImmutableList()
} else {
persistentListOf()
return combineTransform(hasPermission, providerIds) { perm, providerIds ->
val providers = providerIds.mapNotNull {
when (it) {
"local" -> if (perm) AndroidCalendarProvider(context) else null
else -> PluginCalendarProvider(context, it)
}
}
val now = System.currentTimeMillis()
emitAll(
queryCalendarEvents(
query = query,
excludeAllDayEvents = false,
providers = providers,
intervalStart = now,
intervalEnd = now + 730.days.inWholeMilliseconds,
allowNetwork = allowNetwork,
)
)
}
}
override fun findMany(
from: Long,
to: Long,
excludeCalendars: List<Long>,
excludeCalendars: List<String>,
excludeAllDayEvents: Boolean,
limit: Int,
) = channelFlow<ImmutableList<CalendarEvent>> {
): Flow<ImmutableList<CalendarEvent>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
hasPermission.collectLatest {
if (it) {
val events = withContext(Dispatchers.IO) {
queryCalendarEvents(
query = null,
intervalStart = from,
intervalEnd = to,
limit = limit,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars
)
}
send(events.toImmutableList())
} else {
send(persistentListOf())
val plugins = pluginRepository.findMany(
type = PluginType.Calendar,
enabled = true,
)
return combineTransform(hasPermission, plugins) { perm, plugins ->
val providers = buildList {
if (perm) add(AndroidCalendarProvider(context)) else null
addAll(
plugins.map {
PluginCalendarProvider(context, it.authority)
}
)
}
emitAll(
queryCalendarEvents(
query = null,
intervalStart = from,
intervalEnd = to,
excludeAllDayEvents = excludeAllDayEvents,
excludeCalendars = excludeCalendars,
providers = providers,
allowNetwork = false,
)
)
}
}
@@ -97,124 +120,70 @@ internal class CalendarRepositoryImpl(
query: String?,
intervalStart: Long,
intervalEnd: Long,
limit: Int = 10,
excludeAllDayEvents: Boolean = false,
excludeCalendars: List<Long> = emptyList(),
): List<AndroidCalendarEvent> {
val results = withContext(Dispatchers.IO) {
val results = mutableListOf<AndroidCalendarEvent>()
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, intervalStart)
ContentUris.appendId(builder, intervalEnd)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.DISPLAY_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.DESCRIPTION
)
val selection = mutableListOf<String>()
if (query != null) selection.add("${CalendarContract.Instances.TITLE} LIKE ?")
if (excludeCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${excludeCalendars.joinToString()})")
if (excludeAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0")
val selArgs = if (query != null) arrayOf("%$query%") else null
val sort =
"${CalendarContract.Instances.BEGIN} ASC" + if (limit > -1) " LIMIT $limit" else ""
val cursor = context.contentResolver.query(
uri,
projection,
selection.joinToString(separator = " AND "),
selArgs,
sort
) ?: return@withContext mutableListOf()
val proj = arrayOf(
CalendarContract.Attendees.EVENT_ID,
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL
)
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
while (cursor.moveToNext()) {
val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}"
val cur = context.contentResolver.query(
CalendarContract.Attendees.CONTENT_URI,
proj, sel, null, s
) ?: return@withContext mutableListOf()
val attendees = mutableListOf<String>()
while (cur.moveToNext()) {
attendees.add(
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
?: cur.getStringOrNull(2)
?: continue
excludeCalendars: List<String> = emptyList(),
allowNetwork: Boolean = false,
providers: List<CalendarProvider>,
): Flow<ImmutableList<CalendarEvent>> = flow {
supervisorScope {
val result = MutableStateFlow(persistentListOf<CalendarEvent>())
for (provider in providers) {
launch {
val r = provider.search(
query,
from = intervalStart,
to = intervalEnd,
excludedCalendars = excludeCalendars.mapNotNull {
val (namespace, id) = it.split(":")
if (namespace == provider.namespace) id else null
},
excludeAllDayEvents = excludeAllDayEvents,
allowNetwork = allowNetwork,
)
result.update {
(it + r).toPersistentList()
}
}
cur.close()
val allday = cursor.getInt(4) > 0
val begin = cursor.getLong(2)
val tzOffset = if (allday) {
Calendar.getInstance().timeZone.getOffset(begin)
} else {
0
}
val event = AndroidCalendarEvent(
label = cursor.getStringOrNull(1) ?: "",
id = cursor.getLong(0),
color = cursor.getInt(5),
startTime = begin - tzOffset,
endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0,
allDay = allday,
location = cursor.getStringOrNull(6) ?: "",
attendees = attendees,
description = cursor.getStringOrNull(8)
?: "",
calendar = cursor.getLong(7)
)
results.add(event)
}
cursor.close()
return@withContext results
emitAll(result)
}
return results
}
override suspend fun getCalendars(): List<UserCalendar> {
if (!permissionsManager.checkPermissionOnce(PermissionGroup.Calendar)) return emptyList()
return withContext(Dispatchers.IO) {
val calendars = mutableListOf<UserCalendar>()
val uri = CalendarContract.Calendars.CONTENT_URI
val proj = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
override fun getCalendars(providerId: String?): Flow<List<CalendarList>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val providers: Flow<List<CalendarProvider>> = if (providerId != null) {
when(providerId) {
"local" -> hasPermission.map { if (it) listOf(AndroidCalendarProvider(context)) else emptyList() }
else -> pluginRepository.get(providerId).map { if (it?.enabled == true) listOf(PluginCalendarProvider(context, providerId)) else emptyList() }
}
} else {
val plugins = pluginRepository.findMany(
type = PluginType.Calendar,
enabled = true,
)
val cursor = context.contentResolver.query(uri, proj, null, null, null)
?: return@withContext emptyList()
while (cursor.moveToNext()) {
try {
calendars.add(
UserCalendar(
id = cursor.getLong(0),
name = cursor.getStringOrNull(5) ?: cursor.getStringOrNull(1) ?: "",
owner = cursor.getStringOrNull(2) ?: "",
color = cursor.getInt(3)
)
)
} catch (e: NullPointerException) {
continue
combine(hasPermission, plugins) { perm, plugins ->
buildList {
if (perm) add(AndroidCalendarProvider(context))
addAll(plugins.map { PluginCalendarProvider(context, it.authority) })
}
}
cursor.close()
calendars.sortBy { it.owner }
return@withContext calendars
}
return providers.transform { providers ->
supervisorScope {
val result = MutableStateFlow(emptyList<CalendarList>())
for (provider in providers) {
launch {
val r = provider.getCalendarLists()
result.update { it + r }
}
}
emitAll(result)
}
}
}
}
@@ -1,19 +1,31 @@
package de.mm20.launcher2.calendar
import android.Manifest
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.provider.CalendarContract
import android.net.Uri
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.calendar.providers.AndroidCalendarEvent
import de.mm20.launcher2.calendar.providers.AndroidCalendarProvider
import de.mm20.launcher2.calendar.providers.PluginCalendarEvent
import de.mm20.launcher2.calendar.providers.PluginCalendarProvider
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
import de.mm20.launcher2.serialization.Json
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import org.json.JSONObject
import java.util.*
class CalendarEventSerializer: SearchableSerializer {
class AndroidCalendarEventSerializer: SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as AndroidCalendarEvent
val json = JSONObject()
@@ -22,84 +34,117 @@ class CalendarEventSerializer: SearchableSerializer {
}
override val typePrefix: String
get() = "calendar"
get() = AndroidCalendarEvent.Domain
}
class CalendarEventDeserializer(val context: Context): SearchableDeserializer {
class AndroidCalendarEventDeserializer(val context: Context): SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) return null
val json = JSONObject(serialized)
val id = json.getLong("id")
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, System.currentTimeMillis())
ContentUris.appendId(builder, System.currentTimeMillis() + 63072000000L)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.DISPLAY_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.DESCRIPTION
)
val selection = CalendarContract.Instances.EVENT_ID + " = ?"
val selArgs = arrayOf(id.toString())
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, null)
?: return null
if (cursor.moveToNext()) {
val title = cursor.getStringOrNull(1) ?: ""
val begin = cursor.getLong(2)
val end = cursor.getLong(3)
val allday = cursor.getInt(4) != 0
val color = cursor.getInt(5)
val location = cursor.getStringOrNull(6)
val calendar = cursor.getLong(7)
val description = cursor.getStringOrNull(8)
?: ""
cursor.close()
val proj = arrayOf(
CalendarContract.Attendees.EVENT_ID,
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL
return AndroidCalendarProvider(context).get(id)
}
}
@Serializable
internal data class SerializedCalendarEvent(
val id: String? = null,
val authority: String? = null,
val color: Int? = null,
val startTime: Long? = null,
val endTime: Long? = null,
val allDay: Boolean? = null,
val description: String? = null,
val calendarName: String? = null,
val location: String? = null,
val attendees: List<String>? = null,
val uri: String? = null,
val completed: Boolean? = null,
val label: String? = null,
val timestamp: Long = 0L,
val strategy: StorageStrategy = StorageStrategy.StoreCopy,
)
class PluginCalendarEventSerializer: SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as PluginCalendarEvent
if (searchable.storageStrategy == StorageStrategy.StoreCopy) {
return Json.Lenient.encodeToString(
SerializedCalendarEvent(
id = searchable.id,
authority = searchable.authority,
color = searchable.color,
startTime = searchable.startTime,
endTime = searchable.endTime,
allDay = searchable.allDay,
description = searchable.description,
calendarName = searchable.calendarName,
location = searchable.location,
attendees = searchable.attendees,
uri = searchable.uri.toString(),
completed = searchable.isCompleted,
label = searchable.label,
strategy = searchable.storageStrategy,
timestamp = searchable.timestamp,
)
)
val sel = "${CalendarContract.Attendees.EVENT_ID} = $id"
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
val cur = context.contentResolver.query(
CalendarContract.Attendees.CONTENT_URI,
proj, sel, null, s
) ?: return null
val attendees = mutableListOf<String>()
while (cur.moveToNext()) {
attendees.add(
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
?: cur.getStringOrNull(2)
?: continue
} else {
return Json.Lenient.encodeToString(
SerializedCalendarEvent(
id = searchable.id,
authority = searchable.authority,
strategy = searchable.storageStrategy,
)
}
cur.close()
val tzOffset = if (allday) {
Calendar.getInstance().timeZone.getOffset(begin)
} else {
0
}
return AndroidCalendarEvent(
label = title,
id = id,
color = color,
startTime = begin - tzOffset,
endTime = end - tzOffset - if (allday) 1 else 0,
allDay = allday,
location = location ?: "",
attendees = attendees,
description = description,
calendar = calendar
)
}
cursor.close()
return null
}
override val typePrefix: String
get() = PluginCalendarEvent.Domain
}
class PluginCalendarEventDeserializer(
val context: Context,
private val pluginRepository: PluginRepository,
): SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val json = Json.Lenient.decodeFromString<SerializedCalendarEvent>(serialized)
val authority = json.authority ?: return null
val id = json.id ?: return null
val strategy = json.strategy
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
return when(strategy) {
StorageStrategy.StoreReference -> {
PluginCalendarProvider(context, authority).get(id).getOrNull()
}
else -> {
val timestamp = json.timestamp
PluginCalendarEvent(
id = id,
color = json.color,
startTime = json.startTime ?: 0,
endTime = json.endTime ?: 0,
allDay = json.allDay ?: false,
description = json.description,
calendarName = json.calendarName,
location = json.location,
attendees = json.attendees ?: emptyList(),
label = json.label ?: return null,
uri = Uri.parse(json.uri ?: return null),
isCompleted = json.completed,
storageStrategy = StorageStrategy.StoreCopy,
authority = authority,
timestamp = timestamp,
updatedSelf = {
if (it !is PluginCalendarEvent) UpdateResult.TemporarilyUnavailable()
else PluginCalendarProvider(context, authority).refresh(it, timestamp).asUpdateResult()
}
)
}
}
}
}
@@ -1,5 +1,7 @@
package de.mm20.launcher2.calendar
import de.mm20.launcher2.calendar.providers.AndroidCalendarEvent
import de.mm20.launcher2.calendar.providers.PluginCalendarEvent
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
@@ -9,6 +11,7 @@ import org.koin.dsl.module
val calendarModule = module {
factory<SearchableRepository<CalendarEvent>>(named<CalendarEvent>()) { get<CalendarRepository>() }
factory<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get(), get()) }
factory<SearchableDeserializer>(named(AndroidCalendarEvent.Domain)) { CalendarEventDeserializer(androidContext()) }
factory<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get(), get(), get()) }
factory<SearchableDeserializer>(named(AndroidCalendarEvent.Domain)) { AndroidCalendarEventDeserializer(androidContext()) }
factory<SearchableDeserializer>(named(PluginCalendarEvent.Domain)) { PluginCalendarEventDeserializer(androidContext(), get()) }
}
@@ -1,4 +1,4 @@
package de.mm20.launcher2.calendar
package de.mm20.launcher2.calendar.providers
import android.content.ContentUris
import android.content.Context
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import de.mm20.launcher2.calendar.AndroidCalendarEventSerializer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SearchableSerializer
@@ -21,7 +22,8 @@ internal data class AndroidCalendarEvent(
override val location: String?,
override val attendees: List<String>,
override val description: String?,
val calendar: Long,
internal val calendarId: Long,
override val calendarName: String?,
override val labelOverride: String? = null,
) : CalendarEvent {
@@ -61,7 +63,7 @@ internal data class AndroidCalendarEvent(
}
override fun getSerializer(): SearchableSerializer {
return CalendarEventSerializer()
return AndroidCalendarEventSerializer()
}
companion object {
@@ -69,9 +71,10 @@ internal data class AndroidCalendarEvent(
}
}
data class UserCalendar(
val id: Long,
data class CalendarList(
val id: String,
val name: String,
val owner: String,
val color: Int
val owner: String?,
val color: Int,
val providerId: String,
)
@@ -0,0 +1,218 @@
package de.mm20.launcher2.calendar.providers
import android.content.ContentUris
import android.content.Context
import android.provider.CalendarContract
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.search.CalendarEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Calendar
class AndroidCalendarProvider(
private val context: Context,
): CalendarProvider {
override suspend fun search(
query: String?,
from: Long,
to: Long,
excludedCalendars: List<String>,
excludeAllDayEvents: Boolean,
allowNetwork: Boolean
): List<CalendarEvent> {
val results = withContext(Dispatchers.IO) {
val results = mutableListOf<AndroidCalendarEvent>()
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, from)
ContentUris.appendId(builder, to)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.DISPLAY_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.DESCRIPTION,
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
)
val selection = mutableListOf<String>()
if (query != null) selection.add("${CalendarContract.Instances.TITLE} LIKE ?")
if (excludedCalendars.isNotEmpty()) selection.add("${CalendarContract.Instances.CALENDAR_ID} NOT IN (${excludedCalendars.joinToString()})")
if (excludeAllDayEvents) selection.add("${CalendarContract.Instances.ALL_DAY} = 0")
val selArgs = if (query != null) arrayOf("%$query%") else null
val sort = "${CalendarContract.Instances.BEGIN} ASC"
val cursor = context.contentResolver.query(
uri,
projection,
selection.joinToString(separator = " AND "),
selArgs,
sort
) ?: return@withContext mutableListOf()
val proj = arrayOf(
CalendarContract.Attendees.EVENT_ID,
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL
)
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
while (cursor.moveToNext()) {
val sel = "${CalendarContract.Attendees.EVENT_ID} = ${cursor.getLong(0)}"
val cur = context.contentResolver.query(
CalendarContract.Attendees.CONTENT_URI,
proj, sel, null, s
) ?: return@withContext mutableListOf()
val attendees = mutableListOf<String>()
while (cur.moveToNext()) {
attendees.add(
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
?: cur.getStringOrNull(2)
?: continue
)
}
cur.close()
val allday = cursor.getInt(4) > 0
val begin = cursor.getLong(2)
val tzOffset = if (allday) {
Calendar.getInstance().timeZone.getOffset(begin)
} else {
0
}
val event = AndroidCalendarEvent(
label = cursor.getStringOrNull(1) ?: "",
id = cursor.getLong(0),
color = cursor.getInt(5),
startTime = begin - tzOffset,
endTime = cursor.getLong(3) - tzOffset - if (allday) 1 else 0,
allDay = allday,
location = cursor.getStringOrNull(6) ?: "",
attendees = attendees,
description = cursor.getStringOrNull(8)
?: "",
calendarId = cursor.getLong(7),
calendarName = cursor.getStringOrNull(9)
)
results.add(event)
}
cursor.close()
return@withContext results
}
return results
}
suspend fun get(id: Long): CalendarEvent? = withContext(Dispatchers.IO) {
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, System.currentTimeMillis())
ContentUris.appendId(builder, System.currentTimeMillis() + 63072000000L)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.DISPLAY_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.DESCRIPTION,
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
)
val selection = CalendarContract.Instances.EVENT_ID + " = ?"
val selArgs = arrayOf(id.toString())
val cursor = context.contentResolver.query(uri, projection, selection, selArgs, null)
?: return@withContext null
if (cursor.moveToNext()) {
val title = cursor.getStringOrNull(1) ?: ""
val begin = cursor.getLong(2)
val end = cursor.getLong(3)
val allday = cursor.getInt(4) != 0
val color = cursor.getInt(5)
val location = cursor.getStringOrNull(6)
val calendar = cursor.getLong(7)
val description = cursor.getStringOrNull(8)
val calendarName = cursor.getStringOrNull(9)
cursor.close()
val proj = arrayOf(
CalendarContract.Attendees.EVENT_ID,
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL
)
val sel = "${CalendarContract.Attendees.EVENT_ID} = $id"
val s = "${CalendarContract.Attendees.ATTENDEE_NAME} COLLATE NOCASE ASC"
val cur = context.contentResolver.query(
CalendarContract.Attendees.CONTENT_URI,
proj, sel, null, s
) ?: return@withContext null
val attendees = mutableListOf<String>()
while (cur.moveToNext()) {
attendees.add(
cur.getStringOrNull(1).takeUnless { it.isNullOrBlank() }
?: cur.getStringOrNull(2)
?: continue
)
}
cur.close()
val tzOffset = if (allday) {
Calendar.getInstance().timeZone.getOffset(begin)
} else {
0
}
return@withContext AndroidCalendarEvent(
label = title,
id = id,
color = color,
startTime = begin - tzOffset,
endTime = end - tzOffset - if (allday) 1 else 0,
allDay = allday,
location = location ?: "",
attendees = attendees,
description = description,
calendarId = calendar,
calendarName = calendarName
)
}
cursor.close()
return@withContext null
}
override suspend fun getCalendarLists(): List<CalendarList> {
return withContext(Dispatchers.IO) {
val calendars = mutableListOf<CalendarList>()
val uri = CalendarContract.Calendars.CONTENT_URI
val proj = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
)
val cursor = context.contentResolver.query(uri, proj, null, null, null)
?: return@withContext emptyList()
while (cursor.moveToNext()) {
try {
calendars.add(
CalendarList(
id = "local:${cursor.getLong(0)}",
name = cursor.getStringOrNull(5) ?: cursor.getStringOrNull(1) ?: "",
owner = cursor.getStringOrNull(2),
color = cursor.getInt(3),
providerId = "local",
)
)
} catch (e: NullPointerException) {
continue
}
}
cursor.close()
calendars.sortBy { it.owner }
return@withContext calendars
}
}
override val namespace: String = "local"
}
@@ -0,0 +1,18 @@
package de.mm20.launcher2.calendar.providers
import de.mm20.launcher2.search.CalendarEvent
internal interface CalendarProvider {
suspend fun search(
query: String? = null,
from: Long = System.currentTimeMillis(),
to: Long = from + 14 * 24 * 60 * 60 * 1000L,
excludedCalendars: List<String> = emptyList(),
excludeAllDayEvents: Boolean = false,
allowNetwork: Boolean = false,
): List<CalendarEvent>
suspend fun getCalendarLists(): List<CalendarList>
val namespace: String
}
@@ -0,0 +1,65 @@
package de.mm20.launcher2.calendar.providers
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import de.mm20.launcher2.calendar.PluginCalendarEventSerializer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
data class PluginCalendarEvent(
val id: String,
val authority: String,
override val color: Int?,
override val startTime: Long?,
override val endTime: Long,
override val allDay: Boolean,
override val description: String?,
override val calendarName: String?,
override val location: String?,
override val attendees: List<String>,
val uri: Uri,
override val isCompleted: Boolean?,
override val label: String,
override val labelOverride: String? = null,
override val timestamp: Long,
internal val storageStrategy: StorageStrategy,
override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<CalendarEvent>)?,
) : CalendarEvent, UpdatableSearchable<CalendarEvent> {
override val domain: String = Domain
override val key: String
get() = "$domain://$authority:$id"
override fun overrideLabel(label: String): SavableSearchable {
return copy(
labelOverride = label
)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(
Intent(
Intent.ACTION_VIEW
).apply {
data = uri
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}, options
)
}
override fun getSerializer(): SearchableSerializer {
return PluginCalendarEventSerializer()
}
companion object {
const val Domain = "plugin.calendar"
}
}
@@ -0,0 +1,186 @@
package de.mm20.launcher2.calendar.providers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.os.CancellationSignal
import android.util.Log
import de.mm20.launcher2.plugin.QueryPluginApi
import de.mm20.launcher2.plugin.contracts.CalendarPluginContract
import de.mm20.launcher2.plugin.contracts.CalendarPluginContract.CalendarListColumns
import de.mm20.launcher2.plugin.contracts.CalendarPluginContract.EventColumns
import de.mm20.launcher2.plugin.data.set
import de.mm20.launcher2.plugin.data.withColumns
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
import de.mm20.launcher2.search.calendar.CalendarQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
class PluginCalendarProvider(
private val context: Context,
private val pluginAuthority: String,
) : QueryPluginApi<CalendarQuery, PluginCalendarEvent>(
context, pluginAuthority
), CalendarProvider {
override suspend fun search(
query: String?,
from: Long,
to: Long,
excludedCalendars: List<String>,
excludeAllDayEvents: Boolean,
allowNetwork: Boolean
): List<CalendarEvent> {
return search(
CalendarQuery(
query = query,
start = from,
end = to,
excludedCalendars = excludedCalendars,
),
allowNetwork,
)
}
override fun Uri.Builder.appendQueryParameters(query: CalendarQuery): Uri.Builder {
appendQueryParameter(
CalendarPluginContract.Params.Query,
query.query
)
val start = query.start
if (start != null) {
appendQueryParameter(
CalendarPluginContract.Params.StartTime,
start.toString()
)
}
val end = query.end
if (end != null) {
appendQueryParameter(
CalendarPluginContract.Params.EndTime,
end.toString()
)
}
if (query.excludedCalendars.isNotEmpty()) {
appendQueryParameter(
CalendarPluginContract.Params.Exclude,
query.excludedCalendars.joinToString(",")
)
}
return this
}
override fun Cursor.getData(): List<PluginCalendarEvent>? {
val config = getConfig()
val cursor = this
if (config == null) {
Log.e("MM20", "Plugin ${pluginAuthority} returned null config")
cursor.close()
return null
}
val results = mutableListOf<PluginCalendarEvent>()
val timestamp = System.currentTimeMillis()
cursor.withColumns(EventColumns) {
while (cursor.moveToNext()) {
results += PluginCalendarEvent(
id = cursor[EventColumns.Id] ?: continue,
authority = pluginAuthority,
uri = Uri.parse(cursor[EventColumns.Uri] ?: continue),
color = cursor[EventColumns.Color],
label = cursor[EventColumns.Title] ?: continue,
description = cursor[EventColumns.Description],
location = cursor[EventColumns.Location],
calendarName = cursor[EventColumns.CalendarName],
allDay = cursor[EventColumns.IncludeTime] == false,
startTime = cursor[EventColumns.StartTime],
endTime = cursor[EventColumns.EndTime] ?: continue,
attendees = cursor[EventColumns.Attendees] ?: emptyList(),
isCompleted = cursor[EventColumns.IsCompleted],
updatedSelf = {
if (it !is PluginCalendarEvent) UpdateResult.TemporarilyUnavailable()
else refresh(it, timestamp).asUpdateResult()
},
storageStrategy = config.storageStrategy,
timestamp = timestamp,
)
}
}
cursor.close()
return results
}
override fun PluginCalendarEvent.toBundle(): Bundle {
return Bundle().apply {
set(EventColumns.Id, id)
set(EventColumns.Title, label)
set(EventColumns.Description, description)
set(EventColumns.CalendarName, calendarName)
set(EventColumns.Location, location)
set(EventColumns.Color, color)
set(EventColumns.StartTime, startTime)
set(EventColumns.EndTime, endTime)
set(EventColumns.IncludeTime, !allDay)
set(EventColumns.Attendees, attendees)
set(EventColumns.Uri, uri.toString())
set(EventColumns.IsCompleted, isCompleted)
}
}
override suspend fun getCalendarLists(): List<CalendarList> = withContext(Dispatchers.IO) {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(CalendarPluginContract.Paths.CalendarLists)
.build()
val cancellationSignal = CancellationSignal()
return@withContext suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val cursor = try {
context.contentResolver.query(
uri,
null,
null,
cancellationSignal
)
} catch (e: Exception) {
Log.e("MM20", "Plugin $pluginAuthority threw exception", e)
it.resume(emptyList())
return@suspendCancellableCoroutine
}
if (cursor == null) {
Log.e("MM20", "Plugin $pluginAuthority returned null cursor")
it.resume(emptyList())
return@suspendCancellableCoroutine
}
val results = mutableListOf<CalendarList>()
cursor.use {
cursor.withColumns(CalendarListColumns) {
while (cursor.moveToNext()) {
results += CalendarList(
id = "${pluginAuthority}:" + (cursor[CalendarListColumns.Id] ?: continue),
color = cursor[CalendarListColumns.Color] ?: 0,
name = cursor[CalendarListColumns.Name] ?: continue,
owner = cursor[CalendarListColumns.AccountName],
providerId = pluginAuthority,
)
}
}
}
it.resume(results)
}
}
override val namespace: String = pluginAuthority
}
@@ -95,6 +95,7 @@ internal class PluginLocationProvider(
)
}
}
cursor.close()
return results
}
@@ -1,12 +1,7 @@
package de.mm20.launcher2.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import de.mm20.launcher2.database.entities.PartialWidgetEntity
import de.mm20.launcher2.ktx.tryStartActivity
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -15,7 +10,11 @@ import java.util.UUID
@Serializable
data class CalendarWidgetConfig(
val allDayEvents: Boolean = true,
val excludedCalendarIds: List<Long> = emptyList(),
@Deprecated("Use excludedCalendars instead")
@SerialName("excludedCalendarIds")
val legacyExcludedCalendarIds: List<Long>? = null,
@SerialName("excludedCalendars")
val excludedCalendarIds: List<String>? = null,
)
data class CalendarWidget(
override val id: UUID,