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 17 package com.android.providers.media.scan; 18 19 import static org.junit.Assert.assertEquals; 20 import static org.junit.Assert.assertTrue; 21 import static org.junit.Assert.fail; 22 import static org.junit.Assume.assumeFalse; 23 import static org.junit.Assume.assumeTrue; 24 25 import android.content.ContentResolver; 26 import android.content.ContentUris; 27 import android.content.ContentValues; 28 import android.content.Context; 29 import android.content.res.AssetFileDescriptor; 30 import android.database.Cursor; 31 import android.drm.DrmConvertedStatus; 32 import android.drm.DrmManagerClient; 33 import android.drm.DrmSupportInfo; 34 import android.net.Uri; 35 import android.os.ParcelFileDescriptor; 36 import android.provider.MediaStore; 37 import android.provider.MediaStore.Files.FileColumns; 38 import android.provider.MediaStore.MediaColumns; 39 import android.system.ErrnoException; 40 import android.system.Os; 41 import android.util.Log; 42 43 import androidx.annotation.NonNull; 44 import androidx.annotation.Nullable; 45 import androidx.test.InstrumentationRegistry; 46 import androidx.test.runner.AndroidJUnit4; 47 48 import com.android.providers.media.R; 49 import com.android.providers.media.util.DatabaseUtils; 50 import com.android.providers.media.util.FileUtils; 51 52 import org.junit.After; 53 import org.junit.Before; 54 import org.junit.Test; 55 import org.junit.runner.RunWith; 56 57 import java.io.ByteArrayInputStream; 58 import java.io.File; 59 import java.io.FileDescriptor; 60 import java.io.FileInputStream; 61 import java.io.IOException; 62 import java.io.InputStream; 63 import java.io.RandomAccessFile; 64 import java.io.SequenceInputStream; 65 import java.nio.charset.StandardCharsets; 66 import java.util.ArrayList; 67 import java.util.Arrays; 68 import java.util.Enumeration; 69 import java.util.Iterator; 70 import java.util.List; 71 import java.util.Objects; 72 import java.util.function.Consumer; 73 74 /** 75 * Verify that we scan various DRM files correctly. This is accomplished by 76 * generating DRM files locally and confirming the scan results. 77 */ 78 @RunWith(AndroidJUnit4.class) 79 public class DrmTest { 80 private static final String TAG = "DrmTest"; 81 82 private Context mContext; 83 private ContentResolver mResolver; 84 private DrmManagerClient mClient; 85 86 private static final String MIME_FORWARD_LOCKED = "application/vnd.oma.drm.message"; 87 private static final String MIME_UNSUPPORTED = "unsupported/drm.mimetype"; 88 89 @Before setUp()90 public void setUp() throws Exception { 91 mContext = InstrumentationRegistry.getContext(); 92 mResolver = mContext.getContentResolver(); 93 mClient = new DrmManagerClient(mContext); 94 } 95 96 @After tearDown()97 public void tearDown() throws Exception { 98 FileUtils.closeQuietly(mClient); 99 } 100 101 @Test testForwardLock_Audio()102 public void testForwardLock_Audio() throws Exception { 103 assumeTrue(isForwardLockSupported()); 104 doForwardLock("audio/mpeg", R.raw.test_audio, (values) -> { 105 assertEquals(1_045L, (long) values.getAsLong(FileColumns.DURATION)); 106 assertEquals(FileColumns.MEDIA_TYPE_AUDIO, 107 (int) values.getAsInteger(FileColumns.MEDIA_TYPE)); 108 }); 109 } 110 111 @Test testForwardLock_Video()112 public void testForwardLock_Video() throws Exception { 113 assumeTrue(isForwardLockSupported()); 114 doForwardLock("video/mp4", R.raw.test_video, (values) -> { 115 assertEquals(40_000L, (long) values.getAsLong(FileColumns.DURATION)); 116 assertEquals(FileColumns.MEDIA_TYPE_VIDEO, 117 (int) values.getAsInteger(FileColumns.MEDIA_TYPE)); 118 }); 119 } 120 121 @Test testForwardLock_Image()122 public void testForwardLock_Image() throws Exception { 123 assumeTrue(isForwardLockSupported()); 124 doForwardLock("image/jpeg", R.raw.test_image, (values) -> { 125 // ExifInterface currently doesn't know how to scan DRM images, so 126 // the best we can do is verify the base test metadata 127 assertEquals(FileColumns.MEDIA_TYPE_IMAGE, 128 (int) values.getAsInteger(FileColumns.MEDIA_TYPE)); 129 }); 130 } 131 132 @Test testForwardLock_Binary()133 public void testForwardLock_Binary() throws Exception { 134 assumeTrue(isForwardLockSupported()); 135 doForwardLock("application/octet-stream", R.raw.test_image, null); 136 } 137 138 /** 139 * Verify that empty files that created with {@link #MIME_FORWARD_LOCKED} 140 * can be adjusted by rescanning to reflect their final MIME type. 141 */ 142 @Test testForwardLock_130680734()143 public void testForwardLock_130680734() throws Exception { 144 assumeTrue(isForwardLockSupported()); 145 146 final ContentValues values = new ContentValues(); 147 values.put(MediaColumns.DISPLAY_NAME, "temp" + System.nanoTime() + ".fl"); 148 values.put(MediaColumns.MIME_TYPE, MIME_FORWARD_LOCKED); 149 values.put(MediaColumns.IS_PENDING, 1); 150 151 // Stream our forward-locked file into place 152 final Uri uri = mResolver.insert( 153 MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values); 154 try (InputStream dmStream = createDmStream("video/mp4", R.raw.test_video); 155 ParcelFileDescriptor pfd = mResolver.openFile(uri, "rw", null)) { 156 convertDmToFl(mContext, dmStream, pfd.getFileDescriptor()); 157 } 158 159 // Publish, which will kick off a media scan 160 values.clear(); 161 values.put(MediaColumns.IS_PENDING, 0); 162 assertEquals(1, mResolver.update(uri, values, null)); 163 164 // Verify that published item reflects final collection 165 final Uri filesUri = MediaStore.Files.getContentUri(MediaStore.getVolumeName(uri), 166 ContentUris.parseId(uri)); 167 try (Cursor c = mResolver.query(filesUri, null, null, null)) { 168 assertTrue(c.moveToFirst()); 169 170 final String mimeType = c.getString(c.getColumnIndex(FileColumns.MIME_TYPE)); 171 // To be consistent with the logic in doForwardLock() below: if the devices does not 172 // handle .fl files we won't consider the test failing, but we also can't carry on here, 173 // thus let's do an AssumptionViolatedException. 174 assumeFalse(MIME_UNSUPPORTED.equals(mimeType)); 175 assertEquals("video/mp4", mimeType); 176 177 assertEquals(FileColumns.MEDIA_TYPE_VIDEO, 178 c.getInt(c.getColumnIndex(FileColumns.MEDIA_TYPE))); 179 } 180 } 181 createDmStream(@onNull String mimeType, int resId)182 public @NonNull InputStream createDmStream(@NonNull String mimeType, int resId) 183 throws IOException { 184 List<InputStream> sequence = new ArrayList<>(); 185 186 String dmHeader = "--mime_content_boundary\r\n" + 187 "Content-Type: " + mimeType + "\r\n" + 188 "Content-Transfer-Encoding: binary\r\n\r\n"; 189 sequence.add(new ByteArrayInputStream(dmHeader.getBytes(StandardCharsets.UTF_8))); 190 191 AssetFileDescriptor afd = mContext.getResources().openRawResourceFd(resId); 192 FileInputStream body = afd.createInputStream(); 193 sequence.add(body); 194 195 String dmFooter = "\r\n--mime_content_boundary--"; 196 sequence.add(new ByteArrayInputStream(dmFooter.getBytes(StandardCharsets.UTF_8))); 197 198 return new SequenceInputStream(new EnumerationAdapter<InputStream>(sequence.iterator())); 199 } 200 doForwardLock(String mimeType, int resId, @Nullable Consumer<ContentValues> verifier)201 private void doForwardLock(String mimeType, int resId, 202 @Nullable Consumer<ContentValues> verifier) throws Exception { 203 InputStream dmStream = createDmStream(mimeType, resId); 204 205 File flPath = new File(mContext.getExternalMediaDirs()[0], 206 "temp" + System.nanoTime() + ".fl"); 207 RandomAccessFile flFile = new RandomAccessFile(flPath, "rw"); 208 assertTrue("couldn't convert to fl file", 209 convertDmToFl(mContext, dmStream, flFile.getFD())); 210 dmStream.close(); // this closes the underlying streams and AFD as well 211 flFile.close(); 212 213 // Scan the DRM file and confirm that it looks sane 214 final Uri flUri = MediaStore.scanFile(mContext.getContentResolver(), flPath); 215 final Uri fileUri = MediaStore.Files.getContentUri(MediaStore.getVolumeName(flUri), 216 ContentUris.parseId(flUri)); 217 try (Cursor c = mContext.getContentResolver().query(fileUri, null, null, null)) { 218 assertTrue(c.moveToFirst()); 219 220 final ContentValues values = new ContentValues(); 221 DatabaseUtils.copyFromCursorToContentValues(FileColumns.DISPLAY_NAME, c, values); 222 DatabaseUtils.copyFromCursorToContentValues(FileColumns.MIME_TYPE, c, values); 223 DatabaseUtils.copyFromCursorToContentValues(FileColumns.MEDIA_TYPE, c, values); 224 DatabaseUtils.copyFromCursorToContentValues(FileColumns.IS_DRM, c, values); 225 DatabaseUtils.copyFromCursorToContentValues(FileColumns.DURATION, c, values); 226 Log.v(TAG, values.toString()); 227 228 // Filename should match what we found on disk 229 assertEquals(flPath.getName(), values.get(FileColumns.DISPLAY_NAME)); 230 // Should always be marked as a DRM file 231 assertEquals("1", values.get(FileColumns.IS_DRM)); 232 233 final String actualMimeType = values.getAsString(FileColumns.MIME_TYPE); 234 if (Objects.equals(mimeType, actualMimeType)) { 235 // We scanned the item successfully, so we can also check our 236 // custom verifier, if any 237 if (verifier != null) { 238 verifier.accept(values); 239 } 240 } else if (Objects.equals(MIME_UNSUPPORTED, actualMimeType)) { 241 // We don't scan unsupported items, so we can't check our custom 242 // verifier, but we're still willing to consider this as passing. 243 } else { 244 fail("Unexpected MIME type " + actualMimeType); 245 } 246 } 247 } 248 249 /** 250 * Shamelessly copied from 251 * cts/common/device-side/util-axt/src/com/android/compatibility/common/util/MediaUtils.java 252 */ convertDmToFl( Context context, InputStream dmStream, FileDescriptor fd)253 public static boolean convertDmToFl( 254 Context context, 255 InputStream dmStream, 256 FileDescriptor fd) { 257 final String MIMETYPE_DRM_MESSAGE = "application/vnd.oma.drm.message"; 258 byte[] dmData = new byte[10000]; 259 int totalRead = 0; 260 int numRead; 261 while (true) { 262 try { 263 numRead = dmStream.read(dmData, totalRead, dmData.length - totalRead); 264 } catch (IOException e) { 265 Log.w(TAG, "Failed to read from input file"); 266 return false; 267 } 268 if (numRead == -1) { 269 break; 270 } 271 totalRead += numRead; 272 if (totalRead == dmData.length) { 273 // grow array 274 dmData = Arrays.copyOf(dmData, dmData.length + 10000); 275 } 276 } 277 byte[] fileData = Arrays.copyOf(dmData, totalRead); 278 279 DrmManagerClient drmClient = null; 280 try { 281 drmClient = new DrmManagerClient(context); 282 } catch (IllegalArgumentException e) { 283 Log.w(TAG, "DrmManagerClient instance could not be created, context is Illegal."); 284 return false; 285 } catch (IllegalStateException e) { 286 Log.w(TAG, "DrmManagerClient didn't initialize properly."); 287 return false; 288 } 289 290 try { 291 int convertSessionId = -1; 292 try { 293 convertSessionId = drmClient.openConvertSession(MIMETYPE_DRM_MESSAGE); 294 } catch (IllegalArgumentException e) { 295 Log.w(TAG, "Conversion of Mimetype: " + MIMETYPE_DRM_MESSAGE 296 + " is not supported.", e); 297 return false; 298 } catch (IllegalStateException e) { 299 Log.w(TAG, "Could not access Open DrmFramework.", e); 300 return false; 301 } 302 303 if (convertSessionId < 0) { 304 Log.w(TAG, "Failed to open session."); 305 return false; 306 } 307 308 DrmConvertedStatus convertedStatus = null; 309 try { 310 convertedStatus = drmClient.convertData(convertSessionId, fileData); 311 } catch (IllegalArgumentException e) { 312 Log.w(TAG, "Buffer with data to convert is illegal. Convertsession: " 313 + convertSessionId, e); 314 return false; 315 } catch (IllegalStateException e) { 316 Log.w(TAG, "Could not convert data. Convertsession: " + convertSessionId, e); 317 return false; 318 } 319 320 if (convertedStatus == null || 321 convertedStatus.statusCode != DrmConvertedStatus.STATUS_OK || 322 convertedStatus.convertedData == null) { 323 Log.w(TAG, "Error in converting data. Convertsession: " + convertSessionId); 324 try { 325 DrmConvertedStatus result = drmClient.closeConvertSession(convertSessionId); 326 if (result.statusCode != DrmConvertedStatus.STATUS_OK) { 327 Log.w(TAG, "Conversion failed with status: " + result.statusCode); 328 return false; 329 } 330 } catch (IllegalStateException e) { 331 Log.w(TAG, "Could not close session. Convertsession: " + 332 convertSessionId, e); 333 } 334 return false; 335 } 336 337 try { 338 Os.write(fd, convertedStatus.convertedData, 0, 339 convertedStatus.convertedData.length); 340 } catch (IOException | ErrnoException e) { 341 Log.w(TAG, "Failed to write to output file: " + e); 342 return false; 343 } 344 345 try { 346 convertedStatus = drmClient.closeConvertSession(convertSessionId); 347 } catch (IllegalStateException e) { 348 Log.w(TAG, "Could not close convertsession. Convertsession: " + 349 convertSessionId, e); 350 return false; 351 } 352 353 if (convertedStatus == null || 354 convertedStatus.statusCode != DrmConvertedStatus.STATUS_OK || 355 convertedStatus.convertedData == null) { 356 Log.w(TAG, "Error in closing session. Convertsession: " + convertSessionId); 357 return false; 358 } 359 360 try { 361 Os.pwrite(fd, convertedStatus.convertedData, 0, 362 convertedStatus.convertedData.length, convertedStatus.offset); 363 } catch (IOException | ErrnoException e) { 364 Log.w(TAG, "Could not update file.", e); 365 return false; 366 } 367 368 return true; 369 } finally { 370 drmClient.close(); 371 } 372 } 373 isForwardLockSupported()374 private boolean isForwardLockSupported() { 375 for (DrmSupportInfo info : mClient.getAvailableDrmSupportInfo()) { 376 Iterator<String> it = info.getMimeTypeIterator(); 377 while (it.hasNext()) { 378 if (Objects.equals(MIME_FORWARD_LOCKED, it.next())) { 379 return true; 380 } 381 } 382 } 383 return false; 384 } 385 386 /** 387 * This is purely an adapter to convert modern {@link Iterator} back into an 388 * {@link Enumeration} for legacy code. 389 */ 390 @SuppressWarnings("JdkObsolete") 391 private static class EnumerationAdapter<T> implements Enumeration<T> { 392 private final Iterator<T> it; 393 EnumerationAdapter(Iterator<T> it)394 public EnumerationAdapter(Iterator<T> it) { 395 this.it = it; 396 } 397 398 @Override hasMoreElements()399 public boolean hasMoreElements() { 400 return it.hasNext(); 401 } 402 403 @Override nextElement()404 public T nextElement() { 405 return it.next(); 406 } 407 } 408 } 409