From f7d1f5fa729de9aa07b80faea354914eb21ae5a3 Mon Sep 17 00:00:00 2001 From: Marten Gajda Date: Sun, 8 Sep 2019 23:03:07 +0200 Subject: [PATCH] Don't perform noop updates, implements #836 (#838) Updates TaskProvider to skip updates which don't change any value. In such case no database operation is necessary and no notifications will be triggered. --- opentasks-provider/build.gradle | 4 + .../tasks/TaskProviderObserverTest.java | 239 ++++++++++++++++++ .../tasks/matchers/NotifiesMatcher.java | 122 +++++++++ .../provider/tasks/matchers/UriMatcher.java | 49 ++++ .../org/dmfs/provider/tasks/TaskProvider.java | 30 ++- .../CursorContentValuesInstanceAdapter.java | 3 +- .../model/CursorContentValuesListAdapter.java | 3 +- .../model/CursorContentValuesTaskAdapter.java | 3 +- .../provider/tasks/utils/ContainsValues.java | 72 ++++++ .../tasks/utils/ContainsValuesTest.java | 94 +++++++ .../tables/LocalTaskListsTable.java | 6 +- 11 files changed, 606 insertions(+), 19 deletions(-) create mode 100644 opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderObserverTest.java create mode 100644 opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/NotifiesMatcher.java create mode 100644 opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/UriMatcher.java create mode 100644 opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java create mode 100644 opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java diff --git a/opentasks-provider/build.gradle b/opentasks-provider/build.gradle index 112d39ac8..21857c950 100644 --- a/opentasks-provider/build.gradle +++ b/opentasks-provider/build.gradle @@ -43,4 +43,8 @@ dependencies { androidTestImplementation deps.support_annotations androidTestImplementation deps.support_test_runner androidTestImplementation deps.support_test_rules + androidTestImplementation deps.mockito + androidTestImplementation deps.jems_testing + androidTestImplementation deps.hamcrest + androidTestImplementation deps.contentpal_testing } diff --git a/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderObserverTest.java b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderObserverTest.java new file mode 100644 index 000000000..4784e53f9 --- /dev/null +++ b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/TaskProviderObserverTest.java @@ -0,0 +1,239 @@ +/* + * 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.operations.BulkDelete; +import org.dmfs.android.contentpal.operations.Put; +import org.dmfs.android.contentpal.queues.BasicOperationsQueue; +import org.dmfs.android.contentpal.rowsnapshots.VirtualRowSnapshot; +import org.dmfs.android.contentpal.tables.Synced; +import org.dmfs.android.contenttestpal.operations.AssertEmptyTable; +import org.dmfs.iterables.elementary.Seq; +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.tasklists.NameData; +import org.dmfs.opentaskspal.tasks.TitleData; +import org.dmfs.tasks.contract.TaskContract; +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 androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import static org.dmfs.android.contentpal.testing.android.uri.UriMatcher.hasParam; +import static org.dmfs.provider.tasks.matchers.NotifiesMatcher.notifies; +import static org.dmfs.provider.tasks.matchers.UriMatcher.authority; +import static org.dmfs.provider.tasks.matchers.UriMatcher.path; +import static org.dmfs.provider.tasks.matchers.UriMatcher.scheme; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + + +/** + * Tests for {@link TaskProvider}. + * + * @author Marten Gajda + */ +@RunWith(AndroidJUnit4.class) +public class TaskProviderObserverTest +{ + private String mAuthority; + private Context mContext; + private ContentProviderClient mClient; + private final Account testAccount = new Account("foo", "bar"); + + + @Before + public void setUp() throws Exception + { + mContext = InstrumentationRegistry.getTargetContext(); + mAuthority = AuthorityUtil.taskAuthority(mContext); + mClient = mContext.getContentResolver().acquireContentProviderClient(mAuthority); + + // Assert that tables are empty: + OperationsQueue queue = new BasicOperationsQueue(mClient); + 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 Seq>( + new BulkDelete<>(new LocalTaskListsTable(mAuthority)), + new BulkDelete<>(new Synced<>(testAccount, new TaskListsTable(mAuthority))))); + queue.flush(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + { + mClient.close(); + } + else + { + mClient.release(); + } + } + + + /** + * Test notifications for creating one task list and task. + */ + @Test + public void testSingleInsert() throws RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new LocalTaskListsTable(mAuthority)); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + OperationsQueue queue = new BasicOperationsQueue(mClient); + + assertThat(new Seq<>( + new Put<>(taskList, new NameData("list1")), + new Put<>(task, new TitleData("task1"))), + notifies( + TaskContract.getContentUri(mAuthority), + queue, + containsInAnyOrder( + allOf( + scheme("content"), + authority(mAuthority), + path(is("/tasks")) + ), + allOf( + scheme("content"), + authority(mAuthority), + path(startsWith("/tasks/")) + ), + allOf( + scheme("content"), + authority(mAuthority), + path(startsWith("/instances")) + ), + allOf( + scheme("content"), + authority(mAuthority), + path(startsWith("/tasklists/")) + ), + allOf( + scheme("content"), + authority(mAuthority), + path(is("/tasklists")), + hasParam(TaskContract.CALLER_IS_SYNCADAPTER, "true"), + hasParam(TaskContract.ACCOUNT_NAME, TaskContract.LOCAL_ACCOUNT_NAME), + hasParam(TaskContract.ACCOUNT_TYPE, TaskContract.LOCAL_ACCOUNT_TYPE) + )))); + } + + + /** + * Update a task and check the notifications. + */ + @Test + public void testSingleUpdate() throws RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new LocalTaskListsTable(mAuthority)); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + OperationsQueue queue = new BasicOperationsQueue(mClient); + + queue.enqueue( + new Seq<>( + new Put<>(taskList, new NameData("list1")), + new Put<>(task, new TitleData("task1")))); + queue.flush(); + + assertThat(new Seq<>( + new Put<>(task, new TitleData("task1b"))), + notifies( + TaskContract.getContentUri(mAuthority), + queue, + // taskprovider should notity the tasks URI iself, the task diretory and the instances directory + containsInAnyOrder( + allOf( + scheme("content"), + authority(mAuthority), + path(is("/tasks")) + ), + allOf( + scheme("content"), + authority(mAuthority), + path(startsWith("/tasks/")) + ), + allOf( + scheme("content"), + authority(mAuthority), + path(is("/instances")) + )))); + } + + + /** + * Test that an update that doesn't change anything doesn't trigger a notification. + */ + @Test + public void testNoOpUpdate() throws RemoteException, OperationApplicationException + { + RowSnapshot taskList = new VirtualRowSnapshot<>(new LocalTaskListsTable(mAuthority)); + RowSnapshot task = new VirtualRowSnapshot<>(new TaskListScoped(taskList, new TasksTable(mAuthority))); + OperationsQueue queue = new BasicOperationsQueue(mClient); + + queue.enqueue( + new Seq<>( + new Put<>(taskList, new NameData("list1")), + new Put<>(task, new TitleData("task1")))); + queue.flush(); + + assertThat(new Seq<>( + new Put<>(task, new TitleData("task1"))), + notifies( + TaskContract.getContentUri(mAuthority), + queue, + // there should no notification + emptyIterable())); + } + +} diff --git a/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/NotifiesMatcher.java b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/NotifiesMatcher.java new file mode 100644 index 000000000..39014d6c9 --- /dev/null +++ b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/NotifiesMatcher.java @@ -0,0 +1,122 @@ +/* + * 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.matchers; + +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; + +import org.dmfs.android.contentpal.Operation; +import org.dmfs.android.contentpal.OperationsQueue; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; + +import androidx.annotation.NonNull; +import androidx.test.platform.app.InstrumentationRegistry; + + +/** + * @author Marten Gajda + */ +public final class NotifiesMatcher extends TypeSafeDiagnosingMatcher>> +{ + private final Uri mUri; + private final OperationsQueue mOperationsQueue; + private final Matcher> mDelegate; + + + public static Matcher>> notifies(@NonNull Uri uri, @NonNull OperationsQueue operationsQueue, @NonNull Matcher> delegate) + { + return new NotifiesMatcher(uri, operationsQueue, delegate); + } + + + public NotifiesMatcher(Uri uri, @NonNull OperationsQueue operationsQueue, @NonNull Matcher> delegate) + { + mUri = uri; + mOperationsQueue = operationsQueue; + mDelegate = delegate; + } + + + @Override + protected boolean matchesSafely(Iterable> item, Description mismatchDescription) + { + Collection notifications = Collections.synchronizedCollection(new HashSet<>()); + HandlerThread handlerThread = new HandlerThread("ObserverHandlerThread"); + handlerThread.start(); + + ContentObserver observer = new ContentObserver(new Handler(handlerThread.getLooper())) + { + @Override + public void onChange(boolean selfChange, Uri uri) + { + super.onChange(selfChange, uri); + System.out.println("Notifcation: " + uri); + notifications.add(uri); + } + }; + + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + context.getContentResolver().registerContentObserver(mUri, true, observer); + try + { + try + { + mOperationsQueue.enqueue(item); + mOperationsQueue.flush(); + } + catch (Exception e) + { + throw new RuntimeException("Exception during executing the target OperationBatch", e); + } + + Thread.sleep(100); + if (!mDelegate.matches(notifications)) + { + mismatchDescription.appendText("Wrong notifications "); + mDelegate.describeMismatch(notifications, mismatchDescription); + return false; + } + return true; + } + catch (InterruptedException e) + { + e.printStackTrace(); + return false; + } + finally + { + context.getContentResolver().unregisterContentObserver(observer); + handlerThread.quit(); + } + } + + + @Override + public void describeTo(Description description) + { + description.appendText("Notifies ").appendDescriptionOf(mDelegate); + } +} diff --git a/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/UriMatcher.java b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/UriMatcher.java new file mode 100644 index 000000000..8af65df06 --- /dev/null +++ b/opentasks-provider/src/androidTest/java/org/dmfs/provider/tasks/matchers/UriMatcher.java @@ -0,0 +1,49 @@ +/* + * 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.matchers; + +import android.net.Uri; + +import org.hamcrest.Matcher; + +import static org.dmfs.jems.hamcrest.matchers.LambdaMatcher.having; +import static org.hamcrest.Matchers.is; + + +/** + * @author Marten Gajda + */ +public final class UriMatcher +{ + public static Matcher scheme(String scheme) + { + return having(Uri::getScheme, is(scheme)); + } + + + public static Matcher authority(String authority) + { + return having(Uri::getEncodedAuthority, is(authority)); + } + + + public static Matcher path(Matcher patchMatcher) + { + return having(Uri::getEncodedPath, patchMatcher); + } + +} 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 07d272d4a..694d47a41 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 @@ -80,6 +80,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -154,9 +155,9 @@ public final class TaskProvider extends SQLiteContentProvider implements OnAccou /** * Boolean to track if there are changes within a transaction. *

- * This can be shared by multiple threads, hence the {@link AtomicReference}. + * This can be shared by multiple threads, hence the {@link AtomicBoolean}. */ - private AtomicReference mChanged = new AtomicReference<>(false); + private AtomicBoolean mChanged = new AtomicBoolean(false); /** * This is a per transaction/thread flag which indicates whether new lists with an unknown account have been added. @@ -1020,7 +1021,7 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa final boolean isSyncAdapter) { int count = 0; - boolean dataChanged = !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); + boolean dataChanged = false; switch (mUriMatcher.match(uri)) { case SYNCSTATE_ID: @@ -1076,8 +1077,12 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa // we need this, because the processors may change the values final ListAdapter list = new CursorContentValuesListAdapter(listId, cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); - mListProcessorChain.update(db, list, isSyncAdapter); - mChanged.set(true); + if (list.hasUpdates()) + { + mListProcessorChain.update(db, list, isSyncAdapter); + dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); + } + // note we still count the row even if no update was necessary count++; } } @@ -1104,11 +1109,12 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa // we need this, because the processors may change the values final TaskAdapter task = new CursorContentValuesTaskAdapter(cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); - mTaskProcessorChain.update(db, task, isSyncAdapter); - if (dataChanged && task.hasUpdates()) + if (task.hasUpdates()) { - mChanged.set(true); + mTaskProcessorChain.update(db, task, isSyncAdapter); + dataChanged |= !TASK_LIST_SYNC_COLUMNS.containsAll(values.keySet()); } + // note we still count the row even if no update was necessary count++; } } @@ -1142,11 +1148,12 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa final InstanceAdapter instance = new CursorContentValuesInstanceAdapter(cursor, cursor.getCount() > 1 ? new ContentValues(values) : values); - mInstanceProcessorChain.update(db, instance, isSyncAdapter); - if (dataChanged && instance.hasUpdates()) + if (instance.hasUpdates()) { - mChanged.set(true); + mInstanceProcessorChain.update(db, instance, isSyncAdapter); + dataChanged = true; } + // note we still count the row even if no update was necessary count++; } } @@ -1229,6 +1236,7 @@ public int updateInTransaction(final SQLiteDatabase db, Uri uri, final ContentVa { // send notifications, because non-sync columns have been updated postNotifyUri(uri); + mChanged.set(true); } return count; diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java index cba268b6a..182196599 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesInstanceAdapter.java @@ -28,6 +28,7 @@ import org.dmfs.jems.single.elementary.Reduced; import org.dmfs.provider.tasks.TaskDatabaseHelper; import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; import org.dmfs.tasks.contract.TaskContract; import java.util.ArrayList; @@ -111,7 +112,7 @@ public boolean isWriteable() @Override public boolean hasUpdates() { - return mValues != null && mValues.size() > 0; + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java index 3c542ffb6..76fa73152 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesListAdapter.java @@ -22,6 +22,7 @@ import org.dmfs.provider.tasks.TaskDatabaseHelper; import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; import org.dmfs.tasks.contract.TaskContract; @@ -81,7 +82,7 @@ public boolean isWriteable() @Override public boolean hasUpdates() { - return mValues != null && mValues.size() > 0; + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java index d8e087122..d6c49331a 100644 --- a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/model/CursorContentValuesTaskAdapter.java @@ -22,6 +22,7 @@ import org.dmfs.provider.tasks.TaskDatabaseHelper; import org.dmfs.provider.tasks.model.adapters.FieldAdapter; +import org.dmfs.provider.tasks.utils.ContainsValues; import org.dmfs.tasks.contract.TaskContract; @@ -103,7 +104,7 @@ public boolean isWriteable() @Override public boolean hasUpdates() { - return mValues != null && mValues.size() > 0; + return mValues != null && mValues.size() > 0 && !new ContainsValues(mValues).satisfiedBy(mCursor); } diff --git a/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java new file mode 100644 index 000000000..3f37b2ec5 --- /dev/null +++ b/opentasks-provider/src/main/java/org/dmfs/provider/tasks/utils/ContainsValues.java @@ -0,0 +1,72 @@ +/* + * 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 android.content.ContentValues; +import android.database.Cursor; + +import org.dmfs.jems.predicate.Predicate; + +import java.util.Arrays; + + +/** + * A {@link Predicate} which determines whether all values of a ContentValues object are present in a {@link Cursor}. + * + * @author Marten Gajda + */ +public final class ContainsValues implements Predicate +{ + private final ContentValues mValues; + + + public ContainsValues(ContentValues values) + { + mValues = values; + } + + + @Override + public boolean satisfiedBy(Cursor testedInstance) + { + for (String key : mValues.keySet()) + { + int columnIdx = testedInstance.getColumnIndex(key); + if (columnIdx < 0) + { + return false; + } + + if (testedInstance.getType(columnIdx) == Cursor.FIELD_TYPE_BLOB) + { + if (!Arrays.equals(mValues.getAsByteArray(key), testedInstance.getBlob(columnIdx))) + { + return false; + } + } + else + { + String stringValue = mValues.getAsString(key); + if (stringValue != null && !stringValue.equals(testedInstance.getString(columnIdx)) || stringValue == null && !testedInstance.isNull(columnIdx)) + { + return false; + } + } + } + return true; + } +} diff --git a/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java new file mode 100644 index 000000000..750f4778d --- /dev/null +++ b/opentasks-provider/src/test/java/org/dmfs/provider/tasks/utils/ContainsValuesTest.java @@ -0,0 +1,94 @@ +/* + * 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 android.content.ContentValues; +import android.database.MatrixCursor; + +import org.dmfs.iterables.elementary.Seq; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.dmfs.jems.hamcrest.matchers.predicate.PredicateMatcher.satisfiedBy; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + + +/** + * @author Marten Gajda + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ContainsValuesTest +{ + @Test + public void test() + { + ContentValues values = new ContentValues(); + values.put("a", 123); + values.put("b", "stringValue"); + values.put("c", new byte[] { 3, 2, 1 }); + values.putNull("d"); + + MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b", "a", "d", "f" }); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", "123", null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2 }, "stringValue", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValueX", 123, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 1234, null, "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue", 123, "123", "xyz")); + cursor.addRow(new Seq<>(321, "stringValueX", "1234", "123", "xyz")); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1, 0 }, "stringValueX", 1234, "123", "xyz")); + + cursor.moveToFirst(); + assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(satisfiedBy(cursor))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + cursor.moveToNext(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + } + + + @Test + public void testMissingColumns() + { + ContentValues values = new ContentValues(); + values.put("a", 123); + values.put("b", "stringValue"); + values.put("c", new byte[] { 3, 2, 1 }); + values.putNull("d"); + + MatrixCursor cursor = new MatrixCursor(new String[] { "c", "b" }); + cursor.addRow(new Seq<>(new byte[] { 3, 2, 1 }, "stringValue")); + + cursor.moveToFirst(); + assertThat(new ContainsValues(values), is(not(satisfiedBy(cursor)))); + } +} \ No newline at end of file diff --git a/opentaskspal/src/main/java/org/dmfs/opentaskspal/tables/LocalTaskListsTable.java b/opentaskspal/src/main/java/org/dmfs/opentaskspal/tables/LocalTaskListsTable.java index d9e7b8e94..964c55036 100644 --- a/opentaskspal/src/main/java/org/dmfs/opentaskspal/tables/LocalTaskListsTable.java +++ b/opentaskspal/src/main/java/org/dmfs/opentaskspal/tables/LocalTaskListsTable.java @@ -19,7 +19,6 @@ import android.accounts.Account; import org.dmfs.android.contentpal.Table; -import org.dmfs.android.contentpal.tables.AccountScoped; import org.dmfs.android.contentpal.tables.DelegatingTable; import org.dmfs.android.contentpal.tables.Synced; import org.dmfs.tasks.contract.TaskContract; @@ -42,9 +41,6 @@ public LocalTaskListsTable(@NonNull String authority) private LocalTaskListsTable(@NonNull Account localAccount, @NonNull String authority) { - // TODO When https://github.com/dmfs/opentasks/issues/416 is completed Synced can be removed from here: - super(new Synced<>(localAccount, - new AccountScoped<>(localAccount, - new TaskListsTable(authority)))); + super(new Synced<>(localAccount, new TaskListsTable(authority))); } }