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.StringJoiner;
40 import java.util.regex.Matcher;
41 import java.util.regex.Pattern;
42 
43 /**
44  * Handler that abstract the content provider interactions and allow to use the device side content
45  * provider for different operations.
46  *
47  * <p>All implementation in this class should be mindful of the user currently running on the
48  * device.
49  */
50 public class ContentProviderHandler {
51     public static final String COLUMN_NAME = "name";
52     public static final String COLUMN_ABSOLUTE_PATH = "absolute_path";
53     public static final String COLUMN_DIRECTORY = "is_directory";
54     public static final String COLUMN_MIME_TYPE = "mime_type";
55     public static final String COLUMN_METADATA = "metadata";
56     public static final String QUERY_INFO_VALUE = "INFO";
57     public static final String NO_RESULTS_STRING = "No result found.";
58 
59     // Has to be kept in sync with columns in ManagedFileContentProvider.java.
60     public static final String[] COLUMNS =
61             new String[] {
62                     COLUMN_NAME,
63                     COLUMN_ABSOLUTE_PATH,
64                     COLUMN_DIRECTORY,
65                     COLUMN_MIME_TYPE,
66                     COLUMN_METADATA
67             };
68 
69     public static final String PACKAGE_NAME = "android.tradefed.contentprovider";
70     public static final String CONTENT_PROVIDER_URI = "content://android.tradefed.contentprovider";
71     private static final String APK_NAME = "TradefedContentProvider.apk";
72     private static final String CONTENT_PROVIDER_APK_RES = "/apks/contentprovider/" + APK_NAME;
73     private static final String PROPERTY_RESULT = "LEGACY_STORAGE: allow";
74     private static final String ERROR_MESSAGE_TAG = "[ERROR]";
75     // Error thrown by device if the content provider is not installed for any reason.
76     private static final String ERROR_PROVIDER_NOT_INSTALLED =
77             "Could not find provider: android.tradefed.contentprovider";
78 
79     private ITestDevice mDevice;
80     private File mContentProviderApk = null;
81     private boolean mReportNotFound = false;
82 
83     /** Constructor. */
ContentProviderHandler(ITestDevice device)84     public ContentProviderHandler(ITestDevice device) {
85         mDevice = device;
86     }
87 
88     /**
89      * Returns True if one of the operation failed with Content provider not found. Can be cleared
90      * by running {@link #setUp()} successfully again.
91      */
contentProviderNotFound()92     public boolean contentProviderNotFound() {
93         return mReportNotFound;
94     }
95 
96     /**
97      * Ensure the content provider helper apk is installed and ready to be used.
98      *
99      * @return True if ready to be used, False otherwise.
100      */
setUp()101     public boolean setUp() throws DeviceNotAvailableException {
102         if (mDevice.isPackageInstalled(PACKAGE_NAME, Integer.toString(mDevice.getCurrentUser()))) {
103             mReportNotFound = false;
104             return true;
105         }
106         if (mContentProviderApk == null || !mContentProviderApk.exists()) {
107             try {
108                 mContentProviderApk = extractResourceApk();
109             } catch (IOException e) {
110                 CLog.e(e);
111                 return false;
112             }
113         }
114         // Install package for all users
115         String output =
116                 mDevice.installPackage(
117                         mContentProviderApk,
118                         /** reinstall */
119                         true,
120                         /** grant permission */
121                         true);
122         if (output != null) {
123             CLog.e("Something went wrong while installing the content provider apk: %s", output);
124             FileUtil.deleteFile(mContentProviderApk);
125             return false;
126         }
127         // Enable appops legacy storage
128         CommandResult setResult =
129                 mDevice.executeShellV2Command(
130                         String.format(
131                                 "cmd appops set %s android:legacy_storage allow", PACKAGE_NAME));
132         if (!CommandStatus.SUCCESS.equals(setResult.getStatus())) {
133             CLog.e(
134                     "Failed to set legacy_storage. Stdout: %s\nstderr: %s",
135                     setResult.getStdout(), setResult.getStderr());
136             FileUtil.deleteFile(mContentProviderApk);
137             return false;
138         }
139         // Check that it worked and set on the system
140         CommandResult appOpsResult =
141                 mDevice.executeShellV2Command(String.format("cmd appops get %s", PACKAGE_NAME));
142         if (CommandStatus.SUCCESS.equals(appOpsResult.getStatus())
143                 && appOpsResult.getStdout().contains(PROPERTY_RESULT)) {
144             mReportNotFound = false;
145             return true;
146         }
147         CLog.e(
148                 "Failed to get legacy_storage. Stdout: %s\nstderr: %s",
149                 appOpsResult.getStdout(), appOpsResult.getStderr());
150         FileUtil.deleteFile(mContentProviderApk);
151         return false;
152     }
153 
154     /** Clean the device from the content provider helper. */
tearDown()155     public void tearDown() throws DeviceNotAvailableException {
156         FileUtil.deleteFile(mContentProviderApk);
157         mDevice.uninstallPackage(PACKAGE_NAME);
158     }
159 
160     /**
161      * Content provider callback that delete a file at the URI location. File will be deleted from
162      * the device content.
163      *
164      * @param deviceFilePath The path on the device of the file to delete.
165      * @return True if successful, False otherwise
166      * @throws DeviceNotAvailableException
167      */
deleteFile(String deviceFilePath)168     public boolean deleteFile(String deviceFilePath) throws DeviceNotAvailableException {
169         String contentUri = createEscapedContentUri(deviceFilePath);
170         String deleteCommand =
171                 String.format(
172                         "content delete --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
173         CommandResult deleteResult = mDevice.executeShellV2Command(deleteCommand);
174 
175         if (isSuccessful(deleteResult)) {
176             return true;
177         }
178         CLog.e(
179                 "Failed to remove a file at %s using content provider. Error: '%s'",
180                 deviceFilePath, deleteResult.getStderr());
181         return false;
182     }
183 
184     /**
185      * Recursively pull directory contents from device using content provider.
186      *
187      * @param deviceFilePath the absolute file path of the remote source
188      * @param localDir the local directory to pull files into
189      * @return <code>true</code> if file was pulled successfully. <code>false</code> otherwise.
190      * @throws DeviceNotAvailableException if connection with device is lost and cannot be
191      *     recovered.
192      */
pullDir(String deviceFilePath, File localDir)193     public boolean pullDir(String deviceFilePath, File localDir)
194             throws DeviceNotAvailableException {
195         return pullDirInternal(deviceFilePath, localDir, /* currentUser */ null);
196     }
197 
198     /**
199      * Content provider callback that pulls a file from the URI location into a local file.
200      *
201      * @param deviceFilePath The path on the device where to pull the file from.
202      * @param localFile The {@link File} to store the contents in. If non-empty, contents will be
203      *     replaced.
204      * @return True if successful, False otherwise
205      * @throws DeviceNotAvailableException
206      */
pullFile(String deviceFilePath, File localFile)207     public boolean pullFile(String deviceFilePath, File localFile)
208             throws DeviceNotAvailableException {
209         return pullFileInternal(deviceFilePath, localFile, /* currentUser */ null);
210     }
211 
212     /**
213      * Content provider callback that push a file to the URI location.
214      *
215      * @param fileToPush The {@link File} to be pushed to the device.
216      * @param deviceFilePath The path on the device where to push the file.
217      * @return True if successful, False otherwise
218      * @throws DeviceNotAvailableException
219      * @throws IllegalArgumentException
220      */
pushFile(File fileToPush, String deviceFilePath)221     public boolean pushFile(File fileToPush, String deviceFilePath)
222             throws DeviceNotAvailableException, IllegalArgumentException {
223         if (!fileToPush.exists()) {
224             CLog.w("File '%s' to push does not exist.", fileToPush);
225             return false;
226         }
227         if (fileToPush.isDirectory()) {
228             CLog.w("'%s' is not a file but a directory, can't use #pushFile on it.", fileToPush);
229             return false;
230         }
231         String contentUri = createEscapedContentUri(deviceFilePath);
232         String pushCommand =
233                 String.format(
234                         "content write --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
235         CommandResult pushResult = mDevice.executeShellV2Command(pushCommand, fileToPush);
236 
237         if (isSuccessful(pushResult)) {
238             return true;
239         }
240 
241         CLog.e(
242                 "Failed to push a file '%s' at %s using content provider. Error: '%s'",
243                 fileToPush, deviceFilePath, pushResult.getStderr());
244         return false;
245     }
246 
247     /** Returns true if {@link CommandStatus} is successful and there is no error message. */
isSuccessful(CommandResult result)248     private boolean isSuccessful(CommandResult result) {
249         if (!CommandStatus.SUCCESS.equals(result.getStatus())) {
250             return false;
251         }
252         String stdout = result.getStdout();
253         if (stdout.contains(ERROR_MESSAGE_TAG)) {
254             return false;
255         }
256         String stderr = result.getStderr();
257         if (stderr != null && stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) {
258             mReportNotFound = true;
259         }
260         return Strings.isNullOrEmpty(stderr);
261     }
262 
263     /** Helper method to extract the content provider apk. */
extractResourceApk()264     private File extractResourceApk() throws IOException {
265         File apkTempFile = FileUtil.createTempFile(APK_NAME, ".apk");
266         InputStream apkStream =
267                 ContentProviderHandler.class.getResourceAsStream(CONTENT_PROVIDER_APK_RES);
268         FileUtil.writeToFile(apkStream, apkTempFile);
269         return apkTempFile;
270     }
271 
272     /**
273      * Returns the full URI string for the given device path, escaped and encoded to avoid non-URL
274      * characters.
275      */
createEscapedContentUri(String deviceFilePath)276     public static String createEscapedContentUri(String deviceFilePath) {
277         String escapedFilePath = deviceFilePath;
278         try {
279             // Encode the path then escape it. This logic must invert the logic in
280             // ManagedFileContentProvider.getFileForUri. That calls to Uri.getPath() and then
281             // URLDecoder.decode(), so this must invert each of those two steps and switch the order
282             String encoded = URLEncoder.encode(deviceFilePath, "UTF-8");
283             escapedFilePath = UrlEscapers.urlPathSegmentEscaper().escape(encoded);
284         } catch (UnsupportedEncodingException e) {
285             CLog.e(e);
286         }
287         return String.format("\"%s/%s\"", CONTENT_PROVIDER_URI, escapedFilePath);
288     }
289 
290     /**
291      * Parses the String output of "adb shell content query" for a single row.
292      *
293      * @param row The entire row representing a single file/directory returned by the "adb shell
294      *     content query" command.
295      * @return Key-value map of column name to column value.
296      */
297     @VisibleForTesting
parseQueryResultRow(String row)298     final HashMap<String, String> parseQueryResultRow(String row) {
299         HashMap<String, String> columnValues = new HashMap<>();
300 
301         StringJoiner pattern = new StringJoiner(", ");
302         for (int i = 0; i < COLUMNS.length; i++) {
303             pattern.add(String.format("(%s=.*)", COLUMNS[i]));
304         }
305 
306         Pattern p = Pattern.compile(pattern.toString());
307         Matcher m = p.matcher(row);
308         if (m.find()) {
309             for (int i = 1; i <= m.groupCount(); i++) {
310                 String[] keyValue = m.group(i).split("=");
311                 if (keyValue.length == 2) {
312                     columnValues.put(keyValue[0], keyValue[1]);
313                 }
314             }
315         }
316         return columnValues;
317     }
318 
319     /**
320      * Internal method to actually do the pull directory but without re-querying the current user
321      * while doing the recursive pull.
322      */
pullDirInternal(String deviceFilePath, File localDir, Integer currentUser)323     private boolean pullDirInternal(String deviceFilePath, File localDir, Integer currentUser)
324             throws DeviceNotAvailableException {
325         if (!localDir.isDirectory()) {
326             CLog.e("Local path %s is not a directory", localDir.getAbsolutePath());
327             return false;
328         }
329 
330         String contentUri = createEscapedContentUri(deviceFilePath);
331         if (currentUser == null) {
332             // Keep track of the user so if we recursively pull dir we don't re-query it.
333             currentUser = mDevice.getCurrentUser();
334         }
335         String queryContentCommand =
336                 String.format("content query --user %d --uri %s", currentUser, contentUri);
337 
338         String listCommandResult = mDevice.executeShellCommand(queryContentCommand);
339 
340         if (NO_RESULTS_STRING.equals(listCommandResult.trim())) {
341             // Empty directory.
342             return true;
343         }
344 
345         CLog.d("Received from content provider:\n%s", listCommandResult);
346         String[] listResult = listCommandResult.split("[\\r\\n]+");
347 
348         for (String row : listResult) {
349             HashMap<String, String> columnValues = parseQueryResultRow(row);
350             boolean isDirectory = Boolean.valueOf(columnValues.get(COLUMN_DIRECTORY));
351             String name = columnValues.get(COLUMN_NAME);
352             if (name == null) {
353                 CLog.w("Output from the content provider doesn't seem well formatted:\n%s", row);
354                 return false;
355             }
356             String path = columnValues.get(COLUMN_ABSOLUTE_PATH);
357 
358             File localChild = new File(localDir, name);
359             if (isDirectory) {
360                 if (!localChild.mkdir()) {
361                     CLog.w(
362                             "Failed to create sub directory %s, aborting.",
363                             localChild.getAbsolutePath());
364                     return false;
365                 }
366 
367                 if (!pullDirInternal(path, localChild, currentUser)) {
368                     CLog.w("Failed to pull sub directory %s from device, aborting", path);
369                     return false;
370                 }
371             } else {
372                 // handle regular file
373                 if (!pullFileInternal(path, localChild, currentUser)) {
374                     CLog.w("Failed to pull file %s from device, aborting", path);
375                     return false;
376                 }
377             }
378         }
379         return true;
380     }
381 
pullFileInternal(String deviceFilePath, File localFile, Integer currentUser)382     private boolean pullFileInternal(String deviceFilePath, File localFile, Integer currentUser)
383             throws DeviceNotAvailableException {
384         String contentUri = createEscapedContentUri(deviceFilePath);
385         if (currentUser == null) {
386             currentUser = mDevice.getCurrentUser();
387         }
388         String pullCommand =
389                 String.format("content read --user %d --uri %s", currentUser, contentUri);
390 
391         // Open the output stream to the local file.
392         OutputStream localFileStream;
393         try {
394             localFileStream = new FileOutputStream(localFile);
395         } catch (FileNotFoundException e) {
396             CLog.e("Failed to open OutputStream to the local file. Error: %s", e.getMessage());
397             return false;
398         }
399 
400         try {
401             CommandResult pullResult = mDevice.executeShellV2Command(pullCommand, localFileStream);
402             if (isSuccessful(pullResult)) {
403                 return true;
404             }
405             String stderr = pullResult.getStderr();
406             CLog.e(
407                     "Failed to pull a file at '%s' to %s using content provider. Error: '%s'",
408                     deviceFilePath, localFile, stderr);
409             if (stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) {
410                 mReportNotFound = true;
411             }
412             return false;
413         } finally {
414             StreamUtil.close(localFileStream);
415         }
416     }
417 }
418