Hook up pullFile to use content provider.

Implemented pullFile in handler to use "adb shell content read".

NativeDevice.pullFile will dispatch to use ContentProviderHandler
for "sdcard" paths.

Bug: 123529934
Fixes: 123526185
Test: atest TradefedContentProviderHostTest; ran on both primary and secondary users. unit tests. DeviceFileCollector with "sdcard" path in secondary users

Change-Id: Ia5e7167abc4cfdd9b263c979c74072b51efdbeb2
Merged-In: Ia5e7167abc4cfdd9b263c979c74072b51efdbeb2
diff --git a/src/com/android/tradefed/device/INativeDevice.java b/src/com/android/tradefed/device/INativeDevice.java
index 99a2ecb..6f624a3 100644
--- a/src/com/android/tradefed/device/INativeDevice.java
+++ b/src/com/android/tradefed/device/INativeDevice.java
@@ -36,6 +36,7 @@
 
 import java.io.File;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.Collection;
 import java.util.Date;
 import java.util.List;
@@ -289,6 +290,19 @@
             throws DeviceNotAvailableException;
 
     /**
+     * Helper method which executes an adb shell command and returns the results as a {@link
+     * CommandResult} properly populated with the command status output, stdout and stderr.
+     *
+     * @param command The command that should be run.
+     * @param pipeToOutput {@link OutputStream} where the std output will be redirected.
+     * @return The result in {@link CommandResult}.
+     * @throws DeviceNotAvailableException if connection with device is lost and cannot be
+     *     recovered.
+     */
+    public CommandResult executeShellV2Command(String command, OutputStream pipeToOutput)
+            throws DeviceNotAvailableException;
+
+    /**
      * Executes a adb shell command, with more parameters to control command behavior.
      *
      * @see #executeShellV2Command(String)
diff --git a/src/com/android/tradefed/device/NativeDevice.java b/src/com/android/tradefed/device/NativeDevice.java
index 462ac41..9bc385e 100644
--- a/src/com/android/tradefed/device/NativeDevice.java
+++ b/src/com/android/tradefed/device/NativeDevice.java
@@ -71,6 +71,7 @@
 import java.io.File;
 import java.io.FilenameFilter;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.Clock;
@@ -263,11 +264,13 @@
 
         private String[] mCmd;
         private long mTimeout;
-        private File mPipeAsInput;
+        private File mPipeAsInput; // Used in pushFile, uses local file as input to "content write"
+        private OutputStream mPipeToOutput; // Used in pullFile, to pipe content from "content read"
 
-        AdbShellAction(String[] cmd, File pipeAsInput, long timeout) {
+        AdbShellAction(String[] cmd, File pipeAsInput, OutputStream pipeToOutput, long timeout) {
             mCmd = cmd;
             mPipeAsInput = pipeAsInput;
+            mPipeToOutput = pipeToOutput;
             mTimeout = timeout;
         }
 
@@ -276,7 +279,8 @@
             if (mPipeAsInput != null) {
                 mResult = getRunUtil().runTimedCmdWithInputRedirect(mTimeout, mPipeAsInput, mCmd);
             } else {
-                mResult = getRunUtil().runTimedCmd(mTimeout, mCmd);
+                mResult =
+                        getRunUtil().runTimedCmd(mTimeout, mPipeToOutput, /* stderr= */ null, mCmd);
             }
             if (mResult.getStatus() == CommandStatus.EXCEPTION) {
                 throw new IOException(mResult.getStderr());
@@ -699,7 +703,25 @@
     public CommandResult executeShellV2Command(String cmd, File pipeAsInput)
             throws DeviceNotAvailableException {
         return executeShellV2Command(
-                cmd, pipeAsInput, getCommandTimeout(), TimeUnit.MILLISECONDS, MAX_RETRY_ATTEMPTS);
+                cmd,
+                pipeAsInput,
+                null,
+                getCommandTimeout(),
+                TimeUnit.MILLISECONDS,
+                MAX_RETRY_ATTEMPTS);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public CommandResult executeShellV2Command(String cmd, OutputStream pipeToOutput)
+            throws DeviceNotAvailableException {
+        return executeShellV2Command(
+                cmd,
+                null,
+                pipeToOutput,
+                getCommandTimeout(),
+                TimeUnit.MILLISECONDS,
+                MAX_RETRY_ATTEMPTS);
     }
 
     /** {@inheritDoc} */
@@ -707,7 +729,8 @@
     public CommandResult executeShellV2Command(
             String cmd, final long maxTimeoutForCommand, final TimeUnit timeUnit)
             throws DeviceNotAvailableException {
-        return executeShellV2Command(cmd, null, maxTimeoutForCommand, timeUnit, MAX_RETRY_ATTEMPTS);
+        return executeShellV2Command(
+                cmd, null, null, maxTimeoutForCommand, timeUnit, MAX_RETRY_ATTEMPTS);
     }
 
     /** {@inheritDoc} */
@@ -715,19 +738,25 @@
     public CommandResult executeShellV2Command(
             String cmd, final long maxTimeoutForCommand, final TimeUnit timeUnit, int retryAttempts)
             throws DeviceNotAvailableException {
-        return executeShellV2Command(cmd, null, maxTimeoutForCommand, timeUnit, retryAttempts);
+        return executeShellV2Command(
+                cmd, null, null, maxTimeoutForCommand, timeUnit, retryAttempts);
     }
 
     private CommandResult executeShellV2Command(
             String cmd,
             File pipeAsInput,
+            OutputStream pipeToOutput,
             final long maxTimeoutForCommand,
             final TimeUnit timeUnit,
             int retryAttempts)
             throws DeviceNotAvailableException {
         final String[] fullCmd = buildAdbShellCommand(cmd);
         AdbShellAction adbActionV2 =
-                new AdbShellAction(fullCmd, pipeAsInput, timeUnit.toMillis(maxTimeoutForCommand));
+                new AdbShellAction(
+                        fullCmd,
+                        pipeAsInput,
+                        pipeToOutput,
+                        timeUnit.toMillis(maxTimeoutForCommand));
         performDeviceAction(String.format("adb %s", fullCmd[4]), adbActionV2, retryAttempts);
         return adbActionV2.mResult;
     }
@@ -922,6 +951,13 @@
     public boolean pullFile(final String remoteFilePath, final File localFile)
             throws DeviceNotAvailableException {
 
+        if (remoteFilePath.startsWith(SD_CARD)) {
+            ContentProviderHandler handler = getContentProvider();
+            if (handler != null) {
+                return handler.pullFile(remoteFilePath, localFile);
+            }
+        }
+
         DeviceAction pullAction = new DeviceAction() {
             @Override
             public boolean run() throws TimeoutException, IOException, AdbCommandRejectedException,
@@ -1026,7 +1062,6 @@
         if (remoteFilePath.startsWith(SD_CARD)) {
             ContentProviderHandler handler = getContentProvider();
             if (handler != null) {
-                mShouldSkipContentProviderSetup = true;
                 return handler.pushFile(localFile, remoteFilePath);
             }
         }
@@ -4151,8 +4186,10 @@
         if (!mShouldSkipContentProviderSetup) {
             boolean res = mContentProvider.setUp();
             if (!res) {
+                // TODO: once CP becomes a requirement, throw/fail the test if CP can't be found
                 return null;
             }
+            mShouldSkipContentProviderSetup = true;
         }
         return mContentProvider;
     }
diff --git a/src/com/android/tradefed/device/contentprovider/ContentProviderHandler.java b/src/com/android/tradefed/device/contentprovider/ContentProviderHandler.java
index 2647364..09a4662 100644
--- a/src/com/android/tradefed/device/contentprovider/ContentProviderHandler.java
+++ b/src/com/android/tradefed/device/contentprovider/ContentProviderHandler.java
@@ -21,12 +21,16 @@
 import com.android.tradefed.util.CommandResult;
 import com.android.tradefed.util.CommandStatus;
 import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.StreamUtil;
 
 import com.google.common.base.Strings;
 
 import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.Set;
 
 /**
@@ -116,6 +120,46 @@
     }
 
     /**
+     * Content provider callback that pulls a file from the URI location into a local file.
+     *
+     * @param deviceFilePath The path on the device where to pull the file from.
+     * @param localFile The {@link File} to store the contents in. If non-empty, contents will be
+     *     replaced.
+     * @return True if successful, False otherwise
+     * @throws DeviceNotAvailableException
+     */
+    public boolean pullFile(String deviceFilePath, File localFile)
+            throws DeviceNotAvailableException {
+        String contentUri = createContentUri(deviceFilePath);
+        String pullCommand =
+                String.format(
+                        "content read --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
+
+        // Open the output stream to the local file.
+        OutputStream localFileStream;
+        try {
+            localFileStream = new FileOutputStream(localFile);
+        } catch (FileNotFoundException e) {
+            CLog.e("Failed to open OutputStream to the local file. Error: %s", e.getMessage());
+            return false;
+        }
+
+        try {
+            CommandResult pullResult = mDevice.executeShellV2Command(pullCommand, localFileStream);
+            if (isSuccessful(pullResult)) {
+                return true;
+            }
+
+            CLog.e(
+                    "Failed to pull a file at '%s' to %s using content provider. Error: '%s'",
+                    deviceFilePath, localFile, pullResult.getStderr());
+            return false;
+        } finally {
+            StreamUtil.close(localFileStream);
+        }
+    }
+
+    /**
      * Content provider callback that push a file to the URI location.
      *
      * @param fileToPush The {@link File} to be pushed to the device.
@@ -140,19 +184,22 @@
                         "content write --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
         CommandResult pushResult = mDevice.executeShellV2Command(pushCommand, fileToPush);
 
-        String stderr = pushResult.getStderr();
-        if (CommandStatus.SUCCESS.equals(pushResult.getStatus())) {
-            // Command above returns success even if it prints stack failure.
-            if (Strings.isNullOrEmpty(stderr)) {
-                return true;
-            }
+        if (isSuccessful(pushResult)) {
+            return true;
         }
+
         CLog.e(
                 "Failed to push a file '%s' at %s using content provider. Error: '%s'",
-                fileToPush, deviceFilePath, stderr);
+                fileToPush, deviceFilePath, pushResult.getStderr());
         return false;
     }
 
+    /** Returns true if {@link CommandStatus} is successful and there is no error message. */
+    private boolean isSuccessful(CommandResult result) {
+        String stderr = result.getStderr();
+        return CommandStatus.SUCCESS.equals(result.getStatus()) && Strings.isNullOrEmpty(stderr);
+    }
+
     /** Helper method to extract the content provider apk. */
     private File extractResourceApk() throws IOException {
         File apkTempFile = FileUtil.createTempFile(APK_NAME, ".apk");
diff --git a/tests/src/com/android/tradefed/device/NativeDeviceTest.java b/tests/src/com/android/tradefed/device/NativeDeviceTest.java
index abb27b1..c34c55e 100644
--- a/tests/src/com/android/tradefed/device/NativeDeviceTest.java
+++ b/tests/src/com/android/tradefed/device/NativeDeviceTest.java
@@ -54,6 +54,7 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.text.SimpleDateFormat;
 import java.time.Clock;
 import java.util.ArrayList;
@@ -2326,11 +2327,13 @@
 
     /** Test when {@link NativeDevice#executeShellV2Command(String)} returns a success. */
     public void testExecuteShellV2Command() throws Exception {
+        OutputStream stdout = null, stderr = null;
         CommandResult res = new CommandResult();
         res.setStatus(CommandStatus.SUCCESS);
         EasyMock.expect(
                         mMockRunUtil.runTimedCmd(
-                                100, "adb", "-s", "serial", "shell", "some", "command"))
+                                100, stdout, stderr, "adb", "-s", "serial", "shell", "some",
+                                "command"))
                 .andReturn(res);
         EasyMock.replay(mMockRunUtil, mMockIDevice);
         assertNotNull(mTestDevice.executeShellV2Command("some command"));
@@ -2342,12 +2345,14 @@
      * repeat because of a timeout.
      */
     public void testExecuteShellV2Command_timeout() throws Exception {
+        OutputStream stdout = null, stderr = null;
         CommandResult res = new CommandResult();
         res.setStatus(CommandStatus.TIMED_OUT);
         res.setStderr("timed out");
         EasyMock.expect(
                         mMockRunUtil.runTimedCmd(
-                                200L, "adb", "-s", "serial", "shell", "some", "command"))
+                                200L, stdout, stderr, "adb", "-s", "serial", "shell", "some",
+                                "command"))
                 .andReturn(res)
                 .times(2);
         EasyMock.replay(mMockRunUtil, mMockIDevice);
@@ -2365,12 +2370,14 @@
      * output.
      */
     public void testExecuteShellV2Command_fail() throws Exception {
+        OutputStream stdout = null, stderr = null;
         CommandResult res = new CommandResult();
         res.setStatus(CommandStatus.FAILED);
         res.setStderr("timed out");
         EasyMock.expect(
                         mMockRunUtil.runTimedCmd(
-                                200L, "adb", "-s", "serial", "shell", "some", "command"))
+                                200L, stdout, stderr, "adb", "-s", "serial", "shell", "some",
+                                "command"))
                 .andReturn(res)
                 .times(1);
         EasyMock.replay(mMockRunUtil, mMockIDevice);
@@ -2381,4 +2388,134 @@
         assertSame(res, result);
         EasyMock.verify(mMockRunUtil, mMockIDevice);
     }
+
+// FIXME: delete this, not necessary
+// <<<<<<< HEAD
+// =======
+//
+//     /** Unit test for {@link INativeDevice#setProperty(String, String)}. */
+//     @Test
+//     public void testSetProperty() throws DeviceNotAvailableException {
+//         OutputStream stdout = null, stderr = null;
+//         mTestDevice =
+//                 new TestableAndroidNativeDevice() {
+//                     @Override
+//                     public boolean isAdbRoot() throws DeviceNotAvailableException {
+//                         return true;
+//                     }
+//                 };
+//         CommandResult res = new CommandResult();
+//         res.setStatus(CommandStatus.SUCCESS);
+//         EasyMock.expect(
+//                         mMockRunUtil.runTimedCmd(
+//                                 120000, stdout, stderr, "adb", "-s", "serial", "shell", "setprop",
+//                                 "test", "value"))
+//                 .andReturn(res);
+//         EasyMock.replay(mMockRunUtil, mMockIDevice);
+//         assertTrue(mTestDevice.setProperty("test", "value"));
+//         EasyMock.verify(mMockRunUtil, mMockIDevice);
+//     }
+//
+//     /** Unit test for {@link INativeDevice#setProperty(String, String)}. */
+//     @Test
+//     public void testSetProperty_notRoot() throws DeviceNotAvailableException {
+//         mTestDevice =
+//                 new TestableAndroidNativeDevice() {
+//                     @Override
+//                     public boolean isAdbRoot() throws DeviceNotAvailableException {
+//                         return false;
+//                     }
+//                 };
+//         EasyMock.replay(mMockRunUtil, mMockIDevice);
+//         assertFalse(mTestDevice.setProperty("test", "value"));
+//         EasyMock.verify(mMockRunUtil, mMockIDevice);
+//     }
+//
+//     /**
+//      * Verifies that {@link INativeDevice#isExecutable(String)} recognizes regular executable file
+//      *
+//      * @throws Exception
+//      */
+//     @Test
+//     public void testIsDeviceFileExecutable_executable_rwx() throws Exception {
+//         mTestDevice =
+//                 new TestableAndroidNativeDevice() {
+//                     @Override
+//                     public String executeShellCommand(String command)
+//                             throws DeviceNotAvailableException {
+//                         return "-rwxr-xr-x 1 root shell 42824 2009-01-01 00:00 /system/bin/ping";
+//                     }
+//                 };
+//         assertTrue(mTestDevice.isExecutable("/system/bin/ping"));
+//     }
+//
+//     /**
+//      * Verifies that {@link INativeDevice#isExecutable(String)} recognizes symlink'd executable file
+//      *
+//      * @throws Exception
+//      */
+//     @Test
+//     public void testIsDeviceFileExecutable_executable_lrwx() throws Exception {
+//         mTestDevice =
+//                 new TestableAndroidNativeDevice() {
+//                     @Override
+//                     public String executeShellCommand(String command)
+//                             throws DeviceNotAvailableException {
+//                         return "lrwxr-xr-x 1 root shell 7 2009-01-01 00:00 /system/bin/start -> toolbox";
+//                     }
+//                 };
+//         assertTrue(mTestDevice.isExecutable("/system/bin/start"));
+//     }
+//
+//     /**
+//      * Verifies that {@link INativeDevice#isExecutable(String)} recognizes non-executable file
+//      *
+//      * @throws Exception
+//      */
+//     @Test
+//     public void testIsDeviceFileExecutable_notExecutable() throws Exception {
+//         mTestDevice =
+//                 new TestableAndroidNativeDevice() {
+//                     @Override
+//                     public String executeShellCommand(String command)
+//                             throws DeviceNotAvailableException {
+//                         return "-rw-r--r-- 1 root root 5020 2009-01-01 00:00 /system/build.prop";
+//                     }
+//                 };
+//         assertFalse(mTestDevice.isExecutable("/system/build.prop"));
+//     }
+//
+//     /**
+//      * Verifies that {@link INativeDevice#isExecutable(String)} recognizes a directory listing
+//      *
+//      * @throws Exception
+//      */
+//     @Test
+//     public void testIsDeviceFileExecutable_directory() throws Exception {
+//         mTestDevice =
+//                 new TestableAndroidNativeDevice() {
+//                     @Override
+//                     public String executeShellCommand(String command)
+//                             throws DeviceNotAvailableException {
+//                         return "total 416\n"
+//                                 + "drwxr-xr-x 74 root root    4096 2009-01-01 00:00 app\n"
+//                                 + "drwxr-xr-x  3 root shell   8192 2009-01-01 00:00 bin\n"
+//                                 + "-rw-r--r--  1 root root    5020 2009-01-01 00:00 build.prop\n"
+//                                 + "drwxr-xr-x 15 root root    4096 2009-01-01 00:00 etc\n"
+//                                 + "drwxr-xr-x  2 root root    4096 2009-01-01 00:00 fake-libs\n"
+//                                 + "drwxr-xr-x  2 root root    8192 2009-01-01 00:00 fonts\n"
+//                                 + "drwxr-xr-x  4 root root    4096 2009-01-01 00:00 framework\n"
+//                                 + "drwxr-xr-x  6 root root    8192 2009-01-01 00:00 lib\n"
+//                                 + "drwx------  2 root root    4096 1970-01-01 00:00 lost+found\n"
+//                                 + "drwxr-xr-x  3 root root    4096 2009-01-01 00:00 media\n"
+//                                 + "drwxr-xr-x 68 root root    4096 2009-01-01 00:00 priv-app\n"
+//                                 + "-rw-r--r--  1 root root  137093 2009-01-01 00:00 recovery-from-boot.p\n"
+//                                 + "drwxr-xr-x  9 root root    4096 2009-01-01 00:00 usr\n"
+//                                 + "drwxr-xr-x  8 root shell   4096 2009-01-01 00:00 vendor\n"
+//                                 + "drwxr-xr-x  2 root shell   4096 2009-01-01 00:00 xbin\n";
+//                     }
+//                 };
+//         assertFalse(mTestDevice.isExecutable("/system"));
+//     }
+// >>>>>>> f39a78ced... Hook up pullFile to use content provider.
 }
diff --git a/tests/src/com/android/tradefed/device/TestDeviceTest.java b/tests/src/com/android/tradefed/device/TestDeviceTest.java
index ecb4c42..01cc636 100644
--- a/tests/src/com/android/tradefed/device/TestDeviceTest.java
+++ b/tests/src/com/android/tradefed/device/TestDeviceTest.java
@@ -62,6 +62,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -3776,4 +3777,38 @@
         mTestDevice.postInvocationTearDown();
         verifyMocks();
     }
+
+// FIXME: Delete this, not necessary
+// <<<<<<< HEAD
+// =======
+//
+//     /** Test that displays can be collected. */
+//     public void testListDisplayId() throws Exception {
+//         OutputStream stdout = null, stderr = null;
+//         CommandResult res = new CommandResult(CommandStatus.SUCCESS);
+//         res.setStdout("Display 0 color modes:\nDisplay 5 color modes:\n");
+//         EasyMock.expect(
+//                         mMockRunUtil.runTimedCmd(
+//                                 100L,
+//                                 stdout,
+//                                 stderr,
+//                                 "adb",
+//                                 "-s",
+//                                 "serial",
+//                                 "shell",
+//                                 "dumpsys",
+//                                 "SurfaceFlinger",
+//                                 "|",
+//                                 "grep",
+//                                 "'color",
+//                                 "modes:'"))
+//                 .andReturn(res);
+//         replayMocks();
+//         Set<Integer> displays = mTestDevice.listDisplayIds();
+//         assertEquals(2, displays.size());
+//         assertTrue(displays.contains(0));
+//         assertTrue(displays.contains(5));
+//         verifyMocks();
+//     }
+// >>>>>>> f39a78ced... Hook up pullFile to use content provider.
 }
diff --git a/tests/src/com/android/tradefed/device/contentprovider/ContentProviderHandlerTest.java b/tests/src/com/android/tradefed/device/contentprovider/ContentProviderHandlerTest.java
index 84ec7bc..0b4e31a 100644
--- a/tests/src/com/android/tradefed/device/contentprovider/ContentProviderHandlerTest.java
+++ b/tests/src/com/android/tradefed/device/contentprovider/ContentProviderHandlerTest.java
@@ -15,11 +15,14 @@
  */
 package com.android.tradefed.device.contentprovider;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.verify;
 
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.util.CommandResult;
@@ -31,9 +34,11 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 
 import java.io.File;
+import java.io.OutputStream;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -88,9 +93,8 @@
     @Test
     public void testDeleteFile() throws Exception {
         String devicePath = "path/somewhere/file.txt";
-        CommandResult result = new CommandResult(CommandStatus.SUCCESS);
         doReturn(99).when(mMockDevice).getCurrentUser();
-        doReturn(result)
+        doReturn(mockSuccess())
                 .when(mMockDevice)
                 .executeShellV2Command(
                         eq(
@@ -125,10 +129,8 @@
         File toPush = FileUtil.createTempFile("content-provider-test", ".txt");
         try {
             String devicePath = "path/somewhere/file.txt";
-            CommandResult result = new CommandResult(CommandStatus.SUCCESS);
-            result.setStderr("");
             doReturn(99).when(mMockDevice).getCurrentUser();
-            doReturn(result)
+            doReturn(mockSuccess())
                     .when(mMockDevice)
                     .executeShellV2Command(
                             eq(
@@ -142,4 +144,74 @@
             FileUtil.deleteFile(toPush);
         }
     }
+
+    /** Test {@link ContentProviderHandler#pullFile(String, File)}. */
+    @Test
+    public void testPullFile_verifyShellCommand() throws Exception {
+        File pullTo = FileUtil.createTempFile("content-provider-test", ".txt");
+        String devicePath = "path/somewhere/file.txt";
+        doReturn(99).when(mMockDevice).getCurrentUser();
+        mockPullFileSuccess();
+
+        try {
+            mProvider.pullFile(devicePath, pullTo);
+
+            // Capture the shell command used by pullFile.
+            ArgumentCaptor<String> shellCommandCaptor = ArgumentCaptor.forClass(String.class);
+            verify(mMockDevice)
+                    .executeShellV2Command(shellCommandCaptor.capture(), any(OutputStream.class));
+
+            // Verify the command.
+            assertEquals(
+                    shellCommandCaptor.getValue(),
+                    "content read --user 99 --uri "
+                            + ContentProviderHandler.CONTENT_PROVIDER_URI
+                            + "/"
+                            + devicePath);
+        } finally {
+            FileUtil.deleteFile(pullTo);
+        }
+    }
+
+    /** Test {@link ContentProviderHandler#pullFile(String, File)}. */
+    @Test
+    public void testPullFile_createLocalFileIfNotExist() throws Exception {
+        File pullTo = new File("content-provider-test.txt");
+        String devicePath = "path/somewhere/file.txt";
+        mockPullFileSuccess();
+
+        try {
+            assertFalse(pullTo.exists());
+            mProvider.pullFile(devicePath, pullTo);
+            assertTrue(pullTo.exists());
+        } finally {
+            FileUtil.deleteFile(pullTo);
+        }
+    }
+
+    /** Test {@link ContentProviderHandler#pullFile(String, File)}. */
+    @Test
+    public void testPullFile_success() throws Exception {
+        File pullTo = new File("content-provider-test.txt");
+        String devicePath = "path/somewhere/file.txt";
+
+        try {
+            mockPullFileSuccess();
+            assertTrue(mProvider.pullFile(devicePath, pullTo));
+        } finally {
+            FileUtil.deleteFile(pullTo);
+        }
+    }
+
+    private CommandResult mockSuccess() {
+        CommandResult result = new CommandResult(CommandStatus.SUCCESS);
+        result.setStderr("");
+        return result;
+    }
+
+    private void mockPullFileSuccess() throws Exception {
+        doReturn(mockSuccess())
+                .when(mMockDevice)
+                .executeShellV2Command(anyString(), any(OutputStream.class));
+    }
 }
diff --git a/util-apps/ContentProvider/hostsidetests/src/com/android/tradefed/contentprovider/ContentProviderTest.java b/util-apps/ContentProvider/hostsidetests/src/com/android/tradefed/contentprovider/ContentProviderTest.java
index e6fe49d..597a355 100644
--- a/util-apps/ContentProvider/hostsidetests/src/com/android/tradefed/contentprovider/ContentProviderTest.java
+++ b/util-apps/ContentProvider/hostsidetests/src/com/android/tradefed/contentprovider/ContentProviderTest.java
@@ -15,7 +15,9 @@
  */
 package com.android.tradefed.contentprovider;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertFalse;
 
 import com.android.tradefed.device.contentprovider.ContentProviderHandler;
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
@@ -32,12 +34,16 @@
 /** Host tests for the Tradefed Content Provider. */
 @RunWith(DeviceJUnit4ClassRunner.class)
 public class ContentProviderTest extends BaseHostJUnit4Test {
+    private static final String EXTERNAL_STORAGE_PATH = "/storage/emulated/%d/";
 
     private ContentProviderHandler mHandler;
+    private String mCurrentUserStoragePath;
 
     @Before
     public void setUp() throws Exception {
         mHandler = new ContentProviderHandler(getDevice());
+        mCurrentUserStoragePath =
+                String.format(EXTERNAL_STORAGE_PATH, getDevice().getCurrentUser());
         assertTrue(mHandler.setUp());
     }
 
@@ -54,9 +60,33 @@
         try {
             boolean res = mHandler.pushFile(tmpFile, "/sdcard/" + tmpFile.getName());
             assertTrue(res);
-            assertTrue(getDevice().doesFileExist("/sdcard/" + tmpFile.getName()));
+            assertTrue(getDevice().doesFileExist(mCurrentUserStoragePath + tmpFile.getName()));
         } finally {
             FileUtil.deleteFile(tmpFile);
         }
     }
+
+    @Test
+    public void testPullFile() throws Exception {
+        String fileContent = "some test content";
+
+        // First, push the file onto a device.
+        File tmpFile = FileUtil.createTempFile("tmpFileToPush", ".txt");
+        FileUtil.writeToFile(fileContent, tmpFile);
+        mHandler.pushFile(tmpFile, "/sdcard/" + tmpFile.getName());
+
+        File tmpPullFile = new File("fileToPullTo.txt");
+        // Local file does not exist before we pull the content from the device.
+        assertFalse(tmpPullFile.exists());
+
+        try {
+            boolean res = mHandler.pullFile("/sdcard/" + tmpFile.getName(), tmpPullFile);
+            assertTrue(res);
+            assertTrue(tmpPullFile.exists()); // Verify existence.
+            assertEquals(FileUtil.readStringFromFile(tmpPullFile), fileContent); // Verify content.
+        } finally {
+            FileUtil.deleteFile(tmpFile);
+            FileUtil.deleteFile(tmpPullFile);
+        }
+    }
 }