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.getExifMetadataFromFile; 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.DELETE_MEDIA_BY_URI_QUERY; 28 import static android.scopedstorage.cts.lib.TestUtils.DELETE_RECURSIVE_QUERY; 29 import static android.scopedstorage.cts.lib.TestUtils.FILE_EXISTS_QUERY; 30 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXCEPTION; 31 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_ARGS; 32 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_CALLING_PKG; 33 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_CONTENT; 34 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_PATH; 35 import static android.scopedstorage.cts.lib.TestUtils.INTENT_EXTRA_URI; 36 import static android.scopedstorage.cts.lib.TestUtils.IS_URI_REDACTED_VIA_FILEPATH; 37 import static android.scopedstorage.cts.lib.TestUtils.IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_READ; 38 import static android.scopedstorage.cts.lib.TestUtils.IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE; 39 import static android.scopedstorage.cts.lib.TestUtils.OPEN_FILE_FOR_READ_QUERY; 40 import static android.scopedstorage.cts.lib.TestUtils.OPEN_FILE_FOR_WRITE_QUERY; 41 import static android.scopedstorage.cts.lib.TestUtils.QUERY_MAX_ROW_ID; 42 import static android.scopedstorage.cts.lib.TestUtils.QUERY_MIN_ROW_ID; 43 import static android.scopedstorage.cts.lib.TestUtils.QUERY_OWNER_PACKAGE_NAMES; 44 import static android.scopedstorage.cts.lib.TestUtils.QUERY_TYPE; 45 import static android.scopedstorage.cts.lib.TestUtils.QUERY_URI; 46 import static android.scopedstorage.cts.lib.TestUtils.QUERY_WITH_ARGS; 47 import static android.scopedstorage.cts.lib.TestUtils.READDIR_QUERY; 48 import static android.scopedstorage.cts.lib.TestUtils.RENAME_FILE_PARAMS_SEPARATOR; 49 import static android.scopedstorage.cts.lib.TestUtils.RENAME_FILE_QUERY; 50 import static android.scopedstorage.cts.lib.TestUtils.SETATTR_QUERY; 51 import static android.scopedstorage.cts.lib.TestUtils.canOpen; 52 import static android.scopedstorage.cts.lib.TestUtils.deleteRecursively; 53 import static android.scopedstorage.cts.lib.TestUtils.getFileRowIdFromDatabase; 54 import static android.scopedstorage.cts.lib.TestUtils.getImageContentUri; 55 56 import static com.google.common.truth.Truth.assertThat; 57 58 import android.app.Activity; 59 import android.content.ContentResolver; 60 import android.content.ContentValues; 61 import android.content.Intent; 62 import android.database.Cursor; 63 import android.media.ExifInterface; 64 import android.net.Uri; 65 import android.os.Bundle; 66 import android.os.Environment; 67 import android.os.FileUtils; 68 import android.os.IBinder; 69 import android.os.Parcel; 70 import android.os.ParcelFileDescriptor; 71 import android.os.RemoteException; 72 import android.provider.MediaStore; 73 import android.util.Log; 74 75 import androidx.annotation.Nullable; 76 import androidx.core.content.FileProvider; 77 78 import com.google.common.base.Strings; 79 80 import java.io.File; 81 import java.io.FileDescriptor; 82 import java.io.FileInputStream; 83 import java.io.FileOutputStream; 84 import java.io.IOException; 85 import java.util.ArrayList; 86 import java.util.Collections; 87 import java.util.HashSet; 88 import java.util.Set; 89 import java.util.regex.Matcher; 90 import java.util.regex.Pattern; 91 92 /** 93 * Helper app for ScopedStorageTest. 94 * 95 * <p>Used to perform ScopedStorageTest functions as a different app. Based on the Query type 96 * app can perform different functions and send the result back to host app. 97 */ 98 public class ScopedStorageTestHelper extends Activity { 99 private static final String TAG = "ScopedStorageTestHelper"; 100 /** 101 * Regex that matches paths in all well-known package-specific directories, 102 * and which captures the directory type as the first group (data|media|obb) and the 103 * package name as the 2nd group. 104 */ 105 private static final Pattern PATTERN_OWNED_PATH = Pattern.compile( 106 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(data|media|obb)/([^/]+)(/?.*)?"); 107 108 @Override onCreate(Bundle savedInstanceState)109 public void onCreate(Bundle savedInstanceState) { 110 super.onCreate(savedInstanceState); 111 String queryType = getIntent().getStringExtra(QUERY_TYPE); 112 queryType = queryType == null ? "null" : queryType; 113 Intent returnIntent; 114 try { 115 switch (queryType) { 116 case READDIR_QUERY: 117 returnIntent = sendDirectoryEntries(queryType); 118 break; 119 case FILE_EXISTS_QUERY: 120 case CAN_READ_WRITE_QUERY: 121 case CREATE_FILE_QUERY: 122 case DELETE_FILE_QUERY: 123 case DELETE_RECURSIVE_QUERY: 124 case CAN_OPEN_FILE_FOR_READ_QUERY: 125 case CAN_OPEN_FILE_FOR_WRITE_QUERY: 126 case OPEN_FILE_FOR_READ_QUERY: 127 case OPEN_FILE_FOR_WRITE_QUERY: 128 case SETATTR_QUERY: 129 returnIntent = accessFile(queryType); 130 break; 131 case DELETE_MEDIA_BY_URI_QUERY: 132 returnIntent = deleteMediaByUri(queryType); 133 break; 134 case EXIF_METADATA_QUERY: 135 returnIntent = sendMetadata(queryType); 136 break; 137 case CREATE_IMAGE_ENTRY_QUERY: 138 returnIntent = createImageEntry(queryType); 139 break; 140 case RENAME_FILE_QUERY: 141 returnIntent = renameFile(queryType); 142 break; 143 case CHECK_DATABASE_ROW_EXISTS_QUERY: 144 returnIntent = checkDatabaseRowExists(queryType); 145 break; 146 case IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE: 147 case IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_READ: 148 returnIntent = isFileDescriptorRedactedForUri(queryType); 149 break; 150 case IS_URI_REDACTED_VIA_FILEPATH: 151 returnIntent = isFilePathForUriRedacted(queryType); 152 break; 153 case QUERY_URI: 154 returnIntent = queryForUri(queryType); 155 break; 156 case QUERY_MAX_ROW_ID: 157 case QUERY_MIN_ROW_ID: 158 returnIntent = queryRowId(queryType); 159 break; 160 case QUERY_OWNER_PACKAGE_NAMES: 161 returnIntent = queryOwnerPackageNames(queryType); 162 break; 163 case QUERY_WITH_ARGS: 164 returnIntent = queryWithArgs(queryType); 165 break; 166 case "null": 167 default: 168 throw new IllegalStateException( 169 "Unknown query received from launcher app: " + queryType); 170 } 171 } catch (Exception e) { 172 returnIntent = new Intent(queryType); 173 returnIntent.putExtra(INTENT_EXCEPTION, e); 174 } 175 sendBroadcast(returnIntent); 176 } 177 queryForUri(String queryType)178 private Intent queryForUri(String queryType) { 179 final Intent intent = new Intent(queryType); 180 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 181 182 try { 183 final Cursor c = getContentResolver().query(uri, null, null, null); 184 intent.putExtra(queryType, c != null && c.moveToFirst()); 185 } catch (Exception e) { 186 intent.putExtra(INTENT_EXCEPTION, e); 187 } 188 189 return intent; 190 } 191 queryRowId(String queryType)192 private Intent queryRowId(String queryType) { 193 // Ensure M_E_S permission has been granted. 194 assertThat(Environment.isExternalStorageManager()).isTrue(); 195 final Intent intent = new Intent(queryType); 196 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 197 final Bundle bundle = createQueryArgs(queryType); 198 try { 199 final Cursor c = getContentResolver().query(uri, 200 new String[]{MediaStore.Files.FileColumns._ID}, bundle, null); 201 if (c != null && c.moveToFirst()) { 202 intent.putExtra(queryType, c.getLong(0)); 203 } 204 } catch (Exception e) { 205 intent.putExtra(INTENT_EXCEPTION, e); 206 } 207 208 return intent; 209 } 210 queryOwnerPackageNames(String queryType)211 private Intent queryOwnerPackageNames(String queryType) { 212 final Intent intent = new Intent(queryType); 213 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 214 215 try { 216 final Cursor c = getContentResolver().query(uri, 217 new String[]{MediaStore.MediaColumns.OWNER_PACKAGE_NAME}, null, null); 218 final Set<String> ownerPackageNames = new HashSet<>(); 219 while (c.moveToNext()) { 220 final String ownerPackageName = c.getString(0); 221 if (!Strings.isNullOrEmpty(ownerPackageName)) { 222 ownerPackageNames.add(ownerPackageName); 223 } 224 } 225 intent.putExtra(queryType, ownerPackageNames.toArray(new String[0])); 226 } catch (Exception e) { 227 intent.putExtra(INTENT_EXCEPTION, e); 228 } 229 230 return intent; 231 } 232 deleteMediaByUri(String queryType)233 private Intent deleteMediaByUri(String queryType) { 234 final Intent intent = new Intent(queryType); 235 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 236 237 try { 238 int rowsDeleted = getContentResolver().delete(uri, null); 239 intent.putExtra(queryType, rowsDeleted); 240 } catch (Exception e) { 241 intent.putExtra(INTENT_EXCEPTION, e); 242 } 243 244 return intent; 245 } 246 queryWithArgs(String queryType)247 private Intent queryWithArgs(String queryType) { 248 final Intent intent = new Intent(queryType); 249 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 250 final Bundle args = getIntent().getBundleExtra(INTENT_EXTRA_ARGS); 251 try { 252 final Cursor c = getContentResolver().query(uri, 253 new String[]{MediaStore.MediaColumns.DISPLAY_NAME}, args, null); 254 intent.putExtra(queryType, c.getCount()); 255 } catch (Exception e) { 256 intent.putExtra(INTENT_EXCEPTION, e); 257 } 258 259 return intent; 260 } 261 createQueryArgs(String queryType)262 private Bundle createQueryArgs(String queryType) { 263 switch (queryType){ 264 case QUERY_MAX_ROW_ID: 265 return createQueryArgToRetrieveMaximumRowId(); 266 case QUERY_MIN_ROW_ID: 267 return createQueryArgToRetrieveMinimumRowId(); 268 default: 269 throw new IllegalStateException( 270 "Unknown query type received from launcher app: " + queryType); 271 } 272 } 273 createQueryArgToRetrieveMinimumRowId()274 private Bundle createQueryArgToRetrieveMinimumRowId() { 275 final Bundle queryArgs = new Bundle(); 276 queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, 277 MediaStore.Files.FileColumns._ID + " ASC"); 278 queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 1); 279 return queryArgs; 280 } 281 createQueryArgToRetrieveMaximumRowId()282 private Bundle createQueryArgToRetrieveMaximumRowId() { 283 final Bundle queryArgs = new Bundle(); 284 queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, 285 MediaStore.Files.FileColumns._ID + " DESC"); 286 queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 1); 287 return queryArgs; 288 } 289 isFileDescriptorRedactedForUri(String queryType)290 private Intent isFileDescriptorRedactedForUri(String queryType) { 291 final Intent intent = new Intent(queryType); 292 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 293 294 try { 295 final String mode = queryType.equals(IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE) 296 ? "w" : "r"; 297 try (ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, mode)) { 298 FileDescriptor fd = pfd.getFileDescriptor(); 299 ExifInterface exifInterface = new ExifInterface(fd); 300 intent.putExtra(queryType, exifInterface.getGpsDateTime() == -1); 301 } 302 } catch (Exception e) { 303 intent.putExtra(INTENT_EXCEPTION, e); 304 } 305 306 return intent; 307 } 308 isFilePathForUriRedacted(String queryType)309 private Intent isFilePathForUriRedacted(String queryType) { 310 final Intent intent = new Intent(queryType); 311 final Uri uri = getIntent().getParcelableExtra(INTENT_EXTRA_URI); 312 313 try { 314 final Cursor c = getContentResolver().query(uri, null, null, null); 315 if (!c.moveToFirst()) { 316 intent.putExtra(INTENT_EXCEPTION, new IOException("")); 317 return intent; 318 } 319 final String path = c.getString(c.getColumnIndex(MediaStore.MediaColumns.DATA)); 320 ExifInterface redactedExifInf = new ExifInterface(path); 321 intent.putExtra(queryType, redactedExifInf.getGpsDateTime() == -1); 322 } catch (Exception e) { 323 intent.putExtra(INTENT_EXCEPTION, e); 324 } 325 326 return intent; 327 } 328 sendMetadata(String queryType)329 private Intent sendMetadata(String queryType) throws IOException { 330 final Intent intent = new Intent(queryType); 331 if (getIntent().hasExtra(INTENT_EXTRA_PATH)) { 332 final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH); 333 if (EXIF_METADATA_QUERY.equals(queryType)) { 334 intent.putExtra(queryType, getExifMetadataFromFile(new File(filePath))); 335 } 336 } else { 337 throw new IllegalStateException( 338 EXIF_METADATA_QUERY + ": File path not set from launcher app"); 339 } 340 return intent; 341 } 342 sendDirectoryEntries(String queryType)343 private Intent sendDirectoryEntries(String queryType) throws IOException { 344 if (getIntent().hasExtra(INTENT_EXTRA_PATH)) { 345 final String directoryPath = getIntent().getStringExtra(INTENT_EXTRA_PATH); 346 ArrayList<String> directoryEntriesList = new ArrayList<>(); 347 if (queryType.equals(READDIR_QUERY)) { 348 final String[] directoryEntries = new File(directoryPath).list(); 349 if (directoryEntries == null) { 350 throw new IOException( 351 "I/O exception while listing entries for " + directoryPath); 352 } 353 Collections.addAll(directoryEntriesList, directoryEntries); 354 } 355 final Intent intent = new Intent(queryType); 356 intent.putStringArrayListExtra(queryType, directoryEntriesList); 357 return intent; 358 } else { 359 throw new IllegalStateException( 360 READDIR_QUERY + ": Directory path not set from launcher app"); 361 } 362 } 363 createImageEntry(String queryType)364 private Intent createImageEntry(String queryType) throws Exception { 365 if (getIntent().hasExtra(INTENT_EXTRA_PATH)) { 366 final String path = getIntent().getStringExtra(INTENT_EXTRA_PATH); 367 final String relativePath = path.substring(0, path.lastIndexOf('/')); 368 final String name = path.substring(path.lastIndexOf('/') + 1); 369 370 final ContentValues values = new ContentValues(); 371 values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg"); 372 values.put(MediaStore.Images.Media.RELATIVE_PATH, relativePath); 373 values.put(MediaStore.Images.Media.DISPLAY_NAME, name); 374 375 final Uri imageUri = getContentResolver().insert(getImageContentUri(), values); 376 377 final Intent intent = new Intent(queryType); 378 intent.putExtra(queryType, imageUri.toString()); 379 return intent; 380 } else { 381 throw new IllegalStateException( 382 CREATE_IMAGE_ENTRY_QUERY + ": File path not set from launcher app"); 383 } 384 } 385 accessFile(String queryType)386 private Intent accessFile(String queryType) throws IOException, RemoteException { 387 if (getIntent().hasExtra(INTENT_EXTRA_PATH)) { 388 final String packageName = getIntent().getStringExtra(INTENT_EXTRA_CALLING_PKG); 389 final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH); 390 final File file = new File(filePath); 391 final Intent intent = new Intent(queryType); 392 switch (queryType) { 393 case FILE_EXISTS_QUERY: 394 intent.putExtra(queryType, file.exists()); 395 return intent; 396 case CAN_READ_WRITE_QUERY: 397 intent.putExtra(queryType, file.exists() && file.canRead() && file.canWrite()); 398 return intent; 399 case CREATE_FILE_QUERY: 400 maybeCreateParentDirInAndroid(file); 401 if (!file.getParentFile().exists()) { 402 file.getParentFile().mkdirs(); 403 } 404 boolean success = file.createNewFile(); 405 if (success && getIntent().hasExtra(INTENT_EXTRA_CONTENT)) { 406 success = createFileContent(file); 407 } 408 intent.putExtra(queryType, success); 409 return intent; 410 case DELETE_FILE_QUERY: 411 intent.putExtra(queryType, file.delete()); 412 return intent; 413 case DELETE_RECURSIVE_QUERY: 414 intent.putExtra(queryType, deleteRecursively(file)); 415 return intent; 416 case SETATTR_QUERY: 417 int newTimeMillis = 12345000; 418 intent.putExtra(queryType, file.setLastModified(newTimeMillis)); 419 return intent; 420 case CAN_OPEN_FILE_FOR_READ_QUERY: 421 intent.putExtra(queryType, canOpen(file, false)); 422 return intent; 423 case CAN_OPEN_FILE_FOR_WRITE_QUERY: 424 intent.putExtra(queryType, canOpen(file, true)); 425 return intent; 426 case OPEN_FILE_FOR_READ_QUERY: 427 case OPEN_FILE_FOR_WRITE_QUERY: 428 Uri contentUri = FileProvider.getUriForFile(getApplicationContext(), 429 getApplicationContext().getPackageName(), file); 430 intent.putExtra(queryType, contentUri); 431 // Grant permission to the possible instrumenting test apps 432 if (packageName != null) { 433 getApplicationContext().grantUriPermission(packageName, 434 contentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION 435 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 436 } 437 return intent; 438 default: 439 throw new IllegalStateException( 440 "Unknown query received from launcher app: " + queryType); 441 } 442 } else { 443 throw new IllegalStateException(queryType + ": File path not set from launcher app"); 444 } 445 } 446 createFileContent(File file)447 private boolean createFileContent(File file) throws RemoteException { 448 final Bundle content = getIntent().getBundleExtra( 449 INTENT_EXTRA_CONTENT); 450 IBinder binder = content.getBinder(INTENT_EXTRA_CONTENT); 451 Parcel reply = Parcel.obtain(); 452 binder.transact(IBinder.FIRST_CALL_TRANSACTION, Parcel.obtain(), reply, 0); 453 try (ParcelFileDescriptor inputPFD = reply.readFileDescriptor(); 454 FileInputStream fileInputStream = new FileInputStream( 455 inputPFD.getFileDescriptor()); 456 FileOutputStream outputStream = new FileOutputStream(file)) { 457 long copied = FileUtils.copy(fileInputStream, outputStream); 458 outputStream.getFD().sync(); 459 return copied > 0; 460 } catch (Exception e) { 461 Log.e(TAG, e.getMessage(), e); 462 return false; 463 } 464 } 465 renameFile(String queryType)466 private Intent renameFile(String queryType) { 467 if (getIntent().hasExtra(INTENT_EXTRA_PATH)) { 468 String[] paths = getIntent().getStringExtra(INTENT_EXTRA_PATH) 469 .split(RENAME_FILE_PARAMS_SEPARATOR); 470 File src = new File(paths[0]); 471 File dst = new File(paths[1]); 472 boolean result = src.renameTo(dst); 473 final Intent intent = new Intent(queryType); 474 intent.putExtra(queryType, result); 475 return intent; 476 } else { 477 throw new IllegalStateException( 478 queryType + ": File paths not set from launcher app"); 479 } 480 } 481 checkDatabaseRowExists(String queryType)482 private Intent checkDatabaseRowExists(String queryType) { 483 if (getIntent().hasExtra(INTENT_EXTRA_PATH)) { 484 final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH); 485 boolean result = 486 getFileRowIdFromDatabase(getContentResolver(), new File(filePath)) != -1; 487 final Intent intent = new Intent(queryType); 488 intent.putExtra(queryType, result); 489 return intent; 490 } else { 491 throw new IllegalStateException( 492 queryType + ": File path not set from launcher app"); 493 } 494 } 495 maybeCreateParentDirInAndroid(File file)496 private void maybeCreateParentDirInAndroid(File file) { 497 final String ownedPathType = getOwnedDirectoryType(file); 498 if (ownedPathType == null) { 499 return; 500 } 501 // Create the external app dir first. 502 if (createExternalAppDir(ownedPathType)) { 503 // Then create everything along the path. 504 file.getParentFile().mkdirs(); 505 } 506 } 507 createExternalAppDir(String name)508 private boolean createExternalAppDir(String name) { 509 // Apps are not allowed to create data/cache/obb etc under Android directly and are 510 // expected to call one of the following methods. 511 switch (name) { 512 case "data": 513 getApplicationContext().getExternalFilesDirs(null); 514 getApplicationContext().getExternalCacheDirs(); 515 return true; 516 case "obb": 517 getApplicationContext().getObbDirs(); 518 return true; 519 case "media": 520 getApplicationContext().getExternalMediaDirs(); 521 return true; 522 default: 523 return false; 524 } 525 } 526 527 /** 528 * Returns null if given path is not an owned path. 529 */ 530 @Nullable getOwnedDirectoryType(File path)531 private static String getOwnedDirectoryType(File path) { 532 final Matcher m = PATTERN_OWNED_PATH.matcher(path.getAbsolutePath()); 533 if (m.matches()) { 534 return m.group(1); 535 } 536 return null; 537 } 538 } 539