BP2 ContentResolver.notifyChange tests

 Bug: 3431373

Change-Id: I0ce30213150c6dd51b128723f044bd32eb2b46a3
diff --git a/src/com/android/browser/provider/BrowserProvider2.java b/src/com/android/browser/provider/BrowserProvider2.java
index 74cb773..85d6d64 100644
--- a/src/com/android/browser/provider/BrowserProvider2.java
+++ b/src/com/android/browser/provider/BrowserProvider2.java
@@ -16,6 +16,8 @@
 
 package com.android.browser.provider;
 
+import com.google.common.annotations.VisibleForTesting;
+
 import com.android.browser.BookmarkUtils;
 import com.android.browser.BrowserBookmarksPage;
 import com.android.browser.R;
@@ -34,6 +36,7 @@
 import android.content.res.Resources;
 import android.content.res.TypedArray;
 import android.database.AbstractCursor;
+import android.database.ContentObserver;
 import android.database.Cursor;
 import android.database.DatabaseUtils;
 import android.database.MatrixCursor;
@@ -66,7 +69,7 @@
 
 public class BrowserProvider2 extends SQLiteContentProvider {
 
-    static final String LEGACY_AUTHORITY = "browser";
+    public static final String LEGACY_AUTHORITY = "browser";
     static final Uri LEGACY_AUTHORITY_URI = new Uri.Builder()
             .authority(LEGACY_AUTHORITY).scheme("content").build();
 
@@ -319,6 +322,8 @@
 
     DatabaseHelper mOpenHelper;
     SyncStateContentProviderHelper mSyncHelper = new SyncStateContentProviderHelper();
+    // This is so provider tests can intercept widget updating
+    ContentObserver mWidgetObserver = null;
 
     final class DatabaseHelper extends SQLiteOpenHelper {
         static final String DATABASE_NAME = "browser2.db";
@@ -580,6 +585,19 @@
         resolver.notifyChange(LEGACY_AUTHORITY_URI, null, !callerIsSyncAdapter);
     }
 
+    @VisibleForTesting
+    public void setWidgetObserver(ContentObserver obs) {
+        mWidgetObserver = obs;
+    }
+
+    void refreshWidgets() {
+        if (mWidgetObserver == null) {
+            BookmarkThumbnailWidgetProvider.refreshWidgets(getContext());
+        } else {
+            mWidgetObserver.dispatchChange(false);
+        }
+    }
+
     @Override
     public String getType(Uri uri) {
         final int match = URI_MATCHER.match(uri);
@@ -987,7 +1005,7 @@
                 int deleted = deleteBookmarks(selection, selectionArgs, callerIsSyncAdapter);
                 pruneImages();
                 if (deleted > 0) {
-                    BookmarkThumbnailWidgetProvider.refreshWidgets(getContext());
+                    refreshWidgets();
                 }
                 return deleted;
             }
@@ -1150,7 +1168,7 @@
                 }
 
                 id = db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.DIRTY, values);
-                BookmarkThumbnailWidgetProvider.refreshWidgets(getContext());
+                refreshWidgets();
                 break;
             }
 
@@ -1320,7 +1338,7 @@
                         callerIsSyncAdapter);
                 pruneImages();
                 if (updated > 0) {
-                    BookmarkThumbnailWidgetProvider.refreshWidgets(getContext());
+                    refreshWidgets();
                 }
                 return updated;
             }
@@ -1363,7 +1381,7 @@
                     count = 1;
                 }
                 if (count > 0) {
-                    BookmarkThumbnailWidgetProvider.refreshWidgets(getContext());
+                    refreshWidgets();
                 }
                 return count;
             }
diff --git a/tests/src/com/android/browser/tests/BP2UriObserverTests.java b/tests/src/com/android/browser/tests/BP2UriObserverTests.java
new file mode 100644
index 0000000..28b3ec9
--- /dev/null
+++ b/tests/src/com/android/browser/tests/BP2UriObserverTests.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * 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 com.android.browser.tests;
+
+import com.android.browser.tests.utils.BP2TestCaseHelper;
+
+import android.content.ContentValues;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.net.Uri;
+import android.provider.BrowserContract.Bookmarks;
+import android.provider.BrowserContract.History;
+
+import java.io.ByteArrayOutputStream;
+
+public class BP2UriObserverTests extends BP2TestCaseHelper {
+
+    public void testInsertBookmark() {
+        Uri insertedUri = insertBookmark("http://stub1.com", "Stub1");
+        TriggeredObserver stubObs = new TriggeredObserver(insertedUri);
+        assertObserversTriggered(false, stubObs);
+        insertBookmark("http://stub2.com", "Stub2");
+        perfIdeallyUntriggered(stubObs);
+    }
+
+    public void testUpdateBookmark() {
+        Uri toUpdate = insertBookmark("http://stub1.com", "Stub1");
+        Uri unchanged = insertBookmark("http://stub2.com", "Stub2");
+        TriggeredObserver updateObs = new TriggeredObserver(toUpdate);
+        TriggeredObserver unchangedObs = new TriggeredObserver(unchanged);
+        assertObserversTriggered(false, updateObs, unchangedObs);
+        assertTrue(updateBookmark(toUpdate, "http://stub1.com", "Stub1: Revenge of the stubs"));
+        assertTrue("Update observer not notified!", updateObs.checkTriggered());
+        perfIdeallyUntriggered(unchangedObs);
+    }
+
+    public void testUpdateBookmarkImages() {
+        Uri toUpdate = insertBookmark("http://stub1.com", "Stub1");
+        Uri unchanged = insertBookmark("http://stub2.com", "Stub2");
+        Bitmap favicon = Bitmap.createBitmap(16, 16, Config.ARGB_8888);
+        ByteArrayOutputStream os = new ByteArrayOutputStream();
+        favicon.compress(Bitmap.CompressFormat.PNG, 100, os);
+        byte[] rawFavicon = os.toByteArray();
+        ContentValues values = new ContentValues();
+        values.put(Bookmarks.FAVICON, rawFavicon);
+        values.put(Bookmarks.TITLE, "Stub1");
+        TriggeredObserver updateObs = new TriggeredObserver(toUpdate);
+        TriggeredObserver unchangedObs = new TriggeredObserver(unchanged);
+        assertTrue(updateBookmark(toUpdate, values));
+        assertTrue("Update observer not notified!", updateObs.checkTriggered());
+        perfIdeallyUntriggered(unchangedObs);
+    }
+
+    public void testInsertHistory() {
+        Uri insertedUri = insertHistory("http://stub1.com", "Stub1");
+        TriggeredObserver stubObs = new TriggeredObserver(insertedUri);
+        assertObserversTriggered(false, stubObs);
+        insertHistory("http://stub2.com", "Stub2");
+        perfIdeallyUntriggered(stubObs);
+    }
+
+    public void testUpdateHistory() {
+        Uri toUpdate = insertHistory("http://stub1.com", "Stub1");
+        Uri unchanged = insertHistory("http://stub2.com", "Stub2");
+        TriggeredObserver updateObs = new TriggeredObserver(toUpdate);
+        TriggeredObserver unchangedObs = new TriggeredObserver(unchanged);
+        assertObserversTriggered(false, updateObs, unchangedObs);
+        assertTrue(updateHistory(toUpdate, "http://stub1.com", "Stub1: Revenge of the stubs"));
+        assertTrue("Update observer not notified!", updateObs.checkTriggered());
+        perfIdeallyUntriggered(unchangedObs);
+    }
+
+    public void testUpdateHistoryImages() {
+        Uri toUpdate = insertHistory("http://stub1.com", "Stub1");
+        Uri unchanged = insertHistory("http://stub2.com", "Stub2");
+        Bitmap favicon = Bitmap.createBitmap(16, 16, Config.ARGB_8888);
+        ByteArrayOutputStream os = new ByteArrayOutputStream();
+        favicon.compress(Bitmap.CompressFormat.PNG, 100, os);
+        byte[] rawFavicon = os.toByteArray();
+        ContentValues values = new ContentValues();
+        values.put(History.FAVICON, rawFavicon);
+        values.put(History.TITLE, "Stub1");
+        TriggeredObserver updateObs = new TriggeredObserver(toUpdate);
+        TriggeredObserver unchangedObs = new TriggeredObserver(unchanged);
+        assertTrue(updateHistory(toUpdate, values));
+        assertTrue("Update observer not notified!", updateObs.checkTriggered());
+        perfIdeallyUntriggered(unchangedObs);
+    }
+}
diff --git a/tests/src/com/android/browser/tests/utils/BP2TestCaseHelper.java b/tests/src/com/android/browser/tests/utils/BP2TestCaseHelper.java
new file mode 100644
index 0000000..58e5bbe
--- /dev/null
+++ b/tests/src/com/android/browser/tests/utils/BP2TestCaseHelper.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * 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 com.android.browser.tests.utils;
+
+import com.android.browser.provider.BrowserProvider2;
+
+import android.content.ContentValues;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.provider.Browser;
+import android.provider.BrowserContract;
+import android.provider.BrowserContract.Bookmarks;
+import android.provider.BrowserContract.History;
+import android.util.Log;
+
+/**
+ *  This is a replacement for ProviderTestCase2 that can handle notifyChange testing.
+ *  It also has helper methods specifically for testing BrowserProvider2
+ */
+public abstract class BP2TestCaseHelper extends ProviderTestCase3<BrowserProvider2> {
+
+    // Tag for potential performance impacts
+    private static final String PERFTAG = "BP2-PerfCheck";
+
+    private TriggeredObserver mLegacyObserver;
+    private TriggeredObserver mRootObserver;
+    private TriggeredObserver mBookmarksObserver;
+    private TriggeredObserver mHistoryObserver;
+    private TriggeredObserver mWidgetObserver;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        mLegacyObserver = new TriggeredObserver(Browser.BOOKMARKS_URI);
+        mRootObserver = new TriggeredObserver(BrowserContract.AUTHORITY_URI);
+        mBookmarksObserver = new TriggeredObserver(Bookmarks.CONTENT_URI);
+        mHistoryObserver = new TriggeredObserver(History.CONTENT_URI);
+        mWidgetObserver = new TriggeredObserver();
+        // We don't need to worry about setting this back to null since this
+        // is a private instance local to the MockContentResolver
+        getProvider().setWidgetObserver(mWidgetObserver);
+    }
+
+    public BP2TestCaseHelper() {
+        super(BrowserProvider2.class,
+                BrowserContract.AUTHORITY, BrowserProvider2.LEGACY_AUTHORITY);
+    }
+
+    public void perfIdeallyUntriggered(TriggeredObserver... obs) {
+        for (TriggeredObserver ob : obs) {
+            if (ob.checkTriggered()) {
+                // Not ideal, unnecessary notification
+                Log.i(PERFTAG, ob.mUri + " onChange called but content unaltered!");
+            }
+        }
+    }
+
+    public void assertObserversTriggered(boolean triggered,
+            TriggeredObserver... observers) {
+        for (TriggeredObserver obs : observers) {
+            assertEquals(obs.mUri + ", descendents:" + obs.mNotifyForDescendents,
+                    triggered, obs.checkTriggered());
+        }
+    }
+
+    public class TriggeredObserver extends ContentObserver {
+        private boolean mTriggered;
+        Uri mUri;
+        boolean mNotifyForDescendents;
+
+        /**
+         * Creates an unmanaged TriggeredObserver
+         */
+        public TriggeredObserver() {
+            super(null);
+        }
+
+        /**
+         * Same as TriggeredObserver(uri, true);
+         */
+        public TriggeredObserver(Uri uri) {
+            this(uri, true);
+        }
+
+        /**
+         * Creates a managed TriggeredObserver that self-registers with the
+         * mock ContentResolver
+         */
+        public TriggeredObserver(Uri uri, boolean notifyForDescendents) {
+            super(null);
+            mUri = uri;
+            mNotifyForDescendents = notifyForDescendents;
+            registerContentObserver(uri, notifyForDescendents, this);
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            super.onChange(selfChange);
+            mTriggered = true;
+        }
+
+        public boolean checkTriggered() {
+            boolean ret = mTriggered;
+            mTriggered = false;
+            return ret;
+        }
+    }
+
+    Uri mockInsert(Uri url, ContentValues values) {
+        assertObserversTriggered(false, mLegacyObserver, mRootObserver);
+        Uri ret = getMockContentResolver().insert(url, values);
+        assertObserversTriggered(true, mLegacyObserver, mRootObserver);
+        return ret;
+    }
+
+    int mockUpdate(Uri uri, ContentValues values, String where,
+            String[] selectionArgs) {
+        assertObserversTriggered(false, mLegacyObserver, mRootObserver);
+        int ret = getMockContentResolver().update(uri, values, where, selectionArgs);
+        if (ret > 0) {
+            assertObserversTriggered(true, mLegacyObserver, mRootObserver);
+        } else {
+            perfIdeallyUntriggered(mLegacyObserver);
+            perfIdeallyUntriggered(mRootObserver);
+        }
+        return ret;
+    }
+
+    public Uri insertBookmark(String url, String title) {
+        ContentValues values = new ContentValues();
+        values.put(BrowserContract.Bookmarks.TITLE, title);
+        values.put(BrowserContract.Bookmarks.URL, url);
+        values.put(BrowserContract.Bookmarks.IS_FOLDER, 0);
+        assertObserversTriggered(false, mBookmarksObserver, mWidgetObserver);
+        Uri ret = mockInsert(Bookmarks.CONTENT_URI, values);
+        assertObserversTriggered(true, mBookmarksObserver, mWidgetObserver);
+        perfIdeallyUntriggered(mHistoryObserver);
+        return ret;
+    }
+
+    public boolean updateBookmark(Uri uri, String url, String title) {
+        ContentValues values = new ContentValues();
+        values.put(BrowserContract.Bookmarks.TITLE, title);
+        values.put(BrowserContract.Bookmarks.URL, url);
+        return updateBookmark(uri, values);
+    }
+
+    public boolean updateBookmark(Uri uri, ContentValues values) {
+        assertObserversTriggered(false, mBookmarksObserver, mWidgetObserver);
+        int modifyCount = mockUpdate(uri, values, null, null);
+        assertTrue("UpdatedBookmark modified too much! " + uri, modifyCount <= 1);
+        boolean updated = modifyCount == 1;
+        if (updated) {
+            assertObserversTriggered(updated, mBookmarksObserver, mWidgetObserver);
+        } else {
+            perfIdeallyUntriggered(mBookmarksObserver, mWidgetObserver);
+        }
+        perfIdeallyUntriggered(mHistoryObserver);
+        return updated;
+    }
+
+    public Uri insertHistory(String url, String title) {
+        ContentValues values = new ContentValues();
+        values.put(BrowserContract.History.TITLE, title);
+        values.put(BrowserContract.History.URL, url);
+        assertObserversTriggered(false, mHistoryObserver);
+        Uri ret = mockInsert(History.CONTENT_URI, values);
+        assertObserversTriggered(true, mHistoryObserver);
+        perfIdeallyUntriggered(mBookmarksObserver, mWidgetObserver);
+        return ret;
+    }
+
+    public boolean updateHistory(Uri uri, String url, String title) {
+        ContentValues values = new ContentValues();
+        values.put(BrowserContract.History.TITLE, title);
+        values.put(BrowserContract.History.URL, url);
+        return updateHistory(uri, values);
+    }
+
+    public boolean updateHistory(Uri uri, ContentValues values) {
+        assertObserversTriggered(false, mHistoryObserver);
+        int modifyCount = mockUpdate(uri, values, null, null);
+        assertTrue("UpdatedHistory modified too much! " + uri, modifyCount <= 1);
+        boolean updated = modifyCount == 1;
+        if (updated) {
+            assertObserversTriggered(updated, mHistoryObserver);
+        } else {
+            perfIdeallyUntriggered(mHistoryObserver);
+        }
+        perfIdeallyUntriggered(mBookmarksObserver, mWidgetObserver);
+        return updated;
+    }
+}
diff --git a/tests/src/com/android/browser/tests/utils/MockContentResolver2.java b/tests/src/com/android/browser/tests/utils/MockContentResolver2.java
new file mode 100644
index 0000000..20f5521
--- /dev/null
+++ b/tests/src/com/android/browser/tests/utils/MockContentResolver2.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * 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 com.android.browser.tests.utils;
+
+import com.google.android.collect.Maps;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.IContentProvider;
+import android.database.ContentObserver;
+import android.net.Uri;
+
+import java.util.Map;
+
+public class MockContentResolver2 extends ContentResolver {
+
+    Map<String, ContentProvider> mProviders;
+    private final MockObserverNode mRootNode = new MockObserverNode("");
+
+    /*
+     * Creates a local map of providers. This map is used instead of the global map when an
+     * API call tries to acquire a provider.
+     */
+    public MockContentResolver2() {
+        super(null);
+        mProviders = Maps.newHashMap();
+    }
+
+    /**
+     * Adds access to a provider based on its authority
+     *
+     * @param name The authority name associated with the provider.
+     * @param provider An instance of {@link android.content.ContentProvider} or one of its
+     * subclasses, or null.
+     */
+    public void addProvider(String name, ContentProvider provider) {
+
+        /*
+         * Maps the authority to the provider locally.
+         */
+        mProviders.put(name, provider);
+    }
+
+    /** @hide */
+    @Override
+    protected IContentProvider acquireProvider(Context context, String name) {
+        return acquireExistingProvider(context, name);
+    }
+
+    /** @hide */
+    @Override
+    protected IContentProvider acquireExistingProvider(Context context, String name) {
+
+        /*
+         * Gets the content provider from the local map
+         */
+        final ContentProvider provider = mProviders.get(name);
+
+        if (provider != null) {
+            return provider.getIContentProvider();
+        } else {
+            return null;
+        }
+    }
+
+    /** @hide */
+    @Override
+    public boolean releaseProvider(IContentProvider provider) {
+        return true;
+    }
+
+    @Override
+    public void notifyChange(Uri uri, ContentObserver observer,
+            boolean syncToNetwork) {
+        mRootNode.notifyMyObservers(uri, 0, observer, false);
+    }
+
+    public void safeRegisterContentObserver(Uri uri, boolean notifyForDescendents,
+            ContentObserver observer) {
+        mRootNode.addObserver(uri, observer, notifyForDescendents);
+    }
+
+    public void safeUnregisterContentObserver(ContentObserver observer) {
+        mRootNode.removeObserver(observer);
+    }
+
+}
diff --git a/tests/src/com/android/browser/tests/utils/MockObserverNode.java b/tests/src/com/android/browser/tests/utils/MockObserverNode.java
new file mode 100644
index 0000000..edcffd4
--- /dev/null
+++ b/tests/src/com/android/browser/tests/utils/MockObserverNode.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * 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 com.android.browser.tests.utils;
+
+import android.database.ContentObserver;
+import android.net.Uri;
+
+import java.util.ArrayList;
+
+public final class MockObserverNode {
+    private class MockObserverEntry {
+        public final ContentObserver observer;
+        public final boolean notifyForDescendents;
+
+        public MockObserverEntry(ContentObserver o, boolean n) {
+            observer = o;
+            notifyForDescendents = n;
+        }
+    }
+
+    public static final int INSERT_TYPE = 0;
+    public static final int UPDATE_TYPE = 1;
+    public static final int DELETE_TYPE = 2;
+
+    private String mName;
+    private ArrayList<MockObserverNode> mChildren = new ArrayList<MockObserverNode>();
+    private ArrayList<MockObserverEntry> mObservers = new ArrayList<MockObserverEntry>();
+
+    public MockObserverNode(String name) {
+        mName = name;
+    }
+
+    private String getUriSegment(Uri uri, int index) {
+        if (uri != null) {
+            if (index == 0) {
+                return uri.getAuthority();
+            } else {
+                return uri.getPathSegments().get(index - 1);
+            }
+        } else {
+            return null;
+        }
+    }
+
+    private int countUriSegments(Uri uri) {
+        if (uri == null) {
+            return 0;
+        }
+        return uri.getPathSegments().size() + 1;
+    }
+
+    public void addObserver(Uri uri, ContentObserver observer,
+            boolean notifyForDescendents) {
+        addObserver(uri, 0, observer, notifyForDescendents);
+    }
+
+    private void addObserver(Uri uri, int index, ContentObserver observer,
+            boolean notifyForDescendents) {
+        // If this is the leaf node add the observer
+        if (index == countUriSegments(uri)) {
+            mObservers.add(new MockObserverEntry(observer, notifyForDescendents));
+            return;
+        }
+
+        // Look to see if the proper child already exists
+        String segment = getUriSegment(uri, index);
+        if (segment == null) {
+            throw new IllegalArgumentException("Invalid Uri (" + uri + ") used for observer");
+        }
+        int N = mChildren.size();
+        for (int i = 0; i < N; i++) {
+            MockObserverNode node = mChildren.get(i);
+            if (node.mName.equals(segment)) {
+                node.addObserver(uri, index + 1, observer, notifyForDescendents);
+                return;
+            }
+        }
+
+        // No child found, create one
+        MockObserverNode node = new MockObserverNode(segment);
+        mChildren.add(node);
+        node.addObserver(uri, index + 1, observer, notifyForDescendents);
+    }
+
+    public boolean removeObserver(ContentObserver observer) {
+        int size = mChildren.size();
+        for (int i = 0; i < size; i++) {
+            boolean empty = mChildren.get(i).removeObserver(observer);
+            if (empty) {
+                mChildren.remove(i);
+                i--;
+                size--;
+            }
+        }
+
+        size = mObservers.size();
+        for (int i = 0; i < size; i++) {
+            MockObserverEntry entry = mObservers.get(i);
+            if (entry.observer == observer) {
+                mObservers.remove(i);
+                break;
+            }
+        }
+
+        if (mChildren.size() == 0 && mObservers.size() == 0) {
+            return true;
+        }
+        return false;
+    }
+
+    private void notifyMyObservers(boolean leaf, ContentObserver observer,
+            boolean selfNotify) {
+        int N = mObservers.size();
+        for (int i = 0; i < N; i++) {
+            MockObserverEntry entry = mObservers.get(i);
+
+            // Don't notify the observer if it sent the notification and isn't interesed
+            // in self notifications
+            if (entry.observer == observer && !selfNotify) {
+                continue;
+            }
+
+            // Make sure the observer is interested in the notification
+            if (leaf || (!leaf && entry.notifyForDescendents)) {
+                entry.observer.onChange(selfNotify);
+            }
+        }
+    }
+
+    public void notifyMyObservers(Uri uri, int index, ContentObserver observer,
+            boolean selfNotify) {
+        String segment = null;
+        int segmentCount = countUriSegments(uri);
+        if (index >= segmentCount) {
+            // This is the leaf node, notify all observers
+            notifyMyObservers(true, observer, selfNotify);
+        } else if (index < segmentCount){
+            segment = getUriSegment(uri, index);
+            // Notify any observers at this level who are interested in descendents
+            notifyMyObservers(false, observer, selfNotify);
+        }
+
+        int N = mChildren.size();
+        for (int i = 0; i < N; i++) {
+            MockObserverNode node = mChildren.get(i);
+            if (segment == null || node.mName.equals(segment)) {
+                // We found the child,
+                node.notifyMyObservers(uri, index + 1, observer, selfNotify);
+                if (segment != null) {
+                    break;
+                }
+            }
+        }
+    }
+}
diff --git a/tests/src/com/android/browser/tests/utils/ProviderTestCase3.java b/tests/src/com/android/browser/tests/utils/ProviderTestCase3.java
new file mode 100644
index 0000000..5799b0f
--- /dev/null
+++ b/tests/src/com/android/browser/tests/utils/ProviderTestCase3.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * 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 com.android.browser.tests.utils;
+
+import android.content.ContentProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.test.AndroidTestCase;
+import android.test.IsolatedContext;
+import android.test.RenamingDelegatingContext;
+import android.test.mock.MockContext;
+
+import java.io.File;
+
+/**
+ * Replacement for ProviderTestCase2 that keeps calls to ContentResolver.notifyChanged
+ * internal to observers registered with ProviderTestCase3.registerContentObserver
+ */
+public abstract class ProviderTestCase3<T extends ContentProvider> extends AndroidTestCase {
+
+    Class<T> mProviderClass;
+    String[] mProviderAuthority;
+
+    private IsolatedContext mProviderContext;
+    private MockContentResolver2 mResolver;
+
+    private class MockContext2 extends MockContext {
+
+        @Override
+        public Resources getResources() {
+            return getContext().getResources();
+        }
+
+        @Override
+        public File getDir(String name, int mode) {
+            // name the directory so the directory will be separated from
+            // one created through the regular Context
+            return getContext().getDir("mockcontext2_" + name, mode);
+        }
+
+        @Override
+        public Context getApplicationContext() {
+            return this;
+        }
+    }
+    /**
+     * Constructor.
+     *
+     * @param providerClass The class name of the provider under test
+     * @param providerAuthorities The provider's authority string
+     */
+    public ProviderTestCase3(Class<T> providerClass, String... providerAuthorities) {
+        mProviderClass = providerClass;
+        mProviderAuthority = providerAuthorities;
+    }
+
+    private T mProvider;
+
+    /**
+     * Returns the content provider created by this class in the {@link #setUp()} method.
+     * @return T An instance of the provider class given as a parameter to the test case class.
+     */
+    public T getProvider() {
+        return mProvider;
+    }
+
+    /**
+     * Sets up the environment for the test fixture.
+     * <p>
+     * Creates a new
+     * {@link com.android.browser.tests.utils.MockContentResolver2}, a new IsolatedContext
+     * that isolates the provider's file operations, and a new instance of
+     * the provider under test within the isolated environment.
+     * </p>
+     *
+     * @throws Exception
+     */
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mResolver = new MockContentResolver2();
+        final String filenamePrefix = "test.";
+        RenamingDelegatingContext targetContextWrapper = new
+                RenamingDelegatingContext(
+                new MockContext2(), // The context that most methods are
+                                    //delegated to
+                getContext(), // The context that file methods are delegated to
+                filenamePrefix);
+        mProviderContext = new IsolatedContext(mResolver, targetContextWrapper);
+
+        mProvider = mProviderClass.newInstance();
+        mProvider.attachInfo(mProviderContext, null);
+        assertNotNull(mProvider);
+        for (String auth : mProviderAuthority) {
+            mResolver.addProvider(auth, getProvider());
+        }
+    }
+
+    /**
+     * Tears down the environment for the test fixture.
+     * <p>
+     * Calls {@link android.content.ContentProvider#shutdown()} on the
+     * {@link android.content.ContentProvider} represented by mProvider.
+     */
+    @Override
+    protected void tearDown() throws Exception {
+        mProvider.shutdown();
+        super.tearDown();
+    }
+
+    /**
+     * Gets the {@link MockContentResolver2} created by this class during initialization. You
+     * must use the methods of this resolver to access the provider under test.
+     *
+     * @return A {@link MockContentResolver2} instance.
+     */
+    public MockContentResolver2 getMockContentResolver() {
+        return mResolver;
+    }
+
+    /**
+     * Gets the {@link IsolatedContext} created by this class during initialization.
+     * @return The {@link IsolatedContext} instance
+     */
+    public IsolatedContext getMockContext() {
+        return mProviderContext;
+    }
+
+    public void registerContentObserver(Uri uri, boolean notifyForDescendents,
+            ContentObserver observer) {
+        mResolver.safeRegisterContentObserver(uri, notifyForDescendents, observer);
+    }
+
+    public void unregisterContentObserver(ContentObserver observer) {
+        mResolver.safeUnregisterContentObserver(observer);
+    }
+
+}