From b8c4544ea23b25ff094c8a6d22e584534d769ed5 Mon Sep 17 00:00:00 2001 From: Marten Gajda Date: Wed, 22 Jan 2020 00:40:50 +0100 Subject: [PATCH] Detach completed instances, #617 (#899) In order to support simple recurrence models and also keep long running tasks small we detach completed instances at the beginning of a series into separate task instances. --- dependencies.gradle | 4 +- .../TaskProviderDetachInstancesTest.java | 653 ++++++++++++++++++ .../tasks/TaskProviderRecurrenceTest.java | 226 +++--- .../org/dmfs/provider/tasks/TaskProvider.java | 4 +- .../tasks/processors/instances/Detaching.java | 336 +++++++++ .../instances/TaskValueDelegate.java | 7 +- .../processors/tasks/AutoCompleting.java | 26 +- .../tasks/processors/tasks/Instantiating.java | 21 +- .../tasks/processors/tasks/Validating.java | 14 +- .../tasks/utils/TaskInstanceIterable.java | 23 +- .../dmfs/provider/tasks/utils/Timestamps.java | 55 ++ .../tasks/utils/TaskInstanceIterableTest.java | 80 +++ .../opentaskspal/tasks/RDatesTaskData.java | 11 +- .../opentaskstestpal/InstanceTestData.java | 24 +- .../InstanceTestDataTest.java | 12 +- 15 files changed, 1306 insertions(+), 190 deletions(-) create mode 100644 opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderDetachInstancesTest.java create mode 100644 opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java create mode 100644 opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java diff --git a/dependencies.gradle b/dependencies.gradle index defd06e32..431cf10cb 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,4 +1,4 @@ -def jems_version = '1.24' +def jems_version = '1.33' def contentpal_version = '0.6' def androidx_test_runner_version = '1.1.1' @@ -12,7 +12,7 @@ ext.deps = [ // dmfs jems : "org.dmfs:jems:$jems_version", datetime : 'org.dmfs:rfc5545-datetime:0.2.4', - lib_recur : 'org.dmfs:lib-recur:0.11.2', + lib_recur : 'org.dmfs:lib-recur:0.11.4', xml_magic : 'org.dmfs:android-xml-magic:0.1.1', color_picker : 'com.github.dmfs:color-picker:1.3', android_carrot : 'com.github.dmfs.androidcarrot:androidcarrot:13edc04', diff --git a/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderDetachInstancesTest.java b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderDetachInstancesTest.java new file mode 100644 index 000000000..ce8506378 --- /dev/null +++ b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderDetachInstancesTest.java @@ -0,0 +1,653 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks; + +import android.accounts.Account; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.OperationApplicationException; +import android.os.Build; +import android.os.RemoteException; + +import org.dmfs.android.contentpal.Operation; +import org.dmfs.android.contentpal.OperationsQueue; +import org.dmfs.android.contentpal.RowSnapshot; +import org.dmfs.android.contentpal.Table; +import org.dmfs.android.contentpal.operations.Assert; +import org.dmfs.android.contentpal.operations.BulkAssert; +import org.dmfs.android.contentpal.operations.BulkDelete; +import org.dmfs.android.contentpal.operations.BulkUpdate; +import org.dmfs.android.contentpal.operations.Counted; +import org.dmfs.android.contentpal.operations.Put; +import org.dmfs.android.contentpal.predicates.AllOf; +import org.dmfs.android.contentpal.predicates.AnyOf; +import org.dmfs.android.contentpal.predicates.EqArg; +import org.dmfs.android.contentpal.predicates.Not; +import org.dmfs.android.contentpal.predicates.ReferringTo; +import org.dmfs.android.contentpal.queues.BasicOperationsQueue; +import org.dmfs.android.contentpal.rowdata.CharSequenceRowData; +import org.dmfs.android.contentpal.rowdata.Composite; +import org.dmfs.android.contentpal.rowdata.EmptyRowData; +import org.dmfs.android.contentpal.rowsnapshots.VirtualRowSnapshot; +import org.dmfs.android.contentpal.tables.Synced; +import org.dmfs.android.contenttestpal.operations.AssertEmptyTable; +import org.dmfs.android.contenttestpal.operations.AssertRelated; +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.jems.iterable.elementary.Seq; +import org.dmfs.jems.optional.elementary.Present; +import org.dmfs.opentaskspal.tables.InstanceTable; +import org.dmfs.opentaskspal.tables.LocalTaskListsTable; +import org.dmfs.opentaskspal.tables.TaskListScoped; +import org.dmfs.opentaskspal.tables.TaskListsTable; +import org.dmfs.opentaskspal.tables.TasksTable; +import org.dmfs.opentaskspal.tasks.ExDatesTaskData; +import org.dmfs.opentaskspal.tasks.RDatesTaskData; +import org.dmfs.opentaskspal.tasks.RRuleTaskData; +import org.dmfs.opentaskspal.tasks.StatusData; +import org.dmfs.opentaskspal.tasks.TimeData; +import org.dmfs.opentaskspal.tasks.TitleData; +import org.dmfs.opentaskstestpal.InstanceTestData; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.Duration; +import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.tasks.contract.TaskContract.Instances; +import org.dmfs.tasks.contract.TaskContract.TaskLists; +import org.dmfs.tasks.contract.TaskContract.Tasks; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.TimeZone; + +import androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import static org.dmfs.android.contenttestpal.ContentMatcher.resultsIn; +import static org.dmfs.jems.optional.elementary.Absent.absent; +import static org.junit.Assert.assertThat; + + +/** + * Test {@link TaskProvider} for correctly detaching completed instances. + * + * @author Marten Gajda + */ +@RunWith(AndroidJUnit4.class) +public class TaskProviderDetachInstancesTest +{ + private String mAuthority; + private ContentProviderClient mClient; + private Account mTestAccount = new Account("testname", "testtype"); + + + @Before + public void setUp() throws Exception + { + Context context = InstrumentationRegistry.getTargetContext(); + mAuthority = AuthorityUtil.taskAuthority(context); + mClient = context.getContentResolver().acquireContentProviderClient(mAuthority); + + // Assert that tables are empty: + OperationsQueue queue = new BasicOperationsQueue(mClient); + queue.flush(); + queue.enqueue(new Seq>( + new AssertEmptyTable<>(new TasksTable(mAuthority)), + new AssertEmptyTable<>(new TaskListsTable(mAuthority)), + new AssertEmptyTable<>(new InstanceTable(mAuthority)))); + queue.flush(); + } + + + @After + public void tearDown() throws Exception + { + /* + TODO When Test Orchestration is available, there will be no need for clean up here and check in setUp(), every test method will run in separate instrumentation + https://android-developers.googleblog.com/2017/07/android-testing-support-library-10-is.html + https://developer.android.com/training/testing/junit-runner.html#using-android-test-orchestrator + */ + + // Clear the DB: + BasicOperationsQueue queue = new BasicOperationsQueue(mClient); + queue.enqueue(new SingletonIterable>(new BulkDelete<>(new LocalTaskListsTable(mAuthority)))); + queue.flush(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + { + mClient.close(); + } + else + { + mClient.release(); + } + } + + + /** + * Test if the first instance of a task with a DTSTART, DUE and an RRULE is correctly detached when completed. + */ + @Test + public void testRRule() throws InvalidRecurrenceRuleException, RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new Synced<>(mTestAccount, new TaskListsTable(mAuthority))); + Table instancesTable = new InstanceTable(mAuthority); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + + Duration hour = new Duration(1, 0, 3600 /* 1 hour */); + DateTime start = DateTime.parse("20180104T123456Z"); + DateTime due = start.addDuration(hour); + DateTime localStart = start.shiftTimeZone(TimeZone.getDefault()); + + Duration day = new Duration(1, 1, 0); + + DateTime second = localStart.addDuration(day); + DateTime third = second.addDuration(day); + DateTime fourth = third.addDuration(day); + DateTime fifth = fourth.addDuration(day); + + DateTime localDue = due.shiftTimeZone(TimeZone.getDefault()); + + OperationsQueue queue = new BasicOperationsQueue(mClient); + queue.enqueue(new Seq<>( + new Put<>(taskList, new EmptyRowData<>()), + new Put<>(task, + new Composite<>( + new TimeData<>(start, due), + new RRuleTaskData(new RecurrenceRule("FREQ=DAILY;COUNT=5", RecurrenceRule.RfcMode.RFC2445_LAX)))) + )); + queue.flush(); + + assertThat(new Seq<>( + // update the first non-closed instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + ), + resultsIn(queue, + /* + * We expect three tasks: + * - the original master with updated RRULE, DTSTART and DUE + * - a deleted instance + * - a detached task + */ + + // the original master + new Assert<>(task, + new Composite<>( + new TimeData<>(start.addDuration(day), due.addDuration(day)), + new CharSequenceRowData<>(Tasks.RRULE, "FREQ=DAILY;COUNT=4"))), + // there is one instance referring to the master (the old second instance, now first) + new Counted<>(1, new AssertRelated<>(instancesTable, Instances.TASK_ID, task)), + // the detached task instance: + new Counted<>(1, new BulkAssert<>(new Synced<>(mTestAccount, instancesTable), + new Composite<>( + new InstanceTestData(localStart, localDue, absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task)))), + // the deleted task (doesn't have an instance) + new Counted<>(1, new BulkAssert<>(new Synced<>(mTestAccount, new TasksTable(mAuthority)), + new Composite<>(new TimeData<>(start, due)), + new AllOf<>( + new ReferringTo<>(Tasks.ORIGINAL_INSTANCE_ID, task), + new EqArg<>(Tasks._DELETED, 1)))), + // the former 2nd instance (now first) + new AssertRelated<>(new Synced<>(mTestAccount, instancesTable), Instances.TASK_ID, task, + new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 0), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())))); + } + + + /** + * Test if two instances of a task with a DTSTART, DUE and an RRULE are detached correctly. + */ + @Test + public void testRRuleCompleteAll() throws InvalidRecurrenceRuleException, RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new Synced<>(mTestAccount, new TaskListsTable(mAuthority))); + Table instancesTable = new InstanceTable(mAuthority); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + + Duration hour = new Duration(1, 0, 3600 /* 1 hour */); + DateTime start = DateTime.parse("20180104T123456Z"); + DateTime due = start.addDuration(hour); + DateTime localStart = start.shiftTimeZone(TimeZone.getDefault()); + + Duration day = new Duration(1, 1, 0); + + DateTime second = localStart.addDuration(day); + DateTime third = second.addDuration(day); + DateTime fourth = third.addDuration(day); + DateTime fifth = fourth.addDuration(day); + + DateTime localDue = due.shiftTimeZone(TimeZone.getDefault()); + + OperationsQueue queue = new BasicOperationsQueue(mClient); + queue.enqueue(new Seq<>( + new Put<>(taskList, new EmptyRowData<>()), + new Put<>(task, + new Composite<>( + new TitleData("Test-Task"), + new TimeData<>(start, due), + new RRuleTaskData(new RecurrenceRule("FREQ=DAILY;COUNT=2", RecurrenceRule.RfcMode.RFC2445_LAX)))), + // complete the first non-closed instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + )); + queue.flush(); + + Synced tasksTable = new Synced<>(mTestAccount, new TasksTable(mAuthority)); + Synced syncedInstances = new Synced<>(mTestAccount, instancesTable); + assertThat(new Seq<>( + // update the second instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + ), + resultsIn(queue, + /* + * We expect five tasks: + * - the original master with updated RRULE, DTSTART and DUE, deleted + * - a completed and deleted overrides for the first and second instance + * - a detached first and second instance + */ + + // the original master + new Assert<>(task, + new Composite<>( + // points to former second instance before being deleted + new TimeData<>(start.addDuration(day), due.addDuration(day)), + new CharSequenceRowData<>(Tasks.RRULE, "FREQ=DAILY;COUNT=1"), + new CharSequenceRowData<>(Tasks._DELETED, "1"))), + // there is no instance referring to the master because it has been fully completed (and deleted) + new Counted<>(0, new AssertRelated<>(instancesTable, Instances.TASK_ID, task)), + // the first detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(localStart, localDue, absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, start.getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // the second detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(second, second.addDuration(new Duration(1, 0, 3600)), absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, second.getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // two instances total, both completed + new Counted<>(2, + new BulkAssert<>( + syncedInstances, + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED)), + new AnyOf<>())), + // five tasks in total + new Counted<>(5, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new AnyOf<>())), + // three deleted tasks in total + new Counted<>(3, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new EqArg<>(Tasks._DELETED, 1))))); + } + + + /** + * Test if two instances of a task with a DTSTART, DUE, RRULE and RDATE are detached correctly. + */ + @Test + public void testRRuleRDateCompleteFirstTwo() throws InvalidRecurrenceRuleException, RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new Synced<>(mTestAccount, new TaskListsTable(mAuthority))); + Table instancesTable = new InstanceTable(mAuthority); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + + Duration hour = new Duration(1, 0, 3600 /* 1 hour */); + DateTime start = DateTime.parse("20180104T123456Z"); + DateTime due = start.addDuration(hour); + DateTime localStart = start.shiftTimeZone(TimeZone.getDefault()); + + Duration day = new Duration(1, 1, 0); + + DateTime second = localStart.addDuration(day); + DateTime third = second.addDuration(day); + DateTime fourth = third.addDuration(day); + DateTime fifth = fourth.addDuration(day); + + DateTime localDue = due.shiftTimeZone(TimeZone.getDefault()); + + OperationsQueue queue = new BasicOperationsQueue(mClient); + queue.enqueue(new Seq<>( + new Put<>(taskList, new EmptyRowData<>()), + new Put<>(task, + new Composite<>( + new TitleData("Test-Task"), + new TimeData<>(start, due), + new RRuleTaskData(new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=2", RecurrenceRule.RfcMode.RFC2445_LAX)), + new RDatesTaskData( + new Seq<>( + DateTime.parse("20180103T123456Z"), + DateTime.parse("20180105T123456Z"), + DateTime.parse("20180107T123456Z"))))), + // update the first non-closed instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + )); + queue.flush(); + + Synced tasksTable = new Synced<>(mTestAccount, new TasksTable(mAuthority)); + Synced syncedInstances = new Synced<>(mTestAccount, instancesTable); + assertThat(new Seq<>( + // update the second instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + ), + resultsIn(queue, + /* + * We expect five tasks: + * - the original master with updated RRULE, RDATES, DTSTART and DUE, deleted + * - completed and deleted overrides for the first and second instance + * - a detached first and second instance + */ + + // the first detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(DateTime.parse("20180103T123456Z"), DateTime.parse("20180103T133456Z"), absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, DateTime.parse("20180103T123456Z").getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // the original master has been updated + new Assert<>(task, + new Composite<>( + // points to former third instance before being deleted + new TimeData<>(start.addDuration(day).addDuration(day), due.addDuration(day).addDuration(day)), + new CharSequenceRowData<>(Tasks.RRULE, "FREQ=DAILY;INTERVAL=2;COUNT=1"), + new CharSequenceRowData<>(Tasks._DELETED, "0"), + new RDatesTaskData( + new Seq<>( + DateTime.parse("20180105T123456Z"), + DateTime.parse("20180107T123456Z"))))), + // there is one instance referring to the master + new Counted<>(1, new AssertRelated<>(instancesTable, Instances.TASK_ID, task, + new CharSequenceRowData<>(Instances.INSTANCE_ORIGINAL_TIME, + String.valueOf(DateTime.parse("20180105T123456Z").getTimestamp())))), + // the second detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(start, due, absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, start.getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // two completed instances, neither of them referring to the master + new Counted<>(2, + new BulkAssert<>( + syncedInstances, + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED)), + new AllOf<>( + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, -1), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // one incomplete instance , the first instance of the new master + new Counted<>(1, + new BulkAssert<>( + syncedInstances, + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_NEEDS_ACTION)), + new AllOf<>( + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0), + new ReferringTo<>(Instances.TASK_ID, task)))), + // five tasks in total (two deleted overrides, two detached ones and the new master) + new Counted<>(5, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new AnyOf<>())), + // two deleted tasks in total (the old overrides) + new Counted<>(2, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new EqArg<>(Tasks._DELETED, 1))))); + } + + + /** + * Test if two instances of a task with a DTSTART, DUE, RRULE, RDATE and EXDATE are detached correctly. + */ + @Test + public void testRRuleRDateCompleteWithExdates() throws InvalidRecurrenceRuleException, RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new Synced<>(mTestAccount, new TaskListsTable(mAuthority))); + Table instancesTable = new InstanceTable(mAuthority); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + + Duration hour = new Duration(1, 0, 3600 /* 1 hour */); + DateTime start = DateTime.parse("20180104T123456Z"); + DateTime due = start.addDuration(hour); + DateTime localStart = start.shiftTimeZone(TimeZone.getDefault()); + + Duration day = new Duration(1, 1, 0); + + DateTime second = localStart.addDuration(day); + DateTime third = second.addDuration(day); + DateTime fourth = third.addDuration(day); + DateTime fifth = fourth.addDuration(day); + + DateTime localDue = due.shiftTimeZone(TimeZone.getDefault()); + + OperationsQueue queue = new BasicOperationsQueue(mClient); + queue.enqueue(new Seq<>( + new Put<>(taskList, new EmptyRowData<>()), + new Put<>(task, + new Composite<>( + new TitleData("Test-Task"), + new TimeData<>(start, due), + new RRuleTaskData(new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=2", RecurrenceRule.RfcMode.RFC2445_LAX)), + new RDatesTaskData( + new Seq<>( + DateTime.parse("20180105T123456Z"), + DateTime.parse("20180107T123456Z"))), + new ExDatesTaskData( + new Seq<>( + DateTime.parse("20180104T123456Z"), + DateTime.parse("20180105T123456Z"))))), + // update the first non-closed instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + )); + queue.flush(); + + Synced tasksTable = new Synced<>(mTestAccount, new TasksTable(mAuthority)); + Synced syncedInstances = new Synced<>(mTestAccount, instancesTable); + assertThat(new Seq<>( + // update the second instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + ), + resultsIn(queue, + /* + * We expect five tasks: + * - the original master deleted + * - completed and deleted overrides for the first and second instance + * - detached first and second instances + */ + + // the first detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(DateTime.parse("20180106T123456Z"), DateTime.parse("20180106T133456Z"), absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, DateTime.parse("20180106T123456Z").getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // the original master has been deleted + new Counted<>(0, new Assert<>(task, new Composite<>(new EmptyRowData<>()))), + // there is no instance referring to the master + new Counted<>(0, new AssertRelated<>(instancesTable, Instances.TASK_ID, task)), + // the second detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(DateTime.parse("20180107T123456Z"), DateTime.parse("20180107T133456Z"), absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, DateTime.parse("20180107T123456Z").getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // two completed instances, neither of them referring to the master + new Counted<>(2, + new BulkAssert<>( + syncedInstances, + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED)), + new AllOf<>( + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, -1), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // five tasks in total (two deleted overrides, two detached ones and the old master) + new Counted<>(5, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new AnyOf<>())), + // three deleted tasks in total (the old overrides and the old master) + new Counted<>(3, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new EqArg<>(Tasks._DELETED, 1))))); + } + + + /** + * Test if two instances of a task with a DTSTART, DUE, RRULE, RDATE and EXDATE are detached correctly. + */ + @Test + public void testRRuleRDateCompleteOnlyRRuleInstances() throws InvalidRecurrenceRuleException, RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new Synced<>(mTestAccount, new TaskListsTable(mAuthority))); + Table instancesTable = new InstanceTable(mAuthority); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + + Duration hour = new Duration(1, 0, 3600 /* 1 hour */); + DateTime start = DateTime.parse("20180104T123456Z"); + DateTime due = start.addDuration(hour); + DateTime localStart = start.shiftTimeZone(TimeZone.getDefault()); + + Duration day = new Duration(1, 1, 0); + + DateTime second = localStart.addDuration(day); + DateTime third = second.addDuration(day); + DateTime fourth = third.addDuration(day); + DateTime fifth = fourth.addDuration(day); + + DateTime localDue = due.shiftTimeZone(TimeZone.getDefault()); + + OperationsQueue queue = new BasicOperationsQueue(mClient); + queue.enqueue(new Seq<>( + new Put<>(taskList, new EmptyRowData<>()), + new Put<>(task, + new Composite<>( + new TitleData("Test-Task"), + new TimeData<>(start, due), + new RRuleTaskData(new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=2", RecurrenceRule.RfcMode.RFC2445_LAX)), + new RDatesTaskData( + new Seq<>( + DateTime.parse("20180105T123456Z"), + DateTime.parse("20180107T123456Z"))), + new ExDatesTaskData( + new Seq<>( + DateTime.parse("20180104T123456Z"))))) +/* // update the first non-closed instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0)))*/ + )); + queue.flush(); + + Synced tasksTable = new Synced<>(mTestAccount, new TasksTable(mAuthority)); + Synced syncedInstances = new Synced<>(mTestAccount, instancesTable); + assertThat(new Seq<>( + // update the second instance + new BulkUpdate<>(instancesTable, new StatusData<>(Tasks.STATUS_COMPLETED), + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, 0))) + ), + resultsIn(queue, + /* + * We expect five tasks: + * - the original master deleted + * - completed and deleted overrides for the first and second instance + * - detached first and second instances + */ + + // the first detached task instance: + new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(DateTime.parse("20180105T123456Z"), DateTime.parse("20180105T133456Z"), absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, DateTime.parse("20180105T123456Z").getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // the original master has been updated + new Assert<>(task, + new Composite<>( + new TimeData<>(DateTime.parse("20180106T123456Z"), DateTime.parse("20180106T133456Z")), + new CharSequenceRowData<>(Tasks.RRULE, "FREQ=DAILY;INTERVAL=2;COUNT=1"), + new CharSequenceRowData<>(Tasks._DELETED, "0"), + new RDatesTaskData( + new Seq<>( + DateTime.parse("20180107T123456Z"))))), + // the second detached task instance: + /* new Counted<>(1, new BulkAssert<>(syncedInstances, + new Composite<>( + new InstanceTestData(DateTime.parse("20180106T123456Z"), DateTime.parse("20180106T133456Z"), absent(), -1), + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED))), + new AllOf<>( + new EqArg<>(Instances.INSTANCE_START, DateTime.parse("20180106T123456Z").getTimestamp()), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))),*/ + // one completed instance, not referring to the master + new Counted<>(1, + new BulkAssert<>( + syncedInstances, + new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED)), + new AllOf<>( + new EqArg<>(Instances.DISTANCE_FROM_CURRENT, -1), + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // three tasks in total (one deleted override, one detached one and the master) + new Counted<>(3, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new AnyOf<>())), + // three deleted tasks in total (the old overrides and the old master) + new Counted<>(1, + new BulkAssert<>( + tasksTable, + new TitleData("Test-Task"), + new EqArg<>(Tasks._DELETED, 1))))); + } +} diff --git a/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderRecurrenceTest.java b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderRecurrenceTest.java index 69123f627..babad1a26 100644 --- a/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderRecurrenceTest.java +++ b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderRecurrenceTest.java @@ -32,6 +32,7 @@ import org.dmfs.android.contentpal.operations.Put; import org.dmfs.android.contentpal.predicates.AllOf; import org.dmfs.android.contentpal.predicates.EqArg; +import org.dmfs.android.contentpal.predicates.IsNull; import org.dmfs.android.contentpal.predicates.Not; import org.dmfs.android.contentpal.predicates.ReferringTo; import org.dmfs.android.contentpal.queues.BasicOperationsQueue; @@ -42,7 +43,8 @@ import org.dmfs.android.contenttestpal.operations.AssertEmptyTable; import org.dmfs.android.contenttestpal.operations.AssertRelated; import org.dmfs.iterables.SingletonIterable; -import org.dmfs.iterables.elementary.Seq; +import org.dmfs.jems.iterable.elementary.Seq; +import org.dmfs.jems.optional.elementary.Present; import org.dmfs.opentaskspal.tables.InstanceTable; import org.dmfs.opentaskspal.tables.LocalTaskListsTable; import org.dmfs.opentaskspal.tables.TaskListScoped; @@ -59,7 +61,6 @@ import org.dmfs.opentaskspal.tasks.TimeData; import org.dmfs.opentaskspal.tasks.TitleData; import org.dmfs.opentaskstestpal.InstanceTestData; -import org.dmfs.optional.Present; import org.dmfs.rfc5545.DateTime; import org.dmfs.rfc5545.Duration; import org.dmfs.rfc5545.recur.InvalidRecurrenceRuleException; @@ -179,23 +180,23 @@ public void testRRule() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) ); } @@ -243,23 +244,23 @@ public void testRRuleWithFloatingMismatch() throws InvalidRecurrenceRuleExceptio // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) ); } @@ -305,23 +306,23 @@ public void testAllDayRRule() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) ); } @@ -369,23 +370,23 @@ public void testAllDayRRuleFloatingMismatch() throws InvalidRecurrenceRuleExcept // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp())) */) ); } @@ -428,23 +429,23 @@ public void testRRuleNoDtStart() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(absent(), new Present<>(localDue), new Present<>(due), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, due.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, due.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(absent(), new Present<>(second), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(absent(), new Present<>(third), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(absent(), new Present<>(fourth), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(absent(), new Present<>(fifth), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -487,23 +488,23 @@ public void testRRuleNoDue() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(new Present<>(localStart), absent(), new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(new Present<>(second), absent(), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(new Present<>(third), absent(), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(new Present<>(fourth), absent(), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(new Present<>(fifth), absent(), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -541,7 +542,7 @@ public void testRRuleRemoveInstance() throws InvalidRecurrenceRuleException new RRuleTaskData(new RecurrenceRule("FREQ=DAILY;COUNT=5", RecurrenceRule.RfcMode.RFC2445_LAX)))), // remove the third instance new BulkDelete<>(instancesTable, - new AllOf(new ReferringTo<>(Instances.TASK_ID, task), new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp()))) + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp()))) ), resultsIn(mClient, new Assert<>(task, @@ -553,19 +554,19 @@ public void testRRuleRemoveInstance() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 4th instance (now 3rd): new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance (now 4th): new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))) ); } @@ -625,24 +626,24 @@ public void testRRuleWithOverride() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3th instance (the overridden one): new AssertRelated<>(instancesTable, Instances.TASK_ID, taskOverride, new InstanceTestData(third.addDuration(hour), third.addDuration(hour).addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -685,7 +686,7 @@ public void testRRuleWithOverride2() throws InvalidRecurrenceRuleException new BulkUpdate<>(instancesTable, new Composite<>( new CharSequenceRowData(Tasks.TITLE, "override")), - new AllOf(new ReferringTo<>(Instances.TASK_ID, task), new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp()))) + new AllOf<>(new ReferringTo<>(Instances.TASK_ID, task), new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp()))) ), resultsIn(mClient, new Assert<>(task, new Composite<>( @@ -703,23 +704,23 @@ public void testRRuleWithOverride2() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3th instance (the overridden one). We don't have a row reference to this row, so we select it by the ORIGINAL_INSTANCE-ID new AssertRelated<>(instancesTable, Tasks.ORIGINAL_INSTANCE_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))) ); } @@ -767,15 +768,15 @@ public void testRRuleWithExDates() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 4th instance (now 3rd): new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp()))*/) ); } @@ -827,23 +828,23 @@ public void testRDate() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 4), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -900,23 +901,23 @@ public void testRDateAddExDate() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(localStart, localDue, new Present<>(start), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))/*, // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())), // 3rd instance: // new AssertRelated<>(instancesTable, Instances.TASK_ID, task, // new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 2), -// new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), +// new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -977,23 +978,23 @@ public void testRDateFirstComplete() throws InvalidRecurrenceRuleException // 1st instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, override, new InstanceTestData(localStart, localDue, new Present<>(start), -1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp()))/*, // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -1061,23 +1062,23 @@ public void testRDateFirstCompleteFirstInserted() throws InvalidRecurrenceRuleEx // 1st instance, overridden and completed new AssertRelated<>(instancesTable, Instances.TASK_ID, override, new InstanceTestData(localStart, localDue, new Present<>(start), -1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())), // 2nd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp()))/*, + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp()))/*, // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } @@ -1113,58 +1114,63 @@ public void testRDateFirstCompleteViaInstances() throws InvalidRecurrenceRuleExc new Put<>(task, new Composite<>( new TimeData<>(start, due), - new RDatesTaskData(new Seq<>(start, second, third, fourth, fifth)))), + new RDatesTaskData(start, second, third, fourth, fifth))), // then complete the first instance new BulkUpdate<>(instancesTable, new CharSequenceRowData<>(Tasks.STATUS, String.valueOf(Tasks.STATUS_COMPLETED)), - new AllOf( + new AllOf<>( new ReferringTo<>(Instances.TASK_ID, task), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()))) - ), resultsIn(mClient, - new Assert<>(task, - new Composite<>( - new TimeData<>(start, due), - new CharSequenceRowData<>(Tasks.RDATE, - "20180104T123456Z," + - "20180105T123456Z," + - "20180106T123456Z," + - "20180107T123456Z," + - "20180108T123456Z" - ))), - // there must be one task which is not equal to the original task - new Counted<>(1, - new BulkAssert<>(tasksTable, - new Composite<>( - new TimeData<>(start, due), - new StatusData<>(Tasks.STATUS_COMPLETED)), - new Not(new ReferringTo<>(Tasks._ID, task)))), - // and one instance which doesn't refer to the original task - new Counted<>(1, new BulkAssert<>(instancesTable, new Not(new ReferringTo<>(Instances.TASK_ID, task)))), - // but 4 instances of that original task + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp())))), + resultsIn(mClient, + // we've already closed the first instance which has been detached, the master now points to the second instance + new Counted<>(1, + new Assert<>(task, + new Composite<>( + new TimeData<>(DateTime.parse("20180105T123456Z"), DateTime.parse("20180105T133456Z")), + new RDatesTaskData( + // "20180104T123456Z" // the detached instance + DateTime.parse("20180105T123456Z"), + DateTime.parse("20180106T123456Z"), + DateTime.parse("20180107T123456Z"), + DateTime.parse("20180108T123456Z"))))), + // there must be one task which is not equal to the original task, it's the detached instance + new Counted<>(1, + new BulkAssert<>(tasksTable, + new Composite<>( + new TimeData<>(start, due), + new StatusData<>(Tasks.STATUS_COMPLETED), + new CharSequenceRowData<>(Tasks.ORIGINAL_INSTANCE_ID, null), + new CharSequenceRowData<>(Tasks.ORIGINAL_INSTANCE_SYNC_ID, null), + new CharSequenceRowData<>(Tasks.ORIGINAL_INSTANCE_TIME, null)), + new Not<>(new ReferringTo<>(Tasks._ID, task)))), + // and one instance which doesn't refer to the original task + new Counted<>(1, new BulkAssert<>(instancesTable, new Not<>(new ReferringTo<>(Instances.TASK_ID, task)))), + // but 4 instances of that original task // new Counted<>(4, new AssertRelated<>(instancesTable, Instances.TASK_ID, task)), - new Counted<>(1, new AssertRelated<>(instancesTable, Instances.TASK_ID, task)), - // 1st instance, overridden and completed - new Counted<>(1, new BulkAssert<>(instancesTable, - new Composite<>( - new InstanceTestData(localStart, localDue, new Present<>(start), -1)), - new AllOf( - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, start.getTimestamp()), - new Not(new ReferringTo<>(Instances.TASK_ID, task))))), - // 2nd instance: - new AssertRelated<>(instancesTable, Instances.TASK_ID, task, - new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 0), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp()))/*, + new Counted<>(1, new AssertRelated<>(instancesTable, Instances.TASK_ID, task)), + // 1st instance, detached and completed + new Counted<>(1, new BulkAssert<>(instancesTable, + new Composite<>( + new InstanceTestData(localStart, localDue, absent(), -1)), + new AllOf<>( + new IsNull<>(Instances.INSTANCE_ORIGINAL_TIME), // the detached instance has no INSTANCE_ORIGINAL_TIME + new Not<>(new ReferringTo<>(Instances.TASK_ID, task))))), + // 2nd instance: + new Counted<>(1, + new AssertRelated<>(instancesTable, Instances.TASK_ID, task, + new InstanceTestData(second, second.addDuration(hour), new Present<>(second), 0), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, second.getTimestamp())))/*, // 3rd instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(third, third.addDuration(hour), new Present<>(third), 1), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, third.getTimestamp())), // 4th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fourth, fourth.addDuration(hour), new Present<>(fourth), 2), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fourth.getTimestamp())), // 5th instance: new AssertRelated<>(instancesTable, Instances.TASK_ID, task, new InstanceTestData(fifth, fifth.addDuration(hour), new Present<>(fifth), 3), - new EqArg(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) + new EqArg<>(Instances.INSTANCE_ORIGINAL_TIME, fifth.getTimestamp()))*/) ); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java index 694d47a41..749ee94c9 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/TaskProvider.java @@ -52,6 +52,7 @@ import org.dmfs.provider.tasks.model.ListAdapter; import org.dmfs.provider.tasks.model.TaskAdapter; import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.processors.instances.Detaching; import org.dmfs.provider.tasks.processors.instances.TaskValueDelegate; import org.dmfs.provider.tasks.processors.lists.ListCommitProcessor; import org.dmfs.provider.tasks.processors.tasks.AutoCompleting; @@ -189,7 +190,8 @@ public boolean onCreate() mListProcessorChain = new org.dmfs.provider.tasks.processors.lists.Validating(new ListCommitProcessor()); - mInstanceProcessorChain = new org.dmfs.provider.tasks.processors.instances.Validating(new TaskValueDelegate(mTaskProcessorChain)); + mInstanceProcessorChain = new org.dmfs.provider.tasks.processors.instances.Validating( + new Detaching(new TaskValueDelegate(mTaskProcessorChain), mTaskProcessorChain)); mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); mUriMatcher.addURI(mAuthority, TaskContract.TaskLists.CONTENT_URI_PATH, LISTS); diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java new file mode 100644 index 000000000..ab8c383a9 --- /dev/null +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/Detaching.java @@ -0,0 +1,336 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.processors.instances; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.dmfs.iterables.SingletonIterable; +import org.dmfs.iterables.decorators.Sieved; +import org.dmfs.jems.iterable.composite.Joined; +import org.dmfs.jems.optional.adapters.FirstPresent; +import org.dmfs.jems.optional.elementary.NullSafe; +import org.dmfs.jems.predicate.composite.AnyOf; +import org.dmfs.provider.tasks.TaskDatabaseHelper; +import org.dmfs.provider.tasks.model.CursorContentValuesInstanceAdapter; +import org.dmfs.provider.tasks.model.CursorContentValuesTaskAdapter; +import org.dmfs.provider.tasks.model.InstanceAdapter; +import org.dmfs.provider.tasks.model.TaskAdapter; +import org.dmfs.provider.tasks.model.adapters.IntegerFieldAdapter; +import org.dmfs.provider.tasks.model.adapters.LongFieldAdapter; +import org.dmfs.provider.tasks.processors.EntityProcessor; +import org.dmfs.provider.tasks.utils.Timestamps; +import org.dmfs.rfc5545.DateTime; +import org.dmfs.rfc5545.Duration; +import org.dmfs.rfc5545.recur.RecurrenceRule; +import org.dmfs.rfc5545.recurrenceset.RecurrenceList; +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet; +import org.dmfs.rfc5545.recurrenceset.RecurrenceSetIterator; +import org.dmfs.tasks.contract.TaskContract; + +import java.util.HashSet; +import java.util.TimeZone; + +import static java.util.Arrays.asList; + + +/** + * An instance {@link EntityProcessor} detaches completed instances at the start of a recurring task. + * + * @author Marten Gajda + */ +public final class Detaching implements EntityProcessor +{ + + private final EntityProcessor mDelegate; + private final EntityProcessor mTaskDelegate; + + + public Detaching(EntityProcessor delegate, EntityProcessor taskDelegate) + { + mDelegate = delegate; + mTaskDelegate = taskDelegate; + } + + + @Override + public InstanceAdapter insert(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // just delegate for now + // if we ever support inserting instances, we'll have to make sure that inserting a completed instance results in a detached task + return mDelegate.insert(db, entityAdapter, isSyncAdapter); + } + + + /** + * Detach the given instance if all of the following conditions are met + *

+ * - The instance is a recurrence instance (INSTANCE_ORIGINAL_TIME != null) + * - and the task has been closed (IS_CLOSED != 0) + * - and the instance is the first non-closed instance (DISTANCE_FROM_CURRENT==0). + *

+ */ + @Override + public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + if (entityAdapter.valueOf(InstanceAdapter.DISTANCE_FROM_CURRENT) != 0 // not the first open task + + // not closed, note we can't use IS_CLOSED at this point because its not updated yet + || (!new HashSet<>(asList(TaskContract.Tasks.STATUS_COMPLETED, TaskContract.Tasks.STATUS_CANCELLED)).contains( + entityAdapter.valueOf(new IntegerFieldAdapter<>(TaskContract.Tasks.STATUS)))) + + // not recurring + || entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME) == null) + { + // not a detachable instance + return mDelegate.update(db, entityAdapter, isSyncAdapter); + } + // update instance accordingly and detach it + return detachAll(db, mDelegate.update(db, entityAdapter, isSyncAdapter)); + } + + + @Override + public void delete(SQLiteDatabase db, InstanceAdapter entityAdapter, boolean isSyncAdapter) + { + // just delegate + mDelegate.delete(db, entityAdapter, isSyncAdapter); + } + + + /** + * Detach all closed instances preceding the given one. + *

+ * TODO: this method needs some refactoring + */ + private InstanceAdapter detachAll(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + // keep some values for later + long masterId = new FirstPresent<>( + new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.ORIGINAL_INSTANCE_ID))), + new NullSafe<>(entityAdapter.valueOf(new LongFieldAdapter<>(TaskContract.Instances.TASK_ID)))).value(); + DateTime instanceOriginalTime = entityAdapter.valueOf(InstanceAdapter.INSTANCE_ORIGINAL_TIME); + + // detach instances which are completed + try (Cursor instances = db.query(TaskDatabaseHelper.Tables.INSTANCE_VIEW, + null, + String.format("%s < 0 and %s == ?", TaskContract.Instances.DISTANCE_FROM_CURRENT, TaskContract.Instances.ORIGINAL_INSTANCE_ID), + new String[] { String.valueOf(masterId) }, + null, + null, + null)) + { + while (instances.moveToNext()) + { + detachSingle(db, new CursorContentValuesInstanceAdapter(instances, new ContentValues())); + } + } + + // move the master to the first incomplete task + try (Cursor task = db.query(TaskDatabaseHelper.Tables.TASKS_VIEW, + null, + String.format("%s == ?", TaskContract.Tasks._ID), + new String[] { String.valueOf(masterId) }, + null, + null, + null)) + { + if (task.moveToFirst()) + { + TaskAdapter masterTask = new CursorContentValuesTaskAdapter(task, new ContentValues()); + DateTime oldStart = new FirstPresent<>( + new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); + + // assume we have no instances left + boolean noInstances = true; + + // update RRULE, if existent + RecurrenceRule rule = masterTask.valueOf(TaskAdapter.RRULE); + int count = 0; + if (rule != null) + { + RecurrenceSet ruleSet = new RecurrenceSet(); + ruleSet.addInstances(new RecurrenceRuleAdapter(rule)); + if (rule.getCount() == null) + { + // rule has no count limit, allowing us to exclude exdates + ruleSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); + } + RecurrenceSetIterator ruleIterator = ruleSet.iterator( + oldStart.getTimeZone(), + oldStart.getTimestamp()); + + // move DTSTART to next RRULE instance which is > instanceOriginalTime + // reduce COUNT by the number of skipped instances, if present + while (count < 1000 && ruleIterator.hasNext()) + { + DateTime inst = new DateTime(oldStart.getTimeZone(), ruleIterator.next()); + if (instanceOriginalTime.before(inst)) + { + updateStart(masterTask, inst); + noInstances = false; // just found another instance + break; + } + count += 1; + } + + if (noInstances) + { + // remove the RRULE but keep a mask for the old start + masterTask.set(TaskAdapter.EXDATE, + new Joined<>(new SingletonIterable<>(oldStart), new Sieved<>(oldStart::equals, masterTask.valueOf(TaskAdapter.EXDATE)))); + masterTask.set(TaskAdapter.RRULE, null); + } + else + { + // adjust COUNT if present + if (rule.getCount() != null) + { + rule.setCount(rule.getCount() - count); + masterTask.set(TaskAdapter.RRULE, rule); + } + } + } + + DateTime newStart = new FirstPresent<>( + new NullSafe<>(masterTask.valueOf(TaskAdapter.DTSTART)), + new NullSafe<>(masterTask.valueOf(TaskAdapter.DUE))).value(); + + // update RDATE and EXDATE + masterTask.set(TaskAdapter.RDATE, new Sieved<>(instanceOriginalTime::before, masterTask.valueOf(TaskAdapter.RDATE))); + masterTask.set(TaskAdapter.EXDATE, + new Sieved<>(new AnyOf<>(instanceOriginalTime::before, newStart::equals), masterTask.valueOf(TaskAdapter.EXDATE))); + + // First check if we still have any RDATE instances left + // TODO: 6 lines for something we should be able to express in one simple expression, we need to straighten lib-recur!! + RecurrenceSet rdateSet = new RecurrenceSet(); + rdateSet.addInstances(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.RDATE)).value())); + rdateSet.addExceptions(new RecurrenceList(new Timestamps(masterTask.valueOf(TaskAdapter.EXDATE)).value())); + RecurrenceSetIterator iterator = rdateSet.iterator(DateTime.UTC, Long.MIN_VALUE); + iterator.fastForward(Long.MIN_VALUE + 1); // skip bogus start + noInstances &= !iterator.hasNext(); + + if (noInstances) + { + // no more instances left, remove the master + mTaskDelegate.delete(db, masterTask, false); + } + else + { + if (masterTask.valueOf(TaskAdapter.RRULE) == null) + { + // we don't have any RRULE, allowing us to adjust DTSTART/DUE to the first RDATE + DateTime start = new DateTime(iterator.next()); + if (masterTask.valueOf(TaskAdapter.IS_ALLDAY)) + { + start = start.toAllDay(); + } + else if (masterTask.valueOf(TaskAdapter.TIMEZONE_RAW) != null) + { + start = start.shiftTimeZone(TimeZone.getTimeZone(masterTask.valueOf(TaskAdapter.TIMEZONE_RAW))); + } + updateStart(masterTask, start); + } + + // we still have instances, update the database + mTaskDelegate.update(db, masterTask, false); + } + } + } + + return entityAdapter; + } + + + private void updateStart(TaskAdapter task, DateTime newStart) + { + // this new instance becomes the new start (or due if we don't have a start) + if (task.valueOf(TaskAdapter.DTSTART) != null) + { + DateTime oldStart = task.valueOf(TaskAdapter.DTSTART); + task.set(TaskAdapter.DTSTART, newStart); + if (task.valueOf(TaskAdapter.DUE) != null) + { + long duration = task.valueOf(TaskAdapter.DUE).getTimestamp() - oldStart.getTimestamp(); + task.set(TaskAdapter.DUE, + newStart.addDuration( + new Duration(1, (int) (duration / (3600 * 24 * 1000)), (int) (duration % (3600 * 24 * 1000)) / 1000))); + } + } + else + { + task.set(TaskAdapter.DUE, newStart); + } + + } + + + /** + * Detach the given instance. + *

+ * - clone the override into a new deleted task (set _DELETED == 1) + * - detach the original override by removing the ORIGINAL_INSTANCE_ID, ORIGINAL_INSTANCE_SYNC_ID, ORIGINAL_INSTANCE_START and ORIGINAL_INSTANCE_ALLDAY + * (i.e. all columns which relate this to the original) + * - wipe _SYNC_ID, _UID and all sync columns (make this an unsynced task) + */ + private void detachSingle(SQLiteDatabase db, InstanceAdapter entityAdapter) + { + TaskAdapter original = entityAdapter.taskAdapter(); + TaskAdapter cloneAdapter = original.duplicate(); + + // first prepare the original to resemble the same instance but as a new, detached task + original.set(TaskAdapter.SYNC_ID, null); + original.set(TaskAdapter.SYNC_VERSION, null); + original.set(TaskAdapter.SYNC1, null); + original.set(TaskAdapter.SYNC2, null); + original.set(TaskAdapter.SYNC3, null); + original.set(TaskAdapter.SYNC4, null); + original.set(TaskAdapter.SYNC5, null); + original.set(TaskAdapter.SYNC6, null); + original.set(TaskAdapter.SYNC7, null); + original.set(TaskAdapter.SYNC8, null); + original.set(TaskAdapter._UID, null); + original.set(TaskAdapter._DIRTY, true); + original.set(TaskAdapter.ORIGINAL_INSTANCE_ID, null); + original.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, null); + original.set(TaskAdapter.ORIGINAL_INSTANCE_TIME, null); + original.unset(TaskAdapter.COMPLETED); + original.commit(db); + + // wipe INSTANCE_ORIGINAL_TIME from instances entry + ContentValues noOriginalTime = new ContentValues(); + noOriginalTime.putNull(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); + db.update(TaskDatabaseHelper.Tables.INSTANCES, noOriginalTime, "_ID = ?", new String[] { String.valueOf(entityAdapter.id()) }); + + // reset the clone to be a deleted instance + cloneAdapter.set(TaskAdapter._DELETED, true); + // remove joined field values + cloneAdapter.unset(TaskAdapter.LIST_ACCESS_LEVEL); + cloneAdapter.unset(TaskAdapter.LIST_COLOR); + cloneAdapter.unset(TaskAdapter.LIST_NAME); + cloneAdapter.unset(TaskAdapter.LIST_OWNER); + cloneAdapter.unset(TaskAdapter.LIST_VISIBLE); + cloneAdapter.unset(TaskAdapter.ACCOUNT_NAME); + cloneAdapter.unset(TaskAdapter.ACCOUNT_TYPE); + cloneAdapter.commit(db); + + // note, we don't have to create an instance for the clone because it's deleted + } +} diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java index 02c502e2d..f02ed1da3 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/instances/TaskValueDelegate.java @@ -80,7 +80,9 @@ public final class TaskValueDelegate implements EntityProcessor // also unset any recurrence fields TaskAdapter.RRULE, TaskAdapter.RDATE, - TaskAdapter.EXDATE + TaskAdapter.EXDATE, + TaskAdapter.CREATED, + TaskAdapter.LAST_MODIFIED ); private final EntityProcessor mDelegate; @@ -184,8 +186,7 @@ public InstanceAdapter update(SQLiteDatabase db, InstanceAdapter entityAdapter, // copy original instance allday flag override.set(TaskAdapter.ORIGINAL_INSTANCE_ALLDAY, taskAdapter.valueOf(TaskAdapter.IS_ALLDAY)); - // TODO: if this is the first instance (and maybe no other overrides exist), don't create an override but split the series into two tasks - TaskAdapter newTask = mDelegate.insert(db, override, true /* for now insert as a sync adapter to retain the UID */); + TaskAdapter newTask = mDelegate.insert(db, override, false); copyProperties(db, taskAdapter.id(), newTask.id()); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java index 9b8619e39..65bbe9ade 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/AutoCompleting.java @@ -128,8 +128,7 @@ private void updateFields(SQLiteDatabase db, TaskAdapter task, boolean isSyncAda if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID)) { String[] syncId = { task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID) }; - Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_ID_PROJECTION, SYNC_ID_SELECTION, syncId, null, null, null); - try + try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_ID_PROJECTION, SYNC_ID_SELECTION, syncId, null, null, null)) { if (cursor.moveToNext()) { @@ -137,19 +136,11 @@ private void updateFields(SQLiteDatabase db, TaskAdapter task, boolean isSyncAda task.set(TaskAdapter.ORIGINAL_INSTANCE_ID, originalId); } } - finally - { - if (cursor != null) - { - cursor.close(); - } - } } else if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) // Find corresponding ORIGINAL_INSTANCE_SYNC_ID { String[] id = { Long.toString(task.valueOf(TaskAdapter.ORIGINAL_INSTANCE_ID)) }; - Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_SYNC_ID_PROJECTION, TASK_ID_SELECTION, id, null, null, null); - try + try (Cursor cursor = db.query(TaskDatabaseHelper.Tables.TASKS, TASK_SYNC_ID_PROJECTION, TASK_ID_SELECTION, id, null, null, null)) { if (cursor.moveToNext()) { @@ -157,13 +148,6 @@ else if (task.isUpdated(TaskAdapter.ORIGINAL_INSTANCE_ID)) // Find corresponding task.set(TaskAdapter.ORIGINAL_INSTANCE_SYNC_ID, originalSyncId); } } - finally - { - if (cursor != null) - { - cursor.close(); - } - } } // check that PERCENT_COMPLETE is an Integer between 0 and 100 if supplied also update status and completed accordingly @@ -202,8 +186,8 @@ else if (!isSyncAdapter && percent != null) task.set(TaskAdapter.STATUS, status); } - task.set(TaskAdapter.IS_NEW, status == null || status == TaskContract.Tasks.STATUS_NEEDS_ACTION); - task.set(TaskAdapter.IS_CLOSED, status != null && (status == TaskContract.Tasks.STATUS_COMPLETED || status == TaskContract.Tasks.STATUS_CANCELLED)); + task.set(TaskAdapter.IS_NEW, status == TaskContract.Tasks.STATUS_NEEDS_ACTION); + task.set(TaskAdapter.IS_CLOSED, status == TaskContract.Tasks.STATUS_COMPLETED || status == TaskContract.Tasks.STATUS_CANCELLED); /* * Update PERCENT_COMPLETE and COMPLETED (if not given). Sync adapters should know what they're doing, so don't update anything if caller is a sync @@ -212,7 +196,7 @@ else if (!isSyncAdapter && percent != null) if (status == TaskContract.Tasks.STATUS_COMPLETED && !isSyncAdapter) { task.set(TaskAdapter.PERCENT_COMPLETE, 100); - if (!task.isUpdated(TaskAdapter.COMPLETED)) + if (!task.isUpdated(TaskAdapter.COMPLETED) || task.valueOf(TaskAdapter.COMPLETED) == null) { task.set(TaskAdapter.COMPLETED, new DateTime(System.currentTimeMillis())); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java index c2a18fd70..825d3a9be 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Instantiating.java @@ -213,7 +213,7 @@ private void updateOverrideInstance(SQLiteDatabase db, TaskAdapter taskAdapter, */ private void updateMasterInstances(SQLiteDatabase db, TaskAdapter taskAdapter, long id) { - final Cursor existingInstances = db.query( + try (Cursor existingInstances = db.query( TaskDatabaseHelper.Tables.INSTANCE_VIEW, new String[] { TaskContract.Instances._ID, TaskContract.Instances.INSTANCE_ORIGINAL_TIME, TaskContract.Instances.TASK_ID, @@ -222,15 +222,14 @@ private void updateMasterInstances(SQLiteDatabase db, TaskAdapter taskAdapter, l null, null, null, - TaskContract.Instances.INSTANCE_ORIGINAL_TIME); - - /* - * The goal of the code below is to update existing instances in place (as opposed to delete and recreate all instances). We do this for two reasons: - * 1) efficiency, in most cases existing instances don't change, deleting and recreating them would be overly expensive - * 2) stable row ids, deleting and recreating instances would change their id and void any existing URIs to them - */ - try + TaskContract.Instances.INSTANCE_ORIGINAL_TIME)) { + + /* + * The goal of the code below is to update existing instances in place (as opposed to delete and recreate all instances). We do this for two reasons: + * 1) efficiency, in most cases existing instances don't change, deleting and recreating them would be overly expensive + * 2) stable row ids, deleting and recreating instances would change their id and void any existing URIs to them + */ final int idIdx = existingInstances.getColumnIndex(TaskContract.Instances._ID); final int startIdx = existingInstances.getColumnIndex(TaskContract.Instances.INSTANCE_ORIGINAL_TIME); final int taskIdIdx = existingInstances.getColumnIndex(TaskContract.Instances.TASK_ID); @@ -321,10 +320,6 @@ else if (distance >= 0 || existingInstances.getInt(isClosedIdx) == 0) } } } - finally - { - existingInstances.close(); - } } } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java index 018ed54ff..a28a97ea5 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/processors/tasks/Validating.java @@ -88,6 +88,13 @@ public TaskAdapter update(SQLiteDatabase db, TaskAdapter task, boolean isSyncAda { throw new IllegalArgumentException("ORIGINAL_INSTANCE_SYNC_ID and ORIGINAL_INSTANCE_ID can be modified by sync adapters only"); } + + // only sync adapters are allowed to change the UID of existing tasks + if (!isSyncAdapter && task.isUpdated(TaskAdapter._UID)) + { + throw new IllegalArgumentException("modification of _UID is not allowed to non-sync adapters"); + } + return mDelegate.update(db, task, isSyncAdapter); } @@ -144,13 +151,6 @@ private void verifyCommon(TaskAdapter task, boolean isSyncAdapter) throw new IllegalArgumentException("modification of _DELETE is not allowed"); } - // only sync adapters are allowed to change the UID - // TODO: we probably should allow clients to set a UID on inserts - if (!isSyncAdapter && task.isUpdated(TaskAdapter._UID)) - { - throw new IllegalArgumentException("modification of _UID is not allowed"); - } - // only sync adapters are allowed to remove the dirty flag if (!isSyncAdapter && task.isUpdated(TaskAdapter._DIRTY)) { diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java index 24cb9e3c6..212fa6f68 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/TaskInstanceIterable.java @@ -16,8 +16,8 @@ package org.dmfs.provider.tasks.utils; +import org.dmfs.jems.optional.elementary.NullSafe; import org.dmfs.jems.single.combined.Backed; -import org.dmfs.optional.NullSafe; import org.dmfs.provider.tasks.model.TaskAdapter; import org.dmfs.rfc5545.DateTime; import org.dmfs.rfc5545.recur.RecurrenceRule; @@ -73,29 +73,12 @@ public Iterator iterator() set.addInstances(new RecurrenceRuleAdapter(rule)); } - set.addInstances(new RecurrenceList(toLongArray(mTaskAdapter.valueOf(TaskAdapter.RDATE)))); - set.addExceptions(new RecurrenceList(toLongArray(mTaskAdapter.valueOf(TaskAdapter.EXDATE)))); + set.addInstances(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.RDATE)).value())); + set.addExceptions(new RecurrenceList(new Timestamps(mTaskAdapter.valueOf(TaskAdapter.EXDATE)).value())); RecurrenceSetIterator setIterator = set.iterator(dtstart.getTimeZone(), dtstart.getTimestamp(), System.currentTimeMillis() + 10L * 356L * 3600L * 1000L); return new TaskInstanceIterator(dtstart, setIterator, mTaskAdapter.valueOf(TaskAdapter.TIMEZONE_RAW)); } - - - private long[] toLongArray(Iterable dates) - { - int count = 0; - for (DateTime ignored : dates) - { - count += 1; - } - long[] timeStamps = new long[count]; - int i = 0; - for (DateTime dt : dates) - { - timeStamps[i++] = dt.getTimestamp(); - } - return timeStamps; - } } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java new file mode 100644 index 000000000..84f0949de --- /dev/null +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/Timestamps.java @@ -0,0 +1,55 @@ +/* + * Copyright 2019 dmfs GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dmfs.provider.tasks.utils; + +import org.dmfs.jems.single.Single; +import org.dmfs.rfc5545.DateTime; + + +/** + * A {@link Single} of an array of timestamp values of a given {@link Iterable} of {@link DateTime}s. + * + * @author Marten Gajda + */ +public final class Timestamps implements Single +{ + private final Iterable mDateTimes; + + + public Timestamps(Iterable dateTimes) + { + mDateTimes = dateTimes; + } + + + @Override + public long[] value() + { + int count = 0; + for (DateTime ignored : mDateTimes) + { + count += 1; + } + long[] timeStamps = new long[count]; + int i = 0; + for (DateTime dt : mDateTimes) + { + timeStamps[i++] = dt.getTimestamp(); + } + return timeStamps; + } +} diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java index 8d84e538f..81eff9c6f 100644 --- a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/TaskInstanceIterableTest.java @@ -18,6 +18,7 @@ import android.content.ContentValues; +import org.dmfs.iterables.elementary.Seq; import org.dmfs.provider.tasks.model.ContentValuesTaskAdapter; import org.dmfs.provider.tasks.model.TaskAdapter; import org.dmfs.rfc5545.DateTime; @@ -105,4 +106,83 @@ public void testFloating() throws Exception DateTime.parse("20170624T121314") )); } + + + @Test + public void testRDate() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RDATE, new Seq<>( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } + + + @Test + public void testRDateAndRRule() throws Exception + { + TaskAdapter taskAdapter = new ContentValuesTaskAdapter(new ContentValues()); + taskAdapter.set(TaskAdapter.DTSTART, DateTime.parse("Europe/Berlin", "20170606T121314")); + taskAdapter.set(TaskAdapter.RRULE, new RecurrenceRule("FREQ=DAILY;INTERVAL=2;COUNT=10")); + taskAdapter.set(TaskAdapter.RDATE, new Seq<>( + DateTime.parse("Europe/Berlin", "20170606T121313"), + DateTime.parse("Europe/Berlin", "20170608T121313"), + DateTime.parse("Europe/Berlin", "20170610T121313"), + DateTime.parse("Europe/Berlin", "20170612T121313"), + DateTime.parse("Europe/Berlin", "20170614T121313"), + DateTime.parse("Europe/Berlin", "20170616T121313"), + DateTime.parse("Europe/Berlin", "20170618T121313"), + DateTime.parse("Europe/Berlin", "20170620T121313"), + DateTime.parse("Europe/Berlin", "20170622T121313"), + DateTime.parse("Europe/Berlin", "20170624T121313") + )); + + assertThat(new TaskInstanceIterable(taskAdapter), + iteratesTo( + DateTime.parse("Europe/Berlin", "20170606T121313"), + DateTime.parse("Europe/Berlin", "20170606T121314"), + DateTime.parse("Europe/Berlin", "20170608T121313"), + DateTime.parse("Europe/Berlin", "20170608T121314"), + DateTime.parse("Europe/Berlin", "20170610T121313"), + DateTime.parse("Europe/Berlin", "20170610T121314"), + DateTime.parse("Europe/Berlin", "20170612T121313"), + DateTime.parse("Europe/Berlin", "20170612T121314"), + DateTime.parse("Europe/Berlin", "20170614T121313"), + DateTime.parse("Europe/Berlin", "20170614T121314"), + DateTime.parse("Europe/Berlin", "20170616T121313"), + DateTime.parse("Europe/Berlin", "20170616T121314"), + DateTime.parse("Europe/Berlin", "20170618T121313"), + DateTime.parse("Europe/Berlin", "20170618T121314"), + DateTime.parse("Europe/Berlin", "20170620T121313"), + DateTime.parse("Europe/Berlin", "20170620T121314"), + DateTime.parse("Europe/Berlin", "20170622T121313"), + DateTime.parse("Europe/Berlin", "20170622T121314"), + DateTime.parse("Europe/Berlin", "20170624T121313"), + DateTime.parse("Europe/Berlin", "20170624T121314") + )); + } } \ No newline at end of file diff --git a/opentaskspal/src/main/java/org/dmfs/opentaskspal/tasks/RDatesTaskData.java b/opentaskspal/src/main/java/org/dmfs/opentaskspal/tasks/RDatesTaskData.java index cb25acaf1..57765a8af 100644 --- a/opentaskspal/src/main/java/org/dmfs/opentaskspal/tasks/RDatesTaskData.java +++ b/opentaskspal/src/main/java/org/dmfs/opentaskspal/tasks/RDatesTaskData.java @@ -16,14 +16,15 @@ package org.dmfs.opentaskspal.tasks; -import androidx.annotation.NonNull; - import org.dmfs.android.contentpal.RowData; import org.dmfs.android.contentpal.rowdata.DelegatingRowData; +import org.dmfs.jems.iterable.elementary.Seq; import org.dmfs.opentaskspal.rowdata.DateTimeListData; import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; +import androidx.annotation.NonNull; + /** * {@link RowData} for tasks with RDATEs. @@ -34,6 +35,12 @@ */ public final class RDatesTaskData extends DelegatingRowData { + public RDatesTaskData(@NonNull DateTime... rdates) + { + this(new Seq<>(rdates)); + } + + public RDatesTaskData(@NonNull Iterable rdates) { super(new DateTimeListData<>(TaskContract.Tasks.RDATE, rdates)); diff --git a/opentaskspal/src/main/java/org/dmfs/opentaskstestpal/InstanceTestData.java b/opentaskspal/src/main/java/org/dmfs/opentaskstestpal/InstanceTestData.java index 3a2c51c02..357faa2b1 100644 --- a/opentaskspal/src/main/java/org/dmfs/opentaskstestpal/InstanceTestData.java +++ b/opentaskspal/src/main/java/org/dmfs/opentaskstestpal/InstanceTestData.java @@ -17,7 +17,6 @@ package org.dmfs.opentaskstestpal; import android.content.ContentProviderOperation; -import androidx.annotation.NonNull; import org.dmfs.android.contentpal.RowData; import org.dmfs.android.contentpal.TransactionContext; @@ -29,6 +28,10 @@ import org.dmfs.rfc5545.DateTime; import org.dmfs.tasks.contract.TaskContract; +import java.util.TimeZone; + +import androidx.annotation.NonNull; + import static org.dmfs.jems.optional.elementary.Absent.absent; @@ -75,12 +78,17 @@ public ContentProviderOperation.Builder updatedBuilder(@NonNull TransactionConte { return builder .withValue(TaskContract.Instances.INSTANCE_START, new Mapped<>(DateTime::getTimestamp, mInstanceStart).value(null)) - .withValue(TaskContract.Instances.INSTANCE_START_SORTING, new Mapped<>(DateTime::getInstance, mInstanceStart).value(null)) + .withValue(TaskContract.Instances.INSTANCE_START_SORTING, new Mapped<>(this::toInstance, mInstanceStart).value(null)) .withValue(TaskContract.Instances.INSTANCE_DUE, new Mapped<>(DateTime::getTimestamp, mInstanceDue).value(null)) - .withValue(TaskContract.Instances.INSTANCE_DUE_SORTING, new Mapped<>(DateTime::getInstance, mInstanceDue).value(null)) + .withValue(TaskContract.Instances.INSTANCE_DUE_SORTING, new Mapped<>(this::toInstance, mInstanceDue).value(null)) .withValue(TaskContract.Instances.INSTANCE_DURATION, - new Backed( - new Zipped<>(mInstanceStart, mInstanceDue, (start, due) -> (due.getTimestamp() - start.getTimestamp())), () -> null).value()) + new Backed<>( + new Zipped<>( + mInstanceStart, + mInstanceDue, + (start, due) -> (due.getTimestamp() - start.getTimestamp())), + () -> null) + .value()) .withValue(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, new Mapped<>(DateTime::getTimestamp, mOriginalTime).value(null)) .withValue(TaskContract.Instances.DISTANCE_FROM_CURRENT, mDistanceFromCurrent) // the instances view overrides some of the task values. Since they are closely tied to the instance data we test them here as well. @@ -93,4 +101,10 @@ public ContentProviderOperation.Builder updatedBuilder(@NonNull TransactionConte .withValue(TaskContract.Instances.EXDATE, null); } + + private long toInstance(DateTime dateTime) + { + return (dateTime.isAllDay() ? dateTime : dateTime.shiftTimeZone(TimeZone.getDefault())).getInstance(); + } + } diff --git a/opentaskspal/src/test/java/org/dmfs/opentaskstestpal/InstanceTestDataTest.java b/opentaskspal/src/test/java/org/dmfs/opentaskstestpal/InstanceTestDataTest.java index f85437ad6..860c23f1c 100644 --- a/opentaskspal/src/test/java/org/dmfs/opentaskstestpal/InstanceTestDataTest.java +++ b/opentaskspal/src/test/java/org/dmfs/opentaskstestpal/InstanceTestDataTest.java @@ -76,9 +76,9 @@ public void testWithDate() builds( withValuesOnly( containing(TaskContract.Instances.INSTANCE_START, start.getTimestamp()), - containing(TaskContract.Instances.INSTANCE_START_SORTING, start.swapTimeZone(TimeZone.getDefault()).getInstance()), + containing(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance()), containing(TaskContract.Instances.INSTANCE_DUE, due.getTimestamp()), - containing(TaskContract.Instances.INSTANCE_DUE_SORTING, due.swapTimeZone(TimeZone.getDefault()).getInstance()), + containing(TaskContract.Instances.INSTANCE_DUE_SORTING, due.shiftTimeZone(TimeZone.getDefault()).getInstance()), containing(TaskContract.Instances.INSTANCE_DURATION, due.getTimestamp() - start.getTimestamp()), withNullValue(TaskContract.Instances.INSTANCE_ORIGINAL_TIME), containing(TaskContract.Instances.DISTANCE_FROM_CURRENT, 5), @@ -104,9 +104,9 @@ public void testWithDateAndOriginalTime() builds( withValuesOnly( containing(TaskContract.Instances.INSTANCE_START, start.getTimestamp()), - containing(TaskContract.Instances.INSTANCE_START_SORTING, start.swapTimeZone(TimeZone.getDefault()).getInstance()), + containing(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance()), containing(TaskContract.Instances.INSTANCE_DUE, due.getTimestamp()), - containing(TaskContract.Instances.INSTANCE_DUE_SORTING, due.swapTimeZone(TimeZone.getDefault()).getInstance()), + containing(TaskContract.Instances.INSTANCE_DUE_SORTING, due.shiftTimeZone(TimeZone.getDefault()).getInstance()), containing(TaskContract.Instances.INSTANCE_DURATION, due.getTimestamp() - start.getTimestamp()), containing(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, original.getTimestamp()), containing(TaskContract.Instances.DISTANCE_FROM_CURRENT, 5), @@ -131,7 +131,7 @@ public void testWithStartDateAndOriginalTime() builds( withValuesOnly( containing(TaskContract.Instances.INSTANCE_START, start.getTimestamp()), - containing(TaskContract.Instances.INSTANCE_START_SORTING, start.swapTimeZone(TimeZone.getDefault()).getInstance()), + containing(TaskContract.Instances.INSTANCE_START_SORTING, start.shiftTimeZone(TimeZone.getDefault()).getInstance()), withNullValue(TaskContract.Instances.INSTANCE_DUE), withNullValue(TaskContract.Instances.INSTANCE_DUE_SORTING), withNullValue(TaskContract.Instances.INSTANCE_DURATION), @@ -160,7 +160,7 @@ public void testWithDueDateAndOriginalTime() withNullValue(TaskContract.Instances.INSTANCE_START), withNullValue(TaskContract.Instances.INSTANCE_START_SORTING), containing(TaskContract.Instances.INSTANCE_DUE, due.getTimestamp()), - containing(TaskContract.Instances.INSTANCE_DUE_SORTING, due.swapTimeZone(TimeZone.getDefault()).getInstance()), + containing(TaskContract.Instances.INSTANCE_DUE_SORTING, due.shiftTimeZone(TimeZone.getDefault()).getInstance()), withNullValue(TaskContract.Instances.INSTANCE_DURATION), containing(TaskContract.Instances.INSTANCE_ORIGINAL_TIME, original.getTimestamp()), containing(TaskContract.Instances.DISTANCE_FROM_CURRENT, 5),