Implement Tasks support via OpenTasks

This adds support for tasks via OpenTasks.
https://github.com/dmfs/opentasks

Need the OpenTasks client for it to be used.

Currently you can't create new task lists. You can only have the default
one, but that's just a UI thing.

Fixes #7
pull/61/head
Tom Hacohen 6 years ago
parent c15f894a71
commit 7f2ab44bca

@ -106,6 +106,19 @@
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars" />
</service>
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks"/>
</service>
<!-- Address book account -->
<service

@ -51,7 +51,8 @@ class CollectionInfo : Serializable {
enum class Type {
ADDRESS_BOOK,
CALENDAR
CALENDAR,
TASKS,
}
init {

@ -146,7 +146,15 @@ public class JournalModel {
CollectionInfo.Type type;
public static ServiceEntity fetch(EntityDataStore<Persistable> data, String account, CollectionInfo.Type type) {
return data.select(ServiceEntity.class).where(ServiceEntity.ACCOUNT.eq(account).and(ServiceEntity.TYPE.eq(type))).limit(1).get().firstOrNull();
ServiceEntity service = data.select(ServiceEntity.class).where(ServiceEntity.ACCOUNT.eq(account).and(ServiceEntity.TYPE.eq(type))).limit(1).get().firstOrNull();
if (service == null) {
// If our first time, create service and a journal
ServiceEntity serviceEntity = new ServiceEntity();
serviceEntity.account = account;
serviceEntity.type = CollectionInfo.Type.TASKS;
service = data.insert(serviceEntity);
}
return service;
}
}

@ -151,7 +151,7 @@ class LocalAddressBook(
_mainAccount = newMainAccount
}
var url: String
override var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)

@ -88,6 +88,9 @@ class LocalCalendar private constructor(
}
}
override val url: String?
get() = name
fun update(journalEntity: JournalEntity, updateColor: Boolean) =
update(valuesFromCollectionInfo(journalEntity, updateColor))

@ -9,6 +9,8 @@
package com.etesync.syncadapter.resource
interface LocalCollection<out T: LocalResource<*>> {
val url: String?
fun findDeleted(): List<T>
fun findDirty(): List<T>
fun findWithoutFileName(): List<T>

@ -48,7 +48,7 @@ class LocalTask : AndroidTask, LocalResource<Task> {
val uuid: String?
get() = fileName
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?)
: super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
@ -63,6 +63,15 @@ class LocalTask : AndroidTask, LocalResource<Task> {
/* process LocalTask-specific fields */
override fun populateTask(values: ContentValues) {
super.populateTask(values)
fileName = values.getAsString(TaskContract.Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
task?.uid = values.getAsString(COLUMN_UID)
task?.sequence = values.getAsInteger(COLUMN_SEQUENCE)
}
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
super.buildTask(builder, update)
builder.withValue(TaskContract.Tasks._SYNC_ID, fileName)
@ -75,7 +84,14 @@ class LocalTask : AndroidTask, LocalResource<Task> {
/* custom queries */
override fun prepareForUpload() {
val uid = UUID.randomUUID().toString()
var uid: String? = null
val c = taskList.provider.client.query(taskSyncURI(), arrayOf(COLUMN_UID), null, null, null)
if (c.moveToNext())
uid = c.getString(0)
if (uid == null)
uid = UUID.randomUUID().toString()
c.close()
val values = ContentValues(2)
values.put(TaskContract.Tasks._SYNC_ID, uid)

@ -62,6 +62,9 @@ class LocalTaskList private constructor(
}
override val url: String?
get() = syncId
fun update(journalEntity: JournalEntity, updateColor: Boolean) =
update(valuesFromCollectionInfo(journalEntity, updateColor))

@ -10,9 +10,7 @@ package com.etesync.syncadapter.syncadapter
import android.accounts.Account
import android.annotation.TargetApi
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.content.res.Resources
import android.os.Bundle
import com.etesync.syncadapter.AccountSettings
@ -40,7 +38,6 @@ import com.etesync.syncadapter.ui.ViewCollectionActivity
import java.io.FileNotFoundException
import java.io.IOException
import java.util.ArrayList
import java.util.Arrays
import java.util.LinkedList
import java.util.Locale
import java.util.logging.Level

@ -0,0 +1,152 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package com.etesync.syncadapter.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.database.sqlite.SQLiteException
import android.os.Build
import android.os.Bundle
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import com.etesync.syncadapter.*
import com.etesync.syncadapter.journalmanager.Exceptions
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.JournalEntity
import com.etesync.syncadapter.model.JournalModel
import com.etesync.syncadapter.model.ServiceEntity
import com.etesync.syncadapter.resource.LocalTaskList
import com.etesync.syncadapter.ui.DebugInfoActivity
import okhttp3.HttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.util.*
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
class TasksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = TasksSyncAdapter(this)
class TasksSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
super.onPerformSync(account, extras, authority, provider, syncResult)
val notificationManager = NotificationHelper(context, "journals-tasks", Constants.NOTIFICATION_TASK_SYNC)
notificationManager.cancel()
try {
val taskProvider = TaskProvider.fromProviderClient(context, provider)
// make sure account can be seen by OpenTasks
if (Build.VERSION.SDK_INT >= 26)
AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE)
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
RefreshCollections(account, CollectionInfo.Type.TASKS).run()
updateLocalTaskLists(taskProvider, account, accountSettings)
val principal = HttpUrl.get(accountSettings.uri!!)!!
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
App.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
val tasksSyncManager = TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList, principal);
tasksSyncManager.performSync()
}
} catch (e: Exceptions.ServiceUnavailableException) {
syncResult.stats.numIoExceptions++
syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY
} catch (e: Exception) {
if (e is SQLiteException) {
App.log.log(Level.SEVERE, "Couldn't prepare local task list", e)
syncResult.databaseError = true
}
val syncPhase = R.string.sync_phase_journals
val title = context.getString(R.string.sync_error_tasks, account.name)
notificationManager.setThrowable(e)
val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(Constants.KEY_ACCOUNT, account)
if (e !is Exceptions.UnauthorizedException) {
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
}
notificationManager.notify(title, context.getString(syncPhase))
} catch (e: OutOfMemoryError) {
val syncPhase = R.string.sync_phase_journals
val title = context.getString(R.string.sync_error_tasks, account.name)
notificationManager.setThrowable(e)
val detailsIntent = notificationManager.detailsIntent
detailsIntent.putExtra(Constants.KEY_ACCOUNT, account)
notificationManager.notify(title, context.getString(syncPhase))
}
App.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val data = (context.applicationContext as App).data
var service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.TASKS)
val remote = HashMap<String, JournalEntity>()
val remoteJournals = JournalEntity.getJournals(data, service)
for (journalEntity in remoteJournals) {
remote[journalEntity.uid] = journalEntity
}
val local = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
val updateColors = settings.manageCalendarColors
// delete obsolete local TaskList
for (taskList in local) {
val url = taskList.url
val journalEntity = remote[url]
if (journalEntity == null) {
App.log.fine("Deleting obsolete local task list $url")
taskList.delete()
} else {
// remote CollectionInfo found for this local collection, update data
App.log.fine("Updating local task list $url with $journalEntity")
taskList.update(journalEntity, updateColors)
// we already have a local tasks for this remote collection, don't take into consideration anymore
remote.remove(url)
}
}
// create new local taskss
for (url in remote.keys) {
val journalEntity = remote[url]!!
App.log.info("Adding local task list $journalEntity")
LocalTaskList.create(account, provider, journalEntity)
}
}
}
}

@ -0,0 +1,119 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package com.etesync.syncadapter.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.Task
import com.etesync.syncadapter.AccountSettings
import com.etesync.syncadapter.App
import com.etesync.syncadapter.Constants
import com.etesync.syncadapter.R
import com.etesync.syncadapter.journalmanager.JournalEntryManager
import com.etesync.syncadapter.model.CollectionInfo
import com.etesync.syncadapter.model.SyncEntry
import com.etesync.syncadapter.resource.LocalEvent
import com.etesync.syncadapter.resource.LocalTask
import com.etesync.syncadapter.resource.LocalTaskList
import okhttp3.HttpUrl
import java.io.Reader
import java.io.StringReader
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles tasks (VTODO)
*/
class TasksSyncManager(
context: Context,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
taskList: LocalTaskList,
private val remote: HttpUrl
): SyncManager<LocalTask>(context, account, accountSettings, extras, authority, syncResult, taskList.url!!, CollectionInfo.Type.TASKS, account.name) {
override val syncErrorTitle: String
get() = context.getString(R.string.sync_error_tasks, account.name)
override val syncSuccessfullyTitle: String
get() = context.getString(R.string.sync_successfully_tasks, info.displayName,
account.name)
init {
localCollection = taskList
}
override fun notificationId(): Int {
return Constants.NOTIFICATION_TASK_SYNC
}
override fun prepare(): Boolean {
if (!super.prepare())
return false
journal = JournalEntryManager(httpClient, remote, localTaskList().url!!)
return true
}
// helpers
private fun localTaskList(): LocalTaskList {
return localCollection as LocalTaskList
}
override fun processSyncEntry(cEntry: SyncEntry) {
val inputReader = StringReader(cEntry.content)
val tasks = Task.fromReader(inputReader)
if (tasks.size == 0) {
App.log.warning("Received VCard without data, ignoring")
return
} else if (tasks.size > 1) {
App.log.warning("Received multiple VCALs, using first one")
}
val event = tasks[0]
val local = localCollection!!.findByUid(event.uid!!)
if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) {
processTask(event, local)
} else {
if (local != null) {
App.log.info("Removing local record #" + local.id + " which has been deleted on the server")
local.delete()
} else {
App.log.warning("Tried deleting a non-existent record: " + event.uid)
}
}
}
private fun processTask(newData: Task, localTask: LocalTask?): LocalTask {
var localTask = localTask
// delete local Task, if it exists
if (localTask != null) {
App.log.info("Updating " + newData.uid + " in local calendar")
localTask.eTag = newData.uid
localTask.update(newData)
syncResult.stats.numUpdates++
} else {
App.log.info("Adding " + newData.uid + " to local calendar")
localTask = LocalTask(localTaskList(), newData, newData.uid, newData.uid)
localTask.add()
syncResult.stats.numInserts++
}
return localTask
}
}

@ -278,29 +278,32 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe
for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) {
val id = serviceEntity.id.toLong()
val service = serviceEntity.type
if (service == CollectionInfo.Type.ADDRESS_BOOK) {
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.id = id
info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.journals = JournalEntity.getJournals(data, serviceEntity)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
when (service) {
CollectionInfo.Type.ADDRESS_BOOK -> {
info.carddav = AccountInfo.ServiceInfo()
info.carddav!!.id = id
info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority)
info.carddav!!.journals = JournalEntity.getJournals(data, serviceEntity)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch (e: ContactsStorageException) {
}
}
}
CollectionInfo.Type.CALENDAR -> {
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.id = id
info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
info.caldav!!.journals = JournalEntity.getJournals(data, serviceEntity)
}
} else if (service == CollectionInfo.Type.CALENDAR) {
info.caldav = AccountInfo.ServiceInfo()
info.caldav!!.id = id
info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
info.caldav!!.journals = JournalEntity.getJournals(data, serviceEntity)
}
}
return info

@ -43,26 +43,29 @@ open class CreateCollectionActivity : BaseActivity() {
setContentView(R.layout.activity_create_collection)
val displayName = findViewById<View>(R.id.display_name) as EditText
if (info!!.type == CollectionInfo.Type.CALENDAR) {
setTitle(R.string.create_calendar)
displayName.setHint(R.string.create_calendar_display_name_hint)
val colorSquare = findViewById<View>(R.id.color)
colorSquare.setOnClickListener {
AmbilWarnaDialog(this@CreateCollectionActivity, (colorSquare.background as ColorDrawable).color, true, object : AmbilWarnaDialog.OnAmbilWarnaListener {
override fun onCancel(dialog: AmbilWarnaDialog) {}
override fun onOk(dialog: AmbilWarnaDialog, color: Int) {
colorSquare.setBackgroundColor(color)
}
}).show()
when (info.type) {
CollectionInfo.Type.CALENDAR, CollectionInfo.Type.TASKS -> {
setTitle(R.string.create_calendar)
displayName.setHint(R.string.create_calendar_display_name_hint)
val colorSquare = findViewById<View>(R.id.color)
colorSquare.setOnClickListener {
AmbilWarnaDialog(this@CreateCollectionActivity, (colorSquare.background as ColorDrawable).color, true, object : AmbilWarnaDialog.OnAmbilWarnaListener {
override fun onCancel(dialog: AmbilWarnaDialog) {}
override fun onOk(dialog: AmbilWarnaDialog, color: Int) {
colorSquare.setBackgroundColor(color)
}
}).show()
}
}
} else {
setTitle(R.string.create_addressbook)
displayName.setHint(R.string.create_addressbook_display_name_hint)
CollectionInfo.Type.ADDRESS_BOOK -> {
setTitle(R.string.create_addressbook)
displayName.setHint(R.string.create_addressbook_display_name_hint)
val colorGroup = findViewById<View>(R.id.color_group)
colorGroup.visibility = View.GONE
val colorGroup = findViewById<View>(R.id.color_group)
colorGroup.visibility = View.GONE
}
}
}

@ -19,6 +19,7 @@ import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import at.bitfire.ical4android.TaskProvider
import com.etesync.syncadapter.*
import com.etesync.syncadapter.journalmanager.Crypto
import com.etesync.syncadapter.journalmanager.Exceptions
@ -83,17 +84,15 @@ class CreateCollectionFragment : DialogFragment(), LoaderManager.LoaderCallbacks
override fun loadInBackground(): Exception? {
try {
var authority: String
var authority: String = ""
val data = (context.applicationContext as App).data
// 1. find service ID
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
authority = App.addressBooksAuthority
} else if (info.type == CollectionInfo.Type.CALENDAR) {
authority = CalendarContract.AUTHORITY
} else {
throw IllegalArgumentException("Collection must be an address book or calendar")
when (info.type){
CollectionInfo.Type.ADDRESS_BOOK -> authority = App.addressBooksAuthority
CollectionInfo.Type.CALENDAR -> authority = CalendarContract.AUTHORITY
CollectionInfo.Type.TASKS -> authority = TaskProvider.ProviderName.OpenTasks.authority
}
val serviceEntity = JournalModel.Service.fetch(data, account.name, info.type)

@ -80,7 +80,7 @@ class PermissionsActivity : BaseActivity() {
val PERMISSION_WRITE_TASKS = "org.dmfs.permission.WRITE_TASKS"
fun requestAllPermissions(activity: Activity) {
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS)
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS, PERMISSION_READ_TASKS, PERMISSION_WRITE_TASKS), REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS)
}
}
}

@ -306,6 +306,7 @@
<string name="sync_error_permissions_text">Additional permissions required</string>
<string name="sync_error_calendar">Calendar sync failed (%s)</string>
<string name="sync_error_contacts">Contacts sync failed (%s)</string>
<string name="sync_error_tasks">Tasks sync failed (%s)</string>
<string name="sync_error">Error while %s</string>
<string name="sync_error_integrity">Integrity error while %s</string>
<string name="sync_error_http_dav">Server error while %s</string>
@ -325,6 +326,7 @@
<string name="sync_error_user_inactive">User is inactive</string>
<string name="sync_successfully_calendar" formatted="false">Calendar \"%s\" modified (%s)</string>
<string name="sync_successfully_contacts" formatted="false">Contacts modified (%s)</string>
<string name="sync_successfully_tasks" formatted="false">Tasks \"%s\" modified (%s)</string>
<string name="sync_successfully_modified" formatted="false">%s modified.</string>
<string name="sync_successfully_modified_full" formatted="false">%s added.\n%s updated.\n%s deleted.</string>

@ -0,0 +1,12 @@
<!--
~ Copyright © Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type"
android:contentAuthority="org.dmfs.tasks"
android:supportsUploading="true" />
Loading…
Cancel
Save