Skip to content

Commit

Permalink
Detach completed instances, #617 (#899)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dmfs committed Jan 21, 2020
1 parent 8431d4a commit b8c4544
Show file tree
Hide file tree
Showing 15 changed files with 1,306 additions and 190 deletions.
4 changes: 2 additions & 2 deletions 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'

Expand All @@ -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',
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
@@ -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<InstanceAdapter>
{

private final EntityProcessor<InstanceAdapter> mDelegate;
private final EntityProcessor<TaskAdapter> mTaskDelegate;


public Detaching(EntityProcessor<InstanceAdapter> delegate, EntityProcessor<TaskAdapter> 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
* <p>
* - 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).
* <p>
*/
@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.
* <p>
* 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.
* <p>
* - 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
}
}
Expand Up @@ -80,7 +80,9 @@ public final class TaskValueDelegate implements EntityProcessor<InstanceAdapter>
// also unset any recurrence fields
TaskAdapter.RRULE,
TaskAdapter.RDATE,
TaskAdapter.EXDATE
TaskAdapter.EXDATE,
TaskAdapter.CREATED,
TaskAdapter.LAST_MODIFIED
);

private final EntityProcessor<TaskAdapter> mDelegate;
Expand Down Expand Up @@ -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());
}
Expand Down

0 comments on commit b8c4544

Please sign in to comment.