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