From 7132e69ec86774cba84a044db8f051fcb3d69a85 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sat, 26 Apr 2025 12:48:39 +0200 Subject: [PATCH] Add tasks.org integration --- app/app/src/main/AndroidManifest.xml | 1 + .../mm20/launcher2/ui/common/FavoritesVM.kt | 74 +++++++---- .../launcher/sheets/ConfigureWidgetSheet.kt | 1 + .../widgets/calendar/CalendarWidgetVM.kt | 4 +- .../CalendarProviderSettingsScreen.kt | 4 +- .../CalendarSearchSettingsScreen.kt | 33 ++++- .../CalendarSearchSettingsScreenVM.kt | 20 ++- .../settings/search/SearchSettingsScreen.kt | 2 +- .../de/mm20/launcher2/search/CalendarEvent.kt | 20 ++- core/i18n/src/main/res/values/strings.xml | 4 + .../launcher2/calendar/CalendarRepository.kt | 61 ++++++--- .../calendar/CalendarSerialization.kt | 26 +++- .../java/de/mm20/launcher2/calendar/Module.kt | 2 + .../providers/AndroidCalendarEvent.kt | 19 +-- .../calendar/providers/TasksCalendarEvent.kt | 54 ++++++++ .../providers/TasksCalendarProvider.kt | 122 ++++++++++++++++++ 16 files changed, 370 insertions(+), 77 deletions(-) create mode 100644 data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarEvent.kt create mode 100644 data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarProvider.kt diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index 9a50ce2c..84565e04 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -30,6 +30,7 @@ + (null) - val showEditButton = settings.showEditButton.stateIn(viewModelScope, SharingStarted.Lazily, false) + val showEditButton = + settings.showEditButton.stateIn(viewModelScope, SharingStarted.Lazily, false) abstract val tagsExpanded: Flow abstract val compactTags: Flow @@ -46,34 +56,44 @@ abstract class FavoritesVM : ViewModel(), KoinComponent { ) { (a, b) -> a as Boolean to b as FavoritesSettingsData } .transformLatest { - val columns = it.second.columns - val excludeCalendar = it.first - val includeFrequentlyUsed = it.second.frequentlyUsed - val frequentlyUsedRows = it.second.frequentlyUsedRows + val columns = it.second.columns + val excludeCalendar = it.first + val includeFrequentlyUsed = it.second.frequentlyUsed + val frequentlyUsedRows = it.second.frequentlyUsedRows - val pinned = favoritesService.getFavorites( - 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", "plugin.calendar") else listOf("tag"), - maxPinnedLevel = PinnedLevel.FrequentlyUsed, - minPinnedLevel = PinnedLevel.FrequentlyUsed, - limit = frequentlyUsedRows * columns - pinned.size % columns, - ).map { - pinned + it - } - .withCustomLabels(customAttributesRepository) - }) - } else { - emitAll( - pinned.withCustomLabels(customAttributesRepository) + val pinned = favoritesService.getFavorites( + excludeTypes = if (excludeCalendar) listOf( + "calendar", + "tasks.org", + "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", + "tasks.org", + "tag", + "plugin.calendar" + ) else listOf("tag"), + maxPinnedLevel = PinnedLevel.FrequentlyUsed, + minPinnedLevel = PinnedLevel.FrequentlyUsed, + limit = frequentlyUsedRows * columns - pinned.size % columns, + ).map { + pinned + it + } + .withCustomLabels(customAttributesRepository) + }) + } else { + emitAll( + pinned.withCustomLabels(customAttributesRepository) + ) + } } - } } else { customAttributesRepository .getItemsForTag(tag) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt index 3920d0ed..87f211b2 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt @@ -554,6 +554,7 @@ fun ColumnScope.ConfigureCalendarWidget( for (group in groups) { val pluginName = remember(plugins, group.key) { 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 } if (pluginName != null) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/calendar/CalendarWidgetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/calendar/CalendarWidgetVM.kt index 53fb063d..efb42202 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/calendar/CalendarWidgetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/calendar/CalendarWidgetVM.kt @@ -43,7 +43,7 @@ class CalendarWidgetVM : ViewModel(), KoinComponent { val calendarEvents = mutableStateOf>(emptyList()) val pinnedCalendarEvents = favoritesService.getFavorites( - includeTypes = listOf("calendar", "plugin.calendar"), + includeTypes = listOf("calendar", "tasks.org", "plugin.calendar"), minPinnedLevel = PinnedLevel.AutomaticallySorted, ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) val nextEvents = mutableStateOf>(emptyList()) @@ -172,7 +172,7 @@ class CalendarWidgetVM : ViewModel(), KoinComponent { excludeCalendars = config.excludedCalendarIds ?: config.legacyExcludedCalendarIds?.map { "local:$it" } ?: emptyList(), ).collectLatest { events -> searchableRepository.getKeys( - includeTypes = listOf("calendar", "plugin.calendar"), + includeTypes = listOf("calendar", "tasks.org", "plugin.calendar"), maxVisibility = VisibilityLevel.SearchOnly, limit = 9999, ).collectLatest { hidden -> diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt index 4b645fae..4f2b5ade 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarProviderSettingsScreen.kt @@ -46,7 +46,7 @@ fun CalendarProviderSettingsScreen(providerId: String) { val pluginState by viewModel.pluginState.collectAsStateWithLifecycle(null) - val providerAvailable = providerId == "local" || pluginState != null + val providerAvailable = providerId == "local" || providerId == "tasks.org" || pluginState != null PreferenceScreen( title = pluginState?.plugin?.label ?: stringResource(R.string.preference_search_calendar) @@ -59,9 +59,11 @@ fun CalendarProviderSettingsScreen(providerId: String) { SwitchPreference( title = if (providerId == "local") stringResource(R.string.preference_search_calendar) + else if (providerId == "tasks.org") stringResource(R.string.preference_search_tasks) else pluginState?.plugin?.label ?: "", 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 ?: pluginState?.plugin?.description, value = enabled && (pluginState == null || pluginState?.state is PluginState.Ready), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt index dbf9567e..143eeb2c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreen.kt @@ -11,9 +11,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -39,7 +36,12 @@ fun CalendarSearchSettingsScreen() { val navController = LocalNavController.current 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()) PreferenceScreen(title = stringResource(R.string.preference_search_calendar)) { @@ -66,6 +68,29 @@ fun CalendarSearchSettingsScreen() { 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) { val state = plugin.state if (state is PluginState.SetupRequired) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreenVM.kt index 3c5141cd..fed8b0e6 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/calendarsearch/CalendarSearchSettingsScreenVM.kt @@ -1,7 +1,10 @@ package de.mm20.launcher2.ui.settings.calendarsearch +import android.os.Process import androidx.appcompat.app.AppCompatActivity 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.providers.CalendarList 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.preferences.search.CalendarSearchSettings 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.inject class CalendarSearchSettingsScreenVM : ViewModel(), KoinComponent { private val settings: CalendarSearchSettings by inject() private val calendarRepository: CalendarRepository by inject() + private val appRepository: AppRepository by inject() private val pluginService: PluginService by inject() private val permissionsManager: PermissionsManager by inject() 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( type = PluginType.Calendar, @@ -37,10 +49,8 @@ class CalendarSearchSettingsScreenVM : ViewModel(), KoinComponent { permissionsManager.requestPermission(activity, PermissionGroup.Calendar) } - val calendarLists = calendarRepository.getCalendars() - - val excludedCalendars = settings.excludedCalendars - fun setCalendarExcluded(calendarId: String, excluded: Boolean) { - settings.setCalendarExcluded(calendarId, excluded) + fun requestTasksPermission(activity: AppCompatActivity) { + permissionsManager.requestPermission(activity, PermissionGroup.Tasks) } + } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt index 9914e039..78c4e02d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt @@ -111,7 +111,7 @@ fun SearchSettingsScreen() { } ) - if (hasLocationPlugins != false) { + if (hasContactPlugins != false) { Preference( title = stringResource(R.string.preference_search_contacts), summary = stringResource(R.string.preference_search_contacts_summary), diff --git a/core/base/src/main/java/de/mm20/launcher2/search/CalendarEvent.kt b/core/base/src/main/java/de/mm20/launcher2/search/CalendarEvent.kt index c6e73adc..1bf78e04 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/CalendarEvent.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/CalendarEvent.kt @@ -1,9 +1,13 @@ package de.mm20.launcher2.search import android.content.Context +import android.content.Intent +import androidx.core.net.toUri import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.StaticLauncherIcon import de.mm20.launcher2.icons.TextLayer +import de.mm20.launcher2.ktx.tryStartActivity +import java.net.URLEncoder import java.text.SimpleDateFormat 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) + ) + } } \ No newline at end of file diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 29cd0dea..a46b1634 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -388,6 +388,7 @@ Call permission is required to start calls Calendar permission is required to search calendar + Tasks permission is required to search tasks This widget requires calendar permission Can\'t find your calendars? @@ -608,6 +609,8 @@ Calendar Search upcoming appointments and events Search calendars on this device + Tasks + Search tasks in the Tasks.org app App shortcuts Search app shortcuts Calculator @@ -630,6 +633,7 @@ Search %1$s\'s files Owncloud Calendars + Tasks Hide completed tasks Build information More information about this build of this app diff --git a/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt b/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt index fed0555c..b2398a84 100644 --- a/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt +++ b/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarRepository.kt @@ -1,11 +1,11 @@ package de.mm20.launcher2.calendar import android.content.Context -import android.util.Log 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.calendar.providers.TasksCalendarProvider import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.plugin.PluginRepository @@ -21,10 +21,7 @@ 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.flow.transform 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 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 { 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) } } @@ -90,14 +94,16 @@ internal class CalendarRepositoryImpl( excludeCalendars: List, excludeAllDayEvents: Boolean, ): Flow> { - val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) + val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) + val hasTasksPermission = permissionsManager.hasPermission(PermissionGroup.Tasks) val plugins = pluginRepository.findMany( type = PluginType.Calendar, enabled = true, ) - return combineTransform(hasPermission, plugins) { perm, plugins -> + return combineTransform(hasCalendarPermission, hasTasksPermission, plugins) { calPerm, taskPerm, plugins -> 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( plugins.map { PluginCalendarProvider(context, it.authority) @@ -119,7 +125,7 @@ internal class CalendarRepositoryImpl( } } - private suspend fun queryCalendarEvents( + private fun queryCalendarEvents( query: String?, intervalStart: Long, intervalEnd: Long, @@ -154,21 +160,42 @@ internal class CalendarRepositoryImpl( } override fun getCalendars(providerId: String?): Flow> { - val hasPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) + val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar) + val hasTaskPermission = permissionsManager.hasPermission(PermissionGroup.Tasks) val providers: Flow> = 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() } + when (providerId) { + "local" -> hasCalendarPermission.map { + 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 { val plugins = pluginRepository.findMany( type = PluginType.Calendar, enabled = true, ) - combine(hasPermission, plugins) { perm, plugins -> + combine( + hasCalendarPermission, + hasTaskPermission, + plugins + ) { calPerm, tasksPerm, plugins -> buildList { - if (perm) add(AndroidCalendarProvider(context)) + if (calPerm) add(AndroidCalendarProvider(context)) + if (tasksPerm) add(TasksCalendarProvider(context)) addAll(plugins.map { PluginCalendarProvider(context, it.authority) }) } } diff --git a/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarSerialization.kt b/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarSerialization.kt index 15de68e9..51c10776 100644 --- a/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarSerialization.kt +++ b/data/calendar/src/main/java/de/mm20/launcher2/calendar/CalendarSerialization.kt @@ -10,6 +10,8 @@ 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.calendar.providers.TasksCalendarEvent +import de.mm20.launcher2.calendar.providers.TasksCalendarProvider import de.mm20.launcher2.plugin.PluginRepository import de.mm20.launcher2.plugin.config.StorageStrategy import de.mm20.launcher2.search.SavableSearchable @@ -44,9 +46,31 @@ class AndroidCalendarEventDeserializer(val context: Context): SearchableDeserial val id = json.getLong("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 internal data class SerializedCalendarEvent( val id: String? = null, diff --git a/data/calendar/src/main/java/de/mm20/launcher2/calendar/Module.kt b/data/calendar/src/main/java/de/mm20/launcher2/calendar/Module.kt index 79d80bdd..49d711ed 100644 --- a/data/calendar/src/main/java/de/mm20/launcher2/calendar/Module.kt +++ b/data/calendar/src/main/java/de/mm20/launcher2/calendar/Module.kt @@ -2,6 +2,7 @@ package de.mm20.launcher2.calendar import de.mm20.launcher2.calendar.providers.AndroidCalendarEvent 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.SearchableDeserializer import de.mm20.launcher2.search.SearchableRepository @@ -13,5 +14,6 @@ val calendarModule = module { factory>(named()) { get() } factory { CalendarRepositoryImpl(androidContext(), get(), get(), get()) } factory(named(AndroidCalendarEvent.Domain)) { AndroidCalendarEventDeserializer(androidContext()) } + factory(named(TasksCalendarEvent.Domain)) { TasksCalendarEventDeserializer(androidContext()) } factory(named(PluginCalendarEvent.Domain)) { PluginCalendarEventDeserializer(androidContext(), get()) } } \ No newline at end of file diff --git a/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/AndroidCalendarEvent.kt b/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/AndroidCalendarEvent.kt index c8018caa..a0bb4d1b 100644 --- a/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/AndroidCalendarEvent.kt +++ b/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/AndroidCalendarEvent.kt @@ -11,6 +11,7 @@ import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.SearchableSerializer import java.net.URLEncoder +import androidx.core.net.toUri internal data class AndroidCalendarEvent( override val label: String, @@ -44,24 +45,6 @@ internal data class AndroidCalendarEvent( 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 { return AndroidCalendarEventSerializer() } diff --git a/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarEvent.kt b/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarEvent.kt new file mode 100644 index 00000000..f29e8131 --- /dev/null +++ b/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarEvent.kt @@ -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 = 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" + } +} \ No newline at end of file diff --git a/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarProvider.kt b/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarProvider.kt new file mode 100644 index 00000000..9fcfc394 --- /dev/null +++ b/data/calendar/src/main/java/de/mm20/launcher2/calendar/providers/TasksCalendarProvider.kt @@ -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, + excludeAllDayEvents: Boolean, + allowNetwork: Boolean + ): List { + 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 { + 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() + + 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? = arrayOf(), + ): List { + val uri = "content://org.tasks/todoagenda".toUri() + val cursor = context.contentResolver.query(uri, arrayOf(), selection, selectionArgs, null) + + val results = mutableListOf() + + 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" +} \ No newline at end of file