Sync with opentasks-provider

pull/1066/head
Alex Baker 5 years ago
parent 1ba66f4006
commit 14f46f0688

@ -1,6 +1,42 @@
Change Log Change Log
--- ---
======= =======
### 10.0 (2020-08-03)
🚧 Currently in alpha 🚧
* PRO: DAVx⁵ support (alpha requires custom DAVx⁵ build)
* PRO: EteSync client support (alpha requires custom EteSync client build)
* [ToDo Agenda](https://play.google.com/store/apps/details?id=org.andstatus.todoagenda) integration
* Changed backstack behavior to follow Android conventions
* Major internal changes! Please report any bugs!
* Remove Mapbox tiles (Google Play only)
* Added 'Astrid manual sort' information to backup file
* Bug fixes
* Performance improvements
* Security improvements
* Update translations
* Basque - @osoitz
* Bengali - @Oymate
* Brazilian Portuguese - Pedro Lucas Porcellis
* Chinese - WH Julie
* Czech - @vitSkalicky, Radek Řehořek
* Dutch - @fvbommel
* Finnish - J. Lavoie
* French - @FlorianLeChat, J. Lavoie, @sephrat
* German - @franconian
* Hebrew - @yarons, @avipars
* Hungarian - kaciokos
* Italian - @ppasserini, J. Lavoie
* Norwegian Bokmål - @comradekingu, Erlend Ydse
* Polish - @alex-ter
* Portuguese - @SantosSi
* Russian - Nikita Epifanov
* Simplified Chinese - @sr093906, @cccClyde
* Spanish - @FlorianLeChat
* Tamil - @balogic, @Thiya-velu
* Turkish - @emintufan
### 9.7.3 (2020-07-07) ### 9.7.3 (2020-07-07)
* Fix Google Task bugs * Fix Google Task bugs

@ -44,8 +44,8 @@ android {
defaultConfig { defaultConfig {
testApplicationId = "org.tasks.test" testApplicationId = "org.tasks.test"
applicationId = "org.tasks" applicationId = "org.tasks"
versionCode = 90704 versionCode = 100000
versionName = "9.7.3" versionName = "10.0"
targetSdkVersion(Versions.targetSdk) targetSdkVersion(Versions.targetSdk)
minSdkVersion(Versions.minSdk) minSdkVersion(Versions.minSdk)
testInstrumentationRunner = "org.tasks.TestRunner" testInstrumentationRunner = "org.tasks.TestRunner"

@ -774,6 +774,7 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: https://api.github.com/licenses/apache-2.0 licenseUrl: https://api.github.com/licenses/apache-2.0
url: https://github.com/dmfs/opentasks url: https://github.com/dmfs/opentasks
forceGenerate: true
- artifact: org.dmfs:lib-recur:+ - artifact: org.dmfs:lib-recur:+
name: lib-recur name: lib-recur
copyrightHolder: Marten Gajda copyrightHolder: Marten Gajda

@ -344,6 +344,10 @@
android:name=".etesync.EteSyncAccountSettingsActivity" android:name=".etesync.EteSyncAccountSettingsActivity"
android:theme="@style/Tasks" /> android:theme="@style/Tasks" />
<activity
android:name=".opentasks.OpenTaskAccountSettingsActivity"
android:theme="@style/Tasks" />
<activity <activity
android:name=".caldav.CaldavCalendarSettingsActivity" android:name=".caldav.CaldavCalendarSettingsActivity"
android:theme="@style/Tasks"/> android:theme="@style/Tasks"/>
@ -352,6 +356,10 @@
android:name=".caldav.LocalListSettingsActivity" android:name=".caldav.LocalListSettingsActivity"
android:theme="@style/Tasks"/> android:theme="@style/Tasks"/>
<activity
android:name=".opentasks.OpenTasksListSettingsActivity"
android:theme="@style/Tasks"/>
<activity <activity
android:name=".activities.PlaceSettingsActivity" android:name=".activities.PlaceSettingsActivity"
android:theme="@style/Tasks" /> android:theme="@style/Tasks" />

@ -54,7 +54,9 @@ class TaskMover @Inject constructor(
if (selectedList is CaldavFilter) { if (selectedList is CaldavFilter) {
caldavDao.updateParents(selectedList.uuid) caldavDao.updateParents(selectedList.uuid)
} }
taskDao.touch(tasks) tasks.dbchunk().forEach {
taskDao.touch(it)
}
localBroadcastManager.broadcastRefresh() localBroadcastManager.broadcastRefresh()
syncAdapters.sync() syncAdapters.sync()
} }

@ -22,6 +22,7 @@ import org.tasks.injection.InjectingJobIntentService
import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager
import org.tasks.locale.Locale import org.tasks.locale.Locale
import org.tasks.location.GeofenceApi import org.tasks.location.GeofenceApi
import org.tasks.opentasks.OpenTaskContentObserver
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.receivers.RefreshReceiver import org.tasks.receivers.RefreshReceiver
import org.tasks.scheduling.CalendarNotificationIntentService import org.tasks.scheduling.CalendarNotificationIntentService
@ -47,12 +48,14 @@ class Tasks : Application(), Configuration.Provider {
@Inject lateinit var billingClient: Lazy<BillingClient> @Inject lateinit var billingClient: Lazy<BillingClient>
@Inject lateinit var appWidgetManager: Lazy<AppWidgetManager> @Inject lateinit var appWidgetManager: Lazy<AppWidgetManager>
@Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var contentObserver: Lazy<OpenTaskContentObserver>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
buildSetup.setup() buildSetup.setup()
upgrade() upgrade()
preferences.isSyncOngoing = false preferences.isSyncOngoing = false
preferences.setBoolean(R.string.p_sync_ongoing_opentasks, false)
ThemeBase.getThemeBase(preferences, inventory, null).setDefaultNightMode() ThemeBase.getThemeBase(preferences, inventory, null).setDefaultNightMode()
localBroadcastManager.registerRefreshReceiver(RefreshBroadcastReceiver()) localBroadcastManager.registerRefreshReceiver(RefreshBroadcastReceiver())
Locale.getInstance(this).createConfigurationContext(applicationContext) Locale.getInstance(this).createConfigurationContext(applicationContext)
@ -80,6 +83,7 @@ class Tasks : Application(), Configuration.Provider {
scheduleMidnightRefresh() scheduleMidnightRefresh()
scheduleBackup() scheduleBackup()
scheduleConfigRefresh() scheduleConfigRefresh()
OpenTaskContentObserver.registerObserver(context, contentObserver.get())
} }
geofenceApi.get().registerAll() geofenceApi.get().registerAll()
FileHelper.delete(context, preferences.cacheDirectory) FileHelper.delete(context, preferences.cacheDirectory)

@ -171,7 +171,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
protected abstract val newPassword: String? protected abstract val newPassword: String?
private fun save() = lifecycleScope.launch { protected open fun save() = lifecycleScope.launch {
if (requestInProgress()) { if (requestInProgress()) {
return@launch return@launch
} }
@ -282,7 +282,7 @@ abstract class BaseCaldavAccountSettingsActivity : ThemedInjectingAppCompatActiv
return snackbar return snackbar
} }
private fun hasChanges(): Boolean { protected open fun hasChanges(): Boolean {
return if (caldavAccount == null) { return if (caldavAccount == null) {
(!isNullOrEmpty(binding!!.name.text.toString().trim { it <= ' ' }) (!isNullOrEmpty(binding!!.name.text.toString().trim { it <= ' ' })
|| !isNullOrEmpty(newPassword) || !isNullOrEmpty(newPassword)

@ -68,7 +68,7 @@ public class CaldavConverter {
} }
} }
private static @Priority int fromRemote(int remotePriority) { public static @Priority int fromRemote(int remotePriority) {
// https://tools.ietf.org/html/rfc5545#section-3.8.1.9 // https://tools.ietf.org/html/rfc5545#section-3.8.1.9
if (remotePriority == 0) { if (remotePriority == 0) {
return Priority.NONE; return Priority.NONE;
@ -79,7 +79,7 @@ public class CaldavConverter {
return remotePriority < 5 ? Priority.HIGH : Priority.LOW; return remotePriority < 5 ? Priority.HIGH : Priority.LOW;
} }
private static int toRemote(int remotePriority, int localPriority) { public static int toRemote(int remotePriority, int localPriority) {
if (localPriority == Priority.NONE) { if (localPriority == Priority.NONE) {
return 0; return 0;
} }

@ -209,14 +209,14 @@ class CaldavSynchronizer @Inject constructor(
iCal.fromVtodo(caldavCalendar, caldavTask, remote, vtodo, fileName, eTag.eTag) iCal.fromVtodo(caldavCalendar, caldavTask, remote, vtodo, fileName, eTag.eTag)
} }
} }
val deleted = caldavDao caldavDao
.getObjects(caldavCalendar.uuid!!) .getObjects(caldavCalendar.uuid!!)
.subtract(members.map { it.hrefName() }) .subtract(members.map { it.hrefName() })
.toList() .takeIf { it.isNotEmpty() }
if (deleted.isNotEmpty()) { ?.let {
Timber.d("DELETED %s", deleted) Timber.d("DELETED $it")
taskDeleter.delete(caldavDao.getTasks(caldavCalendar.uuid!!, deleted)) taskDeleter.delete(caldavDao.getTasks(caldavCalendar.uuid!!, it.toList()))
} }
caldavCalendar.ctag = remoteCtag caldavCalendar.ctag = remoteCtag
Timber.d("UPDATE %s", caldavCalendar) Timber.d("UPDATE %s", caldavCalendar)
caldavDao.update(caldavCalendar) caldavDao.update(caldavCalendar)

@ -38,7 +38,7 @@ class iCalendar @Inject constructor(
private val caldavDao: CaldavDao) { private val caldavDao: CaldavDao) {
companion object { companion object {
private const val APPLE_SORT_ORDER = "X-APPLE-SORT-ORDER" const val APPLE_SORT_ORDER = "X-APPLE-SORT-ORDER"
private val IS_PARENT = { r: RelatedTo? -> private val IS_PARENT = { r: RelatedTo? ->
r!!.parameters.isEmpty || r.parameters.getParameter<RelType>(Parameter.RELTYPE) === RelType.PARENT r!!.parameters.isEmpty || r.parameters.getParameter<RelType>(Parameter.RELTYPE) === RelType.PARENT

@ -12,6 +12,7 @@ import org.tasks.activities.BaseListSettingsActivity
import org.tasks.caldav.CaldavCalendarSettingsActivity import org.tasks.caldav.CaldavCalendarSettingsActivity
import org.tasks.caldav.LocalListSettingsActivity import org.tasks.caldav.LocalListSettingsActivity
import org.tasks.etesync.EteSyncCalendarSettingsActivity import org.tasks.etesync.EteSyncCalendarSettingsActivity
import org.tasks.opentasks.OpenTasksListSettingsActivity
import org.tasks.security.KeyStoreEncryption import org.tasks.security.KeyStoreEncryption
@Entity(tableName = "caldav_accounts") @Entity(tableName = "caldav_accounts")
@ -85,9 +86,13 @@ class CaldavAccount : Parcelable {
val isEteSyncAccount: Boolean val isEteSyncAccount: Boolean
get() = accountType == TYPE_ETESYNC get() = accountType == TYPE_ETESYNC
val isOpenTasks: Boolean
get() = accountType == TYPE_OPENTASKS
fun listSettingsClass(): Class<out BaseListSettingsActivity> = when(accountType) { fun listSettingsClass(): Class<out BaseListSettingsActivity> = when(accountType) {
TYPE_ETESYNC -> EteSyncCalendarSettingsActivity::class.java TYPE_ETESYNC -> EteSyncCalendarSettingsActivity::class.java
TYPE_LOCAL -> LocalListSettingsActivity::class.java TYPE_LOCAL -> LocalListSettingsActivity::class.java
TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java
else -> CaldavCalendarSettingsActivity::class.java else -> CaldavCalendarSettingsActivity::class.java
} }
@ -149,6 +154,7 @@ class CaldavAccount : Parcelable {
const val TYPE_CALDAV = 0 const val TYPE_CALDAV = 0
const val TYPE_ETESYNC = 1 const val TYPE_ETESYNC = 1
const val TYPE_LOCAL = 2 const val TYPE_LOCAL = 2
const val TYPE_OPENTASKS = 3
@JvmField val CREATOR: Parcelable.Creator<CaldavAccount> = object : Parcelable.Creator<CaldavAccount> { @JvmField val CREATOR: Parcelable.Creator<CaldavAccount> = object : Parcelable.Creator<CaldavAccount> {
override fun createFromParcel(source: Parcel): CaldavAccount? { override fun createFromParcel(source: Parcel): CaldavAccount? {

@ -11,6 +11,8 @@ import com.todoroo.astrid.helper.UUIDHelper
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.tasks.R import org.tasks.R
import org.tasks.data.CaldavAccount.Companion.TYPE_LOCAL
import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS
import org.tasks.date.DateTimeUtils.toAppleEpoch import org.tasks.date.DateTimeUtils.toAppleEpoch
import org.tasks.db.SuspendDbUtils.chunkedMap import org.tasks.db.SuspendDbUtils.chunkedMap
import org.tasks.filters.CaldavFilters import org.tasks.filters.CaldavFilters
@ -158,7 +160,7 @@ SELECT EXISTS(SELECT 1
@Query("SELECT * FROM caldav_lists WHERE cdl_uuid = :uuid LIMIT 1") @Query("SELECT * FROM caldav_lists WHERE cdl_uuid = :uuid LIMIT 1")
abstract suspend fun getCalendar(uuid: String): CaldavCalendar? abstract suspend fun getCalendar(uuid: String): CaldavCalendar?
@Query("SELECT cd_object FROM caldav_tasks WHERE cd_calendar = :calendar") @Query("SELECT cd_object FROM caldav_tasks WHERE cd_calendar = :calendar AND cd_deleted = 0")
abstract suspend fun getObjects(calendar: String): List<String> abstract suspend fun getObjects(calendar: String): List<String>
suspend fun getTasks(calendar: String, objects: List<String>): List<Long> = suspend fun getTasks(calendar: String, objects: List<String>): List<Long> =
@ -167,6 +169,14 @@ SELECT EXISTS(SELECT 1
@Query("SELECT cd_task FROM caldav_tasks WHERE cd_calendar = :calendar AND cd_object IN (:objects)") @Query("SELECT cd_task FROM caldav_tasks WHERE cd_calendar = :calendar AND cd_object IN (:objects)")
internal abstract suspend fun getTasksInternal(calendar: String, objects: List<String>): List<Long> internal abstract suspend fun getTasksInternal(calendar: String, objects: List<String>): List<Long>
@Query("""
SELECT *
FROM caldav_accounts
WHERE cda_account_type = $TYPE_OPENTASKS
AND cda_uuid NOT IN (:accounts)
""")
abstract suspend fun findDeletedAccounts(accounts: List<String>): List<CaldavAccount>
@Query("SELECT * FROM caldav_lists WHERE cdl_account = :account AND cdl_url NOT IN (:urls)") @Query("SELECT * FROM caldav_lists WHERE cdl_account = :account AND cdl_url NOT IN (:urls)")
abstract suspend fun findDeletedCalendars(account: String, urls: List<String>): List<CaldavCalendar> abstract suspend fun findDeletedCalendars(account: String, urls: List<String>): List<CaldavCalendar>

@ -0,0 +1,204 @@
package org.tasks.data
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import at.bitfire.ical4android.UnknownProperty
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.fortuna.ical4j.model.property.XProperty
import org.dmfs.tasks.contract.TaskContract.*
import org.dmfs.tasks.contract.TaskContract.Property.Category
import org.dmfs.tasks.contract.TaskContract.Property.Relation
import org.json.JSONObject
import org.tasks.R
import org.tasks.caldav.iCalendar.Companion.APPLE_SORT_ORDER
import timber.log.Timber
import javax.inject.Inject
class OpenTaskDao @Inject constructor(@ApplicationContext context: Context) {
private val cr = context.contentResolver
val authority = context.getString(R.string.opentasks_authority)
suspend fun accounts(): List<String> = getLists().map { it.account!! }.distinct()
@Deprecated("add davx5/etesync accounts manually")
suspend fun accountCount(): Int = accounts().size
suspend fun getLists(): List<CaldavCalendar> = withContext(Dispatchers.IO) {
val calendars = ArrayList<CaldavCalendar>()
cr.query(
TaskLists.getContentUri(authority),
null,
"${TaskListColumns.SYNC_ENABLED}=1 AND ($ACCOUNT_TYPE = '$ACCOUNT_TYPE_DAVx5' OR $ACCOUNT_TYPE = '$ACCOUNT_TYPE_ETESYNC')",
null,
null)?.use {
while (it.moveToNext()) {
val accountType = it.getString(TaskLists.ACCOUNT_TYPE)
val accountName = it.getString(TaskLists.ACCOUNT_NAME)
calendars.add(CaldavCalendar().apply {
id = it.getLong(TaskLists._ID)
account = "$accountType:$accountName"
name = it.getString(TaskLists.LIST_NAME)
color = it.getInt(TaskLists.LIST_COLOR)
url = it.getString(CommonSyncColumns._SYNC_ID)
ctag = it.getString(TaskLists.SYNC_VERSION)
?.let(::JSONObject)
?.getString("value")
})
}
}
calendars
}
suspend fun getEtags(listId: Long): List<Pair<String, String>> = withContext(Dispatchers.IO) {
val items = ArrayList<Pair<String, String>>()
cr.query(
Tasks.getContentUri(authority),
arrayOf(Tasks._SYNC_ID, "version"),
"${Tasks.LIST_ID} = $listId",
null,
null)?.use {
while (it.moveToNext()) {
items.add(Pair(it.getString(Tasks._SYNC_ID)!!, it.getLong("version").toString()))
}
}
items
}
suspend fun delete(listId: Long, item: String): Int = withContext(Dispatchers.IO) {
cr.delete(
Tasks.getContentUri(authority),
"${Tasks.LIST_ID} = $listId AND ${Tasks._SYNC_ID} = '$item'",
null)
}
suspend fun getId(uid: String?): Long =
uid?.let {
withContext(Dispatchers.IO) {
cr.query(
Tasks.getContentUri(authority),
arrayOf(Tasks._ID),
"${Tasks._UID} = '$uid'",
null,
null)?.use {
if (it.moveToFirst()) {
it.getLong(Tasks._ID)
} else {
Timber.e("No task with uid=$uid")
null
}
}
}
} ?: 0L
suspend fun getTags(caldavTask: CaldavTask): List<String> = withContext(Dispatchers.IO) {
val id = getId(caldavTask.remoteId)
val tags = ArrayList<String>()
cr.query(
Properties.getContentUri(authority),
arrayOf(Properties.DATA1),
"${Properties.TASK_ID} = $id AND ${Properties.MIMETYPE} = '${Category.CONTENT_ITEM_TYPE}'",
null,
null)?.use {
while (it.moveToNext()) {
it.getString(Properties.DATA1)?.let(tags::add)
}
}
return@withContext tags
}
suspend fun setTags(caldavTask: CaldavTask, tags: List<String>) = withContext(Dispatchers.IO) {
val id = getId(caldavTask.remoteId)
cr.delete(
Properties.getContentUri(authority),
"${Properties.TASK_ID} = $id AND ${Properties.MIMETYPE} = '${Category.CONTENT_ITEM_TYPE}'",
null)
tags.forEach {
cr.insert(Properties.getContentUri(authority), ContentValues().apply {
put(Category.MIMETYPE, Category.CONTENT_ITEM_TYPE)
put(Category.TASK_ID, id)
put(Category.CATEGORY_NAME, it)
})
}
}
suspend fun getRemoteOrder(caldavTask: CaldavTask): Long? = withContext(Dispatchers.IO) {
val id = getId(caldavTask.remoteId)
cr.query(
Properties.getContentUri(authority),
arrayOf(Properties.DATA0),
"${Properties.TASK_ID} = $id AND ${Properties.MIMETYPE} = '${UnknownProperty.CONTENT_ITEM_TYPE}' AND ${Properties.DATA0} LIKE '%$APPLE_SORT_ORDER%'",
null,
null)?.use {
while (it.moveToNext()) {
it.getString(Properties.DATA0)
?.let(UnknownProperty::fromJsonString)
?.takeIf { xprop -> xprop.name.equals(APPLE_SORT_ORDER, true) }
?.let { xprop ->
return@withContext xprop.value.toLong()
}
}
}
return@withContext null
}
suspend fun setRemoteOrder(caldavTask: CaldavTask) = withContext(Dispatchers.IO) {
val id = getId(caldavTask.remoteId)
cr.delete(
Properties.getContentUri(authority),
"${Properties.TASK_ID} = $id AND ${Properties.MIMETYPE} = '${UnknownProperty.CONTENT_ITEM_TYPE}' AND ${Properties.DATA0} LIKE '%$APPLE_SORT_ORDER%'",
null)
caldavTask.order?.let {
cr.insert(Properties.getContentUri(authority), ContentValues().apply {
put(Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE)
put(Properties.TASK_ID, id)
put(Properties.DATA0, UnknownProperty.toJsonString(XProperty(APPLE_SORT_ORDER, it.toString())))
})
}
}
suspend fun updateParent(caldavTask: CaldavTask) = withContext(Dispatchers.IO) {
caldavTask.remoteParent
?.takeIf { it.isNotBlank() }
?.let {
cr.insert(Properties.getContentUri(authority), ContentValues().apply {
put(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE)
put(Relation.TASK_ID, getId(caldavTask.remoteId))
put(Relation.RELATED_TYPE, Relation.RELTYPE_PARENT)
put(Relation.RELATED_ID, getId(caldavTask.remoteParent))
})
}
}
suspend fun getParent(id: Long): String? = withContext(Dispatchers.IO) {
cr.query(
Properties.getContentUri(authority),
arrayOf(Relation.RELATED_UID),
"${Relation.TASK_ID} = $id AND ${Properties.MIMETYPE} = '${Relation.CONTENT_ITEM_TYPE}' AND ${Relation.RELATED_TYPE} = ${Relation.RELTYPE_PARENT}",
null,
null)?.use {
if (it.moveToFirst()) {
it.getString(Relation.RELATED_UID)
} else {
null
}
}
}
companion object {
const val ACCOUNT_TYPE_DAVx5 = "bitfire.at.davdroid"
const val ACCOUNT_TYPE_ETESYNC = "com.etesync.syncadapter"
fun Cursor.getString(columnName: String): String? =
getString(getColumnIndex(columnName))
fun Cursor.getInt(columnName: String): Int =
getInt(getColumnIndex(columnName))
fun Cursor.getLong(columnName: String): Long =
getLong(getColumnIndex(columnName))
}
}

@ -30,7 +30,7 @@ abstract class TagDao {
abstract suspend fun delete(tags: List<Tag>) abstract suspend fun delete(tags: List<Tag>)
@Transaction @Transaction
open suspend fun applyTags(task: Task, tagDataDao: TagDataDao, current: List<TagData>): Boolean { open suspend fun applyTags(task: Task, tagDataDao: TagDataDao, current: List<TagData>) {
val taskId = task.id val taskId = task.id
val existing = HashSet(tagDataDao.getTagDataForTask(taskId)) val existing = HashSet(tagDataDao.getTagDataForTask(taskId))
val selected = HashSet<TagData>(current) val selected = HashSet<TagData>(current)
@ -38,7 +38,6 @@ abstract class TagDao {
val removed = existing subtract selected val removed = existing subtract selected
deleteTags(taskId, removed.map { td -> td.remoteId!! }) deleteTags(taskId, removed.map { td -> td.remoteId!! })
insert(task, added) insert(task, added)
return removed.isNotEmpty() || added.isNotEmpty()
} }
suspend fun insert(task: Task, tags: Collection<TagData>) { suspend fun insert(task: Task, tags: Collection<TagData>) {

@ -68,7 +68,8 @@ abstract class TaskDao(private val database: Database) {
FROM tasks FROM tasks
INNER JOIN caldav_tasks ON tasks._id = caldav_tasks.cd_task INNER JOIN caldav_tasks ON tasks._id = caldav_tasks.cd_task
WHERE caldav_tasks.cd_calendar = :calendar WHERE caldav_tasks.cd_calendar = :calendar
AND (tasks.modified > caldav_tasks.cd_last_sync OR caldav_tasks.cd_last_sync = 0)""") AND (tasks.modified > caldav_tasks.cd_last_sync OR caldav_tasks.cd_last_sync = 0)
ORDER BY created""")
abstract suspend fun getCaldavTasksToPush(calendar: String): List<Task> abstract suspend fun getCaldavTasksToPush(calendar: String): List<Task>
@Query("SELECT * FROM TASKS " @Query("SELECT * FROM TASKS "

@ -17,6 +17,7 @@ import org.tasks.billing.Inventory
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.* import org.tasks.data.*
import org.tasks.data.CaldavAccount.Companion.TYPE_LOCAL import org.tasks.data.CaldavAccount.Companion.TYPE_LOCAL
import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS
import org.tasks.filters.NavigationDrawerSubheader.SubheaderType import org.tasks.filters.NavigationDrawerSubheader.SubheaderType
import org.tasks.location.LocationPickerActivity import org.tasks.location.LocationPickerActivity
import org.tasks.preferences.HelpAndFeedback import org.tasks.preferences.HelpAndFeedback
@ -225,7 +226,7 @@ class FilterProvider @Inject constructor(
caldavDao.getAccounts() caldavDao.getAccounts()
.ifEmpty { listOf(caldavDao.setupLocalAccount(context)) } .ifEmpty { listOf(caldavDao.setupLocalAccount(context)) }
.filter { it.accountType != TYPE_LOCAL || preferences.getBoolean(R.string.p_lists_enabled, true) } .filter { it.accountType != TYPE_LOCAL || preferences.getBoolean(R.string.p_lists_enabled, true) }
.flatMap { caldavFilter(it, showCreate) } .flatMap { caldavFilter(it, showCreate && it.accountType != TYPE_OPENTASKS) }
private suspend fun caldavFilter(account: CaldavAccount, showCreate: Boolean): List<FilterListItem> = private suspend fun caldavFilter(account: CaldavAccount, showCreate: Boolean): List<FilterListItem> =
listOf( listOf(

@ -12,6 +12,7 @@ import org.tasks.BuildConfig
import org.tasks.R import org.tasks.R
import org.tasks.data.CaldavDao import org.tasks.data.CaldavDao
import org.tasks.data.GoogleTaskListDao import org.tasks.data.GoogleTaskListDao
import org.tasks.data.OpenTaskDao
import org.tasks.db.Migrations import org.tasks.db.Migrations
import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager
import org.tasks.jobs.WorkManagerImpl import org.tasks.jobs.WorkManagerImpl
@ -41,7 +42,8 @@ internal class ProductionModule {
@ApplicationContext context: Context, @ApplicationContext context: Context,
preferences: Preferences, preferences: Preferences,
googleTaskListDao: GoogleTaskListDao, googleTaskListDao: GoogleTaskListDao,
caldavDao: CaldavDao): WorkManager { caldavDao: CaldavDao,
return WorkManagerImpl(context, preferences, googleTaskListDao, caldavDao) openTaskDao: OpenTaskDao): WorkManager {
return WorkManagerImpl(context, preferences, googleTaskListDao, caldavDao, openTaskDao)
} }
} }

@ -0,0 +1,35 @@
package org.tasks.jobs
import android.content.Context
import androidx.hilt.Assisted
import androidx.hilt.work.WorkerInject
import androidx.work.WorkerParameters
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.analytics.Firebase
import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS
import org.tasks.data.CaldavDao
import org.tasks.data.OpenTaskDao
import org.tasks.opentasks.OpenTasksSynchronizer
import org.tasks.preferences.Preferences
class SyncOpenTasksWork @WorkerInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
firebase: Firebase,
localBroadcastManager: LocalBroadcastManager,
preferences: Preferences,
private val openTasksSynchronizer: OpenTasksSynchronizer,
private val caldavDao: CaldavDao,
private val openTaskDao: OpenTaskDao
) : SyncWork(context, workerParams, firebase, localBroadcastManager, preferences) {
override val syncStatus = R.string.p_sync_ongoing_opentasks
override suspend fun enabled() =
caldavDao.getAccounts(TYPE_OPENTASKS).isNotEmpty()
|| openTaskDao.accountCount() > 0
override suspend fun doSync() {
openTasksSynchronizer.sync()
}
}

@ -17,6 +17,7 @@ interface WorkManager {
fun eteSync(immediate: Boolean) fun eteSync(immediate: Boolean)
fun openTaskSync()
fun reverseGeocode(place: Place) fun reverseGeocode(place: Place)
@ -48,9 +49,11 @@ interface WorkManager {
const val TAG_SYNC_GOOGLE_TASKS = "tag_sync_google_tasks" const val TAG_SYNC_GOOGLE_TASKS = "tag_sync_google_tasks"
const val TAG_SYNC_CALDAV = "tag_sync_caldav" const val TAG_SYNC_CALDAV = "tag_sync_caldav"
const val TAG_SYNC_ETESYNC = "tag_sync_etesync" const val TAG_SYNC_ETESYNC = "tag_sync_etesync"
const val TAG_SYNC_OPENTASK = "tag_sync_opentask"
const val TAG_BACKGROUND_SYNC_GOOGLE_TASKS = "tag_background_sync_google_tasks" const val TAG_BACKGROUND_SYNC_GOOGLE_TASKS = "tag_background_sync_google_tasks"
const val TAG_BACKGROUND_SYNC_CALDAV = "tag_background_sync_caldav" const val TAG_BACKGROUND_SYNC_CALDAV = "tag_background_sync_caldav"
const val TAG_BACKGROUND_SYNC_ETESYNC = "tag_background_sync_etesync" const val TAG_BACKGROUND_SYNC_ETESYNC = "tag_background_sync_etesync"
const val TAG_BACKGROUND_SYNC_OPENTASKS = "tag_background_sync_opentasks"
const val TAG_REMOTE_CONFIG = "tag_remote_config" const val TAG_REMOTE_CONFIG = "tag_remote_config"
} }
} }

@ -14,8 +14,10 @@ import org.tasks.BuildConfig
import org.tasks.R import org.tasks.R
import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV
import org.tasks.data.CaldavAccount.Companion.TYPE_ETESYNC import org.tasks.data.CaldavAccount.Companion.TYPE_ETESYNC
import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS
import org.tasks.data.CaldavDao import org.tasks.data.CaldavDao
import org.tasks.data.GoogleTaskListDao import org.tasks.data.GoogleTaskListDao
import org.tasks.data.OpenTaskDao
import org.tasks.data.Place import org.tasks.data.Place
import org.tasks.date.DateTimeUtils.midnight import org.tasks.date.DateTimeUtils.midnight
import org.tasks.date.DateTimeUtils.newDateTime import org.tasks.date.DateTimeUtils.newDateTime
@ -24,6 +26,7 @@ import org.tasks.jobs.WorkManager.Companion.REMOTE_CONFIG_INTERVAL_HOURS
import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_CALDAV import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_CALDAV
import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_ETESYNC
import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_GOOGLE_TASKS
import org.tasks.jobs.WorkManager.Companion.TAG_BACKGROUND_SYNC_OPENTASKS
import org.tasks.jobs.WorkManager.Companion.TAG_BACKUP import org.tasks.jobs.WorkManager.Companion.TAG_BACKUP
import org.tasks.jobs.WorkManager.Companion.TAG_MIDNIGHT_REFRESH import org.tasks.jobs.WorkManager.Companion.TAG_MIDNIGHT_REFRESH
import org.tasks.jobs.WorkManager.Companion.TAG_REFRESH import org.tasks.jobs.WorkManager.Companion.TAG_REFRESH
@ -31,6 +34,7 @@ import org.tasks.jobs.WorkManager.Companion.TAG_REMOTE_CONFIG
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_CALDAV import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_CALDAV
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_OPENTASK
import org.tasks.notifications.Throttle import org.tasks.notifications.Throttle
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.time.DateTimeUtils import org.tasks.time.DateTimeUtils
@ -43,7 +47,8 @@ class WorkManagerImpl constructor(
private val context: Context, private val context: Context,
private val preferences: Preferences, private val preferences: Preferences,
private val googleTaskListDao: GoogleTaskListDao, private val googleTaskListDao: GoogleTaskListDao,
private val caldavDao: CaldavDao): WorkManager { private val caldavDao: CaldavDao,
private val openTaskDao: OpenTaskDao): WorkManager {
private val throttle = Throttle(200, 60000, "WORK") private val throttle = Throttle(200, 60000, "WORK")
private val alarmManager: AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager private val alarmManager: AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private val workManager = androidx.work.WorkManager.getInstance(context) private val workManager = androidx.work.WorkManager.getInstance(context)
@ -76,6 +81,9 @@ class WorkManagerImpl constructor(
override fun eteSync(immediate: Boolean) = override fun eteSync(immediate: Boolean) =
sync(immediate, TAG_SYNC_ETESYNC, SyncEteSyncWork::class.java) sync(immediate, TAG_SYNC_ETESYNC, SyncEteSyncWork::class.java)
override fun openTaskSync() =
sync(true, TAG_SYNC_OPENTASK, SyncOpenTasksWork::class.java, false)
@SuppressLint("EnqueueWork") @SuppressLint("EnqueueWork")
private fun sync(immediate: Boolean, tag: String, c: Class<out SyncWork>, requireNetwork: Boolean = true) { private fun sync(immediate: Boolean, tag: String, c: Class<out SyncWork>, requireNetwork: Boolean = true) {
Timber.d("sync(immediate = $immediate, $tag, $c, requireNetwork = $requireNetwork)") Timber.d("sync(immediate = $immediate, $tag, $c, requireNetwork = $requireNetwork)")
@ -139,6 +147,12 @@ class WorkManagerImpl constructor(
enabled && caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty(), enabled && caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty(),
unmetered) unmetered)
} }
throttle.run {
scheduleBackgroundSync(
TAG_BACKGROUND_SYNC_OPENTASKS,
SyncOpenTasksWork::class.java,
enabled && (caldavDao.getAccounts(TYPE_OPENTASKS).isNotEmpty() || openTaskDao.accountCount() > 0))
}
} }
private fun scheduleBackgroundSync( private fun scheduleBackgroundSync(

@ -0,0 +1,65 @@
package org.tasks.opentasks
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.caldav.BaseCaldavAccountSettingsActivity
@AndroidEntryPoint
class OpenTaskAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolbar.OnMenuItemClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding!!.userLayout.visibility = View.GONE
binding!!.passwordLayout.visibility = View.GONE
binding!!.urlLayout.visibility = View.GONE
}
override val description: Int
get() = 0
override val newPassword: String?
get() = ""
private suspend fun updateAccount(principal: String?) {
hideProgressIndicator()
caldavAccount!!.name = newName
caldavAccount!!.url = principal
caldavAccount!!.username = newUsername
caldavAccount!!.error = ""
if (passwordChanged()) {
caldavAccount!!.password = encryption.encrypt(newPassword!!)
}
caldavAccount!!.isSuppressRepeatingTasks = binding!!.repeat.isChecked
caldavDao.update(caldavAccount!!)
setResult(Activity.RESULT_OK)
finish()
}
override fun hasChanges() =
newName != caldavAccount!!.name
|| binding!!.repeat.isChecked != caldavAccount!!.isSuppressRepeatingTasks
override fun save() = lifecycleScope.launch {
if (newName.isBlank()) {
binding!!.nameLayout.error = getString(R.string.name_cannot_be_empty)
return@launch
}
updateAccount()
}
override suspend fun addAccount(url: String, username: String, password: String) {}
override suspend fun updateAccount(url: String, username: String, password: String) {}
override suspend fun updateAccount() = updateAccount(caldavAccount!!.url)
override val helpUrl: String
get() = "https://tasks.org/sync"
}

@ -0,0 +1,49 @@
package org.tasks.opentasks
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import org.dmfs.tasks.contract.TaskContract.*
import org.tasks.R
import org.tasks.sync.SyncAdapters
import timber.log.Timber
import javax.inject.Inject
class OpenTaskContentObserver @Inject constructor(
private val syncAdapters: SyncAdapters
) : ContentObserver(getHandler()) {
override fun onChange(selfChange: Boolean) = onChange(selfChange, null)
override fun onChange(selfChange: Boolean, uri: Uri?) {
if (selfChange || uri == null) {
Timber.d("Ignoring onChange(selfChange = $selfChange, uri = $uri)")
return
} else {
Timber.v("onChange($selfChange, $uri)")
}
syncAdapters.syncOpenTasks()
}
companion object {
fun getHandler() = HandlerThread("OT-handler)").let {
it.start()
Handler(it.looper)
}
fun registerObserver(context: Context, observer: ContentObserver) {
getUris(context.getString(R.string.opentasks_authority))
.forEach {
context.contentResolver.registerContentObserver(it, false, observer)
}
}
private fun getUris(authority: String): List<Uri> =
listOf(TaskLists.getContentUri(authority),
Tasks.getContentUri(authority),
Properties.getContentUri(authority))
}
}

@ -0,0 +1,34 @@
package org.tasks.opentasks
import android.os.Bundle
import android.view.View
import android.widget.RelativeLayout
import butterknife.BindView
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.R
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavCalendar
@AndroidEntryPoint
class OpenTasksListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
@BindView(R.id.color_row)
lateinit var colorRow: RelativeLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
toolbar.menu.findItem(R.id.delete).isVisible = false
nameLayout.visibility = View.GONE
colorRow.visibility = View.GONE
}
override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) {}
override suspend fun updateNameAndColor(
account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) =
updateCalendar()
override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) {}
}

@ -0,0 +1,324 @@
package org.tasks.opentasks
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import com.todoroo.andlib.utility.DateUtilities
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY
import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY_TIME
import com.todoroo.astrid.data.Task.Companion.sanitizeRRule
import com.todoroo.astrid.helper.UUIDHelper
import com.todoroo.astrid.service.TaskCreator
import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.property.Geo
import net.fortuna.ical4j.model.property.RRule
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.caldav.CaldavConverter
import org.tasks.caldav.CaldavConverter.toRemote
import org.tasks.caldav.iCalendar
import org.tasks.data.*
import org.tasks.data.OpenTaskDao.Companion.getInt
import org.tasks.data.OpenTaskDao.Companion.getLong
import org.tasks.data.OpenTaskDao.Companion.getString
import org.tasks.date.DateTimeUtils.newDateTime
import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils.currentTimeMillis
import timber.log.Timber
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OpenTasksSynchronizer @Inject constructor(
@ApplicationContext private val context: Context,
private val caldavDao: CaldavDao,
private val taskDeleter: TaskDeleter,
private val localBroadcastManager: LocalBroadcastManager,
private val taskCreator: TaskCreator,
private val taskDao: TaskDao,
private val firebase: Firebase,
private val iCalendar: iCalendar,
private val locationDao: LocationDao,
private val openTaskDao: OpenTaskDao,
private val tagDao: TagDao,
private val tagDataDao: TagDataDao,
private val inventory: Inventory) {
private val cr = context.contentResolver
suspend fun sync() {
val lists = getLists()
caldavDao.getAccounts(CaldavAccount.TYPE_OPENTASKS).forEach { account ->
if (!lists.containsKey(account.uuid)) {
setError(account, context.getString(R.string.account_not_found))
} else if (!inventory.hasPro()) {
setError(account, context.getString(R.string.requires_pro_subscription))
} else {
sync(account, lists[account.uuid]!!)
}
}
}
private suspend fun getLists(): Map<String, List<CaldavCalendar>> =
openTaskDao.getLists().groupBy { it.account!! }
private suspend fun sync(account: CaldavAccount, lists: List<CaldavCalendar>) {
caldavDao
.findDeletedCalendars(account.uuid!!, lists.mapNotNull { it.url })
.forEach { taskDeleter.delete(it) }
lists.forEach {
val calendar = toLocalCalendar(account.uuid!!, it)
sync(calendar, it.ctag, it.id)
}
setError(account, null)
}
private suspend fun toLocalCalendar(account: String, remote: CaldavCalendar): CaldavCalendar {
val local = caldavDao.getCalendarByUrl(account, remote.url!!) ?: CaldavCalendar().apply {
uuid = UUIDHelper.newUUID()
url = remote.url
this.account = account
caldavDao.insert(this)
Timber.d("Created calendar: $this")
}
if (local.name != remote.name || local.color != remote.color) {
local.color = remote.color
local.name = remote.name
caldavDao.update(local)
Timber.d("Updated calendar: $local")
localBroadcastManager.broadcastRefreshList()
}
return local
}
private suspend fun sync(calendar: CaldavCalendar, ctag: String?, listId: Long) {
Timber.d("SYNC $calendar")
caldavDao.getDeleted(calendar.uuid!!).forEach {
openTaskDao.delete(listId, it.`object`!!)
caldavDao.delete(it)
}
taskDao
.getCaldavTasksToPush(calendar.uuid!!)
.mapNotNull { push(it, listId) }
.forEach {
val tags = tagDataDao.getTagDataForTask(it.task).mapNotNull(TagData::name)
openTaskDao.setTags(it, tags)
openTaskDao.setRemoteOrder(it)
openTaskDao.updateParent(it)
it.lastSync = currentTimeMillis()
caldavDao.update(it)
Timber.d("SENT $it")
}
ctag?.let {
if (ctag == calendar.ctag) {
Timber.d("UP TO DATE: $calendar")
return@sync
}
}
val etags = openTaskDao.getEtags(listId)
etags.forEach { (syncId, etag) ->
val caldavTask = caldavDao.getTask(calendar.uuid!!, syncId)
applyChanges(calendar, listId, syncId, etag, caldavTask)
}
removeDeleted(calendar.uuid!!, etags.map { it.first })
calendar.ctag = ctag
Timber.d("UPDATE $calendar")
caldavDao.update(calendar)
caldavDao.updateParents(calendar.uuid!!)
localBroadcastManager.broadcastRefresh()
}
private suspend fun removeDeleted(calendar: String, objects: List<String>) {
caldavDao
.getObjects(calendar)
.subtract(objects)
.takeIf { it.isNotEmpty() }
?.let {
Timber.d("DELETED $it")
taskDeleter.delete(caldavDao.getTasks(calendar, it.toList()))
}
}
private suspend fun setError(account: CaldavAccount, message: String?) {
account.error = message
caldavDao.update(account)
localBroadcastManager.broadcastRefreshList()
if (!message.isNullOrBlank()) {
Timber.e(message)
}
}
private suspend fun push(task: Task, listId: Long): CaldavTask? = withContext(Dispatchers.IO) {
val caldavTask = caldavDao.getTask(task.id) ?: return@withContext null
if (task.isDeleted) {
openTaskDao.delete(listId, caldavTask.`object`!!)
taskDeleter.delete(task)
return@withContext null
}
val values = ContentValues()
values.put(Tasks._SYNC_ID, caldavTask.`object`)
values.put(Tasks.LIST_ID, listId)
values.put(Tasks.TITLE, task.title)
values.put(Tasks.DESCRIPTION, task.notes)
values.put(Tasks.GEO, locationDao.getGeofences(task.id).toGeoString())
values.put(Tasks.RRULE, if (task.isRecurring) {
val rrule = RRule(task.getRecurrenceWithoutFrom()!!.replace("RRULE:", ""))
if (task.repeatUntil > 0) {
rrule.recur.until = DateTime(task.repeatUntil).toUTC().toDateTime()
}
RRule(rrule.value.sanitizeRRule()).value
} else {
null
})
values.put(Tasks.IS_ALLDAY, if (task.hasDueDate() && !task.hasDueTime()) 1 else 0)
values.put(Tasks.DUE, when {
task.hasDueTime() -> newDateTime(task.dueDate).toDateTime().time
task.hasDueDate() -> Date(task.dueDate).time
else -> null
})
values.put(Tasks.COMPLETED_IS_ALLDAY, 0)
values.put(Tasks.COMPLETED, if (task.isCompleted) task.completionDate else null)
values.put(Tasks.STATUS, if (task.isCompleted) Tasks.STATUS_COMPLETED else null)
values.put(Tasks.PERCENT_COMPLETE, if (task.isCompleted) 100 else null)
values.put(Tasks.TZ, if (task.hasDueTime() || task.isCompleted) {
TimeZone.getDefault().id
} else {
null
})
values.put(Tasks.PARENT_ID, null as Long?)
val existing = cr.query(
Tasks.getContentUri(openTaskDao.authority),
arrayOf(Tasks.PRIORITY),
"${Tasks.LIST_ID} = $listId AND ${Tasks._SYNC_ID} = '${caldavTask.`object`}'",
null,
null)?.use {
if (!it.moveToFirst()) {
return@use false
}
values.put(Tasks.PRIORITY, toRemote(it.getInt(Tasks.PRIORITY), task.priority))
true
} ?: false
try {
if (existing) {
val updated = cr.update(
Tasks.getContentUri(openTaskDao.authority),
values,
"${Tasks.LIST_ID} = $listId AND ${Tasks._SYNC_ID} = '${caldavTask.`object`}'",
null)
if (updated <= 0) {
throw Exception("update failed")
}
} else {
values.put(Tasks._UID, caldavTask.remoteId)
values.put(Tasks.PRIORITY, toRemote(task.priority, task.priority))
cr.insert(Tasks.getContentUri(openTaskDao.authority), values)
?: throw Exception("insert returned null")
}
caldavTask
} catch (e: Exception) {
firebase.reportException(e)
return@withContext null
}
}
private suspend fun applyChanges(
calendar: CaldavCalendar,
listId: Long,
syncId: String,
etag: String,
existing: CaldavTask?) {
cr.query(
Tasks.getContentUri(openTaskDao.authority),
null,
"${Tasks.LIST_ID} = $listId AND ${Tasks._SYNC_ID} = '$syncId'",
null,
null)?.use {
if (!it.moveToFirst()) {
return
}
val task: Task
val caldavTask: CaldavTask
if (existing == null) {
task = taskCreator.createWithValues("")
taskDao.createNew(task)
val remoteId = it.getString(Tasks._UID)
caldavTask = CaldavTask(task.id, calendar.uuid, remoteId, syncId)
} else {
task = taskDao.fetch(existing.task)!!
caldavTask = existing
}
if (caldavTask.etag == null || caldavTask.etag != etag) {
task.title = it.getString(Tasks.TITLE)
task.priority = CaldavConverter.fromRemote(it.getInt(Tasks.PRIORITY))
task.completionDate = it.getLong(Tasks.COMPLETED)
task.notes = it.getString(Tasks.DESCRIPTION)
task.modificationDate = currentTimeMillis()
task.creationDate = it.getLong(Tasks.CREATED).toLocal()
task.setDueDateAdjustingHideUntil(it.getLong(Tasks.DUE).let { due ->
when {
due == 0L -> 0
it.getBoolean(Tasks.IS_ALLDAY) ->
Task.createDueDate(URGENCY_SPECIFIC_DAY, due - DateTime(due).offset)
else -> Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, due)
}
})
iCalendar.setPlace(task.id, it.getString(Tasks.GEO).toGeo())
task.setRecurrence(it.getString(Tasks.RRULE).toRRule())
task.suppressSync()
task.suppressRefresh()
taskDao.save(task)
caldavTask.lastSync = DateUtilities.now() + 1000L
caldavTask.etag = etag
}
val tags = openTaskDao.getTags(caldavTask)
tagDao.applyTags(task, tagDataDao, iCalendar.getTags(tags))
caldavTask.order = openTaskDao.getRemoteOrder(caldavTask)
caldavTask.remoteParent = openTaskDao.getParent(it.getLong(Tasks._ID))
if (caldavTask.id == Task.NO_ID) {
caldavTask.id = caldavDao.insert(caldavTask)
Timber.d("NEW $caldavTask")
} else {
caldavDao.update(caldavTask)
Timber.d("UPDATE $caldavTask")
}
}
}
companion object {
private fun Location?.toGeoString(): String? = this?.let { "$longitude,$latitude" }
private fun String?.toGeo(): Geo? =
this
?.takeIf { it.isNotBlank() }
?.split(",")
?.takeIf {
it.size == 2
&& it[0].toDoubleOrNull() != null
&& it[1].toDoubleOrNull() != null }
?.let { Geo("${it[1]};${it[0]}") }
private fun String?.toRRule(): RRule? =
this?.takeIf { it.isNotBlank() }?.let(::RRule)
private fun Cursor.getBoolean(columnName: String): Boolean =
getInt(getColumnIndex(columnName)) != 0
private fun Long.toLocal(): Long =
DateTime(this).toLocal().millis
}
}

@ -15,7 +15,7 @@ public class PermissionChecker {
private final Context context; private final Context context;
@Inject @Inject
PermissionChecker(@ApplicationContext Context context) { public PermissionChecker(@ApplicationContext Context context) {
this.context = context; this.context = context;
} }

@ -205,6 +205,10 @@ class Advanced : InjectingPreferenceFragment() {
.newDialog() .newDialog()
.setMessage(R.string.EPr_delete_task_data_warning) .setMessage(R.string.EPr_delete_task_data_warning)
.setPositiveButton(R.string.EPr_delete_task_data) { _, _ -> .setPositiveButton(R.string.EPr_delete_task_data) { _, _ ->
context?.let {
it.deleteDatabase(database.name)
it.deleteDatabase("tasks.db") // opentasks
}
requireContext().deleteDatabase(database.name) requireContext().deleteDatabase(database.name)
restart() restart()
} }

@ -17,22 +17,26 @@ import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.caldav.BaseCaldavAccountSettingsActivity import org.tasks.caldav.BaseCaldavAccountSettingsActivity
import org.tasks.caldav.CaldavAccountSettingsActivity import org.tasks.caldav.CaldavAccountSettingsActivity
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavAccount.Companion.TYPE_LOCAL import org.tasks.data.CaldavAccount.Companion.TYPE_LOCAL
import org.tasks.data.CaldavDao import org.tasks.data.CaldavDao
import org.tasks.data.GoogleTaskAccount import org.tasks.data.GoogleTaskAccount
import org.tasks.data.GoogleTaskListDao import org.tasks.data.GoogleTaskListDao
import org.tasks.data.OpenTaskDao
import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_DAVx5
import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_ETESYNC
import org.tasks.etesync.EteSyncAccountSettingsActivity import org.tasks.etesync.EteSyncAccountSettingsActivity
import org.tasks.injection.InjectingPreferenceFragment import org.tasks.injection.InjectingPreferenceFragment
import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager
import org.tasks.opentasks.OpenTaskAccountSettingsActivity
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.sync.AddAccountDialog import org.tasks.sync.AddAccountDialog.Companion.newAccountDialog
import org.tasks.sync.SyncAdapters import org.tasks.sync.SyncAdapters
import javax.inject.Inject import javax.inject.Inject
const val REQUEST_CALDAV_SETTINGS = 10013 const val REQUEST_CALDAV_SETTINGS = 10013
const val REQUEST_GOOGLE_TASKS = 10014 const val REQUEST_GOOGLE_TASKS = 10014
private const val FRAG_TAG_ADD_ACCOUNT = "frag_tag_add_account" private const val FRAG_TAG_ADD_ACCOUNT = "frag_tag_add_account"
private const val REQUEST_ADD_ACCOUNT = 10015
@AndroidEntryPoint @AndroidEntryPoint
class Synchronization : InjectingPreferenceFragment() { class Synchronization : InjectingPreferenceFragment() {
@ -43,6 +47,7 @@ class Synchronization : InjectingPreferenceFragment() {
@Inject lateinit var googleTaskListDao: GoogleTaskListDao @Inject lateinit var googleTaskListDao: GoogleTaskListDao
@Inject lateinit var taskDeleter: TaskDeleter @Inject lateinit var taskDeleter: TaskDeleter
@Inject lateinit var syncAdapters: SyncAdapters @Inject lateinit var syncAdapters: SyncAdapters
@Inject lateinit var openTaskDao: OpenTaskDao
override fun getPreferenceXml() = R.xml.preferences_synchronization override fun getPreferenceXml() = R.xml.preferences_synchronization
@ -80,11 +85,25 @@ class Synchronization : InjectingPreferenceFragment() {
findPreference(R.string.add_account) findPreference(R.string.add_account)
.setOnPreferenceClickListener { .setOnPreferenceClickListener {
AddAccountDialog().show(parentFragmentManager, FRAG_TAG_ADD_ACCOUNT) lifecycleScope.launch {
val accounts = openTaskDao.accounts().filter {
caldavDao.getAccountByUuid(it) == null
}
newAccountDialog(this@Synchronization, REQUEST_ADD_ACCOUNT, accounts)
.show(parentFragmentManager, FRAG_TAG_ADD_ACCOUNT)
}
false false
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_ADD_ACCOUNT) {
refresh()
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -131,7 +150,7 @@ class Synchronization : InjectingPreferenceFragment() {
} }
private suspend fun addCaldavAccounts(category: PreferenceCategory): Boolean { private suspend fun addCaldavAccounts(category: PreferenceCategory): Boolean {
val accounts: List<CaldavAccount> = caldavDao.getAccounts().filter { val accounts = caldavDao.getAccounts().filter {
it.accountType != TYPE_LOCAL it.accountType != TYPE_LOCAL
} }
for (account in accounts) { for (account in accounts) {
@ -139,18 +158,27 @@ class Synchronization : InjectingPreferenceFragment() {
preference.title = account.name preference.title = account.name
val error = account.error val error = account.error
if (isNullOrEmpty(error)) { if (isNullOrEmpty(error)) {
preference.setSummary( preference.setSummary(when {
if (account.isCaldavAccount) R.string.caldav else R.string.etesync account.isCaldavAccount -> R.string.caldav
) account.isEteSyncAccount
|| (account.isOpenTasks
&& account.uuid?.startsWith(ACCOUNT_TYPE_ETESYNC) == true) ->
R.string.etesync
account.isOpenTasks
&& account.uuid?.startsWith(ACCOUNT_TYPE_DAVx5) == true ->
R.string.davx5
else -> 0
})
} else { } else {
preference.summary = error preference.summary = error
} }
preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { preference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
val intent = Intent( val intent = Intent(context, when {
context, account.isCaldavAccount -> CaldavAccountSettingsActivity::class.java
if (account.isCaldavAccount) CaldavAccountSettingsActivity::class.java account.isEteSyncAccount -> EteSyncAccountSettingsActivity::class.java
else EteSyncAccountSettingsActivity::class.java account.isOpenTasks -> OpenTaskAccountSettingsActivity::class.java
) else -> throw IllegalArgumentException("Unexpected account type: $account")
})
intent.putExtra(BaseCaldavAccountSettingsActivity.EXTRA_CALDAV_DATA, account) intent.putExtra(BaseCaldavAccountSettingsActivity.EXTRA_CALDAV_DATA, account)
startActivityForResult(intent, REQUEST_CALDAV_SETTINGS) startActivityForResult(intent, REQUEST_CALDAV_SETTINGS)
false false

@ -1,5 +1,6 @@
package org.tasks.sync package org.tasks.sync
import android.app.Activity.RESULT_OK
import android.app.Dialog import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@ -10,12 +11,20 @@ import android.widget.ArrayAdapter
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R import org.tasks.R
import org.tasks.caldav.CaldavAccountSettingsActivity import org.tasks.caldav.CaldavAccountSettingsActivity
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao
import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_DAVx5
import org.tasks.data.OpenTaskDao.Companion.ACCOUNT_TYPE_ETESYNC
import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.DialogBuilder
import org.tasks.etesync.EteSyncAccountSettingsActivity import org.tasks.etesync.EteSyncAccountSettingsActivity
import org.tasks.jobs.WorkManager
import org.tasks.preferences.fragments.REQUEST_CALDAV_SETTINGS import org.tasks.preferences.fragments.REQUEST_CALDAV_SETTINGS
import org.tasks.preferences.fragments.REQUEST_GOOGLE_TASKS import org.tasks.preferences.fragments.REQUEST_GOOGLE_TASKS
import org.tasks.themes.DrawableUtil import org.tasks.themes.DrawableUtil
@ -25,16 +34,36 @@ import javax.inject.Inject
class AddAccountDialog : DialogFragment() { class AddAccountDialog : DialogFragment() {
@Inject lateinit var dialogBuilder: DialogBuilder @Inject lateinit var dialogBuilder: DialogBuilder
@Inject lateinit var caldavDao: CaldavDao
@Inject lateinit var syncAdapters: SyncAdapters
@Inject lateinit var workManager: WorkManager
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val services = requireActivity().resources.getStringArray(R.array.synchronization_services) val services = requireActivity().resources.getStringArray(R.array.synchronization_services).toMutableList()
val descriptions = requireActivity().resources.getStringArray(R.array.synchronization_services_description) val descriptions = requireActivity().resources.getStringArray(R.array.synchronization_services_description).toMutableList()
val typedArray = requireActivity().resources.obtainTypedArray(R.array.synchronization_services_icons) val icons = arrayListOf(
val icons = IntArray(typedArray.length()) R.drawable.ic_google,
for (i in icons.indices) { R.drawable.ic_webdav_logo,
icons[i] = typedArray.getResourceId(i, 0) R.drawable.ic_etesync
)
val types = arrayListOf("", "", "")
requireArguments().getStringArrayList(EXTRA_ACCOUNTS)?.forEach { account ->
val (type, name) = account.split(":")
when (type) {
ACCOUNT_TYPE_DAVx5 -> {
services.add(name)
descriptions.add(getString(R.string.davx5))
types.add(ACCOUNT_TYPE_DAVx5)
icons.add(R.drawable.ic_davx5_icon_green_bg)
}
ACCOUNT_TYPE_ETESYNC -> {
services.add(name)
descriptions.add(getString(R.string.etesync))
types.add(ACCOUNT_TYPE_ETESYNC)
icons.add(R.drawable.ic_etesync)
}
}
} }
typedArray.recycle()
val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>( val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>(
requireActivity(), R.layout.simple_list_item_2_themed, R.id.text1, services) { requireActivity(), R.layout.simple_list_item_2_themed, R.id.text1, services) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
@ -54,17 +83,38 @@ class AddAccountDialog : DialogFragment() {
.setTitle(R.string.choose_synchronization_service) .setTitle(R.string.choose_synchronization_service)
.setSingleChoiceItems(adapter, -1) { dialog, which -> .setSingleChoiceItems(adapter, -1) { dialog, which ->
when (which) { when (which) {
0 -> activity?.startActivityForResult( 0 -> {
Intent(activity, GtasksLoginActivity::class.java), activity?.startActivityForResult(
REQUEST_GOOGLE_TASKS) Intent(activity, GtasksLoginActivity::class.java),
1 -> activity?.startActivityForResult( REQUEST_GOOGLE_TASKS)
Intent(activity, CaldavAccountSettingsActivity::class.java), dialog.dismiss()
REQUEST_CALDAV_SETTINGS) }
2 -> activity?.startActivityForResult( 1 -> {
Intent(activity, EteSyncAccountSettingsActivity::class.java), activity?.startActivityForResult(
REQUEST_CALDAV_SETTINGS) Intent(activity, CaldavAccountSettingsActivity::class.java),
REQUEST_CALDAV_SETTINGS)
dialog.dismiss()
}
2 -> {
activity?.startActivityForResult(
Intent(activity, EteSyncAccountSettingsActivity::class.java),
REQUEST_CALDAV_SETTINGS)
dialog.dismiss()
}
else -> {
lifecycleScope.launch {
caldavDao.insert(CaldavAccount().apply {
name = services[which]
uuid = "${types[which]}:${name}"
accountType = CaldavAccount.TYPE_OPENTASKS
})
syncAdapters.sync(true)
workManager.updateBackgroundSync()
dialog.dismiss()
targetFragment?.onActivityResult(targetRequestCode, RESULT_OK, null)
}
}
} }
dialog.dismiss()
} }
.setNeutralButton(R.string.help) { _, _ -> .setNeutralButton(R.string.help) { _, _ ->
activity?.startActivity(Intent( activity?.startActivity(Intent(
@ -75,4 +125,18 @@ class AddAccountDialog : DialogFragment() {
.show() .show()
} }
companion object {
private const val EXTRA_ACCOUNTS = "extra_accounts"
fun newAccountDialog(
targetFragment: Fragment, rc: Int, openTaskAccounts: List<String>
): AddAccountDialog {
val dialog = AddAccountDialog()
dialog.arguments = Bundle().apply {
putStringArrayList(EXTRA_ACCOUNTS, ArrayList(openTaskAccounts))
}
dialog.setTargetFragment(targetFragment, rc)
return dialog
}
}
} }

@ -5,6 +5,7 @@ import com.todoroo.astrid.data.Task
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV
import org.tasks.data.CaldavAccount.Companion.TYPE_ETESYNC import org.tasks.data.CaldavAccount.Companion.TYPE_ETESYNC
import org.tasks.data.CaldavAccount.Companion.TYPE_OPENTASKS
import org.tasks.data.CaldavDao import org.tasks.data.CaldavDao
import org.tasks.data.GoogleTaskDao import org.tasks.data.GoogleTaskDao
import org.tasks.data.GoogleTaskListDao import org.tasks.data.GoogleTaskListDao
@ -13,6 +14,7 @@ import org.tasks.jobs.WorkManager
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_CALDAV import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_CALDAV
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_OPENTASK
import java.util.concurrent.Executors.newSingleThreadExecutor import java.util.concurrent.Executors.newSingleThreadExecutor
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -22,11 +24,13 @@ class SyncAdapters @Inject constructor(
workManager: WorkManager, workManager: WorkManager,
private val caldavDao: CaldavDao, private val caldavDao: CaldavDao,
private val googleTaskDao: GoogleTaskDao, private val googleTaskDao: GoogleTaskDao,
private val googleTaskListDao: GoogleTaskListDao) { private val googleTaskListDao: GoogleTaskListDao,
private val openTaskDao: OpenTaskDao) {
private val scope = CoroutineScope(newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob()) private val scope = CoroutineScope(newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob())
private val googleTasks = Debouncer(TAG_SYNC_GOOGLE_TASKS) { workManager.googleTaskSync(it) } private val googleTasks = Debouncer(TAG_SYNC_GOOGLE_TASKS) { workManager.googleTaskSync(it) }
private val caldav = Debouncer(TAG_SYNC_CALDAV) { workManager.caldavSync(it) } private val caldav = Debouncer(TAG_SYNC_CALDAV) { workManager.caldavSync(it) }
private val eteSync = Debouncer(TAG_SYNC_ETESYNC) { workManager.eteSync(it) } private val eteSync = Debouncer(TAG_SYNC_ETESYNC) { workManager.eteSync(it) }
private val opentasks = Debouncer(TAG_SYNC_OPENTASK) { workManager.openTaskSync() }
fun sync(task: Task, original: Task?) = scope.launch { fun sync(task: Task, original: Task?) = scope.launch {
if (task.checkTransitory(SyncFlags.SUPPRESS_SYNC)) { if (task.checkTransitory(SyncFlags.SUPPRESS_SYNC)) {
@ -43,9 +47,16 @@ class SyncAdapters @Inject constructor(
if (caldavDao.isAccountType(task.id, TYPE_ETESYNC)) { if (caldavDao.isAccountType(task.id, TYPE_ETESYNC)) {
eteSync.sync(false) eteSync.sync(false)
} }
if (caldavDao.isAccountType(task.id, TYPE_OPENTASKS)) {
opentasks.sync(false)
}
} }
} }
fun syncOpenTasks() = scope.launch {
opentasks.sync(false)
}
fun sync() { fun sync() {
sync(false) sync(false)
} }
@ -54,6 +65,7 @@ class SyncAdapters @Inject constructor(
val googleTasksEnabled = async { isGoogleTaskSyncEnabled() } val googleTasksEnabled = async { isGoogleTaskSyncEnabled() }
val caldavEnabled = async { isCaldavSyncEnabled() } val caldavEnabled = async { isCaldavSyncEnabled() }
val eteSyncEnabled = async { isEteSyncEnabled() } val eteSyncEnabled = async { isEteSyncEnabled() }
val opentasksEnabled = async { isOpenTaskSyncEnabled() }
if (googleTasksEnabled.await()) { if (googleTasksEnabled.await()) {
googleTasks.sync(immediate) googleTasks.sync(immediate)
@ -67,6 +79,9 @@ class SyncAdapters @Inject constructor(
eteSync.sync(immediate) eteSync.sync(immediate)
} }
if (opentasksEnabled.await()) {
opentasks.sync(immediate)
}
} }
private suspend fun isGoogleTaskSyncEnabled() = googleTaskListDao.getAccounts().isNotEmpty() private suspend fun isGoogleTaskSyncEnabled() = googleTaskListDao.getAccounts().isNotEmpty()
@ -75,4 +90,7 @@ class SyncAdapters @Inject constructor(
private suspend fun isEteSyncEnabled() = caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty() private suspend fun isEteSyncEnabled() = caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty()
private suspend fun isOpenTaskSyncEnabled() =
caldavDao.getAccounts(TYPE_OPENTASKS).isNotEmpty()
|| openTaskDao.accountCount() > 0
} }

@ -163,10 +163,18 @@ public class DateTime {
return startOfDay().setTime(hours, minutes, seconds, millisOfDay); return startOfDay().setTime(hours, minutes, seconds, millisOfDay);
} }
public long getOffset() {
return timeZone.getOffset(timestamp);
}
public long getMillis() { public long getMillis() {
return timestamp; return timestamp;
} }
public TimeZone getTimeZone() {
return timeZone;
}
public int getMillisOfDay() { public int getMillisOfDay() {
Calendar calendar = getCalendar(); Calendar calendar = getCalendar();
long millisOfDay = long millisOfDay =
@ -338,6 +346,10 @@ public class DateTime {
return calendar; return calendar;
} }
public net.fortuna.ical4j.model.DateTime toDateTime() {
return timestamp == 0 ? null : new net.fortuna.ical4j.model.DateTime(timestamp);
}
public DateValue toDateValue() { public DateValue toDateValue() {
return timestamp == 0 ? null : new DateValueImpl(getYear(), getMonthOfYear(), getDayOfMonth()); return timestamp == 0 ? null : new DateValueImpl(getYear(), getMonthOfYear(), getDayOfMonth());
} }

@ -0,0 +1,135 @@
<vector android:height="24dp" android:viewportHeight="1024"
android:viewportWidth="1024" android:width="24dp"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#77A646" android:pathData="M887.67,957.05L140.08,957.05c-39.35,0 -71.25,-31.9 -71.25,-71.25L68.82,138.2c0,-39.35 31.9,-71.25 71.25,-71.25l747.6,0c39.35,0 71.25,31.9 71.25,71.25l0,747.6c0,39.35 -31.9,71.25 -71.25,71.25z"/>
<path android:fillColor="#A2CF6E" android:pathData="M887.67,918.68L140.08,918.68c-39.35,0 -71.25,-31.9 -71.25,-71.25L68.82,138.2c0,-39.35 31.9,-71.25 71.25,-71.25l747.6,0c39.35,0 71.25,31.9 71.25,71.25l0,709.23c0,39.35 -31.9,71.25 -71.25,71.25z"/>
<path android:fillColor="#8BC34A" android:pathData="M887.67,935.13L140.08,935.13c-39.35,0 -71.25,-31.9 -71.25,-71.25L68.82,160.12c0,-39.35 31.9,-71.25 71.25,-71.25l747.6,0c39.35,0 71.25,31.9 71.25,71.25l0,703.75c0,39.35 -31.9,71.25 -71.25,71.25z"/>
<group>
<clip-path android:pathData="M887.67,935.13L140.08,935.13c-39.35,0 -71.25,-31.9 -71.25,-71.25L68.82,160.12c0,-39.35 31.9,-71.25 71.25,-71.25l747.6,0c39.35,0 71.25,31.9 71.25,71.25l0,703.75c0,39.35 -31.9,71.25 -71.25,71.25z"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m201.18,550.35h27.7l707.11,707.11h-27.7z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m228.88,550.35c25.78,0 44.44,-13.44 44.44,-44.99l707.11,707.11c0,31.54 -18.65,44.99 -44.44,44.99z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m273.32,505.36c0,-14.75 -4.08,-25.36 -11.2,-32.48l707.11,707.11c7.12,7.12 11.2,17.73 11.2,32.48z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m262.12,472.88c-8.11,-8.11 -20.15,-11.68 -34.61,-11.68l707.11,707.11c14.46,0 26.51,3.58 34.61,11.68z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m227.51,461.2h-26.33l707.11,707.11h26.33z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="M224.77,531.42L224.77,480.13L931.88,1187.23v51.29z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m224.77,480.13h1.37l707.11,707.11h-1.37z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m226.14,480.13c6.78,0 12.8,1.21 16.99,5.4l707.11,707.11c-4.19,-4.19 -10.21,-5.4 -16.99,-5.4z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m243.13,485.53c3.77,3.77 6.05,9.95 6.05,19.84l707.11,707.11c0,-9.88 -2.28,-16.06 -6.05,-19.84z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m249.18,505.36c0,20.85 -10.15,26.06 -23.04,26.06l707.11,707.11c12.89,0 23.04,-5.21 23.04,-26.06z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m201.18,550.35h27.7c25.78,0 44.44,-13.44 44.44,-44.99 0,-31.54 -18.65,-44.16 -45.81,-44.16h-26.33zM224.77,531.42v-51.29h1.37c12.89,0 23.04,4.39 23.04,25.24 0,20.85 -10.15,26.06 -23.04,26.06z"
android:strokeAlpha="0.2" android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m282.62,280.51 l-85.53,-85.53 707.11,707.11 85.53,85.53z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m197.09,194.98 l-0,228.08 707.11,707.11 0,-228.08z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="M197.09,423.06L425.17,423.06L1132.28,1130.17L904.2,1130.17Z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m425.17,423.06 l-85.53,-85.53 707.11,707.11 85.53,85.53z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m339.64,337.53c94.37,-94.37 247.76,-94.37 342.12,0L1388.88,1044.63c-94.37,-94.37 -247.76,-94.37 -342.12,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m681.77,337.53c1.95,1.95 3.86,3.85 5.73,5.72l707.11,707.11c-1.87,-1.87 -3.78,-3.77 -5.73,-5.72z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m687.49,343.25c25.77,25.77 44.04,44.85 54.15,78.88l707.11,707.11c-10.1,-34.02 -28.38,-53.11 -54.15,-78.88z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m741.64,422.13h83.25l707.11,707.11h-83.25z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="M824.89,422.13C812.06,364.82 783.55,325.27 738.79,280.51l707.11,707.11c44.76,44.76 73.27,84.31 86.1,141.62z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m738.79,280.51c-0,-0 -0,-0 -0,-0l707.11,707.11c0,0 0,0 0,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m738.79,280.51c0,0 -0,-0 -0,-0l707.11,707.11c0,0 0,0 0,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m738.79,280.51c-126.02,-126.02 -330.15,-126.02 -456.17,0l707.11,707.11c126.02,-126.02 330.15,-126.02 456.17,-0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m681.77,679.65c-94.37,94.37 -247.76,94.37 -342.12,0l707.11,707.11c94.37,94.37 247.76,94.37 342.12,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m339.64,679.65c-0,-0 -0,-0 -0,-0l707.11,707.11c0,0 0,0 0,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m339.64,679.65c-1.95,-1.95 -3.86,-3.85 -5.73,-5.72l707.11,707.11c1.87,1.87 3.78,3.78 5.73,5.72z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m333.91,673.93c-25.76,-25.76 -44.04,-44.85 -54.14,-78.88l707.11,707.11c10.1,34.02 28.38,53.11 54.14,78.88z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m279.77,595.05h-83.25l707.11,707.11h83.25z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m196.52,595.05c12.83,57.31 41.34,96.86 86.1,141.62l707.11,707.11c-44.76,-44.76 -73.27,-84.32 -86.1,-141.62z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m282.62,736.67c0,0 0,0 0,0L989.73,1443.78c-0,0 -0,-0 -0,-0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m282.62,736.67c0,0 0,0 0,0L989.73,1443.78c-0,0 -0,0 -0,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m282.62,736.67c126.02,126.02 330.15,126.02 456.17,-0L1445.9,1443.78c-126.02,126.02 -330.15,126.02 -456.17,0z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m738.79,736.67 l85.53,85.53 707.11,707.11 -85.53,-85.53z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m824.32,822.2 l0,-228.08 707.11,707.11v228.08z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="M824.32,594.12L596.24,594.12l707.11,707.11h228.08z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:pathData="m282.62,280.51 l-85.53,-85.53 -0,228.08h228.08l-85.53,-85.53c94.37,-94.37 247.76,-94.37 342.12,0 28.8,28.8 49.04,48.11 59.87,84.6h83.25c-12.83,-57.31 -41.34,-96.86 -86.1,-141.62 -126.02,-126.02 -330.15,-126.02 -456.17,0zM681.77,679.65c-94.37,94.37 -247.76,94.37 -342.12,0 -28.8,-28.8 -49.04,-48.11 -59.87,-84.6h-83.25c12.83,57.31 41.34,96.86 86.1,141.62 126.02,126.02 330.15,126.02 456.17,0l85.53,85.53 0,-228.08L596.24,594.12Z"
android:strokeAlpha="0.2" android:strokeWidth="40.319767"/>
</group>
<path android:fillColor="#ffffff"
android:pathData="m282.78,280.51 l-85.53,-85.53 -0,228.08h228.08l-85.53,-85.53c94.37,-94.37 247.76,-94.37 342.12,0 28.8,28.8 49.04,48.11 59.87,84.6h83.25c-12.83,-57.31 -41.34,-96.86 -86.1,-141.62 -126.02,-126.02 -330.15,-126.02 -456.17,0zM681.93,679.65c-94.37,94.37 -247.76,94.37 -342.12,0 -28.8,-28.8 -49.04,-48.11 -59.87,-84.6h-83.25c12.83,57.31 41.34,96.86 86.1,141.62 126.02,126.02 330.15,126.02 456.17,0l85.53,85.53 0,-228.08L596.4,594.12Z" android:strokeWidth="40.319767"/>
<path android:fillColor="#ffffff"
android:pathData="m201.34,550.35h27.7c25.78,0 44.44,-13.44 44.44,-44.99 0,-31.54 -18.65,-44.16 -45.81,-44.16h-26.33zM224.93,531.42v-51.29h1.37c12.89,0 23.04,4.39 23.04,25.24 0,20.85 -10.15,26.06 -23.04,26.06z"
android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillColor="#ffffff"
android:pathData="m311.14,507.01c2.19,-8.5 4.39,-19.2 6.31,-28.25h0.55c2.19,8.91 4.39,19.75 6.58,28.25l1.51,6.17L309.63,513.18ZM276.85,550.35h24.14l4.39,-18.93h24.96l4.39,18.93h24.96l-27.16,-89.15h-28.53z"
android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillColor="#ffffff"
android:pathData="m380.57,550.35h28.53l26.33,-89.15h-24.14l-9.05,38.95c-2.33,9.46 -4.11,18.65 -6.58,28.25h-0.55c-2.47,-9.6 -4.11,-18.79 -6.58,-28.25l-9.33,-38.95h-24.96z"
android:strokeColor="#00000000" android:strokeWidth="3.42874193"/>
<path android:fillColor="#ffffff"
android:pathData="m449.65,601.24h39.36l6.19,-15.48c2.43,-6.41 5.09,-12.82 7.52,-19.01h0.88c3.32,6.19 6.41,12.82 9.73,19.01l8.84,15.48h40.68l-33.17,-53.06 31.4,-57.49h-39.36l-5.31,15.48c-1.99,6.19 -4.64,12.82 -6.63,19.01h-0.88c-2.87,-6.19 -5.97,-12.82 -8.84,-19.01l-7.96,-15.48h-40.68l31.4,53.06z"
android:strokeColor="#00000000" android:strokeWidth="5.52750778"/>
<path android:fillColor="#ffffff"
android:pathData="m601.96,514.52c18.54,0 34.49,-11.78 34.49,-32.19 0,-19.26 -13.51,-28.17 -29.32,-28.17 -3.02,0 -5.46,0.29 -8.62,1.44l1.15,-13.22h32.77v-20.69h-54.04l-2.3,46.85 10.63,6.9c5.46,-3.45 7.76,-4.31 12.65,-4.31 7.19,0 12.36,4.02 12.36,11.78 0,8.05 -4.89,11.78 -13.51,11.78 -6.61,0 -12.93,-3.74 -18.4,-8.62l-10.92,15.52c7.47,7.47 18.11,12.93 33.05,12.93z"
android:strokeColor="#00000000" android:strokeWidth="5.52750778"/>
</vector>

@ -1,7 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string-array name="changelog"> <string-array name="changelog">
<item>Fix Google Task bugs</item> <item>🚧 ALPHA VERSION 🚧</item>
<item>Join Tasks on Reddit: https://reddit.com/r/tasks</item> <item>PRO: DAVx⁵ support (alpha requires custom DAVx⁵ build)</item>
<item>PRO: EteSync client support (alpha requires custom EteSync client build)</item>
<item>ToDo Agenda integration</item>
<item>Changed backstack behavior to follow Android conventions</item>
<item>Major internal changes! Please report any bugs!</item>
<item>Remove Mapbox tiles (Google Play only)</item>
<item>Added \'Astrid manual sort\' information to backup file</item>
<item>Bug fixes</item>
<item>Performance improvements</item>
<item>Security improvements</item>
<item>Update translations</item>
<item>Find Tasks on Reddit: https://reddit.com/r/tasks</item>
</string-array> </string-array>
</resources> </resources>

@ -4,10 +4,10 @@
These should not be translated These should not be translated
--> -->
<resources> <resources>
<string name="FSA_label">Tasks Shortcut</string> <string name="FSA_label">Tasks Shortcut</string>
<string name="caldav">CalDAV</string> <string name="caldav">CalDAV</string>
<string name="etesync">EteSync</string> <string name="etesync">EteSync</string>
<string name="davx5">DAVx⁵</string>
<string name="etesync_url">https://api.etesync.com</string> <string name="etesync_url">https://api.etesync.com</string>
<string name="help_url_sync">https://tasks.org/sync</string> <string name="help_url_sync">https://tasks.org/sync</string>
@ -336,6 +336,7 @@
<string name="p_sync_ongoing_google_tasks">sync_ongoing_google_tasks</string> <string name="p_sync_ongoing_google_tasks">sync_ongoing_google_tasks</string>
<string name="p_sync_ongoing_caldav">sync_ongoing_caldav</string> <string name="p_sync_ongoing_caldav">sync_ongoing_caldav</string>
<string name="p_sync_ongoing_etesync">sync_ongoing_etesync</string> <string name="p_sync_ongoing_etesync">sync_ongoing_etesync</string>
<string name="p_sync_ongoing_opentasks">sync_ongoing_opentasks</string>
<string name="p_last_backup">last_backup</string> <string name="p_last_backup">last_backup</string>
<string name="p_show_description">show_description</string> <string name="p_show_description">show_description</string>
<string name="p_show_full_description">show_full_description</string> <string name="p_show_full_description">show_full_description</string>

@ -635,4 +635,5 @@ File %1$s contained %2$s.\n\n
<string name="lists">Lists</string> <string name="lists">Lists</string>
<string name="reset_sort_order">Reset sort order</string> <string name="reset_sort_order">Reset sort order</string>
<string name="permission_read_tasks">Full access to Tasks database</string> <string name="permission_read_tasks">Full access to Tasks database</string>
<string name="account_not_found">Account not found</string>
</resources> </resources>

Loading…
Cancel
Save