Work on issue #26390151: Add new JobScheduler API...

...for monitoring content providers

Add CTS tests.

Change-Id: Ic88d0666ac384a3b404d2e0fa6c8ed4b4c1a01d5
diff --git a/tests/JobScheduler/src/android/jobscheduler/DummyJobContentProvider.java b/tests/JobScheduler/src/android/jobscheduler/DummyJobContentProvider.java
new file mode 100644
index 0000000..cc8cf21
--- /dev/null
+++ b/tests/JobScheduler/src/android/jobscheduler/DummyJobContentProvider.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2016 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 android.jobscheduler;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+
+/**
+ * Stub content provider used for generating content change reports
+ */
+public class DummyJobContentProvider extends ContentProvider {
+    private static final String DATABASE_NAME = "dummy.db";
+    private static final String NAME_VALUE_TABLE = "name_value";
+
+    private DatabaseHelper mDbHelper;
+    private static UriMatcher sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final int MATCH_NAME_VALUE      = 1;
+
+    public static final String AUTHORITY = "android.jobscheduler.dummyprovider";
+    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY);
+
+    public static final String _ID   = "_id";
+    public static final String NAME  = "name";
+    public static final String VALUE = "value";
+
+    static {
+        sMatcher.addURI(AUTHORITY, null, MATCH_NAME_VALUE);
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see android.content.ContentProvider#onCreate()
+     */
+    @Override
+    public boolean onCreate() {
+        mDbHelper = new DatabaseHelper(getContext());
+        return true;
+    }
+
+    private class DatabaseHelper extends SQLiteOpenHelper {
+        private static final int DATABASE_VERSION = 1;
+
+        DatabaseHelper(Context context) {
+            super(context, DATABASE_NAME, null, DATABASE_VERSION);
+        }
+
+        @Override
+        public void onCreate(SQLiteDatabase db) {
+            // create an empty name_value table
+            db.execSQL("CREATE TABLE " + NAME_VALUE_TABLE + " (" + _ID + " INTEGER PRIMARY KEY,"
+                    + NAME + " TEXT," + VALUE + " TEXT"+ ");");
+        }
+
+        @Override
+        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        }
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see android.content.ContentProvider#insert(android.net.Uri,
+     * android.content.ContentValues)
+     */
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        String tbName = getTableName(uri);
+        if (tbName == null) {
+            return null;
+        }
+        SQLiteDatabase db = mDbHelper.getWritableDatabase();
+        db.insert(tbName, VALUE, values);
+        getContext().getContentResolver().notifyChange(uri, null);
+        return uri;
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see android.content.ContentProvider#query(android.net.Uri,
+     * java.lang.String[], java.lang.String, java.lang.String[],
+     * java.lang.String)
+     */
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        String tbName = getTableName(uri);
+        if (tbName == null) {
+            return null;
+        }
+        SQLiteDatabase db = mDbHelper.getReadableDatabase();
+        Cursor c = db.query(tbName, projection, selection, selectionArgs, null, null, sortOrder);
+        c.setNotificationUri(getContext().getContentResolver(), uri);
+        return c;
+    }
+
+    private String getTableName(Uri uri) {
+        switch (sMatcher.match(uri)) {
+            case MATCH_NAME_VALUE:
+                return NAME_VALUE_TABLE;
+            default:
+                throw new UnsupportedOperationException();
+        }
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see android.content.ContentProvider#update(android.net.Uri,
+     * android.content.ContentValues, java.lang.String, java.lang.String[])
+     */
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        String tbName = getTableName(uri);
+        if (tbName == null) {
+            return 0;
+        }
+        SQLiteDatabase db = mDbHelper.getWritableDatabase();
+        int count = db.update(tbName, values, selection, selectionArgs);
+        getContext().getContentResolver().notifyChange(uri, null);
+        return count;
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see android.content.ContentProvider#delete(android.net.Uri,
+     * java.lang.String, java.lang.String[])
+     */
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        String tbName = getTableName(uri);
+        if (tbName == null) {
+            return 0;
+        }
+        SQLiteDatabase db = mDbHelper.getWritableDatabase();
+        int count = db.delete(tbName, selection, selectionArgs);
+        getContext().getContentResolver().notifyChange(uri, null);
+        return count;
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see android.content.ContentProvider#getType(android.net.Uri)
+     */
+    @Override
+    public String getType(Uri uri) {
+        return null;
+    }
+}
diff --git a/tests/JobScheduler/src/android/jobscheduler/TriggerContentJobService.java b/tests/JobScheduler/src/android/jobscheduler/TriggerContentJobService.java
new file mode 100644
index 0000000..d87f1a7
--- /dev/null
+++ b/tests/JobScheduler/src/android/jobscheduler/TriggerContentJobService.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2016 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 android.jobscheduler;
+
+import android.annotation.TargetApi;
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobScheduler;
+import android.app.job.JobService;
+import android.content.Context;
+import android.os.Handler;
+import android.util.Log;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Handles callback from the framework {@link android.app.job.JobScheduler}. The behaviour of this
+ * class is configured through the static
+ * {@link TestEnvironment}.
+ */
+@TargetApi(21)
+public class TriggerContentJobService extends JobService {
+    private static final String TAG = "TriggerContentJobService";
+
+    /** Wait this long before timing out the test. */
+    private static final long DEFAULT_TIMEOUT_MILLIS = 30000L; // 30 seconds.
+
+    /** How long to delay before rescheduling the job each time we repeat. */
+    private static final long REPEAT_INTERVAL = 1000L; // 1 second.
+
+    JobInfo mRunningJobInfo;
+    JobParameters mRunningParams;
+
+    final Handler mHandler = new Handler();
+    final Runnable mWorker = new Runnable() {
+        @Override public void run() {
+            scheduleJob(TriggerContentJobService.this, mRunningJobInfo);
+            jobFinished(mRunningParams, false);
+        }
+    };
+
+    public static void scheduleJob(Context context, JobInfo jobInfo) {
+        JobScheduler js = context.getSystemService(JobScheduler.class);
+        js.schedule(jobInfo);
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        Log.e(TAG, "Created test service.");
+    }
+
+    @Override
+    public boolean onStartJob(JobParameters params) {
+        Log.i(TAG, "Test job executing: " + params.getJobId());
+
+        int mode = TestEnvironment.getTestEnvironment().getMode();
+        mRunningJobInfo = TestEnvironment.getTestEnvironment().getModeJobInfo();
+        TestEnvironment.getTestEnvironment().setMode(TestEnvironment.MODE_ONESHOT, null);
+        TestEnvironment.getTestEnvironment().notifyExecution(params);
+
+        if (mode == TestEnvironment.MODE_ONE_REPEAT) {
+            mRunningParams = params;
+            mHandler.postDelayed(mWorker, REPEAT_INTERVAL);
+            return true;
+        } else {
+            return false;  // No work to do.
+        }
+    }
+
+    @Override
+    public boolean onStopJob(JobParameters params) {
+        return false;
+    }
+
+    /**
+     * Configures the expected behaviour for each test. This object is shared across consecutive
+     * tests, so to clear state each test is responsible for calling
+     * {@link TestEnvironment#setUp()}.
+     */
+    public static final class TestEnvironment {
+
+        private static TestEnvironment kTestEnvironment;
+        //public static final int INVALID_JOB_ID = -1;
+
+        private CountDownLatch mLatch;
+        private JobParameters mExecutedJobParameters;
+        private int mMode;
+        private JobInfo mModeJobInfo;
+
+        public static final int MODE_ONESHOT = 0;
+        public static final int MODE_ONE_REPEAT = 1;
+
+        public static TestEnvironment getTestEnvironment() {
+            if (kTestEnvironment == null) {
+                kTestEnvironment = new TestEnvironment();
+            }
+            return kTestEnvironment;
+        }
+
+        public JobParameters getLastJobParameters() {
+            return mExecutedJobParameters;
+        }
+
+        /**
+         * Block the test thread, waiting on the JobScheduler to execute some previously scheduled
+         * job on this service.
+         */
+        public boolean awaitExecution() throws InterruptedException {
+            final boolean executed = mLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+            return executed;
+        }
+
+        public void setMode(int mode, JobInfo jobInfo) {
+            synchronized (this) {
+                mMode = mode;
+                mModeJobInfo = jobInfo;
+            }
+        }
+
+        public int getMode() {
+            synchronized (this) {
+                return mMode;
+            }
+        }
+
+        public JobInfo getModeJobInfo() {
+            synchronized (this) {
+                return mModeJobInfo;
+            }
+        }
+
+        /**
+         * Block the test thread, expecting to timeout but still listening to ensure that no jobs
+         * land in the interim.
+         * @return True if the latch timed out waiting on an execution.
+         */
+        public boolean awaitTimeout() throws InterruptedException {
+            return !mLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        }
+
+        private void notifyExecution(JobParameters params) {
+            Log.d(TAG, "Job executed:" + params.getJobId());
+            mExecutedJobParameters = params;
+            mLatch.countDown();
+        }
+
+        public void setExpectedExecutions(int numExecutions) {
+            // For no executions expected, set count to 1 so we can still block for the timeout.
+            if (numExecutions == 0) {
+                mLatch = new CountDownLatch(1);
+            } else {
+                mLatch = new CountDownLatch(numExecutions);
+            }
+        }
+
+        /** Called in each testCase#setup */
+        public void setUp() {
+            mLatch = null;
+            mExecutedJobParameters = null;
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/tests/JobScheduler/src/android/jobscheduler/cts/ConstraintTest.java b/tests/JobScheduler/src/android/jobscheduler/cts/ConstraintTest.java
index b9a498f..35903d7 100644
--- a/tests/JobScheduler/src/android/jobscheduler/cts/ConstraintTest.java
+++ b/tests/JobScheduler/src/android/jobscheduler/cts/ConstraintTest.java
@@ -21,6 +21,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.jobscheduler.MockJobService;
+import android.jobscheduler.TriggerContentJobService;
 import android.test.AndroidTestCase;
 
 /**
@@ -35,14 +36,21 @@
     /** Environment that notifies of JobScheduler callbacks. */
     static MockJobService.TestEnvironment kTestEnvironment =
             MockJobService.TestEnvironment.getTestEnvironment();
+    static TriggerContentJobService.TestEnvironment kTriggerTestEnvironment =
+            TriggerContentJobService.TestEnvironment.getTestEnvironment();
     /** Handle for the service which receives the execution callbacks from the JobScheduler. */
     static ComponentName kJobServiceComponent;
+    static ComponentName kTriggerContentServiceComponent;
     JobScheduler mJobScheduler;
 
     @Override
     public void setUp() throws Exception {
+        super.setUp();
         kTestEnvironment.setUp();
+        kTriggerTestEnvironment.setUp();
         kJobServiceComponent = new ComponentName(getContext(), MockJobService.class);
+        kTriggerContentServiceComponent = new ComponentName(getContext(),
+                TriggerContentJobService.class);
         mJobScheduler = (JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE);
         mJobScheduler.cancelAll();
     }
diff --git a/tests/JobScheduler/src/android/jobscheduler/cts/TriggerContentTest.java b/tests/JobScheduler/src/android/jobscheduler/cts/TriggerContentTest.java
new file mode 100644
index 0000000..32959a0
--- /dev/null
+++ b/tests/JobScheduler/src/android/jobscheduler/cts/TriggerContentTest.java
@@ -0,0 +1,394 @@
+/*
+ * Copyright (C) 2016 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 android.jobscheduler.cts;
+
+import android.annotation.TargetApi;
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.jobscheduler.DummyJobContentProvider;
+import android.jobscheduler.TriggerContentJobService;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.Process;
+import android.provider.MediaStore;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Schedules jobs that look for content URI changes and ensures they are triggered correctly.
+ */
+@TargetApi(23)
+public class TriggerContentTest extends ConstraintTest {
+    public static final int TRIGGER_CONTENT_JOB_ID = ConnectivityConstraintTest.class.hashCode();
+
+    // The root URI of the media provider, to monitor for generic changes to its content.
+    static final Uri MEDIA_URI = Uri.parse("content://" + MediaStore.AUTHORITY + "/");
+
+    // Media URI for all external media content.
+    static final Uri MEDIA_EXTERNAL_URI = Uri.parse("content://" + MediaStore.AUTHORITY
+            + "/external");
+
+    // Path segments for image-specific URIs in the provider.
+    static final List<String> EXTERNAL_PATH_SEGMENTS
+            = MediaStore.Images.Media.EXTERNAL_CONTENT_URI.getPathSegments();
+
+    // The columns we want to retrieve about a particular image.
+    static final String[] PROJECTION = new String[] {
+            MediaStore.Images.ImageColumns._ID, MediaStore.Images.ImageColumns.DATA
+    };
+    static final int PROJECTION_ID = 0;
+    static final int PROJECTION_DATA = 1;
+
+    // This is the external storage directory where cameras place pictures.
+    static final String DCIM_DIR = Environment.getExternalStoragePublicDirectory(
+            Environment.DIRECTORY_DCIM).getPath();
+
+    static final String PIC_1_NAME = "TriggerContentTest1_" + Process.myPid();
+    static final String PIC_2_NAME = "TriggerContentTest2_" + Process.myPid();
+
+    File[] mActiveFiles = new File[5];
+    Uri[] mActiveUris = new Uri[5];
+
+    static class MediaScanner implements MediaScannerConnection.OnScanCompletedListener {
+        private static final long DEFAULT_TIMEOUT_MILLIS = 1000L; // 1 second.
+
+        private CountDownLatch mLatch;
+        private String mScannedPath;
+        private Uri mScannedUri;
+
+        public boolean scan(Context context, String file, String mimeType)
+                throws InterruptedException {
+            mLatch = new CountDownLatch(1);
+            MediaScannerConnection.scanFile(context,
+                    new String[] { file.toString() }, new String[] { mimeType }, this);
+            return mLatch.await(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+        }
+
+        public String getScannedPath() {
+            synchronized (this) {
+                return mScannedPath;
+            }
+        }
+
+        public Uri getScannedUri() {
+            synchronized (this) {
+                return mScannedUri;
+            }
+        }
+
+        @Override public void onScanCompleted(String path, Uri uri) {
+            synchronized (this) {
+                mScannedPath = path;
+                mScannedUri = uri;
+                mLatch.countDown();
+            }
+        }
+    }
+
+    private void cleanupActive(int which) {
+        if (mActiveUris[which] != null) {
+            getContext().getContentResolver().delete(mActiveUris[which], null, null);
+            mActiveUris[which] = null;
+        }
+        if (mActiveFiles[which] != null) {
+            mActiveFiles[which].delete();
+            mActiveFiles[which] = null;
+        }
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        super.tearDown();
+        for (int i=0; i<mActiveFiles.length; i++) {
+            cleanupActive(i);
+        }
+    }
+
+    private JobInfo makeJobInfo(Uri uri, int flags) {
+        JobInfo.Builder builder = new JobInfo.Builder(TRIGGER_CONTENT_JOB_ID,
+                kTriggerContentServiceComponent);
+        builder.addTriggerContentUri(new JobInfo.TriggerContentUri(uri, flags));
+        // For testing purposes, react quickly.
+        builder.setTriggerContentUpdateDelay(500);
+        builder.setTriggerContentMaxDelay(500);
+        return builder.build();
+    }
+
+    private JobInfo makePhotosJobInfo() {
+        JobInfo.Builder builder = new JobInfo.Builder(TRIGGER_CONTENT_JOB_ID,
+                kTriggerContentServiceComponent);
+        // Look for specific changes to images in the provider.
+        builder.addTriggerContentUri(new JobInfo.TriggerContentUri(
+                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+                JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS));
+        // Also look for general reports of changes in the overall provider.
+        builder.addTriggerContentUri(new JobInfo.TriggerContentUri(MEDIA_URI, 0));
+        // For testing purposes, react quickly.
+        builder.setTriggerContentUpdateDelay(500);
+        builder.setTriggerContentMaxDelay(500);
+        return builder.build();
+    }
+
+    public static void copyToFileOrThrow(InputStream inputStream, File destFile)
+            throws IOException {
+        if (destFile.exists()) {
+            destFile.delete();
+        }
+        FileOutputStream out = new FileOutputStream(destFile);
+        try {
+            byte[] buffer = new byte[4096];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) >= 0) {
+                out.write(buffer, 0, bytesRead);
+            }
+        } finally {
+            out.flush();
+            try {
+                out.getFD().sync();
+            } catch (IOException e) {
+            }
+            out.close();
+            inputStream.close();
+        }
+    }
+
+    public Uri createAndAddImage(File destFile, InputStream image) throws IOException,
+            InterruptedException {
+        copyToFileOrThrow(image, destFile);
+        MediaScanner scanner = new MediaScanner();
+        boolean success = scanner.scan(getContext(), destFile.toString(), "image/jpeg");
+        if (success) {
+            return scanner.getScannedUri();
+        }
+        return null;
+    }
+
+    public Uri makeActiveFile(int which, File file, InputStream source) throws IOException,
+                InterruptedException {
+        mActiveFiles[which] = file;
+        mActiveUris[which] = createAndAddImage(file, source);
+        return mActiveUris[which];
+    }
+
+    private void assertUriArrayLength(int length, Uri[] uris) {
+        if (uris.length != length) {
+            StringBuilder sb = new StringBuilder();
+            sb.append("Expected ");
+            sb.append(length);
+            sb.append(" URI, got ");
+            sb.append(uris.length);
+            if (uris.length > 0) {
+                sb.append(": ");
+                for (int i=0; i<uris.length; i++) {
+                    if (i > 0) {
+                        sb.append(", ");
+                    }
+                    sb.append(uris[i]);
+                }
+            }
+            fail(sb.toString());
+        }
+    }
+
+    private void assertHasUri(Uri wanted, Uri[] uris) {
+        for (int i=0; i<uris.length; i++) {
+            if (wanted.equals(uris[i])) {
+                return;
+            }
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("Don't have uri ");
+        sb.append(wanted);
+        sb.append(" in: ");
+        for (int i=0; i<uris.length; i++) {
+            if (i > 0) {
+                sb.append(", ");
+            }
+            sb.append(uris[i]);
+        }
+        fail(sb.toString());
+    }
+
+    public void testDescendantsObserver() throws Exception {
+        String base = "content://" + DummyJobContentProvider.AUTHORITY + "/root";
+        Uri uribase = Uri.parse(base);
+        Uri uri1 = Uri.parse(base + "/sub1");
+        Uri uri2 = Uri.parse(base + "/sub2");
+
+        // Start watching.
+        JobInfo triggerJob = makeJobInfo(uribase,
+                JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS);
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        kTriggerTestEnvironment.setMode(TriggerContentJobService.TestEnvironment.MODE_ONE_REPEAT,
+                triggerJob);
+        mJobScheduler.schedule(triggerJob);
+
+        // Report changes.
+        getContext().getContentResolver().notifyChange(uribase, null, 0);
+        getContext().getContentResolver().notifyChange(uri1, null, 0);
+
+        // Wait and check results
+        boolean executed = kTriggerTestEnvironment.awaitExecution();
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        assertTrue("Timed out waiting for trigger content.", executed);
+        JobParameters params = kTriggerTestEnvironment.getLastJobParameters();
+        Uri[] uris = params.getTriggeredContentUris();
+        assertUriArrayLength(2, uris);
+        assertHasUri(uribase, uris);
+        assertHasUri(uri1, uris);
+        String[] auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(DummyJobContentProvider.AUTHORITY, auths[0]);
+
+        // Report more changes, this time not letting it see the top-level change
+        getContext().getContentResolver().notifyChange(uribase, null,
+                ContentResolver.NOTIFY_SKIP_NOTIFY_FOR_DESCENDANTS);
+        getContext().getContentResolver().notifyChange(uri2, null, 0);
+
+        // Wait for the job to wake up and verify it saw the change.
+        executed = kTriggerTestEnvironment.awaitExecution();
+        assertTrue("Timed out waiting for trigger content.", executed);
+        params = kTriggerTestEnvironment.getLastJobParameters();
+        uris = params.getTriggeredContentUris();
+        assertUriArrayLength(1, uris);
+        assertEquals(uri2, uris[0]);
+        auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(DummyJobContentProvider.AUTHORITY, auths[0]);
+    }
+
+    public void testNonDescendantsObserver() throws Exception {
+        String base = "content://" + DummyJobContentProvider.AUTHORITY + "/root";
+        Uri uribase = Uri.parse(base);
+        Uri uri1 = Uri.parse(base + "/sub1");
+        Uri uri2 = Uri.parse(base + "/sub2");
+
+        // Start watching.
+        JobInfo triggerJob = makeJobInfo(uribase, 0);
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        kTriggerTestEnvironment.setMode(TriggerContentJobService.TestEnvironment.MODE_ONE_REPEAT,
+                triggerJob);
+        mJobScheduler.schedule(triggerJob);
+
+        // Report changes.
+        getContext().getContentResolver().notifyChange(uribase, null, 0);
+        getContext().getContentResolver().notifyChange(uri1, null, 0);
+
+        // Wait and check results
+        boolean executed = kTriggerTestEnvironment.awaitExecution();
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        assertTrue("Timed out waiting for trigger content.", executed);
+        JobParameters params = kTriggerTestEnvironment.getLastJobParameters();
+        Uri[] uris = params.getTriggeredContentUris();
+        assertUriArrayLength(1, uris);
+        assertEquals(uribase, uris[0]);
+        String[] auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(DummyJobContentProvider.AUTHORITY, auths[0]);
+
+        // Report more changes, this time not letting it see the top-level change
+        getContext().getContentResolver().notifyChange(uribase, null,
+                ContentResolver.NOTIFY_SKIP_NOTIFY_FOR_DESCENDANTS);
+        getContext().getContentResolver().notifyChange(uri2, null, 0);
+
+        // Wait for the job to wake up and verify it saw the change.
+        executed = kTriggerTestEnvironment.awaitExecution();
+        assertTrue("Timed out waiting for trigger content.", executed);
+        params = kTriggerTestEnvironment.getLastJobParameters();
+        uris = params.getTriggeredContentUris();
+        assertUriArrayLength(1, uris);
+        assertEquals(uribase, uris[0]);
+        auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(DummyJobContentProvider.AUTHORITY, auths[0]);
+    }
+
+    public void testPhotoAdded() throws Exception {
+        JobInfo triggerJob = makePhotosJobInfo();
+
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        kTriggerTestEnvironment.setMode(TriggerContentJobService.TestEnvironment.MODE_ONE_REPEAT,
+                triggerJob);
+        mJobScheduler.schedule(triggerJob);
+
+        // Create a file that our job should see.
+        makeActiveFile(0, new File(DCIM_DIR, PIC_1_NAME),
+                getContext().getResources().getAssets().open("violet.jpg"));
+        assertNotNull(mActiveUris[0]);
+
+        // Wait for the job to wake up with the change and verify it.
+        boolean executed = kTriggerTestEnvironment.awaitExecution();
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        assertTrue("Timed out waiting for trigger content.", executed);
+        JobParameters params = kTriggerTestEnvironment.getLastJobParameters();
+        Uri[] uris = params.getTriggeredContentUris();
+        assertUriArrayLength(1, uris);
+        assertEquals(mActiveUris[0], uris[0]);
+        String[] auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(MediaStore.AUTHORITY, auths[0]);
+
+        // While the job is still running, create another file it should see.
+        // (This tests that it will see changes that happen before the next job
+        // is scheduled.)
+        makeActiveFile(1, new File(DCIM_DIR, PIC_2_NAME),
+                getContext().getResources().getAssets().open("violet.jpg"));
+        assertNotNull(mActiveUris[1]);
+
+        // Wait for the job to wake up and verify it saw the change.
+        executed = kTriggerTestEnvironment.awaitExecution();
+        assertTrue("Timed out waiting for trigger content.", executed);
+        params = kTriggerTestEnvironment.getLastJobParameters();
+        uris = params.getTriggeredContentUris();
+        assertUriArrayLength(1, uris);
+        assertEquals(mActiveUris[1], uris[0]);
+        auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(MediaStore.AUTHORITY, auths[0]);
+
+        // Schedule a new job to look at what we see when deleting the files.
+        kTriggerTestEnvironment.setExpectedExecutions(1);
+        kTriggerTestEnvironment.setMode(TriggerContentJobService.TestEnvironment.MODE_ONESHOT,
+                triggerJob);
+        mJobScheduler.schedule(triggerJob);
+
+        // Delete the files.  Note that this will result in a general change, not for specific URIs.
+        cleanupActive(0);
+        cleanupActive(1);
+
+        // Wait for the job to wake up and verify it saw the change.
+        executed = kTriggerTestEnvironment.awaitExecution();
+        assertTrue("Timed out waiting for trigger content.", executed);
+        params = kTriggerTestEnvironment.getLastJobParameters();
+        uris = params.getTriggeredContentUris();
+        assertUriArrayLength(1, uris);
+        assertEquals(MEDIA_EXTERNAL_URI, uris[0]);
+        auths = params.getTriggeredContentAuthorities();
+        assertEquals(1, auths.length);
+        assertEquals(MediaStore.AUTHORITY, auths[0]);
+    }
+}