1 /*
2  * Copyright (C) 2022 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 android.photopicker.cts;
18 
19 import static android.provider.CloudMediaProviderContract.AlbumColumns;
20 import static android.provider.CloudMediaProviderContract.EXTRA_ALBUM_ID;
21 import static android.provider.CloudMediaProviderContract.EXTRA_MEDIA_COLLECTION_ID;
22 import static android.provider.CloudMediaProviderContract.EXTRA_PAGE_SIZE;
23 import static android.provider.CloudMediaProviderContract.EXTRA_SYNC_GENERATION;
24 import static android.provider.CloudMediaProviderContract.MediaCollectionInfo;
25 import static android.provider.CloudMediaProviderContract.MediaColumns;
26 
27 import android.content.ContentResolver;
28 import android.content.Context;
29 import android.content.Intent;
30 import android.database.Cursor;
31 import android.database.MatrixCursor;
32 import android.os.Bundle;
33 import android.os.FileUtils;
34 import android.os.ParcelFileDescriptor;
35 import android.provider.CloudMediaProvider;
36 import android.provider.CloudMediaProviderContract;
37 import android.provider.MediaStore;
38 
39 import java.io.File;
40 import java.io.FileNotFoundException;
41 import java.io.FileOutputStream;
42 import java.io.IOException;
43 import java.io.InputStream;
44 import java.util.ArrayList;
45 import java.util.HashMap;
46 import java.util.List;
47 import java.util.Map;
48 import java.util.Objects;
49 
50 /**
51  * Generates {@link TestMedia} items that can be accessed via test {@link CloudMediaProvider}
52  * instances.
53  */
54 public class PickerProviderMediaGenerator {
55     private static final Map<String, MediaGenerator> sMediaGeneratorMap = new HashMap<>();
56     private static final String[] MEDIA_PROJECTION = new String[] {
57         MediaColumns.ID,
58         MediaColumns.MEDIA_STORE_URI,
59         MediaColumns.MIME_TYPE,
60         MediaColumns.STANDARD_MIME_TYPE_EXTENSION,
61         MediaColumns.DATE_TAKEN_MILLIS,
62         MediaColumns.SYNC_GENERATION,
63         MediaColumns.SIZE_BYTES,
64         MediaColumns.DURATION_MILLIS,
65         MediaColumns.IS_FAVORITE,
66     };
67 
68     private static final String[] ALBUM_PROJECTION = new String[] {
69         AlbumColumns.ID,
70         AlbumColumns.DISPLAY_NAME,
71         AlbumColumns.DATE_TAKEN_MILLIS,
72         AlbumColumns.MEDIA_COVER_ID,
73         AlbumColumns.MEDIA_COUNT,
74     };
75 
76     private static final String[] DELETED_MEDIA_PROJECTION = new String[] { MediaColumns.ID };
77 
78     public static class MediaGenerator {
79         private final List<TestMedia> mMedia = new ArrayList<>();
80         private final List<TestMedia> mDeletedMedia = new ArrayList<>();
81         private final List<TestAlbum> mAlbums = new ArrayList<>();
82         private final File mPrivateDir;
83         private final Context mContext;
84 
85         private String mCollectionId;
86         private long mLastSyncGeneration;
87         private String mAccountName;
88         private Intent mAccountConfigurationIntent;
89 
MediaGenerator(Context context)90         public MediaGenerator(Context context) {
91             mContext = context;
92             mPrivateDir = context.getFilesDir();
93         }
94 
getMedia(long generation, String albumId, String mimeType, long sizeBytes, int pageSize)95         public Cursor getMedia(long generation, String albumId, String mimeType, long sizeBytes,
96                 int pageSize) {
97             final Cursor cursor = getCursor(mMedia, generation, albumId, mimeType, sizeBytes,
98                     /* isDeleted */ false);
99             cursor.setExtras(buildCursorExtras(mCollectionId, generation > 0, albumId != null,
100                     pageSize > -1));
101             return cursor;
102         }
103 
getAlbums(String mimeType, long sizeBytes)104         public Cursor getAlbums(String mimeType, long sizeBytes) {
105             final Cursor cursor = getCursor(mAlbums, mimeType, sizeBytes);
106             cursor.setExtras(buildCursorExtras(mCollectionId, false, false, false));
107             return cursor;
108         }
109 
getDeletedMedia(long generation)110         public Cursor getDeletedMedia(long generation) {
111             final Cursor cursor = getCursor(mDeletedMedia, generation, /* albumId */ null,
112                     /* mimeType */ null, /* sizeBytes */ 0, /* isDeleted */ true);
113             cursor.setExtras(buildCursorExtras(mCollectionId, generation > 0, false, false));
114             return cursor;
115         }
116 
getMediaCollectionInfo()117         public Bundle getMediaCollectionInfo() {
118             Bundle bundle = new Bundle();
119             bundle.putString(MediaCollectionInfo.MEDIA_COLLECTION_ID, mCollectionId);
120             bundle.putLong(MediaCollectionInfo.LAST_MEDIA_SYNC_GENERATION, mLastSyncGeneration);
121             bundle.putString(MediaCollectionInfo.ACCOUNT_NAME, mAccountName);
122             bundle.putParcelable(MediaCollectionInfo.ACCOUNT_CONFIGURATION_INTENT,
123                     mAccountConfigurationIntent);
124 
125             return bundle;
126         }
127 
setAccountInfo(String accountName, Intent configIntent)128         public void setAccountInfo(String accountName, Intent configIntent) {
129             mAccountName = accountName;
130             mAccountConfigurationIntent = configIntent;
131         }
132 
buildCursorExtras(String mediaCollectionId, boolean honoredSyncGeneration, boolean honoredAlbumdId, boolean honoredPageSize)133         public Bundle buildCursorExtras(String mediaCollectionId, boolean honoredSyncGeneration,
134                 boolean honoredAlbumdId, boolean honoredPageSize) {
135             final ArrayList<String> honoredArgs = new ArrayList<>();
136             if (honoredSyncGeneration) {
137                 honoredArgs.add(EXTRA_SYNC_GENERATION);
138             }
139             if (honoredAlbumdId) {
140                 honoredArgs.add(EXTRA_ALBUM_ID);
141             }
142 
143             if (honoredPageSize) {
144                 honoredArgs.add(EXTRA_PAGE_SIZE);
145             }
146 
147             final Bundle bundle = new Bundle();
148             bundle.putString(EXTRA_MEDIA_COLLECTION_ID, mediaCollectionId);
149             bundle.putStringArrayList(ContentResolver.EXTRA_HONORED_ARGS, honoredArgs);
150 
151             return bundle;
152         }
153 
addMedia(String localId, String cloudId, String albumId, String mimeType, int standardMimeTypeExtension, long sizeBytes, boolean isFavorite, int resId)154         public void addMedia(String localId, String cloudId, String albumId, String mimeType,
155                 int standardMimeTypeExtension, long sizeBytes, boolean isFavorite, int resId)
156                 throws IOException {
157             mDeletedMedia.remove(createPlaceholderMedia(localId, cloudId));
158             mMedia.add(0, createTestMedia(localId, cloudId, albumId, mimeType,
159                             standardMimeTypeExtension, sizeBytes, isFavorite, resId));
160         }
161 
deleteMedia(String localId, String cloudId, boolean trackDeleted)162         public void deleteMedia(String localId, String cloudId, boolean trackDeleted)
163                 throws IOException {
164             if (mMedia.remove(createPlaceholderMedia(localId, cloudId)) && trackDeleted) {
165                 mDeletedMedia.add(createTestMedia(localId, cloudId, /* albumId */ null,
166                                 /* mimeType */ null, /* mimeTypeExtension */ 0, /* sizeBytes */ 0,
167                                 /* isFavorite */ false, /* resId */ -1));
168             }
169         }
170 
openMedia(String cloudId)171         public ParcelFileDescriptor openMedia(String cloudId) throws FileNotFoundException {
172             try {
173                 return ParcelFileDescriptor.open(getTestMedia(cloudId),
174                         ParcelFileDescriptor.MODE_READ_ONLY);
175             } catch (IOException e) {
176                 throw new FileNotFoundException("Failed to open: " + cloudId);
177             }
178         }
179 
createAlbum(String id)180         public void createAlbum(String id) {
181             mAlbums.add(createTestAlbum(id));
182         }
183 
resetAll()184         public void resetAll() {
185             mMedia.clear();
186             mDeletedMedia.clear();
187             mAlbums.clear();
188         }
189 
setMediaCollectionId(String id)190         public void setMediaCollectionId(String id) {
191             mCollectionId = id;
192         }
193 
getCount()194         public long getCount() {
195             return mMedia.size();
196         }
197 
createTestAlbum(String id)198         private TestAlbum createTestAlbum(String id) {
199             return new TestAlbum(id, mMedia);
200         }
201 
createTestMedia(String localId, String cloudId, String albumId, String mimeType, int standardMimeTypeExtension, long sizeBytes, boolean isFavorite, int resId)202         private TestMedia createTestMedia(String localId, String cloudId, String albumId,
203                 String mimeType, int standardMimeTypeExtension, long sizeBytes,
204                 boolean isFavorite, int resId) throws IOException {
205             // Increase generation
206             TestMedia media = new TestMedia(localId, cloudId, albumId, mimeType,
207                     standardMimeTypeExtension, sizeBytes, /* durationMs */ 0, ++mLastSyncGeneration,
208                     isFavorite);
209 
210             if (resId >= 0) {
211                 media.createFile(mContext, resId, getTestMedia(cloudId));
212             }
213 
214             return media;
215         }
216 
createPlaceholderMedia(String localId, String cloudId)217         private static TestMedia createPlaceholderMedia(String localId, String cloudId) {
218             // Don't increase generation. Used to create a throw-away element used for removal from
219             // |mMedia| or |mDeletedMedia|
220             return new TestMedia(localId, cloudId, /* albumId */ null,
221                     /* mimeType */ null, /* mimeTypeExtension */ 0, /* sizeBytes */ 0,
222                     /* durationMs */ 0, /* generation */ 0, /* isFavorite */ false);
223         }
224 
getTestMedia(String cloudId)225         private File getTestMedia(String cloudId) {
226             return new File(mPrivateDir, cloudId);
227         }
228 
getCursor(List<TestMedia> mediaList, long generation, String albumId, String mimeType, long sizeBytes, boolean isDeleted)229         private static Cursor getCursor(List<TestMedia> mediaList, long generation,
230                 String albumId, String mimeType, long sizeBytes, boolean isDeleted) {
231             final MatrixCursor matrix;
232             if (isDeleted) {
233                 matrix = new MatrixCursor(DELETED_MEDIA_PROJECTION);
234             } else {
235                 matrix = new MatrixCursor(MEDIA_PROJECTION);
236             }
237 
238             for (TestMedia media : mediaList) {
239                 if (media.generation > generation
240                         && matchesFilter(media, albumId, mimeType, sizeBytes)) {
241                     matrix.addRow(media.toArray(isDeleted));
242                 }
243             }
244             return matrix;
245         }
246 
getCursor(List<TestAlbum> albumList, String mimeType, long sizeBytes)247         private static Cursor getCursor(List<TestAlbum> albumList, String mimeType,
248                 long sizeBytes) {
249             final MatrixCursor matrix = new MatrixCursor(ALBUM_PROJECTION);
250 
251             for (TestAlbum album : albumList) {
252                 final String[] res = album.toArray(mimeType, sizeBytes);
253                 if (res != null) {
254                     matrix.addRow(res);
255                 }
256             }
257             return matrix;
258         }
259     }
260 
261     private static class TestMedia {
262         public final String localId;
263         public final String cloudId;
264         public final String albumId;
265         public final String mimeType;
266         public final long dateTakenMs;
267         public final long durationMs;
268         public final long generation;
269         public final int standardMimeTypeExtension;
270         public final boolean isFavorite;
271         public long sizeBytes;
272 
TestMedia(String localId, String cloudId, String albumId, String mimeType, int standardMimeTypeExtension, long sizeBytes, long durationMs, long generation, boolean isFavorite)273         TestMedia(String localId, String cloudId, String albumId, String mimeType,
274                 int standardMimeTypeExtension, long sizeBytes, long durationMs, long generation,
275                 boolean isFavorite) {
276             this.localId = localId;
277             this.cloudId = cloudId;
278             this.albumId = albumId;
279             this.mimeType = mimeType;
280             this.standardMimeTypeExtension = standardMimeTypeExtension;
281             this.sizeBytes = sizeBytes;
282             this.dateTakenMs = System.currentTimeMillis();
283             this.durationMs = durationMs;
284             this.generation = generation;
285             this.isFavorite = isFavorite;
286         }
287 
toArray(boolean isDeleted)288         public String[] toArray(boolean isDeleted) {
289             if (isDeleted) {
290                 return new String[] {getId()};
291             }
292 
293             return new String[] {
294                 getId(),
295                 localId == null ? null : "content://media/external/files/" + localId,
296                 mimeType,
297                 String.valueOf(standardMimeTypeExtension),
298                 String.valueOf(dateTakenMs),
299                 String.valueOf(generation),
300                 String.valueOf(sizeBytes),
301                 String.valueOf(durationMs),
302                 String.valueOf(isFavorite ? 1 : 0)
303             };
304         }
305 
306         @Override
equals(Object o)307         public boolean equals(Object o) {
308             if (o == null || !(o instanceof TestMedia)) {
309                 return false;
310             }
311             TestMedia other = (TestMedia) o;
312             return Objects.equals(localId, other.localId) && Objects.equals(cloudId, other.cloudId);
313         }
314 
315         @Override
hashCode()316         public int hashCode() {
317             return Objects.hash(localId, cloudId);
318         }
319 
getId()320         public String getId() {
321             return cloudId;
322         }
323 
createFile(Context context, int sourceResId, File targetFile)324         public void createFile(Context context, int sourceResId, File targetFile)
325                 throws IOException {
326             try (InputStream source = context.getResources().openRawResource(sourceResId);
327                     FileOutputStream target = new FileOutputStream(targetFile)) {
328                 FileUtils.copy(source, target);
329             }
330 
331             // Set size
332             sizeBytes = targetFile.length();
333         }
334     }
335 
336     private static class TestAlbum {
337         public final String id;
338         private final List<TestMedia> mMedia;
339 
TestAlbum(String id, List<TestMedia> media)340         TestAlbum(String id, List<TestMedia> media) {
341             this.id = id;
342             this.mMedia = media;
343         }
344 
toArray(String mimeType, long sizeBytes)345         public String[] toArray(String mimeType, long sizeBytes) {
346             long mediaCount = 0;
347             String mediaCoverId = null;
348             long dateTakenMs = 0;
349 
350             for (TestMedia m : mMedia) {
351                 if (matchesFilter(m, id, mimeType, sizeBytes)) {
352                     if (mediaCount++ == 0) {
353                         mediaCoverId = m.getId();
354                         dateTakenMs = m.dateTakenMs;
355                     }
356                 }
357             }
358 
359             if (mediaCount == 0) {
360                 return null;
361             }
362 
363             return new String[] {
364                 id,
365                 mediaCoverId,
366                 /* displayName */ id,
367                 String.valueOf(dateTakenMs),
368                 String.valueOf(mediaCount),
369             };
370         }
371 
372         @Override
equals(Object o)373         public boolean equals(Object o) {
374             if (o == null || !(o instanceof TestAlbum)) {
375                 return false;
376             }
377 
378             TestAlbum other = (TestAlbum) o;
379             return Objects.equals(id, other.id);
380         }
381 
382         @Override
hashCode()383         public int hashCode() {
384             return Objects.hash(id);
385         }
386     }
387 
matchesFilter(TestMedia media, String albumId, String mimeType, long sizeBytes)388     private static boolean matchesFilter(TestMedia media, String albumId, String mimeType,
389             long sizeBytes) {
390         if ((albumId != null) && albumId != media.albumId) {
391             return false;
392         }
393         if ((mimeType != null) && !media.mimeType.startsWith(mimeType)) {
394             return false;
395         }
396         if (sizeBytes != 0 && media.sizeBytes > sizeBytes) {
397             return false;
398         }
399 
400         return true;
401     }
402 
getMediaGenerator(Context context, String authority)403     public static MediaGenerator getMediaGenerator(Context context, String authority) {
404         MediaGenerator generator = sMediaGeneratorMap.get(authority);
405         if (generator == null) {
406             generator = new MediaGenerator(context);
407             sMediaGeneratorMap.put(authority, generator);
408         }
409         return generator;
410     }
411 
setCloudProvider(Context context, String authority)412     public static void setCloudProvider(Context context, String authority) {
413         // TODO(b/190713331): Use constants from MediaStore after visible from test
414         Bundle in = new Bundle();
415         in.putString("cloud_provider", authority);
416 
417         callMediaStore(context, "set_cloud_provider", in);
418     }
419 
syncCloudProvider(Context context)420     public static void syncCloudProvider(Context context) {
421         // TODO(b/190713331): Use constants from MediaStore after visible from test
422 
423         callMediaStore(context, "sync_providers", /* in */ null);
424     }
425 
callMediaStore(Context context, String method, Bundle in)426     private static void callMediaStore(Context context, String method, Bundle in) {
427         context.getContentResolver().call(MediaStore.AUTHORITY, method, null, in);
428     }
429 
430     public static class QueryExtras {
431         public final String albumId;
432         public final String mimeType;
433         public final long sizeBytes;
434         public final long generation;
435         public final int pageSize;
436 
QueryExtras(Bundle bundle)437         public QueryExtras(Bundle bundle) {
438             if (bundle == null) {
439                 bundle = new Bundle();
440             }
441 
442             albumId = bundle.getString(CloudMediaProviderContract.EXTRA_ALBUM_ID, null);
443             mimeType = bundle.getString(Intent.EXTRA_MIME_TYPES, null);
444             sizeBytes = bundle.getLong(CloudMediaProviderContract.EXTRA_SIZE_LIMIT_BYTES, 0);
445             generation = bundle.getLong(CloudMediaProviderContract.EXTRA_SYNC_GENERATION, 0);
446             pageSize = bundle.getInt(CloudMediaProviderContract.EXTRA_PAGE_SIZE, -1);
447         }
448     }
449 }
450