Add calendar plugins

This commit is contained in:
MM20 2024-07-31 16:56:43 +02:00
parent c7987aabcd
commit 7479c7f401
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
36 changed files with 1261 additions and 352 deletions

View File

@ -1,7 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AndroidLintUnsafeImplicitIntentLaunch" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

View File

@ -51,14 +51,14 @@ abstract class FavoritesVM : ViewModel(), KoinComponent {
val frequentlyUsedRows = it.second.frequentlyUsedRows
val pinned = favoritesService.getFavorites(
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
excludeTypes = if (excludeCalendar) listOf("calendar", "tag", "plugin.calendar") else listOf("tag"),
minPinnedLevel = PinnedLevel.AutomaticallySorted,
limit = 10 * columns,
)
if (includeFrequentlyUsed) {
emitAll(pinned.flatMapLatest { pinned ->
favoritesService.getFavorites(
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
excludeTypes = if (excludeCalendar) listOf("calendar", "tag", "plugin.calendar") else listOf("tag"),
maxPinnedLevel = PinnedLevel.FrequentlyUsed,
minPinnedLevel = PinnedLevel.FrequentlyUsed,
limit = frequentlyUsedRows * columns - pinned.size % columns,

View File

@ -409,15 +409,15 @@ class SearchVM : ViewModel(), KoinComponent {
val missingCalendarPermission = combine(
permissionsManager.hasPermission(PermissionGroup.Calendar),
calendarSearchSettings.enabled,
) { perm, enabled -> !perm && enabled }
calendarSearchSettings.providers,
) { perm, providers -> !perm && providers.contains("local") }
fun requestCalendarPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.Calendar)
}
fun disableCalendarSearch() {
calendarSearchSettings.setEnabled(false)
calendarSearchSettings.setProviderEnabled("local", false)
}
val missingContactsPermission = combine(

View File

@ -8,10 +8,10 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandIn
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.background
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -19,21 +19,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Notes
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.RadioButtonUnchecked
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -49,8 +47,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -63,8 +59,6 @@ import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import kotlinx.coroutines.launch
import palettes.TonalPalette
@Composable
@ -99,33 +93,53 @@ fun CalendarItem(
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 16.dp)
) {
Box(
Icon(
when (calendar.isCompleted) {
true -> Icons.Rounded.CheckCircle
false -> Icons.Rounded.RadioButtonUnchecked
null -> Icons.Rounded.Circle
},
null,
modifier = Modifier
.padding(horizontal = 14.dp)
.padding(horizontal = 14.dp, vertical = 20.dp)
.size(24.dp)
.sharedBounds(
.sharedElement(
rememberSharedContentState("color"),
this@AnimatedContent
)
.background(eventColor, MaterialTheme.shapes.extraSmall)
),
tint = eventColor
)
Text(
Column(
modifier = Modifier
.padding(start = 4.dp, end = 16.dp)
.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent
),
text = calendar.labelOverride ?: calendar.label,
style = MaterialTheme.typography.titleMedium
)
) {
Text(
modifier = Modifier
.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent
),
text = calendar.labelOverride ?: calendar.label,
style = MaterialTheme.typography.titleMedium
)
if (calendar.calendarName != null) {
Text(
modifier = Modifier.animateEnterExit(
enter = expandVertically(),
exit = shrinkVertically()
),
text = calendar.calendarName!!,
style = MaterialTheme.typography.labelSmall,
color = eventColor,
)
}
}
}
Row(
Modifier
.fillMaxWidth().padding(bottom = 8.dp, end = 16.dp),
.fillMaxWidth()
.padding(bottom = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@ -146,7 +160,8 @@ fun CalendarItem(
if (!calendar.description.isNullOrBlank()) {
Row(
Modifier
.fillMaxWidth().padding(bottom = 8.dp, end = 16.dp),
.fillMaxWidth()
.padding(bottom = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@ -165,7 +180,8 @@ fun CalendarItem(
if (calendar.attendees.isNotEmpty()) {
Row(
Modifier
.fillMaxWidth().padding(bottom = 8.dp, end = 16.dp),
.fillMaxWidth()
.padding(bottom = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@ -261,15 +277,21 @@ fun CalendarItem(
modifier = modifier
.padding(16.dp)
) {
Box(
Icon(
when (calendar.isCompleted) {
true -> Icons.Rounded.CheckCircle
false -> Icons.Rounded.RadioButtonUnchecked
null -> Icons.Rounded.Circle
},
null,
modifier = Modifier
.padding(end = 16.dp)
.size(20.dp)
.sharedBounds(
.sharedElement(
rememberSharedContentState("color"),
this@AnimatedContent
)
.background(eventColor, MaterialTheme.shapes.extraSmall)
),
tint = eventColor
)
Column {
Text(
@ -281,13 +303,16 @@ fun CalendarItem(
style = MaterialTheme.typography.titleSmall
)
Text(
modifier = Modifier.padding(top = 2.dp)
modifier = Modifier
.padding(top = 2.dp)
.sharedBounds(
rememberSharedContentState("date"),
this@AnimatedContent
),
text = calendar.getSummary(context),
style = MaterialTheme.typography.bodySmall
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@ -327,6 +352,24 @@ fun CalendarItemGridPopup(
}
private fun CalendarEvent.formatTime(context: Context): String {
val startTime = startTime
if (startTime == null) {
if (allDay) {
return DateUtils.formatDateRange(
context,
endTime,
endTime,
DateUtils.FORMAT_SHOW_DATE
)
}
return DateUtils.formatDateRange(
context,
endTime,
endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME
)
}
if (allDay) return DateUtils.formatDateRange(
context,
startTime,
@ -343,6 +386,39 @@ private fun CalendarEvent.formatTime(context: Context): String {
}
private fun CalendarEvent.getSummary(context: Context): String {
val startTime = startTime
if (startTime == null) {
val isToday = DateUtils.isToday(endTime)
if (isToday) {
if (allDay) {
return "Due today"
}
return "Due ${
DateUtils.formatDateTime(
context,
endTime,
DateUtils.FORMAT_SHOW_TIME
)
}"
}
if (allDay) {
return "Due ${
DateUtils.formatDateTime(
context,
endTime,
DateUtils.FORMAT_SHOW_DATE
)
}"
}
return "Due ${
DateUtils.formatDateTime(
context,
endTime,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME
)
}"
}
val isToday =
DateUtils.isToday(startTime) && DateUtils.isToday(endTime)
return if (isToday) {

View File

@ -34,7 +34,6 @@ import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.LinkOff
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -64,7 +63,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import androidx.core.net.toUri
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.calendar.UserCalendar
import de.mm20.launcher2.calendar.providers.CalendarList
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup
@ -75,7 +74,6 @@ import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.DragResizeHandle
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.ResizeAxis
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@ -91,6 +89,7 @@ import de.mm20.launcher2.widgets.MusicWidget
import de.mm20.launcher2.widgets.NotesWidget
import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.Widget
import kotlinx.coroutines.flow.map
import org.koin.androidx.compose.get
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@ -504,18 +503,16 @@ fun ColumnScope.ConfigureCalendarWidget(
) {
val calendarRepository: CalendarRepository = get()
val permissionsManager: PermissionsManager = get()
var calendars by remember { mutableStateOf(emptyList<UserCalendar>()) }
var ready by remember { mutableStateOf(false) }
val calendars by remember {
calendarRepository.getCalendars().map {
it.sortedBy { it.name }
}
}.collectAsState(null)
val hasPermission by remember {
permissionsManager.hasPermission(PermissionGroup.Calendar)
}.collectAsState(true)
LaunchedEffect(hasPermission) {
calendars = calendarRepository.getCalendars().sortedBy { it.name }
ready = true
}
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
@ -537,26 +534,29 @@ fun ColumnScope.ConfigureCalendarWidget(
text = stringResource(R.string.preference_calendar_calendars)
)
val context = LocalLifecycleOwner.current as AppCompatActivity
if (calendars.isNotEmpty()) {
val excludedCalendars = remember(widget.config) {
widget.config.excludedCalendarIds ?: widget.config.legacyExcludedCalendarIds?.map { "local:$it" } ?: emptyList()
}
if (calendars?.isNotEmpty() == true) {
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
) {
for ((i, calendar) in calendars.withIndex()) {
if (i > 0) Divider()
for ((i, calendar) in calendars!!.withIndex()) {
if (i > 0) HorizontalDivider()
CheckboxPreference(
title = calendar.name,
summary = calendar.owner,
iconPadding = false,
value = !widget.config.excludedCalendarIds.contains(calendar.id),
value = excludedCalendars.contains(calendar.id) != true,
onValueChanged = {
onWidgetUpdated(
widget.copy(
config = widget.config.copy(
excludedCalendarIds = if (it) {
widget.config.excludedCalendarIds - calendar.id
excludedCalendars - calendar.id
} else {
widget.config.excludedCalendarIds + calendar.id
excludedCalendars + calendar.id
}
)
)
@ -572,7 +572,7 @@ fun ColumnScope.ConfigureCalendarWidget(
text = stringResource(R.string.missing_permission_calendar_widget_settings),
onClick = { permissionsManager.requestPermission(context, PermissionGroup.Calendar) },
)
} else if (ready) {
} else if (calendars != null) {
Text(
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
style = MaterialTheme.typography.bodySmall,

View File

@ -43,7 +43,7 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
val calendarEvents = mutableStateOf<List<CalendarEvent>>(emptyList())
val pinnedCalendarEvents =
favoritesService.getFavorites(
includeTypes = listOf("calendar"),
includeTypes = listOf("calendar", "plugin.calendar"),
minPinnedLevel = PinnedLevel.AutomaticallySorted,
).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val nextEvents = mutableStateOf<List<CalendarEvent>>(emptyList())
@ -67,10 +67,10 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
field = value
val dates = value.flatMap {
val startDate =
Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate()
it.startTime?.let { Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() }
val endDate =
Instant.ofEpochMilli(it.endTime).atZone(ZoneId.systemDefault()).toLocalDate()
return@flatMap listOf(
return@flatMap listOfNotNull(
startDate,
endDate
)
@ -136,14 +136,14 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
val dayStart = max(now, date.atStartOfDay().toEpochSecond(offset) * 1000)
val dayEnd = date.plusDays(1).atStartOfDay().toEpochSecond(offset) * 1000
var events = upcomingEvents.filter {
it.endTime >= dayStart && it.startTime < dayEnd
it.endTime >= dayStart && (it.startTime ?: it.endTime) < dayEnd
}
if (!showRunningPastDayEvents) {
val totalCount = events.size
events = events.filter {
it.startTime >= date.atStartOfDay().toEpochSecond(offset) * 1000 ||
(it.startTime != null && it.startTime!! >= date.atStartOfDay().toEpochSecond(offset) * 1000) ||
it.endTime < date.atStartOfDay().plusDays(1).toEpochSecond(offset) * 1000
}
@ -169,14 +169,14 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
from = System.currentTimeMillis(),
to = System.currentTimeMillis() + 14 * 24 * 60 * 60 * 1000L,
excludeAllDayEvents = !config.allDayEvents,
excludeCalendars = config.excludedCalendarIds,
excludeCalendars = config.excludedCalendarIds ?: config.legacyExcludedCalendarIds?.map { "local:$it" } ?: emptyList(),
).collectLatest { events ->
searchableRepository.getKeys(
includeTypes = listOf("calendar"),
includeTypes = listOf("calendar", "plugin.calendar"),
maxVisibility = VisibilityLevel.SearchOnly,
limit = 9999,
).collectLatest { hidden ->
upcomingEvents = events.filter { !hidden.contains(it.key) }
upcomingEvents = events.filter { !hidden.contains(it.key) }.sortedBy { it.startTime ?: it.endTime }
}
}

View File

@ -36,6 +36,7 @@ import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen
import de.mm20.launcher2.ui.settings.cards.CardsSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ThemeSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ThemesSettingsScreen
@ -180,6 +181,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/files") {
FileSearchSettingsScreen()
}
composable("settings/search/calendar") {
CalendarSearchSettingsScreen()
}
composable("settings/search/searchactions") {
SearchActionsSettingsScreen()
}

View File

@ -0,0 +1,91 @@
package de.mm20.launcher2.ui.settings.calendarsearch
import android.app.PendingIntent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
fun CalendarSearchSettingsScreen() {
val viewModel: CalendarSearchSettingsScreenVM = viewModel()
val context = LocalContext.current
val hasCalendarPermission by viewModel.hasCalendarPermission.collectAsState(null)
val plugins by viewModel.availablePlugins.collectAsState(emptyList())
val enabledProviders by viewModel.enabledProviders.collectAsState(emptySet())
PreferenceScreen(title = stringResource(R.string.preference_search_calendar)) {
item {
AnimatedVisibility(hasCalendarPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_calendar_search_settings),
onClick = {
viewModel.requestCalendarPermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_search_calendar),
summary = stringResource(R.string.preference_search_calendar_summary),
value = enabledProviders.contains("local") && hasCalendarPermission == true,
onValueChanged = {
viewModel.setProviderEnabled("local", it)
},
enabled = hasCalendarPermission == true
)
for (plugin in plugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message ?: stringResource(id = R.string.plugin_state_setup_required),
icon = Icons.Rounded.ErrorOutline,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(id = R.string.plugin_action_setup))
}
}
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = state is PluginState.Ready,
summary = (state as? PluginState.Ready)?.text
?: (state as? PluginState.SetupRequired)?.message
?: plugin.plugin.description,
value = enabledProviders.contains(plugin.plugin.authority) && state is PluginState.Ready,
onValueChanged = {
viewModel.setProviderEnabled(plugin.plugin.authority, it)
},
)
}
}
}
}

View File

@ -0,0 +1,34 @@
package de.mm20.launcher2.ui.settings.calendarsearch
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class CalendarSearchSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: CalendarSearchSettings by inject()
private val pluginService: PluginService by inject()
private val permissionsManager: PermissionsManager by inject()
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val availablePlugins = pluginService.getPluginsWithState(
type = PluginType.Calendar,
enabled = true,
)
val enabledProviders = settings.enabledProviders
fun setProviderEnabled(providerId: String, enabled: Boolean) {
settings.setProviderEnabled(providerId, enabled)
}
fun requestCalendarPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.Calendar)
}
}

View File

@ -27,6 +27,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.accounts.AccountType
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
@ -196,7 +197,7 @@ fun FileSearchSettingsScreen() {
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.send()
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}

View File

@ -22,6 +22,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.ui.R
@ -41,6 +42,7 @@ fun LocationsSettingsScreen() {
val viewModel: LocationsSettingsScreenVM = viewModel()
val navController = LocalNavController.current
val context = LocalContext.current
val osmLocations by viewModel.osmLocations.collectAsState()
val imperialUnits by viewModel.imperialUnits.collectAsState()
@ -85,7 +87,7 @@ fun LocationsSettingsScreen() {
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.send()
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}

View File

@ -27,6 +27,7 @@ import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -243,6 +244,7 @@ fun PluginSettingsScreen(pluginId: String) {
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
PluginType.Weather -> Icons.Rounded.LightMode
PluginType.LocationSearch -> Icons.Rounded.Place
PluginType.Calendar -> Icons.Rounded.Today
},
null,
modifier = Modifier.size(16.dp),
@ -253,6 +255,7 @@ fun PluginSettingsScreen(pluginId: String) {
PluginType.FileSearch -> stringResource(R.string.plugin_type_filesearch)
PluginType.Weather -> stringResource(R.string.plugin_type_weather)
PluginType.LocationSearch -> stringResource(R.string.plugin_type_locationsearch)
PluginType.Calendar -> stringResource(R.string.plugin_type_calendar)
},
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,

View File

@ -116,28 +116,13 @@ fun SearchSettingsScreen() {
enabled = hasContactsPermission == true
)
val hasCalendarPermission by viewModel.hasCalendarPermission.collectAsStateWithLifecycle(
null
)
AnimatedVisibility(hasCalendarPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_calendar_search_settings),
onClick = {
viewModel.requestCalendarPermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
val calendar by viewModel.calendar.collectAsStateWithLifecycle(null)
SwitchPreference(
Preference(
title = stringResource(R.string.preference_search_calendar),
summary = stringResource(R.string.preference_search_calendar_summary),
icon = Icons.Rounded.Today,
value = calendar == true && hasCalendarPermission == true,
onValueChanged = {
viewModel.setCalendar(it)
onClick = {
navController?.navigate("settings/search/calendar")
},
enabled = hasCalendarPermission == true
)
val hasAppShortcutsPermission by viewModel.hasAppShortcutPermission.collectAsStateWithLifecycle(

View File

@ -61,19 +61,6 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
permissionsManager.requestPermission(activity, PermissionGroup.Contacts)
}
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val calendar = calendarSearchSettings.enabled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setCalendar(calendar: Boolean) {
calendarSearchSettings.setEnabled(calendar)
}
fun requestCalendarPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.Calendar)
}
val calculator = calculatorSearchSettings.enabled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)

View File

@ -17,6 +17,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.BuildConfig
import de.mm20.launcher2.ui.R
@ -58,7 +59,7 @@ fun WeatherIntegrationSettingsScreen() {
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.send()
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}

View File

@ -46,7 +46,7 @@ abstract class QueryPluginApi<TQuery, TResult>(
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(LocationPluginContract.Paths.Search)
.path(SearchPluginContract.Paths.Search)
.appendQueryParameters(query)
.appendQueryParameter(
SearchPluginContract.Params.AllowNetwork,

View File

@ -6,14 +6,17 @@ import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import java.text.SimpleDateFormat
interface CalendarEvent: SavableSearchable {
interface CalendarEvent : SavableSearchable {
val color: Int?
val startTime: Long
val startTime: Long?
val endTime: Long
val allDay: Boolean
val description: String?
val calendarName: String?
val location: String?
val attendees: List<String>
val isCompleted: Boolean?
get() = null
override val preferDetailsOverLaunch: Boolean
@ -24,7 +27,7 @@ interface CalendarEvent: SavableSearchable {
val df = SimpleDateFormat("dd")
return StaticLauncherIcon(
foregroundLayer = TextLayer(
text = df.format(startTime),
text = df.format(startTime ?: endTime),
color = color ?: 0,
),
backgroundLayer = ColorLayer(color ?: 0)

View File

@ -848,6 +848,7 @@
<string name="search_filter_online">Online results</string>
<string name="search_filter_apps">Apps</string>
<string name="plugin_type_locationsearch">Places search</string>
<string name="plugin_type_calendar">Calendar</string>
<string name="preference_default_filter">Default filter</string>
<string name="preference_default_filter_summary">Customize the default filter for searches</string>
<string name="preference_filter_bar">Show filter bar</string>

View File

@ -53,7 +53,9 @@ data class LauncherSettingsData internal constructor(
val contactSearchEnabled: Boolean = true,
@Deprecated("Use calendarSearchProviders `local` instead")
val calendarSearchEnabled: Boolean = true,
val calendarSearchProviders: Set<String> = setOf("local"),
val shortcutSearchEnabled: Boolean = true,

View File

@ -5,13 +5,20 @@ import kotlinx.coroutines.flow.map
class CalendarSearchSettings internal constructor(
private val dataStore: LauncherDataStore,
){
val enabled
get() = dataStore.data.map { it.calendarSearchEnabled }
) {
val providers
get() = dataStore.data.map { it.calendarSearchProviders }
fun setEnabled(enabled: Boolean) {
val enabledProviders
get() = dataStore.data.map { it.calendarSearchProviders }
fun setProviderEnabled(provider: String, enabled: Boolean) {
dataStore.update {
it.copy(calendarSearchEnabled = enabled)
if (enabled) {
it.copy(calendarSearchProviders = it.calendarSearchProviders + provider)
} else {
it.copy(calendarSearchProviders = it.calendarSearchProviders - provider)
}
}
}
}

View File

@ -1,7 +1,7 @@
package de.mm20.launcher2.plugin.contracts
abstract class CalendarPluginContract {
object EventColumns: Columns() {
object EventColumns : Columns() {
/**
* The unique ID of the event.
*/
@ -17,6 +17,11 @@ abstract class CalendarPluginContract {
*/
val Description = column<String>("description")
/**
* The calendar name of the event.
*/
val CalendarName = column<String>("calendar_name")
/**
* The location of the event.
*/
@ -33,9 +38,9 @@ abstract class CalendarPluginContract {
val EndTime = column<Long>("end_time")
/**
* Whether the event is an all-day event.
* Whether the times should include times or truncate to dates.
*/
val AllDay = column<Boolean>("all_day")
val IncludeTime = column<Boolean>("include_time")
/**
* The color of the event.
@ -46,5 +51,34 @@ abstract class CalendarPluginContract {
* The attendees of the event.
*/
val Attendees = column<List<String>>("attendees")
/**
* The URI of the event.
*/
val Uri = column<String>("uri")
/**
* Whether the event is a task and if so, whether it is completed.
*/
val IsCompleted = column<Boolean>("completed")
}
object CalendarListColumns : Columns() {
val Id = column<String>("id")
val Name = column<String>("name")
val Color = column<Int>("color")
val AccountName = column<String>("account_name")
}
object Params {
const val Query = "query"
const val StartTime = "start"
const val EndTime = "end"
const val Exclude = "exclude"
}
object Paths {
const val CalendarLists = "calendar_lists"
}
}

View File

@ -0,0 +1,11 @@
package de.mm20.launcher2.search.calendar
data class CalendarQuery(
val query: String?,
val start: Long?,
val end: Long?,
/**
* List of calendar list ids that should be excluded from the search results.
*/
val excludedCalendars: List<String>,
)

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
}
android {

View File

@ -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)
}
}
}
}

View File

@ -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
)
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
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,
)
)
} 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()
}
)
}
}
}
}

View File

@ -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()) }
}

View File

@ -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,
)

View File

@ -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"
}

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -95,6 +95,7 @@ internal class PluginLocationProvider(
)
}
}
cursor.close()
return results
}

View File

@ -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,

View File

@ -0,0 +1,41 @@
package de.mm20.launcher2.sdk.calendar
import android.net.Uri
data class CalendarEvent(
val id: String,
val title: String,
/**
* The name of the calendar the event belongs to.
*/
val calendarName: String?,
val description: String? = null,
/**
* The location of the event.
*/
val location: String? = null,
/**
* The color of the event, as 0xAARRGGBB int.
*/
val color: Int? = null,
/**
* Start time of the event in milliseconds since epoch.
* For tasks, this can be null.
*/
val startTime: Long?,
/**
* End time of the event in milliseconds since epoch.
* For tasks: Due date of the task.
*/
val endTime: Long,
/**
* If false, only the date will be shown for the event.
*/
val includeTime: Boolean = true,
val attendees: List<String> = emptyList(),
val uri: Uri,
/**
* If this is not null, the event is treated as a task, indicated by a checkmark in the UI.
*/
val isCompleted: Boolean? = null,
)

View File

@ -0,0 +1,8 @@
package de.mm20.launcher2.sdk.calendar
data class CalendarList(
val id: String,
val name: String,
val accountName: String? = null,
val color: Int? = null,
)

View File

@ -0,0 +1,110 @@
package de.mm20.launcher2.sdk.calendar
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.os.CancellationSignal
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugin.config.QueryPluginConfig
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.contracts.SearchPluginContract
import de.mm20.launcher2.plugin.data.buildCursor
import de.mm20.launcher2.plugin.data.get
import de.mm20.launcher2.sdk.base.QueryPluginProvider
import de.mm20.launcher2.sdk.utils.launchWithCancellationSignal
import de.mm20.launcher2.search.calendar.CalendarQuery
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
abstract class CalendarProvider(
config: QueryPluginConfig,
) : QueryPluginProvider<CalendarQuery, CalendarEvent>(
config
) {
override fun query(
uri: Uri,
projection: Array<out String>?,
queryArgs: Bundle?,
cancellationSignal: CancellationSignal?
): Cursor? {
super.query(uri, projection, queryArgs, cancellationSignal)?.let {
return it
}
if (uri.pathSegments.size == 1 && uri.pathSegments.first() == CalendarPluginContract.Paths.CalendarLists) {
return getCalendarLists(cancellationSignal).toCursor()
}
return null
}
private fun getCalendarLists(cancellationSignal: CancellationSignal?): List<CalendarList> {
return launchWithCancellationSignal(cancellationSignal) {
getCalendarLists()
}
}
abstract suspend fun getCalendarLists(): List<CalendarList>
override fun getQuery(uri: Uri): CalendarQuery {
val query = uri.getQueryParameter(CalendarPluginContract.Params.Query)
val start = uri.getQueryParameter(CalendarPluginContract.Params.StartTime)?.toLongOrNull()
val end = uri.getQueryParameter(CalendarPluginContract.Params.EndTime)?.toLongOrNull()
val excludedCalendars = uri.getQueryParameter(CalendarPluginContract.Params.Exclude)?.split(",") ?: emptyList()
return CalendarQuery(
query = query,
start = start,
end = end,
excludedCalendars = excludedCalendars,
)
}
override fun Bundle.toResult(): CalendarEvent? {
return CalendarEvent(
id = get(EventColumns.Id) ?: return null,
title = get(EventColumns.Title) ?: return null,
description = get(EventColumns.Description),
location = get(EventColumns.Location),
color = get(EventColumns.Color),
calendarName = get(EventColumns.CalendarName),
startTime = get(EventColumns.StartTime),
endTime = get(EventColumns.EndTime) ?: return null,
includeTime = get(EventColumns.IncludeTime) ?: true,
attendees = get(EventColumns.Attendees) ?: emptyList(),
uri = Uri.parse(get(EventColumns.Uri) ?: return null),
)
}
override fun List<CalendarEvent>.toCursor(): Cursor {
return buildCursor(EventColumns, this) {
put(EventColumns.Id, it.id)
put(EventColumns.Title, it.title)
put(EventColumns.Description, it.description)
put(EventColumns.Location, it.location)
put(EventColumns.CalendarName, it.calendarName)
put(EventColumns.Color, it.color)
put(EventColumns.StartTime, it.startTime)
put(EventColumns.EndTime, it.endTime)
put(EventColumns.IncludeTime, it.includeTime)
put(EventColumns.Attendees, it.attendees)
put(EventColumns.Uri, it.uri.toString())
put(EventColumns.IsCompleted, it.isCompleted)
}
}
override fun getPluginType(): PluginType {
return PluginType.Calendar
}
private fun List<CalendarList>.toCursor(): Cursor {
return buildCursor(CalendarListColumns, this) {
put(CalendarListColumns.Id, it.id)
put(CalendarListColumns.Name, it.name)
put(CalendarListColumns.Color, it.color)
put(CalendarListColumns.AccountName, it.accountName)
}
}
}