Sync with opentasks-provider

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

@ -1,6 +1,42 @@
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)
* Fix Google Task bugs

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

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

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

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

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

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

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

@ -38,7 +38,7 @@ class iCalendar @Inject constructor(
private val caldavDao: CaldavDao) {
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? ->
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.LocalListSettingsActivity
import org.tasks.etesync.EteSyncCalendarSettingsActivity
import org.tasks.opentasks.OpenTasksListSettingsActivity
import org.tasks.security.KeyStoreEncryption
@Entity(tableName = "caldav_accounts")
@ -85,9 +86,13 @@ class CaldavAccount : Parcelable {
val isEteSyncAccount: Boolean
get() = accountType == TYPE_ETESYNC
val isOpenTasks: Boolean
get() = accountType == TYPE_OPENTASKS
fun listSettingsClass(): Class<out BaseListSettingsActivity> = when(accountType) {
TYPE_ETESYNC -> EteSyncCalendarSettingsActivity::class.java
TYPE_LOCAL -> LocalListSettingsActivity::class.java
TYPE_OPENTASKS -> OpenTasksListSettingsActivity::class.java
else -> CaldavCalendarSettingsActivity::class.java
}
@ -149,6 +154,7 @@ class CaldavAccount : Parcelable {
const val TYPE_CALDAV = 0
const val TYPE_ETESYNC = 1
const val TYPE_LOCAL = 2
const val TYPE_OPENTASKS = 3
@JvmField val CREATOR: Parcelable.Creator<CaldavAccount> = object : Parcelable.Creator<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.withLock
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.db.SuspendDbUtils.chunkedMap
import org.tasks.filters.CaldavFilters
@ -158,7 +160,7 @@ SELECT EXISTS(SELECT 1
@Query("SELECT * FROM caldav_lists WHERE cdl_uuid = :uuid LIMIT 1")
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>
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)")
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)")
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>)
@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 existing = HashSet(tagDataDao.getTagDataForTask(taskId))
val selected = HashSet<TagData>(current)
@ -38,7 +38,6 @@ abstract class TagDao {
val removed = existing subtract selected
deleteTags(taskId, removed.map { td -> td.remoteId!! })
insert(task, added)
return removed.isNotEmpty() || added.isNotEmpty()
}
suspend fun insert(task: Task, tags: Collection<TagData>) {

@ -68,7 +68,8 @@ abstract class TaskDao(private val database: Database) {
FROM tasks
INNER JOIN caldav_tasks ON tasks._id = caldav_tasks.cd_task
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>
@Query("SELECT * FROM TASKS "

@ -17,6 +17,7 @@ import org.tasks.billing.Inventory
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.*
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.location.LocationPickerActivity
import org.tasks.preferences.HelpAndFeedback
@ -225,7 +226,7 @@ class FilterProvider @Inject constructor(
caldavDao.getAccounts()
.ifEmpty { listOf(caldavDao.setupLocalAccount(context)) }
.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> =
listOf(

@ -12,6 +12,7 @@ import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.data.CaldavDao
import org.tasks.data.GoogleTaskListDao
import org.tasks.data.OpenTaskDao
import org.tasks.db.Migrations
import org.tasks.jobs.WorkManager
import org.tasks.jobs.WorkManagerImpl
@ -41,7 +42,8 @@ internal class ProductionModule {
@ApplicationContext context: Context,
preferences: Preferences,
googleTaskListDao: GoogleTaskListDao,
caldavDao: CaldavDao): WorkManager {
return WorkManagerImpl(context, preferences, googleTaskListDao, caldavDao)
caldavDao: 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 openTaskSync()
fun reverseGeocode(place: Place)
@ -48,9 +49,11 @@ interface WorkManager {
const val TAG_SYNC_GOOGLE_TASKS = "tag_sync_google_tasks"
const val TAG_SYNC_CALDAV = "tag_sync_caldav"
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_CALDAV = "tag_background_sync_caldav"
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"
}
}

@ -14,8 +14,10 @@ import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV
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.GoogleTaskListDao
import org.tasks.data.OpenTaskDao
import org.tasks.data.Place
import org.tasks.date.DateTimeUtils.midnight
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_ETESYNC
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_MIDNIGHT_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_ETESYNC
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.preferences.Preferences
import org.tasks.time.DateTimeUtils
@ -43,7 +47,8 @@ class WorkManagerImpl constructor(
private val context: Context,
private val preferences: Preferences,
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 alarmManager: AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private val workManager = androidx.work.WorkManager.getInstance(context)
@ -76,6 +81,9 @@ class WorkManagerImpl constructor(
override fun eteSync(immediate: Boolean) =
sync(immediate, TAG_SYNC_ETESYNC, SyncEteSyncWork::class.java)
override fun openTaskSync() =
sync(true, TAG_SYNC_OPENTASK, SyncOpenTasksWork::class.java, false)
@SuppressLint("EnqueueWork")
private fun sync(immediate: Boolean, tag: String, c: Class<out SyncWork>, requireNetwork: Boolean = true) {
Timber.d("sync(immediate = $immediate, $tag, $c, requireNetwork = $requireNetwork)")
@ -139,6 +147,12 @@ class WorkManagerImpl constructor(
enabled && caldavDao.getAccounts(TYPE_ETESYNC).isNotEmpty(),
unmetered)
}
throttle.run {
scheduleBackgroundSync(
TAG_BACKGROUND_SYNC_OPENTASKS,
SyncOpenTasksWork::class.java,
enabled && (caldavDao.getAccounts(TYPE_OPENTASKS).isNotEmpty() || openTaskDao.accountCount() > 0))
}
}
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;
@Inject
PermissionChecker(@ApplicationContext Context context) {
public PermissionChecker(@ApplicationContext Context context) {
this.context = context;
}

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

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

@ -1,5 +1,6 @@
package org.tasks.sync
import android.app.Activity.RESULT_OK
import android.app.Dialog
import android.content.Intent
import android.net.Uri
@ -10,12 +11,20 @@ import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
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.etesync.EteSyncAccountSettingsActivity
import org.tasks.jobs.WorkManager
import org.tasks.preferences.fragments.REQUEST_CALDAV_SETTINGS
import org.tasks.preferences.fragments.REQUEST_GOOGLE_TASKS
import org.tasks.themes.DrawableUtil
@ -25,16 +34,36 @@ import javax.inject.Inject
class AddAccountDialog : DialogFragment() {
@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 {
val services = requireActivity().resources.getStringArray(R.array.synchronization_services)
val descriptions = requireActivity().resources.getStringArray(R.array.synchronization_services_description)
val typedArray = requireActivity().resources.obtainTypedArray(R.array.synchronization_services_icons)
val icons = IntArray(typedArray.length())
for (i in icons.indices) {
icons[i] = typedArray.getResourceId(i, 0)
val services = requireActivity().resources.getStringArray(R.array.synchronization_services).toMutableList()
val descriptions = requireActivity().resources.getStringArray(R.array.synchronization_services_description).toMutableList()
val icons = arrayListOf(
R.drawable.ic_google,
R.drawable.ic_webdav_logo,
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>(
requireActivity(), R.layout.simple_list_item_2_themed, R.id.text1, services) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
@ -54,17 +83,38 @@ class AddAccountDialog : DialogFragment() {
.setTitle(R.string.choose_synchronization_service)
.setSingleChoiceItems(adapter, -1) { dialog, which ->
when (which) {
0 -> activity?.startActivityForResult(
Intent(activity, GtasksLoginActivity::class.java),
REQUEST_GOOGLE_TASKS)
1 -> activity?.startActivityForResult(
Intent(activity, CaldavAccountSettingsActivity::class.java),
REQUEST_CALDAV_SETTINGS)
2 -> activity?.startActivityForResult(
Intent(activity, EteSyncAccountSettingsActivity::class.java),
REQUEST_CALDAV_SETTINGS)
0 -> {
activity?.startActivityForResult(
Intent(activity, GtasksLoginActivity::class.java),
REQUEST_GOOGLE_TASKS)
dialog.dismiss()
}
1 -> {
activity?.startActivityForResult(
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) { _, _ ->
activity?.startActivity(Intent(
@ -75,4 +125,18 @@ class AddAccountDialog : DialogFragment() {
.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 org.tasks.data.CaldavAccount.Companion.TYPE_CALDAV
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.GoogleTaskDao
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_ETESYNC
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 javax.inject.Inject
import javax.inject.Singleton
@ -22,11 +24,13 @@ class SyncAdapters @Inject constructor(
workManager: WorkManager,
private val caldavDao: CaldavDao,
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 googleTasks = Debouncer(TAG_SYNC_GOOGLE_TASKS) { workManager.googleTaskSync(it) }
private val caldav = Debouncer(TAG_SYNC_CALDAV) { workManager.caldavSync(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 {
if (task.checkTransitory(SyncFlags.SUPPRESS_SYNC)) {
@ -43,9 +47,16 @@ class SyncAdapters @Inject constructor(
if (caldavDao.isAccountType(task.id, TYPE_ETESYNC)) {
eteSync.sync(false)
}
if (caldavDao.isAccountType(task.id, TYPE_OPENTASKS)) {
opentasks.sync(false)
}
}
}
fun syncOpenTasks() = scope.launch {
opentasks.sync(false)
}
fun sync() {
sync(false)
}
@ -54,6 +65,7 @@ class SyncAdapters @Inject constructor(
val googleTasksEnabled = async { isGoogleTaskSyncEnabled() }
val caldavEnabled = async { isCaldavSyncEnabled() }
val eteSyncEnabled = async { isEteSyncEnabled() }
val opentasksEnabled = async { isOpenTaskSyncEnabled() }
if (googleTasksEnabled.await()) {
googleTasks.sync(immediate)
@ -67,6 +79,9 @@ class SyncAdapters @Inject constructor(
eteSync.sync(immediate)
}
if (opentasksEnabled.await()) {
opentasks.sync(immediate)
}
}
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 isOpenTaskSyncEnabled() =
caldavDao.getAccounts(TYPE_OPENTASKS).isNotEmpty()
|| openTaskDao.accountCount() > 0
}

@ -163,10 +163,18 @@ public class DateTime {
return startOfDay().setTime(hours, minutes, seconds, millisOfDay);
}
public long getOffset() {
return timeZone.getOffset(timestamp);
}
public long getMillis() {
return timestamp;
}
public TimeZone getTimeZone() {
return timeZone;
}
public int getMillisOfDay() {
Calendar calendar = getCalendar();
long millisOfDay =
@ -338,6 +346,10 @@ public class DateTime {
return calendar;
}
public net.fortuna.ical4j.model.DateTime toDateTime() {
return timestamp == 0 ? null : new net.fortuna.ical4j.model.DateTime(timestamp);
}
public DateValue toDateValue() {
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"?>
<resources>
<string-array name="changelog">
<item>Fix Google Task bugs</item>
<item>Join Tasks on Reddit: https://reddit.com/r/tasks</item>
<item>🚧 ALPHA VERSION 🚧</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>
</resources>

@ -4,10 +4,10 @@
These should not be translated
-->
<resources>
<string name="FSA_label">Tasks Shortcut</string>
<string name="caldav">CalDAV</string>
<string name="etesync">EteSync</string>
<string name="davx5">DAVx⁵</string>
<string name="etesync_url">https://api.etesync.com</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_caldav">sync_ongoing_caldav</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_show_description">show_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="reset_sort_order">Reset sort order</string>
<string name="permission_read_tasks">Full access to Tasks database</string>
<string name="account_not_found">Account not found</string>
</resources>

Loading…
Cancel
Save