1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.tradefed.device.contentprovider;
17 
18 import com.android.tradefed.device.DeviceNotAvailableException;
19 import com.android.tradefed.device.ITestDevice;
20 import com.android.tradefed.log.LogUtil.CLog;
21 import com.android.tradefed.util.CommandResult;
22 import com.android.tradefed.util.CommandStatus;
23 import com.android.tradefed.util.FileUtil;
24 import com.android.tradefed.util.StreamUtil;
25 
26 import com.google.common.annotations.VisibleForTesting;
27 import com.google.common.base.Strings;
28 import com.google.common.net.UrlEscapers;
29 
30 import java.io.File;
31 import java.io.FileNotFoundException;
32 import java.io.FileOutputStream;
33 import java.io.IOException;
34 import java.io.InputStream;
35 import java.io.OutputStream;
36 import java.io.UnsupportedEncodingException;
37 import java.net.URLEncoder;
38 import java.util.HashMap;
39 import java.util.Set;
40 import java.util.StringJoiner;
41 import java.util.regex.Matcher;
42 import java.util.regex.Pattern;
43 
44 import javax.annotation.Nullable;
45 
46 /**
47  * Handler that abstract the content provider interactions and allow to use the device side content
48  * provider for different operations.
49  *
50  * <p>All implementation in this class should be mindful of the user currently running on the
51  * device.
52  */
53 public class ContentProviderHandler {
54     public static final String COLUMN_NAME = "name";
55     public static final String COLUMN_ABSOLUTE_PATH = "absolute_path";
56     public static final String COLUMN_DIRECTORY = "is_directory";
57     public static final String COLUMN_MIME_TYPE = "mime_type";
58     public static final String COLUMN_METADATA = "metadata";
59     public static final String QUERY_INFO_VALUE = "INFO";
60     public static final String NO_RESULTS_STRING = "No result found.";
61 
62     // Has to be kept in sync with columns in ManagedFileContentProvider.java.
63     public static final String[] COLUMNS =
64             new String[] {
65                     COLUMN_NAME,
66                     COLUMN_ABSOLUTE_PATH,
67                     COLUMN_DIRECTORY,
68                     COLUMN_MIME_TYPE,
69                     COLUMN_METADATA
70             };
71 
72     public static final String PACKAGE_NAME = "android.tradefed.contentprovider";
73     public static final String CONTENT_PROVIDER_URI = "content://android.tradefed.contentprovider";
74     private static final String APK_NAME = "TradefedContentProvider.apk";
75     private static final String CONTENT_PROVIDER_APK_RES = "/" + APK_NAME;
76     private static final String CONTENT_PROVIDER_APK_RES_FALLBACK =
77             "/android/tradefed/contentprovider/" + APK_NAME;
78 
79     private static final String PROPERTY_RESULT = "LEGACY_STORAGE: allow";
80     private static final String ERROR_MESSAGE_TAG = "[ERROR]";
81     // Error thrown by device if the content provider is not installed for any reason.
82     private static final String ERROR_PROVIDER_NOT_INSTALLED =
83             "Could not find provider: android.tradefed.contentprovider";
84 
85     private final Integer mUserId;
86     private ITestDevice mDevice;
87     private File mContentProviderApk = null;
88     private boolean mReportNotFound = false;
89 
90     /** Constructor. */
ContentProviderHandler(ITestDevice device)91     public ContentProviderHandler(ITestDevice device) throws DeviceNotAvailableException {
92         this(device, /* userId= */ null);
93     }
94 
ContentProviderHandler(ITestDevice device, @Nullable Integer userId)95     public ContentProviderHandler(ITestDevice device, @Nullable Integer userId) {
96         mUserId = userId;
97         mDevice = device;
98     }
99 
100     /** Returns the userId that this instance is initialized with. */
101     @Nullable
getUserId()102     public Integer getUserId() {
103         return mUserId;
104     }
105 
106     /**
107      * Returns True if one of the operation failed with Content provider not found. Can be cleared
108      * by running {@link #setUp()} successfully again.
109      */
contentProviderNotFound()110     public boolean contentProviderNotFound() {
111         return mReportNotFound;
112     }
113 
114     /**
115      * Ensure the content provider helper apk is installed and ready to be used.
116      *
117      * @return True if ready to be used, False otherwise.
118      */
setUp()119     public boolean setUp() throws DeviceNotAvailableException {
120         if (mDevice.isPackageInstalled(PACKAGE_NAME, Integer.toString(getEffectiveUserId()))) {
121             mReportNotFound = false;
122             return true;
123         }
124         if (mContentProviderApk == null || !mContentProviderApk.exists()) {
125             try {
126                 mContentProviderApk = extractResourceApk();
127             } catch (IOException e) {
128                 CLog.e(e);
129                 return false;
130             }
131         }
132         // Install package for all users
133         String output =
134                 mDevice.installPackage(
135                         mContentProviderApk,
136                         /** reinstall */
137                         true,
138                         /** grant permission */
139                         true);
140         if (output != null) {
141             CLog.e("Something went wrong while installing the content provider apk: %s", output);
142             FileUtil.deleteFile(mContentProviderApk);
143             return false;
144         }
145         // Enable appops legacy storage
146         CommandResult setResult =
147                 mDevice.executeShellV2Command(
148                         String.format(
149                                 "cmd appops set %s android:legacy_storage allow", PACKAGE_NAME));
150         if (!CommandStatus.SUCCESS.equals(setResult.getStatus())) {
151             CLog.e(
152                     "Failed to set legacy_storage. Stdout: %s\nstderr: %s",
153                     setResult.getStdout(), setResult.getStderr());
154             FileUtil.deleteFile(mContentProviderApk);
155             return false;
156         }
157         // Check that it worked and set on the system
158         CommandResult appOpsResult =
159                 mDevice.executeShellV2Command(String.format("cmd appops get %s", PACKAGE_NAME));
160         if (CommandStatus.SUCCESS.equals(appOpsResult.getStatus())
161                 && appOpsResult.getStdout().contains(PROPERTY_RESULT)) {
162             mReportNotFound = false;
163             return true;
164         }
165         CLog.e(
166                 "Failed to get legacy_storage. Stdout: %s\nstderr: %s",
167                 appOpsResult.getStdout(), appOpsResult.getStderr());
168         FileUtil.deleteFile(mContentProviderApk);
169         return false;
170     }
171 
172     /** Clean the device from the content provider helper. */
tearDown()173     public void tearDown() throws DeviceNotAvailableException {
174         FileUtil.deleteFile(mContentProviderApk);
175         mDevice.uninstallPackage(PACKAGE_NAME);
176     }
177 
178     /**
179      * Content provider callback that delete a file at the URI location. File will be deleted from
180      * the device content.
181      *
182      * @param deviceFilePath The path on the device of the file to delete.
183      * @return True if successful, False otherwise
184      * @throws DeviceNotAvailableException
185      */
deleteFile(String deviceFilePath)186     public boolean deleteFile(String deviceFilePath) throws DeviceNotAvailableException {
187         String contentUri = createEscapedContentUri(deviceFilePath);
188         String deleteCommand =
189                 String.format(
190                         "content delete --user %d --uri %s", getEffectiveUserId(), contentUri);
191         CommandResult deleteResult = mDevice.executeShellV2Command(deleteCommand);
192 
193         if (isSuccessful(deleteResult)) {
194             return true;
195         }
196         CLog.e(
197                 "Failed to remove a file at %s using content provider. Error: '%s'",
198                 deviceFilePath, deleteResult.getStderr());
199         return false;
200     }
201 
202     /**
203      * Recursively pull directory contents from device using content provider.
204      *
205      * @param deviceFilePath the absolute file path of the remote source
206      * @param localDir the local directory to pull files into
207      * @return <code>true</code> if file was pulled successfully. <code>false</code> otherwise.
208      * @throws DeviceNotAvailableException if connection with device is lost and cannot be
209      *     recovered.
210      */
pullDir(String deviceFilePath, File localDir)211     public boolean pullDir(String deviceFilePath, File localDir)
212             throws DeviceNotAvailableException {
213         return pullDirInternal(deviceFilePath, localDir, getEffectiveUserId());
214     }
215 
216     /**
217      * Content provider callback that pulls a file from the URI location into a local file.
218      *
219      * @param deviceFilePath The path on the device where to pull the file from.
220      * @param localFile The {@link File} to store the contents in. If non-empty, contents will be
221      *     replaced.
222      * @return True if successful, False otherwise
223      * @throws DeviceNotAvailableException
224      */
pullFile(String deviceFilePath, File localFile)225     public boolean pullFile(String deviceFilePath, File localFile)
226             throws DeviceNotAvailableException {
227         return pullFileInternal(deviceFilePath, localFile, getEffectiveUserId());
228     }
229 
230     /**
231      * Content provider callback that push a file to the URI location.
232      *
233      * @param fileToPush The {@link File} to be pushed to the device.
234      * @param deviceFilePath The path on the device where to push the file.
235      * @return True if successful, False otherwise
236      * @throws DeviceNotAvailableException
237      * @throws IllegalArgumentException
238      */
pushFile(File fileToPush, String deviceFilePath)239     public boolean pushFile(File fileToPush, String deviceFilePath)
240             throws DeviceNotAvailableException, IllegalArgumentException {
241         if (!fileToPush.exists()) {
242             CLog.w("File '%s' to push does not exist.", fileToPush);
243             return false;
244         }
245         if (fileToPush.isDirectory()) {
246             CLog.w("'%s' is not a file but a directory, can't use #pushFile on it.", fileToPush);
247             return false;
248         }
249         int userId = getEffectiveUserId();
250         boolean res = pushFileInternal(fileToPush, deviceFilePath, userId);
251         if (!res && mReportNotFound) {
252             // Re-run setup to ensure we have the content provider installed
253             boolean installed = setUp();
254             if (!installed) {
255                 return false;
256             }
257             res = pushFileInternal(fileToPush, deviceFilePath, userId);
258         }
259         return res;
260     }
261 
262     /**
263      * Content provider callback that push a dir to the URI location.
264      *
265      * @param localFileDir The directory to push
266      * @param deviceFilePath The on device location
267      * @param excludedDirectories Directories not included in the push.
268      * @return True if successful
269      * @throws DeviceNotAvailableException
270      */
pushDir(File localFileDir, String deviceFilePath, Set<String> excludedDirectories)271     public boolean pushDir(File localFileDir, String deviceFilePath,
272                            Set<String> excludedDirectories) throws DeviceNotAvailableException {
273         return pushDirInternal(
274                 localFileDir, deviceFilePath, excludedDirectories, getEffectiveUserId());
275     }
276 
277     /**
278      * Determines if the file or non-empty directory exists on the device.
279      *
280      * @param deviceFilePath The absolute file path on device to check for existence.
281      * @return True if file/directory exists, False otherwise. If directory is empty, it will return
282      *     False as well.
283      */
doesFileExist(String deviceFilePath)284     public boolean doesFileExist(String deviceFilePath) throws DeviceNotAvailableException {
285         String contentUri = createEscapedContentUri(deviceFilePath);
286         String queryContentCommand =
287                 String.format("content query --user %d --uri %s", getEffectiveUserId(), contentUri);
288         String listCommandResult = mDevice.executeShellCommand(queryContentCommand);
289 
290         if (NO_RESULTS_STRING.equals(listCommandResult.trim())) {
291             // No file found.
292             return false;
293         }
294 
295         return true;
296     }
297 
298     /** Returns true if {@link CommandStatus} is successful and there is no error message. */
isSuccessful(CommandResult result)299     private boolean isSuccessful(CommandResult result) {
300         if (!CommandStatus.SUCCESS.equals(result.getStatus())) {
301             return false;
302         }
303         String stdout = result.getStdout();
304         if (stdout.contains(ERROR_MESSAGE_TAG)) {
305             return false;
306         }
307         String stderr = result.getStderr();
308         if (stderr != null && stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) {
309             mReportNotFound = true;
310         }
311         return Strings.isNullOrEmpty(stderr);
312     }
313 
314     /** Helper method to extract the content provider apk. */
extractResourceApk()315     private File extractResourceApk() throws IOException {
316         File apkTempFile = FileUtil.createTempFile(APK_NAME, ".apk");
317         try {
318             InputStream apkStream =
319                     ContentProviderHandler.class.getResourceAsStream(CONTENT_PROVIDER_APK_RES);
320             FileUtil.writeToFile(apkStream, apkTempFile);
321         } catch (IOException e) {
322             // Fallback to new path
323             InputStream apkStream =
324                     ContentProviderHandler.class.getResourceAsStream(
325                             CONTENT_PROVIDER_APK_RES_FALLBACK);
326             FileUtil.writeToFile(apkStream, apkTempFile);
327         }
328         return apkTempFile;
329     }
330 
331     /**
332      * Returns the full URI string for the given device path, escaped and encoded to avoid non-URL
333      * characters.
334      */
createEscapedContentUri(String deviceFilePath)335     public static String createEscapedContentUri(String deviceFilePath) {
336         String escapedFilePath = deviceFilePath;
337         try {
338             // Encode the path then escape it. This logic must invert the logic in
339             // ManagedFileContentProvider.getFileForUri. That calls to Uri.getPath() and then
340             // URLDecoder.decode(), so this must invert each of those two steps and switch the order
341             String encoded = URLEncoder.encode(deviceFilePath, "UTF-8");
342             escapedFilePath = UrlEscapers.urlPathSegmentEscaper().escape(encoded);
343         } catch (UnsupportedEncodingException e) {
344             CLog.e(e);
345         }
346         return String.format("\"%s/%s\"", CONTENT_PROVIDER_URI, escapedFilePath);
347     }
348 
349     /**
350      * Parses the String output of "adb shell content query" for a single row.
351      *
352      * @param row The entire row representing a single file/directory returned by the "adb shell
353      *     content query" command.
354      * @return Key-value map of column name to column value.
355      */
356     @VisibleForTesting
parseQueryResultRow(String row)357     final HashMap<String, String> parseQueryResultRow(String row) {
358         HashMap<String, String> columnValues = new HashMap<>();
359 
360         StringJoiner pattern = new StringJoiner(", ");
361         for (int i = 0; i < COLUMNS.length; i++) {
362             pattern.add(String.format("(%s=.*)", COLUMNS[i]));
363         }
364 
365         Pattern p = Pattern.compile(pattern.toString());
366         Matcher m = p.matcher(row);
367         if (m.find()) {
368             for (int i = 1; i <= m.groupCount(); i++) {
369                 String[] keyValue = m.group(i).split("=");
370                 if (keyValue.length == 2) {
371                     columnValues.put(keyValue[0], keyValue[1]);
372                 }
373             }
374         }
375         return columnValues;
376     }
377 
378     /**
379      * Returns the effective userId that this instance is initialized with. If the userId is null,
380      * returns the current user.
381      */
382     @VisibleForTesting
getEffectiveUserId()383     int getEffectiveUserId() throws DeviceNotAvailableException {
384         return mUserId == null ? mDevice.getCurrentUser() : mUserId;
385     }
386 
387     /** Internal method to actually do the pull directory. */
pullDirInternal(String deviceFilePath, File localDir, int userId)388     private boolean pullDirInternal(String deviceFilePath, File localDir, int userId)
389             throws DeviceNotAvailableException {
390         if (!localDir.isDirectory()) {
391             CLog.e("Local path %s is not a directory", localDir.getAbsolutePath());
392             return false;
393         }
394 
395         String contentUri = createEscapedContentUri(deviceFilePath);
396         String queryContentCommand =
397                 String.format("content query --user %d --uri %s", userId, contentUri);
398 
399         String listCommandResult = mDevice.executeShellCommand(queryContentCommand);
400 
401         if (NO_RESULTS_STRING.equals(listCommandResult.trim())) {
402             // Empty directory.
403             return true;
404         }
405 
406         CLog.d("Received from content provider:\n%s", listCommandResult);
407         String[] listResult = listCommandResult.split("[\\r\\n]+");
408 
409         for (String row : listResult) {
410             HashMap<String, String> columnValues = parseQueryResultRow(row);
411             boolean isDirectory = Boolean.valueOf(columnValues.get(COLUMN_DIRECTORY));
412             String name = columnValues.get(COLUMN_NAME);
413             if (name == null) {
414                 CLog.w("Output from the content provider doesn't seem well formatted:\n%s", row);
415                 return false;
416             }
417             String path = columnValues.get(COLUMN_ABSOLUTE_PATH);
418 
419             File localChild = new File(localDir, name);
420             if (isDirectory) {
421                 if (!localChild.mkdir()) {
422                     CLog.w(
423                             "Failed to create sub directory %s, aborting.",
424                             localChild.getAbsolutePath());
425                     return false;
426                 }
427 
428                 if (!pullDirInternal(path, localChild, userId)) {
429                     CLog.w("Failed to pull sub directory %s from device, aborting", path);
430                     return false;
431                 }
432             } else {
433                 // handle regular file
434                 if (!pullFileInternal(path, localChild, userId)) {
435                     CLog.w("Failed to pull file %s from device, aborting", path);
436                     return false;
437                 }
438             }
439         }
440         return true;
441     }
442 
pullFileInternal(String deviceFilePath, File localFile, int userId)443     private boolean pullFileInternal(String deviceFilePath, File localFile, int userId)
444             throws DeviceNotAvailableException {
445         String contentUri = createEscapedContentUri(deviceFilePath);
446         String pullCommand = String.format("content read --user %d --uri %s", userId, contentUri);
447 
448         // Open the output stream to the local file.
449         OutputStream localFileStream;
450         try {
451             localFileStream = new FileOutputStream(localFile);
452         } catch (FileNotFoundException e) {
453             CLog.e("Failed to open OutputStream to the local file. Error: %s", e.getMessage());
454             return false;
455         }
456 
457         try {
458             CommandResult pullResult = mDevice.executeShellV2Command(pullCommand, localFileStream);
459             if (isSuccessful(pullResult)) {
460                 return true;
461             }
462             String stderr = pullResult.getStderr();
463             CLog.e(
464                     "Failed to pull a file at '%s' to %s using content provider. Error: '%s'",
465                     deviceFilePath, localFile, stderr);
466             if (stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) {
467                 mReportNotFound = true;
468             }
469             return false;
470         } finally {
471             StreamUtil.close(localFileStream);
472         }
473     }
474 
pushFileInternal(File fileToPush, String deviceFilePath, int userId)475     private boolean pushFileInternal(File fileToPush, String deviceFilePath, int userId)
476             throws DeviceNotAvailableException {
477         String contentUri = createEscapedContentUri(deviceFilePath);
478         String pushCommand = String.format("content write --user %d --uri %s", userId, contentUri);
479         CommandResult pushResult = mDevice.executeShellV2Command(pushCommand, fileToPush);
480 
481         if (isSuccessful(pushResult)) {
482             return true;
483         }
484 
485         CLog.e(
486                 "Failed to push a file '%s' at %s using content provider. Error: '%s'",
487                 fileToPush, deviceFilePath, pushResult.getStderr());
488         return false;
489     }
490 
pushDirInternal( File localFileDir, String deviceFilePath, Set<String> excludedDirectories, int userId)491     private boolean pushDirInternal(
492             File localFileDir, String deviceFilePath, Set<String> excludedDirectories, int userId)
493             throws DeviceNotAvailableException {
494         File[] childFiles = localFileDir.listFiles();
495         if (childFiles == null) {
496             CLog.e("Could not read files in %s", localFileDir.getAbsolutePath());
497             return false;
498         }
499         for (File childFile : childFiles) {
500             String remotePath = String.format("%s/%s", deviceFilePath, childFile.getName());
501             if (childFile.isDirectory()) {
502                 // If we encounter a filtered directory do not push it.
503                 if (excludedDirectories.contains(childFile.getName())) {
504                     CLog.d(
505                             "%s directory was not pushed because it was filtered.",
506                             childFile.getAbsolutePath());
507                     continue;
508                 }
509                 mDevice.executeShellCommand(String.format("mkdir -p \"%s\"", remotePath));
510                 if (!pushDirInternal(childFile, remotePath, excludedDirectories, userId)) {
511                     return false;
512                 }
513             } else if (childFile.isFile()) {
514                 if (!pushFileInternal(childFile, remotePath, userId)) {
515                     return false;
516                 }
517             }
518         }
519         return true;
520     }
521 }
522