diff --git a/CHANGELOG.md b/CHANGELOG.md index 301c6e24d..e21ccdb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 352bb2112..116553107 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/licenses.yml b/app/licenses.yml index 32934fe44..0186a0702 100644 --- a/app/licenses.yml +++ b/app/licenses.yml @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c7691d3af..8a1087a69 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -344,6 +344,10 @@ android:name=".etesync.EteSyncAccountSettingsActivity" android:theme="@style/Tasks" /> + + @@ -352,6 +356,10 @@ android:name=".caldav.LocalListSettingsActivity" android:theme="@style/Tasks"/> + + diff --git a/app/src/main/java/com/todoroo/astrid/service/TaskMover.kt b/app/src/main/java/com/todoroo/astrid/service/TaskMover.kt index 01bc53632..c9e54be16 100644 --- a/app/src/main/java/com/todoroo/astrid/service/TaskMover.kt +++ b/app/src/main/java/com/todoroo/astrid/service/TaskMover.kt @@ -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() } diff --git a/app/src/main/java/org/tasks/Tasks.kt b/app/src/main/java/org/tasks/Tasks.kt index 38363328b..f1ea96169 100644 --- a/app/src/main/java/org/tasks/Tasks.kt +++ b/app/src/main/java/org/tasks/Tasks.kt @@ -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 @Inject lateinit var appWidgetManager: Lazy @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject lateinit var contentObserver: Lazy 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) diff --git a/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt b/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt index 2b4e7c95b..66e49ca75 100644 --- a/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt +++ b/app/src/main/java/org/tasks/caldav/BaseCaldavAccountSettingsActivity.kt @@ -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) diff --git a/app/src/main/java/org/tasks/caldav/CaldavConverter.java b/app/src/main/java/org/tasks/caldav/CaldavConverter.java index 8795cb629..40b7ccde6 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavConverter.java +++ b/app/src/main/java/org/tasks/caldav/CaldavConverter.java @@ -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; } diff --git a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt index 40dc862c3..455bd37e6 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt +++ b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt @@ -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) diff --git a/app/src/main/java/org/tasks/caldav/iCalendar.kt b/app/src/main/java/org/tasks/caldav/iCalendar.kt index b2dfa1f81..3fb78fd6f 100644 --- a/app/src/main/java/org/tasks/caldav/iCalendar.kt +++ b/app/src/main/java/org/tasks/caldav/iCalendar.kt @@ -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(Parameter.RELTYPE) === RelType.PARENT diff --git a/app/src/main/java/org/tasks/data/CaldavAccount.kt b/app/src/main/java/org/tasks/data/CaldavAccount.kt index 89238b411..c33e5d840 100644 --- a/app/src/main/java/org/tasks/data/CaldavAccount.kt +++ b/app/src/main/java/org/tasks/data/CaldavAccount.kt @@ -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 = 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 = object : Parcelable.Creator { override fun createFromParcel(source: Parcel): CaldavAccount? { diff --git a/app/src/main/java/org/tasks/data/CaldavDao.kt b/app/src/main/java/org/tasks/data/CaldavDao.kt index 5d417a737..79ea0b646 100644 --- a/app/src/main/java/org/tasks/data/CaldavDao.kt +++ b/app/src/main/java/org/tasks/data/CaldavDao.kt @@ -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 suspend fun getTasks(calendar: String, objects: List): List = @@ -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): List + @Query(""" +SELECT * +FROM caldav_accounts +WHERE cda_account_type = $TYPE_OPENTASKS + AND cda_uuid NOT IN (:accounts) + """) + abstract suspend fun findDeletedAccounts(accounts: List): List + @Query("SELECT * FROM caldav_lists WHERE cdl_account = :account AND cdl_url NOT IN (:urls)") abstract suspend fun findDeletedCalendars(account: String, urls: List): List diff --git a/app/src/main/java/org/tasks/data/OpenTaskDao.kt b/app/src/main/java/org/tasks/data/OpenTaskDao.kt new file mode 100644 index 000000000..c5b8dd509 --- /dev/null +++ b/app/src/main/java/org/tasks/data/OpenTaskDao.kt @@ -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 = getLists().map { it.account!! }.distinct() + + @Deprecated("add davx5/etesync accounts manually") + suspend fun accountCount(): Int = accounts().size + + suspend fun getLists(): List = withContext(Dispatchers.IO) { + val calendars = ArrayList() + 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> = withContext(Dispatchers.IO) { + val items = ArrayList>() + 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 = withContext(Dispatchers.IO) { + val id = getId(caldavTask.remoteId) + val tags = ArrayList() + 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) = 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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/data/TagDao.kt b/app/src/main/java/org/tasks/data/TagDao.kt index e4ce4f868..ec076b892 100644 --- a/app/src/main/java/org/tasks/data/TagDao.kt +++ b/app/src/main/java/org/tasks/data/TagDao.kt @@ -30,7 +30,7 @@ abstract class TagDao { abstract suspend fun delete(tags: List) @Transaction - open suspend fun applyTags(task: Task, tagDataDao: TagDataDao, current: List): Boolean { + open suspend fun applyTags(task: Task, tagDataDao: TagDataDao, current: List) { val taskId = task.id val existing = HashSet(tagDataDao.getTagDataForTask(taskId)) val selected = HashSet(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) { diff --git a/app/src/main/java/org/tasks/data/TaskDao.kt b/app/src/main/java/org/tasks/data/TaskDao.kt index 1145ceae5..9414bfb6e 100644 --- a/app/src/main/java/org/tasks/data/TaskDao.kt +++ b/app/src/main/java/org/tasks/data/TaskDao.kt @@ -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 @Query("SELECT * FROM TASKS " diff --git a/app/src/main/java/org/tasks/filters/FilterProvider.kt b/app/src/main/java/org/tasks/filters/FilterProvider.kt index e56ceb26b..b16933dbb 100644 --- a/app/src/main/java/org/tasks/filters/FilterProvider.kt +++ b/app/src/main/java/org/tasks/filters/FilterProvider.kt @@ -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 = listOf( diff --git a/app/src/main/java/org/tasks/injection/ProductionModule.kt b/app/src/main/java/org/tasks/injection/ProductionModule.kt index 08b9c72f7..0691d7eaf 100644 --- a/app/src/main/java/org/tasks/injection/ProductionModule.kt +++ b/app/src/main/java/org/tasks/injection/ProductionModule.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/SyncOpenTasksWork.kt b/app/src/main/java/org/tasks/jobs/SyncOpenTasksWork.kt new file mode 100644 index 000000000..e197cf46b --- /dev/null +++ b/app/src/main/java/org/tasks/jobs/SyncOpenTasksWork.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/WorkManager.kt b/app/src/main/java/org/tasks/jobs/WorkManager.kt index 6d2dd54a3..94947be7d 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManager.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManager.kt @@ -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" } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt index 5d08ed264..f6db1603d 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt @@ -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, 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( diff --git a/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt b/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt new file mode 100644 index 000000000..0a26157c1 --- /dev/null +++ b/app/src/main/java/org/tasks/opentasks/OpenTaskAccountSettingsActivity.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/opentasks/OpenTaskContentObserver.kt b/app/src/main/java/org/tasks/opentasks/OpenTaskContentObserver.kt new file mode 100644 index 000000000..d3cb42abd --- /dev/null +++ b/app/src/main/java/org/tasks/opentasks/OpenTaskContentObserver.kt @@ -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 = + listOf(TaskLists.getContentUri(authority), + Tasks.getContentUri(authority), + Properties.getContentUri(authority)) + } +} diff --git a/app/src/main/java/org/tasks/opentasks/OpenTasksListSettingsActivity.kt b/app/src/main/java/org/tasks/opentasks/OpenTasksListSettingsActivity.kt new file mode 100644 index 000000000..ca1955dc7 --- /dev/null +++ b/app/src/main/java/org/tasks/opentasks/OpenTasksListSettingsActivity.kt @@ -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) {} +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt b/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt new file mode 100644 index 000000000..2ace6a4b3 --- /dev/null +++ b/app/src/main/java/org/tasks/opentasks/OpenTasksSynchronizer.kt @@ -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> = + openTaskDao.getLists().groupBy { it.account!! } + + private suspend fun sync(account: CaldavAccount, lists: List) { + 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) { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/preferences/PermissionChecker.java b/app/src/main/java/org/tasks/preferences/PermissionChecker.java index 41716274b..7f630e16b 100644 --- a/app/src/main/java/org/tasks/preferences/PermissionChecker.java +++ b/app/src/main/java/org/tasks/preferences/PermissionChecker.java @@ -15,7 +15,7 @@ public class PermissionChecker { private final Context context; @Inject - PermissionChecker(@ApplicationContext Context context) { + public PermissionChecker(@ApplicationContext Context context) { this.context = context; } diff --git a/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt b/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt index 7d0d2d00c..e84846402 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt @@ -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() } diff --git a/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt b/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt index ff9afc14b..cdc88bc32 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Synchronization.kt @@ -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 = 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 diff --git a/app/src/main/java/org/tasks/sync/AddAccountDialog.kt b/app/src/main/java/org/tasks/sync/AddAccountDialog.kt index 137b16bd6..d41e1657b 100644 --- a/app/src/main/java/org/tasks/sync/AddAccountDialog.kt +++ b/app/src/main/java/org/tasks/sync/AddAccountDialog.kt @@ -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 = object : ArrayAdapter( 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 + ): AddAccountDialog { + val dialog = AddAccountDialog() + dialog.arguments = Bundle().apply { + putStringArrayList(EXTRA_ACCOUNTS, ArrayList(openTaskAccounts)) + } + dialog.setTargetFragment(targetFragment, rc) + return dialog + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/sync/SyncAdapters.kt b/app/src/main/java/org/tasks/sync/SyncAdapters.kt index a059ae143..01a81ddc9 100644 --- a/app/src/main/java/org/tasks/sync/SyncAdapters.kt +++ b/app/src/main/java/org/tasks/sync/SyncAdapters.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/time/DateTime.java b/app/src/main/java/org/tasks/time/DateTime.java index c91be7482..8c44a9f8a 100644 --- a/app/src/main/java/org/tasks/time/DateTime.java +++ b/app/src/main/java/org/tasks/time/DateTime.java @@ -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()); } diff --git a/app/src/main/res/drawable/ic_davx5_icon_green_bg.xml b/app/src/main/res/drawable/ic_davx5_icon_green_bg.xml new file mode 100644 index 000000000..32f27b815 --- /dev/null +++ b/app/src/main/res/drawable/ic_davx5_icon_green_bg.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/changelog.xml b/app/src/main/res/values/changelog.xml index 413b0a1af..79d997ab4 100644 --- a/app/src/main/res/values/changelog.xml +++ b/app/src/main/res/values/changelog.xml @@ -1,7 +1,18 @@ - Fix Google Task bugs - Join Tasks on Reddit: https://reddit.com/r/tasks + 🚧 ALPHA VERSION 🚧 + PRO: DAVx⁵ support (alpha requires custom DAVx⁵ build) + PRO: EteSync client support (alpha requires custom EteSync client build) + ToDo Agenda 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 + Find Tasks on Reddit: https://reddit.com/r/tasks diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 78e29ebf0..0f8ca0704 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -4,10 +4,10 @@ These should not be translated --> - Tasks Shortcut CalDAV EteSync + DAVx⁵ https://api.etesync.com https://tasks.org/sync @@ -336,6 +336,7 @@ sync_ongoing_google_tasks sync_ongoing_caldav sync_ongoing_etesync + sync_ongoing_opentasks last_backup show_description show_full_description diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef713561d..c1c6801ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -635,4 +635,5 @@ File %1$s contained %2$s.\n\n Lists Reset sort order Full access to Tasks database + Account not found