Add tasks.org integration

This commit is contained in:
MM20 2025-04-26 12:48:39 +02:00
parent da924013b6
commit 7132e69ec8
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
16 changed files with 370 additions and 77 deletions

View File

@ -30,6 +30,7 @@
<uses-permission android:name="android.permission.CALL_PHONE" /> <uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
<application <application
android:name=".LauncherApplication" android:name=".LauncherApplication"

View File

@ -12,7 +12,16 @@ import de.mm20.launcher2.searchable.PinnedLevel
import de.mm20.launcher2.services.favorites.FavoritesService import de.mm20.launcher2.services.favorites.FavoritesService
import de.mm20.launcher2.widgets.CalendarWidget import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.WidgetRepository import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -25,7 +34,8 @@ abstract class FavoritesVM : ViewModel(), KoinComponent {
val selectedTag = MutableStateFlow<String?>(null) val selectedTag = MutableStateFlow<String?>(null)
val showEditButton = settings.showEditButton.stateIn(viewModelScope, SharingStarted.Lazily, false) val showEditButton =
settings.showEditButton.stateIn(viewModelScope, SharingStarted.Lazily, false)
abstract val tagsExpanded: Flow<Boolean> abstract val tagsExpanded: Flow<Boolean>
abstract val compactTags: Flow<Boolean> abstract val compactTags: Flow<Boolean>
@ -52,14 +62,24 @@ abstract class FavoritesVM : ViewModel(), KoinComponent {
val frequentlyUsedRows = it.second.frequentlyUsedRows val frequentlyUsedRows = it.second.frequentlyUsedRows
val pinned = favoritesService.getFavorites( val pinned = favoritesService.getFavorites(
excludeTypes = if (excludeCalendar) listOf("calendar", "tag", "plugin.calendar") else listOf("tag"), excludeTypes = if (excludeCalendar) listOf(
"calendar",
"tasks.org",
"tag",
"plugin.calendar"
) else listOf("tag"),
minPinnedLevel = PinnedLevel.AutomaticallySorted, minPinnedLevel = PinnedLevel.AutomaticallySorted,
limit = 10 * columns, limit = 10 * columns,
) )
if (includeFrequentlyUsed) { if (includeFrequentlyUsed) {
emitAll(pinned.flatMapLatest { pinned -> emitAll(pinned.flatMapLatest { pinned ->
favoritesService.getFavorites( favoritesService.getFavorites(
excludeTypes = if (excludeCalendar) listOf("calendar", "tag", "plugin.calendar") else listOf("tag"), excludeTypes = if (excludeCalendar) listOf(
"calendar",
"tasks.org",
"tag",
"plugin.calendar"
) else listOf("tag"),
maxPinnedLevel = PinnedLevel.FrequentlyUsed, maxPinnedLevel = PinnedLevel.FrequentlyUsed,
minPinnedLevel = PinnedLevel.FrequentlyUsed, minPinnedLevel = PinnedLevel.FrequentlyUsed,
limit = frequentlyUsedRows * columns - pinned.size % columns, limit = frequentlyUsedRows * columns - pinned.size % columns,

View File

@ -554,6 +554,7 @@ fun ColumnScope.ConfigureCalendarWidget(
for (group in groups) { for (group in groups) {
val pluginName = remember(plugins, group.key) { val pluginName = remember(plugins, group.key) {
if (group.key == "local") context.getString(R.string.preference_calendar_calendars) if (group.key == "local") context.getString(R.string.preference_calendar_calendars)
else if (group.key == "tasks.org") context.getString(R.string.preference_calendar_tasks)
else plugins.find { it.authority == group.key }?.label else plugins.find { it.authority == group.key }?.label
} }
if (pluginName != null) { if (pluginName != null) {

View File

@ -43,7 +43,7 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
val calendarEvents = mutableStateOf<List<CalendarEvent>>(emptyList()) val calendarEvents = mutableStateOf<List<CalendarEvent>>(emptyList())
val pinnedCalendarEvents = val pinnedCalendarEvents =
favoritesService.getFavorites( favoritesService.getFavorites(
includeTypes = listOf("calendar", "plugin.calendar"), includeTypes = listOf("calendar", "tasks.org", "plugin.calendar"),
minPinnedLevel = PinnedLevel.AutomaticallySorted, minPinnedLevel = PinnedLevel.AutomaticallySorted,
).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val nextEvents = mutableStateOf<List<CalendarEvent>>(emptyList()) val nextEvents = mutableStateOf<List<CalendarEvent>>(emptyList())
@ -172,7 +172,7 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
excludeCalendars = config.excludedCalendarIds ?: config.legacyExcludedCalendarIds?.map { "local:$it" } ?: emptyList(), excludeCalendars = config.excludedCalendarIds ?: config.legacyExcludedCalendarIds?.map { "local:$it" } ?: emptyList(),
).collectLatest { events -> ).collectLatest { events ->
searchableRepository.getKeys( searchableRepository.getKeys(
includeTypes = listOf("calendar", "plugin.calendar"), includeTypes = listOf("calendar", "tasks.org", "plugin.calendar"),
maxVisibility = VisibilityLevel.SearchOnly, maxVisibility = VisibilityLevel.SearchOnly,
limit = 9999, limit = 9999,
).collectLatest { hidden -> ).collectLatest { hidden ->

View File

@ -46,7 +46,7 @@ fun CalendarProviderSettingsScreen(providerId: String) {
val pluginState by viewModel.pluginState.collectAsStateWithLifecycle(null) val pluginState by viewModel.pluginState.collectAsStateWithLifecycle(null)
val providerAvailable = providerId == "local" || pluginState != null val providerAvailable = providerId == "local" || providerId == "tasks.org" || pluginState != null
PreferenceScreen( PreferenceScreen(
title = pluginState?.plugin?.label ?: stringResource(R.string.preference_search_calendar) title = pluginState?.plugin?.label ?: stringResource(R.string.preference_search_calendar)
@ -59,9 +59,11 @@ fun CalendarProviderSettingsScreen(providerId: String) {
SwitchPreference( SwitchPreference(
title = title =
if (providerId == "local") stringResource(R.string.preference_search_calendar) if (providerId == "local") stringResource(R.string.preference_search_calendar)
else if (providerId == "tasks.org") stringResource(R.string.preference_search_tasks)
else pluginState?.plugin?.label ?: "", else pluginState?.plugin?.label ?: "",
summary = summary =
if (providerId == "local") stringResource(R.string.preference_search_local_calendar_summary) if (providerId == "local") stringResource(R.string.preference_search_local_calendar_summary)
else if (providerId == "tasks.org") stringResource(R.string.preference_search_tasks_summary)
else (pluginState?.state as? PluginState.Ready)?.text else (pluginState?.state as? PluginState.Ready)?.text
?: pluginState?.plugin?.description, ?: pluginState?.plugin?.description,
value = enabled && (pluginState == null || pluginState?.state is PluginState.Ready), value = enabled && (pluginState == null || pluginState?.state is PluginState.Ready),

View File

@ -11,9 +11,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -39,7 +36,12 @@ fun CalendarSearchSettingsScreen() {
val navController = LocalNavController.current val navController = LocalNavController.current
val hasCalendarPermission by viewModel.hasCalendarPermission.collectAsState(null) val hasCalendarPermission by viewModel.hasCalendarPermission.collectAsState(null)
val plugins by viewModel.availablePlugins.collectAsStateWithLifecycle(emptyList(), minActiveState = Lifecycle.State.RESUMED) val hasTasksPermission by viewModel.hasTasksPermission.collectAsState(null)
val isTasksAppInstalled by viewModel.isTasksAppInstalled.collectAsStateWithLifecycle(false)
val plugins by viewModel.availablePlugins.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
val enabledProviders by viewModel.enabledProviders.collectAsState(emptySet()) val enabledProviders by viewModel.enabledProviders.collectAsState(emptySet())
PreferenceScreen(title = stringResource(R.string.preference_search_calendar)) { PreferenceScreen(title = stringResource(R.string.preference_search_calendar)) {
@ -66,6 +68,29 @@ fun CalendarSearchSettingsScreen() {
navController?.navigate("settings/search/calendar/local") navController?.navigate("settings/search/calendar/local")
} }
) )
if (isTasksAppInstalled) {
AnimatedVisibility(hasTasksPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_tasks_search_settings),
onClick = {
viewModel.requestTasksPermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
PreferenceWithSwitch(
title = stringResource(R.string.preference_search_tasks),
summary = stringResource(R.string.preference_search_tasks_summary),
switchValue = enabledProviders.contains("tasks.org") && hasTasksPermission == true,
onSwitchChanged = {
viewModel.setProviderEnabled("tasks.org", it)
},
enabled = hasTasksPermission == true,
onClick = {
navController?.navigate("settings/search/calendar/tasks.org")
}
)
}
for (plugin in plugins) { for (plugin in plugins) {
val state = plugin.state val state = plugin.state
if (state is PluginState.SetupRequired) { if (state is PluginState.SetupRequired) {

View File

@ -1,7 +1,10 @@
package de.mm20.launcher2.ui.settings.calendarsearch package de.mm20.launcher2.ui.settings.calendarsearch
import android.os.Process
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.calendar.CalendarRepository import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.calendar.providers.CalendarList import de.mm20.launcher2.calendar.providers.CalendarList
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
@ -11,16 +14,25 @@ import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.preferences.search.CalendarSearchSettings import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
class CalendarSearchSettingsScreenVM : ViewModel(), KoinComponent { class CalendarSearchSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: CalendarSearchSettings by inject() private val settings: CalendarSearchSettings by inject()
private val calendarRepository: CalendarRepository by inject() private val calendarRepository: CalendarRepository by inject()
private val appRepository: AppRepository by inject()
private val pluginService: PluginService by inject() private val pluginService: PluginService by inject()
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
val isTasksAppInstalled = appRepository.findOne("org.tasks", Process.myUserHandle())
.map { it != null }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
val availablePlugins = pluginService.getPluginsWithState( val availablePlugins = pluginService.getPluginsWithState(
type = PluginType.Calendar, type = PluginType.Calendar,
@ -37,10 +49,8 @@ class CalendarSearchSettingsScreenVM : ViewModel(), KoinComponent {
permissionsManager.requestPermission(activity, PermissionGroup.Calendar) permissionsManager.requestPermission(activity, PermissionGroup.Calendar)
} }
val calendarLists = calendarRepository.getCalendars() fun requestTasksPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.Tasks)
}
val excludedCalendars = settings.excludedCalendars
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
settings.setCalendarExcluded(calendarId, excluded)
}
} }

View File

@ -111,7 +111,7 @@ fun SearchSettingsScreen() {
} }
) )
if (hasLocationPlugins != false) { if (hasContactPlugins != false) {
Preference( Preference(
title = stringResource(R.string.preference_search_contacts), title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary), summary = stringResource(R.string.preference_search_contacts_summary),

View File

@ -1,9 +1,13 @@
package de.mm20.launcher2.search package de.mm20.launcher2.search
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.ktx.tryStartActivity
import java.net.URLEncoder
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
interface CalendarEvent : SavableSearchable { interface CalendarEvent : SavableSearchable {
@ -34,5 +38,19 @@ interface CalendarEvent : SavableSearchable {
) )
} }
fun openLocation(context: Context) {} fun openLocation(context: Context) {
if (location == null) return
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(
"geo:0,0?q=${
URLEncoder.encode(
location,
"utf8"
)
}".toUri()
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
} }

View File

@ -388,6 +388,7 @@
<string name="missing_permission_call_contacts_settings">Call permission is required to start calls</string> <string name="missing_permission_call_contacts_settings">Call permission is required to start calls</string>
<!-- Missing calendar permission in search settings screen --> <!-- Missing calendar permission in search settings screen -->
<string name="missing_permission_calendar_search_settings">Calendar permission is required to search calendar</string> <string name="missing_permission_calendar_search_settings">Calendar permission is required to search calendar</string>
<string name="missing_permission_tasks_search_settings">Tasks permission is required to search tasks</string>
<!-- Missing calendar permission in calendar widget settings screen --> <!-- Missing calendar permission in calendar widget settings screen -->
<string name="missing_permission_calendar_widget_settings">This widget requires calendar permission</string> <string name="missing_permission_calendar_widget_settings">This widget requires calendar permission</string>
<string name="widget_config_calendar_missing_calendars_hint">Can\'t find your calendars?</string> <string name="widget_config_calendar_missing_calendars_hint">Can\'t find your calendars?</string>
@ -608,6 +609,8 @@
<string name="preference_search_calendar">Calendar</string> <string name="preference_search_calendar">Calendar</string>
<string name="preference_search_calendar_summary">Search upcoming appointments and events</string> <string name="preference_search_calendar_summary">Search upcoming appointments and events</string>
<string name="preference_search_local_calendar_summary">Search calendars on this device</string> <string name="preference_search_local_calendar_summary">Search calendars on this device</string>
<string name="preference_search_tasks">Tasks</string>
<string name="preference_search_tasks_summary">Search tasks in the Tasks.org app</string>
<string name="preference_search_appshortcuts">App shortcuts</string> <string name="preference_search_appshortcuts">App shortcuts</string>
<string name="preference_search_appshortcuts_summary">Search app shortcuts</string> <string name="preference_search_appshortcuts_summary">Search app shortcuts</string>
<string name="preference_search_calculator">Calculator</string> <string name="preference_search_calculator">Calculator</string>
@ -630,6 +633,7 @@
<string name="preference_search_cloud_summary">Search %1$s\'s files</string> <string name="preference_search_cloud_summary">Search %1$s\'s files</string>
<string name="preference_search_owncloud">Owncloud</string> <string name="preference_search_owncloud">Owncloud</string>
<string name="preference_calendar_calendars">Calendars</string> <string name="preference_calendar_calendars">Calendars</string>
<string name="preference_calendar_tasks">Tasks</string>
<string name="preference_calendar_hide_completed">Hide completed tasks</string> <string name="preference_calendar_hide_completed">Hide completed tasks</string>
<string name="preference_screen_buildinfo">Build information</string> <string name="preference_screen_buildinfo">Build information</string>
<string name="preference_screen_buildinfo_summary">More information about this build of this app</string> <string name="preference_screen_buildinfo_summary">More information about this build of this app</string>

View File

@ -1,11 +1,11 @@
package de.mm20.launcher2.calendar package de.mm20.launcher2.calendar
import android.content.Context import android.content.Context
import android.util.Log
import de.mm20.launcher2.calendar.providers.AndroidCalendarProvider import de.mm20.launcher2.calendar.providers.AndroidCalendarProvider
import de.mm20.launcher2.calendar.providers.CalendarList import de.mm20.launcher2.calendar.providers.CalendarList
import de.mm20.launcher2.calendar.providers.CalendarProvider import de.mm20.launcher2.calendar.providers.CalendarProvider
import de.mm20.launcher2.calendar.providers.PluginCalendarProvider import de.mm20.launcher2.calendar.providers.PluginCalendarProvider
import de.mm20.launcher2.calendar.providers.TasksCalendarProvider
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository import de.mm20.launcher2.plugin.PluginRepository
@ -21,10 +21,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -57,14 +54,21 @@ internal class CalendarRepositoryImpl(
} }
} }
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
val providerIds = settings.providers val providerIds = settings.providers
val excludedCalendars = settings.excludedCalendars val excludedCalendars = settings.excludedCalendars
return combineTransform(hasPermission, providerIds, excludedCalendars) { perm, providerIds, excludedCalendars -> return combineTransform(
hasCalendarPermission,
hasTasksPermission,
providerIds,
excludedCalendars
) { calPerm, taskPerm, providerIds, excludedCalendars ->
val providers = providerIds.mapNotNull { val providers = providerIds.mapNotNull {
when (it) { when (it) {
"local" -> if (perm) AndroidCalendarProvider(context) else null "local" -> if (calPerm) AndroidCalendarProvider(context) else null
"tasks.org" -> if (taskPerm) TasksCalendarProvider(context) else null
else -> PluginCalendarProvider(context, it) else -> PluginCalendarProvider(context, it)
} }
} }
@ -90,14 +94,16 @@ internal class CalendarRepositoryImpl(
excludeCalendars: List<String>, excludeCalendars: List<String>,
excludeAllDayEvents: Boolean, excludeAllDayEvents: Boolean,
): Flow<ImmutableList<CalendarEvent>> { ): Flow<ImmutableList<CalendarEvent>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
val plugins = pluginRepository.findMany( val plugins = pluginRepository.findMany(
type = PluginType.Calendar, type = PluginType.Calendar,
enabled = true, enabled = true,
) )
return combineTransform(hasPermission, plugins) { perm, plugins -> return combineTransform(hasCalendarPermission, hasTasksPermission, plugins) { calPerm, taskPerm, plugins ->
val providers = buildList { val providers = buildList {
if (perm) add(AndroidCalendarProvider(context)) else null if (calPerm) add(AndroidCalendarProvider(context)) else null
if (taskPerm) add(TasksCalendarProvider(context)) else null
addAll( addAll(
plugins.map { plugins.map {
PluginCalendarProvider(context, it.authority) PluginCalendarProvider(context, it.authority)
@ -119,7 +125,7 @@ internal class CalendarRepositoryImpl(
} }
} }
private suspend fun queryCalendarEvents( private fun queryCalendarEvents(
query: String?, query: String?,
intervalStart: Long, intervalStart: Long,
intervalEnd: Long, intervalEnd: Long,
@ -154,21 +160,42 @@ internal class CalendarRepositoryImpl(
} }
override fun getCalendars(providerId: String?): Flow<List<CalendarList>> { override fun getCalendars(providerId: String?): Flow<List<CalendarList>> {
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
val hasTaskPermission = permissionsManager.hasPermission(PermissionGroup.Tasks)
val providers: Flow<List<CalendarProvider>> = if (providerId != null) { val providers: Flow<List<CalendarProvider>> = if (providerId != null) {
when (providerId) { when (providerId) {
"local" -> hasPermission.map { if (it) listOf(AndroidCalendarProvider(context)) else emptyList() } "local" -> hasCalendarPermission.map {
else -> pluginRepository.get(providerId).map { if (it?.enabled == true) listOf(PluginCalendarProvider(context, providerId)) else emptyList() } if (it) listOf(
AndroidCalendarProvider(
context
)
) else emptyList()
}
"tasks.org" -> hasTaskPermission.map { if (it) listOf(TasksCalendarProvider(context)) else emptyList() }
else -> pluginRepository.get(providerId).map {
if (it?.enabled == true) listOf(
PluginCalendarProvider(
context,
providerId
)
) else emptyList()
}
} }
} else { } else {
val plugins = pluginRepository.findMany( val plugins = pluginRepository.findMany(
type = PluginType.Calendar, type = PluginType.Calendar,
enabled = true, enabled = true,
) )
combine(hasPermission, plugins) { perm, plugins -> combine(
hasCalendarPermission,
hasTaskPermission,
plugins
) { calPerm, tasksPerm, plugins ->
buildList { buildList {
if (perm) add(AndroidCalendarProvider(context)) if (calPerm) add(AndroidCalendarProvider(context))
if (tasksPerm) add(TasksCalendarProvider(context))
addAll(plugins.map { PluginCalendarProvider(context, it.authority) }) addAll(plugins.map { PluginCalendarProvider(context, it.authority) })
} }
} }

View File

@ -10,6 +10,8 @@ import de.mm20.launcher2.calendar.providers.AndroidCalendarEvent
import de.mm20.launcher2.calendar.providers.AndroidCalendarProvider import de.mm20.launcher2.calendar.providers.AndroidCalendarProvider
import de.mm20.launcher2.calendar.providers.PluginCalendarEvent import de.mm20.launcher2.calendar.providers.PluginCalendarEvent
import de.mm20.launcher2.calendar.providers.PluginCalendarProvider import de.mm20.launcher2.calendar.providers.PluginCalendarProvider
import de.mm20.launcher2.calendar.providers.TasksCalendarEvent
import de.mm20.launcher2.calendar.providers.TasksCalendarProvider
import de.mm20.launcher2.plugin.PluginRepository import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.config.StorageStrategy import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
@ -44,9 +46,31 @@ class AndroidCalendarEventDeserializer(val context: Context): SearchableDeserial
val id = json.getLong("id") val id = json.getLong("id")
return AndroidCalendarProvider(context).get(id) return AndroidCalendarProvider(context).get(id)
} }
} }
class TasksCalendarEventSerializer: SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as TasksCalendarEvent
val json = JSONObject()
json.put("id", searchable.id)
return json.toString()
}
override val typePrefix: String
get() = TasksCalendarEvent.Domain
}
class TasksCalendarEventDeserializer(val context: Context): SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
if (ContextCompat.checkSelfPermission(context, "org.tasks.permission.READ_TASKS") != PackageManager.PERMISSION_GRANTED) return null
val json = JSONObject(serialized)
val id = json.getLong("id")
return TasksCalendarProvider(context).get(id)
}
}
@Serializable @Serializable
internal data class SerializedCalendarEvent( internal data class SerializedCalendarEvent(
val id: String? = null, val id: String? = null,

View File

@ -2,6 +2,7 @@ package de.mm20.launcher2.calendar
import de.mm20.launcher2.calendar.providers.AndroidCalendarEvent import de.mm20.launcher2.calendar.providers.AndroidCalendarEvent
import de.mm20.launcher2.calendar.providers.PluginCalendarEvent import de.mm20.launcher2.calendar.providers.PluginCalendarEvent
import de.mm20.launcher2.calendar.providers.TasksCalendarEvent
import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository import de.mm20.launcher2.search.SearchableRepository
@ -13,5 +14,6 @@ val calendarModule = module {
factory<SearchableRepository<CalendarEvent>>(named<CalendarEvent>()) { get<CalendarRepository>() } factory<SearchableRepository<CalendarEvent>>(named<CalendarEvent>()) { get<CalendarRepository>() }
factory<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get(), get(), get()) } factory<CalendarRepository> { CalendarRepositoryImpl(androidContext(), get(), get(), get()) }
factory<SearchableDeserializer>(named(AndroidCalendarEvent.Domain)) { AndroidCalendarEventDeserializer(androidContext()) } factory<SearchableDeserializer>(named(AndroidCalendarEvent.Domain)) { AndroidCalendarEventDeserializer(androidContext()) }
factory<SearchableDeserializer>(named(TasksCalendarEvent.Domain)) { TasksCalendarEventDeserializer(androidContext()) }
factory<SearchableDeserializer>(named(PluginCalendarEvent.Domain)) { PluginCalendarEventDeserializer(androidContext(), get()) } factory<SearchableDeserializer>(named(PluginCalendarEvent.Domain)) { PluginCalendarEventDeserializer(androidContext(), get()) }
} }

View File

@ -11,6 +11,7 @@ import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SearchableSerializer import de.mm20.launcher2.search.SearchableSerializer
import java.net.URLEncoder import java.net.URLEncoder
import androidx.core.net.toUri
internal data class AndroidCalendarEvent( internal data class AndroidCalendarEvent(
override val label: String, override val label: String,
@ -44,24 +45,6 @@ internal data class AndroidCalendarEvent(
return context.tryStartActivity(getLaunchIntent(), options) return context.tryStartActivity(getLaunchIntent(), options)
} }
override fun openLocation(context: Context) {
if (location == null) return
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(
Uri.parse(
"geo:0,0?q=${
URLEncoder.encode(
location,
"utf8"
)
}"
)
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
override fun getSerializer(): SearchableSerializer { override fun getSerializer(): SearchableSerializer {
return AndroidCalendarEventSerializer() return AndroidCalendarEventSerializer()
} }

View File

@ -0,0 +1,54 @@
package de.mm20.launcher2.calendar.providers
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import androidx.core.net.toUri
import de.mm20.launcher2.calendar.TasksCalendarEventSerializer
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SearchableSerializer
data class TasksCalendarEvent(
override val label: String,
val id: Long,
override val color: Int?,
override val startTime: Long?,
override val endTime: Long,
override val allDay: Boolean,
override val isCompleted: Boolean?,
override val description: String?,
override val calendarName: String?,
override val labelOverride: String? = null,
): CalendarEvent {
override val domain: String = Domain
override val key: String = "$domain://$id"
override val location: String? = null
override val attendees: List<String> = emptyList()
override fun overrideLabel(label: String): TasksCalendarEvent {
return this.copy(labelOverride = label)
}
override fun launch(context: Context, options: Bundle?): Boolean {
val uri = ContentUris.withAppendedId("content://org.tasks/tasks".toUri(), id)
val intent = Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return context.tryStartActivity(intent, options)
}
override fun getSerializer(): SearchableSerializer {
return TasksCalendarEventSerializer()
}
companion object {
const val Domain = "tasks.org"
}
}

View File

@ -0,0 +1,122 @@
package de.mm20.launcher2.calendar.providers
import android.content.Context
import androidx.core.database.getIntOrNull
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import androidx.core.net.toUri
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.calendar.CalendarListType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class TasksCalendarProvider(
private val context: Context,
) : CalendarProvider {
override suspend fun search(
query: String?,
from: Long,
to: Long,
excludedCalendars: List<String>,
excludeAllDayEvents: Boolean,
allowNetwork: Boolean
): List<CalendarEvent> {
return withContext(Dispatchers.IO) {
queryTasks(
selection = buildList {
add("dueDate >= $from")
add("dueDate <= $to")
if (excludedCalendars.isNotEmpty()) {
add("cdl_id NOT IN (${excludedCalendars.joinToString()})")
}
if (query != null) {
add(
"title LIKE '%${
query.replace("'", "").replace("%", "")
}%'"
)
}
}.joinToString(" AND ").takeIf { it.isNotEmpty() },
)
}
}
override suspend fun getCalendarLists(): List<CalendarList> {
return withContext(Dispatchers.IO) {
val uri = "content://org.tasks/lists".toUri()
val cursor = context.contentResolver.query(uri, arrayOf(), null, arrayOf(), null)
?: return@withContext emptyList()
val results = mutableListOf<CalendarList>()
val idIndex = cursor.getColumnIndex("cdl_id")
val nameIndex = cursor.getColumnIndex("cdl_name")
val colorIndex = cursor.getColumnIndex("cdl_color")
val accountIndex = cursor.getColumnIndex("cdl_account")
cursor.use {
while (cursor.moveToNext()) {
val id = cursor.getLongOrNull(idIndex)?.toString() ?: continue
results += CalendarList(
id = "$namespace:$id",
name = cursor.getStringOrNull(nameIndex) ?: continue,
color = cursor.getIntOrNull(colorIndex) ?: 0,
types = listOf(CalendarListType.Tasks),
providerId = "tasks.org",
owner = cursor.getStringOrNull(accountIndex)?.substringAfter(":", "")?.takeIf { it.isNotBlank() },
)
}
}
results
}
}
private fun queryTasks(
selection: String? = null,
selectionArgs: Array<String>? = arrayOf(),
): List<CalendarEvent> {
val uri = "content://org.tasks/todoagenda".toUri()
val cursor = context.contentResolver.query(uri, arrayOf(), selection, selectionArgs, null)
val results = mutableListOf<CalendarEvent>()
cursor?.use {
val idIndex = cursor.getColumnIndex("_id")
val titleIndex = cursor.getColumnIndex("title")
val dueIndex = cursor.getColumnIndex("dueDate")
val completedIndex = cursor.getColumnIndex("completed")
val notesIndex = cursor.getColumnIndex("notes")
val colorIndex = cursor.getColumnIndex("cdl_color")
val calendarNameIndex = cursor.getColumnIndex("cdl_name")
while (cursor.moveToNext()) {
val id = cursor.getLongOrNull(idIndex) ?: continue
val dueDate = cursor.getLongOrNull(dueIndex)?.takeIf { it > 0L } ?: continue
results += TasksCalendarEvent(
id = id,
label = cursor.getStringOrNull(titleIndex) ?: continue,
description = cursor.getStringOrNull(notesIndex),
color = cursor.getIntOrNull(colorIndex),
calendarName = cursor.getStringOrNull(calendarNameIndex),
startTime = null,
endTime = dueDate,
allDay = dueDate % 60000 <= 0, // https://github.com/tasks/tasks/blob/13d4c029e855fd32ec91e4d4ec5f740ec506136e/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt#L345
isCompleted = (cursor.getLongOrNull(completedIndex) ?: 0L) != 0L,
)
}
}
return results
}
suspend fun get(id: Long): CalendarEvent? {
return withContext(Dispatchers.IO) {
queryTasks(
selection = "_id = $id",
).firstOrNull()
}
}
override val namespace: String = "tasks.org"
}