1 /*
2  * Copyright (C) 2020 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 android.scopedstorage.cts;
17 
18 import static android.scopedstorage.cts.lib.RedactionTestHelper.EXIF_METADATA_QUERY;
19 import static android.scopedstorage.cts.lib.RedactionTestHelper.getExifMetadata;
20 import static android.scopedstorage.cts.lib.TestUtils.CAN_OPEN_FILE_FOR_READ_QUERY;
21 import static android.scopedstorage.cts.lib.TestUtils.CAN_OPEN_FILE_FOR_WRITE_QUERY;
22 import static android.scopedstorage.cts.lib.TestUtils.CAN_READ_WRITE_QUERY;
23 import static android.scopedstorage.cts.lib.TestUtils.CHECK_DATABASE_ROW_EXISTS_QUERY;
24 import static android.scopedstorage.cts.lib.TestUtils.CREATE_FILE_QUERY;
25 import static android.scopedstorage.cts.lib.TestUtils.CREATE_IMAGE_ENTRY_QUERY;
26 import static android.scopedstorage.cts.lib.TestUtils.DELETE_FILE_QUERY;
27 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXCEPTION;
28 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_CALLING_PKG;
29 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_PATH;
30 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_URI;
31 import static android.scopedstorage.cts.lib.TestUtils.IS_URI_REDACTED_VIA_FILEPATH;
32 import static android.scopedstorage.cts.lib.TestUtils.IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_READ;
33 import static android.scopedstorage.cts.lib.TestUtils.IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE;
34 import static android.scopedstorage.cts.lib.TestUtils.OPEN_FILE_FOR_READ_QUERY;
35 import static android.scopedstorage.cts.lib.TestUtils.OPEN_FILE_FOR_WRITE_QUERY;
36 import static android.scopedstorage.cts.lib.TestUtils.QUERY_TYPE;
37 import static android.scopedstorage.cts.lib.TestUtils.QUERY_URI;
38 import static android.scopedstorage.cts.lib.TestUtils.READDIR_QUERY;
39 import static android.scopedstorage.cts.lib.TestUtils.RENAME_FILE_PARAMS_SEPARATOR;
40 import static android.scopedstorage.cts.lib.TestUtils.RENAME_FILE_QUERY;
41 import static android.scopedstorage.cts.lib.TestUtils.SETATTR_QUERY;
42 import static android.scopedstorage.cts.lib.TestUtils.canOpen;
43 import static android.scopedstorage.cts.lib.TestUtils.getFileRowIdFromDatabase;
44 import static android.scopedstorage.cts.lib.TestUtils.getImageContentUri;
45 
46 import android.app.Activity;
47 import android.content.ContentValues;
48 import android.content.Intent;
49 import android.database.Cursor;
50 import android.media.ExifInterface;
51 import android.net.Uri;
52 import android.os.Bundle;
53 import android.provider.MediaStore;
54 
55 import androidx.annotation.Nullable;
56 import androidx.core.content.FileProvider;
57 
58 import java.io.File;
59 import java.io.FileDescriptor;
60 import java.io.IOException;
61 import java.util.ArrayList;
62 import java.util.Collections;
63 import java.util.regex.Matcher;
64 import java.util.regex.Pattern;
65 
66 /**
67  * Helper app for ScopedStorageTest.
68  *
69  * <p>Used to perform ScopedStorageTest functions as a different app. Based on the Query type
70  * app can perform different functions and send the result back to host app.
71  */
72 public class ScopedStorageTestHelper extends Activity {
73     private static final String TAG = "ScopedStorageTestHelper";
74     /**
75      * Regex that matches paths in all well-known package-specific directories,
76      * and which captures the directory type as the first group (data|media|obb) and the
77      * package name as the 2nd group.
78      */
79     private static final Pattern PATTERN_OWNED_PATH = Pattern.compile(
80             "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(data|media|obb)/([^/]+)(/?.*)?");
81 
82     @Override
onCreate(Bundle savedInstanceState)83     public void onCreate(Bundle savedInstanceState) {
84         super.onCreate(savedInstanceState);
85         String queryType = getIntent().getStringExtra(QUERY_TYPE);
86         queryType = queryType == null ? "null" : queryType;
87         Intent returnIntent;
88         try {
89             switch (queryType) {
90                 case READDIR_QUERY:
91                     returnIntent = sendDirectoryEntries(queryType);
92                     break;
93                 case CAN_READ_WRITE_QUERY:
94                 case CREATE_FILE_QUERY:
95                 case DELETE_FILE_QUERY:
96                 case CAN_OPEN_FILE_FOR_READ_QUERY:
97                 case CAN_OPEN_FILE_FOR_WRITE_QUERY:
98                 case OPEN_FILE_FOR_READ_QUERY:
99                 case OPEN_FILE_FOR_WRITE_QUERY:
100                 case SETATTR_QUERY:
101                     returnIntent = accessFile(queryType);
102                     break;
103                 case EXIF_METADATA_QUERY:
104                     returnIntent = sendMetadata(queryType);
105                     break;
106                 case CREATE_IMAGE_ENTRY_QUERY:
107                     returnIntent = createImageEntry(queryType);
108                     break;
109                 case RENAME_FILE_QUERY:
110                     returnIntent = renameFile(queryType);
111                     break;
112                 case CHECK_DATABASE_ROW_EXISTS_QUERY:
113                     returnIntent = checkDatabaseRowExists(queryType);
114                     break;
115                 case IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE:
116                 case IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_READ:
117                     returnIntent = isFileDescriptorRedactedForUri(queryType);
118                     break;
119                 case IS_URI_REDACTED_VIA_FILEPATH:
120                     returnIntent = isFilePathForUriRedacted(queryType);
121                     break;
122                 case QUERY_URI:
123                     returnIntent = queryForUri(queryType);
124                     break;
125                 case "null":
126                 default:
127                     throw new IllegalStateException(
128                             "Unknown query received from launcher app: " + queryType);
129             }
130         } catch (Exception e) {
131             returnIntent = new Intent(queryType);
132             returnIntent.putExtra(INTENT_EXCEPTION, e);
133         }
134         sendBroadcast(returnIntent);
135     }
136 
queryForUri(String queryType)137     private Intent queryForUri(String queryType) {
138         final Intent intent = new Intent(queryType);
139         final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI);
140 
141         try {
142             final Cursor c = getContentResolver().query(uri, null, null, null);
143             intent.putExtra(queryType, c != null && c.moveToFirst());
144         } catch (Exception e) {
145             intent.putExtra(INTENT_EXCEPTION, e);
146         }
147 
148         return intent;
149     }
150 
isFileDescriptorRedactedForUri(String queryType)151     private Intent isFileDescriptorRedactedForUri(String queryType) {
152         final Intent intent = new Intent(queryType);
153         final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI);
154 
155         try {
156             final String mode = queryType.equals(IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE)
157                     ? "w" : "r";
158             FileDescriptor fd = getContentResolver().openFileDescriptor(uri,
159                     mode).getFileDescriptor();
160             ExifInterface exifInterface = new ExifInterface(fd);
161             intent.putExtra(queryType, exifInterface.getGpsDateTime() == -1);
162         } catch (Exception e) {
163             intent.putExtra(INTENT_EXCEPTION, e);
164         }
165 
166         return intent;
167     }
168 
isFilePathForUriRedacted(String queryType)169     private Intent isFilePathForUriRedacted(String queryType) {
170         final Intent intent = new Intent(queryType);
171         final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI);
172 
173         try {
174             final Cursor c = getContentResolver().query(uri, null, null, null);
175             if (!c.moveToFirst()) {
176                 intent.putExtra(INTENT_EXCEPTION, new IOException(""));
177                 return intent;
178             }
179             final String path = c.getString(c.getColumnIndex(MediaStore.MediaColumns.DATA));
180             ExifInterface redactedExifInf = new ExifInterface(path);
181             intent.putExtra(queryType, redactedExifInf.getGpsDateTime() == -1);
182         } catch (Exception e) {
183             intent.putExtra(INTENT_EXCEPTION, e);
184         }
185 
186         return intent;
187     }
188 
sendMetadata(String queryType)189     private Intent sendMetadata(String queryType) throws IOException {
190         final Intent intent = new Intent(queryType);
191         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
192             final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH);
193             if (EXIF_METADATA_QUERY.equals(queryType)) {
194                 intent.putExtra(queryType, getExifMetadata(new File(filePath)));
195             }
196         } else {
197             throw new IllegalStateException(
198                     EXIF_METADATA_QUERY + ": File path not set from launcher app");
199         }
200         return intent;
201     }
202 
sendDirectoryEntries(String queryType)203     private Intent sendDirectoryEntries(String queryType) throws IOException {
204         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
205             final String directoryPath = getIntent().getStringExtra(INTENT_EXTRA_PATH);
206             ArrayList<String> directoryEntriesList = new ArrayList<>();
207             if (queryType.equals(READDIR_QUERY)) {
208                 final String[] directoryEntries = new File(directoryPath).list();
209                 if (directoryEntries == null) {
210                     throw new IOException(
211                             "I/O exception while listing entries for " + directoryPath);
212                 }
213                 Collections.addAll(directoryEntriesList, directoryEntries);
214             }
215             final Intent intent = new Intent(queryType);
216             intent.putStringArrayListExtra(queryType, directoryEntriesList);
217             return intent;
218         } else {
219             throw new IllegalStateException(
220                     READDIR_QUERY + ": Directory path not set from launcher app");
221         }
222     }
223 
createImageEntry(String queryType)224     private Intent createImageEntry(String queryType) throws Exception {
225         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
226             final String path = getIntent().getStringExtra(INTENT_EXTRA_PATH);
227             final String relativePath = path.substring(0, path.lastIndexOf('/'));
228             final String name = path.substring(path.lastIndexOf('/') + 1);
229 
230             ContentValues values = new ContentValues();
231             values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
232             values.put(MediaStore.Images.Media.RELATIVE_PATH, relativePath);
233             values.put(MediaStore.Images.Media.DISPLAY_NAME, name);
234 
235             getContentResolver().insert(getImageContentUri(), values);
236 
237             final Intent intent = new Intent(queryType);
238             intent.putExtra(queryType, true);
239             return intent;
240         } else {
241             throw new IllegalStateException(
242                     CREATE_IMAGE_ENTRY_QUERY + ": File path not set from launcher app");
243         }
244     }
245 
accessFile(String queryType)246     private Intent accessFile(String queryType) throws IOException {
247         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
248             final String packageName = getIntent().getStringExtra(INTENT_EXTRA_CALLING_PKG);
249             final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH);
250             final File file = new File(filePath);
251             final Intent intent = new Intent(queryType);
252             switch (queryType) {
253                 case CAN_READ_WRITE_QUERY:
254                     intent.putExtra(queryType, file.exists() && file.canRead() && file.canWrite());
255                     return intent;
256                 case CREATE_FILE_QUERY:
257                     maybeCreateParentDirInAndroid(file);
258                     if (!file.getParentFile().exists()) {
259                         file.getParentFile().mkdirs();
260                     }
261                     intent.putExtra(queryType, file.createNewFile());
262                     return intent;
263                 case DELETE_FILE_QUERY:
264                     intent.putExtra(queryType, file.delete());
265                     return intent;
266                 case SETATTR_QUERY:
267                     int newTimeMillis = 12345000;
268                     intent.putExtra(queryType, file.setLastModified(newTimeMillis));
269                     return intent;
270                 case CAN_OPEN_FILE_FOR_READ_QUERY:
271                     intent.putExtra(queryType, canOpen(file, false));
272                     return intent;
273                 case CAN_OPEN_FILE_FOR_WRITE_QUERY:
274                     intent.putExtra(queryType, canOpen(file, true));
275                     return intent;
276                 case OPEN_FILE_FOR_READ_QUERY:
277                 case OPEN_FILE_FOR_WRITE_QUERY:
278                     Uri contentUri = FileProvider.getUriForFile(getApplicationContext(),
279                             getApplicationContext().getPackageName(), file);
280                     intent.putExtra(queryType, contentUri);
281                     // Grant permission to the possible instrumenting test apps
282                     if (packageName != null) {
283                         getApplicationContext().grantUriPermission(packageName,
284                                 contentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
285                                 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
286                     }
287                     return intent;
288                 default:
289                     throw new IllegalStateException(
290                             "Unknown query received from launcher app: " + queryType);
291             }
292         } else {
293             throw new IllegalStateException(queryType + ": File path not set from launcher app");
294         }
295     }
296 
renameFile(String queryType)297     private Intent renameFile(String queryType) {
298         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
299             String[] paths = getIntent().getStringExtra(INTENT_EXTRA_PATH)
300                     .split(RENAME_FILE_PARAMS_SEPARATOR);
301             File src = new File(paths[0]);
302             File dst = new File(paths[1]);
303             boolean result = src.renameTo(dst);
304             final Intent intent = new Intent(queryType);
305             intent.putExtra(queryType, result);
306             return intent;
307         } else {
308             throw new IllegalStateException(
309                     queryType + ": File paths not set from launcher app");
310         }
311     }
312 
checkDatabaseRowExists(String queryType)313     private Intent checkDatabaseRowExists(String queryType) {
314         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
315             final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH);
316             boolean result =
317                     getFileRowIdFromDatabase(getContentResolver(), new File(filePath)) != -1;
318             final Intent intent = new Intent(queryType);
319             intent.putExtra(queryType, result);
320             return intent;
321         } else {
322             throw new IllegalStateException(
323                     queryType + ": File path not set from launcher app");
324         }
325     }
326 
maybeCreateParentDirInAndroid(File file)327     private void maybeCreateParentDirInAndroid(File file) {
328         final String ownedPathType = getOwnedDirectoryType(file);
329         if (ownedPathType == null) {
330             return;
331         }
332         // Create the external app dir first.
333         if (createExternalAppDir(ownedPathType)) {
334             // Then create everything along the path.
335             file.getParentFile().mkdirs();
336         }
337     }
338 
createExternalAppDir(String name)339     private boolean createExternalAppDir(String name) {
340         // Apps are not allowed to create data/cache/obb etc under Android directly and are
341         // expected to call one of the following methods.
342         switch (name) {
343             case "data":
344                 getApplicationContext().getExternalFilesDirs(null);
345                 getApplicationContext().getExternalCacheDirs();
346                 return true;
347             case "obb":
348                 getApplicationContext().getObbDirs();
349                 return true;
350             case "media":
351                 getApplicationContext().getExternalMediaDirs();
352                 return true;
353             default:
354                 return false;
355         }
356     }
357 
358     /**
359      * Returns null if given path is not an owned path.
360      */
361     @Nullable
getOwnedDirectoryType(File path)362     private static String getOwnedDirectoryType(File path) {
363         final Matcher m = PATTERN_OWNED_PATH.matcher(path.getAbsolutePath());
364         if (m.matches()) {
365             return m.group(1);
366         }
367         return null;
368     }
369 }
370