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.photopicker.cts.PhotoPickerCloudUtils.addImage;
20 import static android.photopicker.cts.PhotoPickerCloudUtils.containsExcept;
21 import static android.photopicker.cts.PhotoPickerCloudUtils.disableDeviceConfigSync;
22 import static android.photopicker.cts.PhotoPickerCloudUtils.disablePickImagesPreload;
23 import static android.photopicker.cts.PhotoPickerCloudUtils.enableCloudMediaAndSetAllowedCloudProviders;
24 import static android.photopicker.cts.PhotoPickerCloudUtils.enablePickImagesPreload;
25 import static android.photopicker.cts.PhotoPickerCloudUtils.extractMediaIds;
26 import static android.photopicker.cts.PhotoPickerCloudUtils.isPickImagesPreloadEnabled;
27 import static android.photopicker.cts.PickerProviderMediaGenerator.MediaGenerator;
28 import static android.photopicker.cts.PickerProviderMediaGenerator.syncCloudProvider;
29 import static android.photopicker.cts.util.PhotoPickerFilesUtils.createImagesAndGetUris;
30 import static android.photopicker.cts.util.PhotoPickerFilesUtils.deleteMedia;
31 import static android.photopicker.cts.util.ResultsAssertionsUtils.assertRedactedReadOnlyAccess;
32 import static android.provider.MediaStore.PickerMediaColumns;
33 
34 import static com.google.common.truth.Truth.assertThat;
35 
36 import static org.junit.Assert.assertThrows;
37 
38 import android.content.ClipData;
39 import android.content.ContentResolver;
40 import android.content.Intent;
41 import android.database.Cursor;
42 import android.net.Uri;
43 import android.os.Build;
44 import android.os.storage.StorageManager;
45 import android.photopicker.cts.cloudproviders.CloudProviderNoIntentFilter;
46 import android.photopicker.cts.cloudproviders.CloudProviderNoPermission;
47 import android.photopicker.cts.cloudproviders.CloudProviderPrimary;
48 import android.photopicker.cts.cloudproviders.CloudProviderSecondary;
49 import android.provider.MediaStore;
50 import android.util.Pair;
51 
52 import androidx.annotation.Nullable;
53 import androidx.test.filters.SdkSuppress;
54 import androidx.test.runner.AndroidJUnit4;
55 
56 import org.junit.After;
57 import org.junit.AfterClass;
58 import org.junit.Before;
59 import org.junit.BeforeClass;
60 import org.junit.Test;
61 import org.junit.runner.RunWith;
62 
63 import java.io.File;
64 import java.io.IOException;
65 import java.util.ArrayList;
66 import java.util.Collections;
67 import java.util.List;
68 
69 
70 /**
71  * Photo Picker Device only tests for common flows.
72  */
73 @RunWith(AndroidJUnit4.class)
74 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S)
75 public class CloudPhotoPickerTest extends PhotoPickerBaseTest {
76     private static final String TAG = CloudPhotoPickerTest.class.getSimpleName();
77     private final List<Uri> mUriList = new ArrayList<>();
78     private MediaGenerator mCloudPrimaryMediaGenerator;
79     private MediaGenerator mCloudSecondaryMediaGenerator;
80 
81     private static final long IMAGE_SIZE_BYTES = 107684;
82 
83     private static final String COLLECTION_1 = "COLLECTION_1";
84     private static final String COLLECTION_2 = "COLLECTION_2";
85 
86     private static final String CLOUD_ID1 = "CLOUD_ID1";
87     private static final String CLOUD_ID2 = "CLOUD_ID2";
88     @Nullable
89     private static DeviceStatePreserver sDeviceStatePreserver;
90 
91     @BeforeClass
setUpBeforeClass()92     public static void setUpBeforeClass() throws IOException {
93         sDeviceStatePreserver = new DeviceStatePreserver(sDevice);
94         sDeviceStatePreserver.saveCurrentCloudProviderState();
95         disableDeviceConfigSync();
96 
97         // This is a self-instrumentation test, so both "target" package name and "own" package name
98         // should be the same (android.photopicker.cts).
99         enableCloudMediaAndSetAllowedCloudProviders(sTargetPackageName);
100     }
101 
102     @AfterClass
tearDownClass()103     public static void tearDownClass() throws Exception {
104         if (sDeviceStatePreserver != null) {
105             sDeviceStatePreserver.restoreCloudProviderState();
106         }
107     }
108     @Before
setUp()109     public void setUp() throws Exception {
110         super.setUp();
111 
112         mCloudPrimaryMediaGenerator = PickerProviderMediaGenerator.getMediaGenerator(
113                 mContext, CloudProviderPrimary.AUTHORITY);
114         mCloudSecondaryMediaGenerator = PickerProviderMediaGenerator.getMediaGenerator(
115                 mContext, CloudProviderSecondary.AUTHORITY);
116 
117         mCloudPrimaryMediaGenerator.resetAll();
118         mCloudSecondaryMediaGenerator.resetAll();
119 
120         mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_1);
121         mCloudSecondaryMediaGenerator.setMediaCollectionId(COLLECTION_1);
122 
123         setCloudProvider(null);
124     }
125 
126     @After
tearDown()127     public void tearDown() throws Exception {
128         for (Uri uri : mUriList) {
129             deleteMedia(uri, mContext);
130         }
131         if (mActivity != null) {
132             mActivity.finish();
133         }
134         mUriList.clear();
135         if (mCloudPrimaryMediaGenerator != null) {
136             setCloudProvider(null);
137         }
138     }
139 
140     @Test
testCloudOnlySync()141     public void testCloudOnlySync() throws Exception {
142         initPrimaryCloudProviderWithImage(Pair.create(null, CLOUD_ID1));
143 
144         final ClipData clipData = launchPickerAndFetchMedia(1);
145         final List<String> mediaIds = extractMediaIds(clipData, 1);
146 
147         assertThat(mediaIds).containsExactly(CLOUD_ID1);
148     }
149 
150     @Test
testCloudPlusLocalSyncWithoutDedupe()151     public void testCloudPlusLocalSyncWithoutDedupe() throws Exception {
152         mUriList.addAll(createImagesAndGetUris(1, mContext.getUserId()));
153         initPrimaryCloudProviderWithImage(Pair.create(null, CLOUD_ID1));
154 
155         final ClipData clipData = launchPickerAndFetchMedia(2);
156         final List<String> mediaIds = extractMediaIds(clipData, 2);
157 
158         assertThat(mediaIds).containsExactly(CLOUD_ID1, mUriList.get(0).getLastPathSegment());
159     }
160 
161     @Test
testCloudPlusLocalSyncWithDedupe()162     public void testCloudPlusLocalSyncWithDedupe() throws Exception {
163         mUriList.addAll(createImagesAndGetUris(1, mContext.getUserId()));
164         initPrimaryCloudProviderWithImage(Pair.create(mUriList.get(0).getLastPathSegment(),
165                         CLOUD_ID1));
166 
167         final ClipData clipData = launchPickerAndFetchMedia(1);
168         final List<String> mediaIds = extractMediaIds(clipData, 1);
169 
170         containsExcept(mediaIds, mUriList.get(0).getLastPathSegment(), CLOUD_ID1);
171     }
172 
173     @Test
testDeleteCloudMedia()174     public void testDeleteCloudMedia() throws Exception {
175         initPrimaryCloudProviderWithImage(Pair.create(null, CLOUD_ID1),
176                 Pair.create(null, CLOUD_ID2));
177 
178         ClipData clipData = launchPickerAndFetchMedia(2);
179         List<String> mediaIds = extractMediaIds(clipData, 2);
180 
181         assertThat(mediaIds).containsExactly(CLOUD_ID1, CLOUD_ID2);
182 
183         mCloudPrimaryMediaGenerator.deleteMedia(/* localId */ null, CLOUD_ID1,
184                 /* trackDeleted */ true);
185         syncCloudProvider(mContext);
186 
187         clipData = launchPickerAndFetchMedia(2);
188         mediaIds = extractMediaIds(clipData, 1);
189 
190         containsExcept(mediaIds, CLOUD_ID2, CLOUD_ID1);
191     }
192 
193     @Test
testVersionChange()194     public void testVersionChange() throws Exception {
195         // Save current device config
196         final boolean isPickImagesPreloadEnabled = isPickImagesPreloadEnabled();
197         disablePickImagesPreload();
198 
199         initPrimaryCloudProviderWithImage(Pair.create(null, CLOUD_ID1),
200                 Pair.create(null, CLOUD_ID2));
201 
202         ClipData clipData = launchPickerAndFetchMedia(2);
203         List<String> mediaIds = extractMediaIds(clipData, 2);
204 
205         assertThat(mediaIds).containsExactly(CLOUD_ID1, CLOUD_ID2);
206 
207         mCloudPrimaryMediaGenerator.deleteMedia(/* localId */ null, CLOUD_ID1,
208                 /* trackDeleted */ false);
209         syncCloudProvider(mContext);
210 
211         clipData = launchPickerAndFetchMedia(2);
212         mediaIds = extractMediaIds(clipData, 2);
213 
214         assertThat(mediaIds).containsExactly(CLOUD_ID1, CLOUD_ID2);
215 
216         mCloudPrimaryMediaGenerator.setMediaCollectionId(COLLECTION_2);
217         syncCloudProvider(mContext);
218 
219         clipData = launchPickerAndFetchMedia(2);
220         mediaIds = extractMediaIds(clipData, 1);
221 
222         containsExcept(mediaIds, CLOUD_ID2, CLOUD_ID1);
223 
224         // Restore device config
225         if (isPickImagesPreloadEnabled) {
226             enablePickImagesPreload();
227         }
228     }
229 
230     @Test
testSupportedProviders()231     public void testSupportedProviders() throws Exception {
232         assertThat(MediaStore.isSupportedCloudMediaProviderAuthority(mContext.getContentResolver(),
233                         CloudProviderPrimary.AUTHORITY)).isTrue();
234         assertThat(MediaStore.isSupportedCloudMediaProviderAuthority(mContext.getContentResolver(),
235                         CloudProviderSecondary.AUTHORITY)).isTrue();
236 
237         assertThat(MediaStore.isSupportedCloudMediaProviderAuthority(mContext.getContentResolver(),
238                         CloudProviderNoPermission.AUTHORITY)).isFalse();
239         assertThat(MediaStore.isSupportedCloudMediaProviderAuthority(mContext.getContentResolver(),
240                         CloudProviderNoIntentFilter.AUTHORITY)).isFalse();
241     }
242 
243     @Test
testProviderSwitchSuccess()244     public void testProviderSwitchSuccess() throws Exception {
245         setCloudProvider(CloudProviderPrimary.AUTHORITY);
246         assertThat(MediaStore.isCurrentCloudMediaProviderAuthority(mContext.getContentResolver(),
247                         CloudProviderPrimary.AUTHORITY)).isTrue();
248 
249         addImage(mCloudPrimaryMediaGenerator, /* localId */ null, CLOUD_ID1);
250         addImage(mCloudSecondaryMediaGenerator, /* localId */ null, CLOUD_ID2);
251 
252         syncCloudProvider(mContext);
253 
254         ClipData clipData = launchPickerAndFetchMedia(2);
255         List<String> mediaIds = extractMediaIds(clipData, 1);
256 
257         containsExcept(mediaIds, CLOUD_ID1, CLOUD_ID2);
258 
259         setCloudProvider(CloudProviderSecondary.AUTHORITY);
260         assertThat(MediaStore.isCurrentCloudMediaProviderAuthority(mContext.getContentResolver(),
261                         CloudProviderPrimary.AUTHORITY)).isFalse();
262 
263         clipData = launchPickerAndFetchMedia(2);
264         mediaIds = extractMediaIds(clipData, 1);
265 
266         containsExcept(mediaIds, CLOUD_ID2, CLOUD_ID1);
267     }
268 
269     @Test
testProviderSwitchFailure()270     public void testProviderSwitchFailure() throws Exception {
271         setCloudProvider(CloudProviderNoIntentFilter.AUTHORITY);
272         assertThat(MediaStore.isCurrentCloudMediaProviderAuthority(mContext.getContentResolver(),
273                         CloudProviderPrimary.AUTHORITY)).isFalse();
274 
275         setCloudProvider(CloudProviderNoPermission.AUTHORITY);
276         assertThat(MediaStore.isCurrentCloudMediaProviderAuthority(mContext.getContentResolver(),
277                         CloudProviderPrimary.AUTHORITY)).isFalse();
278     }
279 
280     @Test
testUriAccessWithValidProjection()281     public void testUriAccessWithValidProjection() throws Exception {
282         initPrimaryCloudProviderWithImage(Pair.create(null, CLOUD_ID1));
283 
284         final ClipData clipData = launchPickerAndFetchMedia(1);
285         final List<String> mediaIds = extractMediaIds(clipData, 1);
286 
287         assertThat(mediaIds).containsExactly(CLOUD_ID1);
288 
289         final ContentResolver resolver = mContext.getContentResolver();
290         String expectedDisplayName = CLOUD_ID1 + ".jpg";
291 
292         try (Cursor c = resolver.query(clipData.getItemAt(0).getUri(), null, null, null)) {
293             assertThat(c).isNotNull();
294             assertThat(c.moveToFirst()).isTrue();
295 
296             assertThat(c.getString(c.getColumnIndex(PickerMediaColumns.MIME_TYPE)))
297                     .isEqualTo("image/jpeg");
298             assertThat(c.getString(c.getColumnIndex(PickerMediaColumns.DISPLAY_NAME)))
299                     .isEqualTo(expectedDisplayName);
300             assertThat(c.getLong(c.getColumnIndex(PickerMediaColumns.SIZE)))
301                     .isEqualTo(IMAGE_SIZE_BYTES);
302             assertThat(c.getLong(c.getColumnIndex(PickerMediaColumns.DURATION_MILLIS)))
303                     .isEqualTo(0);
304             assertThat(c.getLong(c.getColumnIndex(PickerMediaColumns.DATE_TAKEN)))
305                     .isGreaterThan(0);
306 
307             final File file = new File(c.getString(c.getColumnIndex(PickerMediaColumns.DATA)));
308             assertThat(file.getPath().endsWith(expectedDisplayName)).isTrue();
309             assertThat(file.length()).isEqualTo(IMAGE_SIZE_BYTES);
310         }
311 
312         assertRedactedReadOnlyAccess(clipData.getItemAt(0).getUri());
313     }
314 
315     @Test
testUriAccessWithInvalidProjection()316     public void testUriAccessWithInvalidProjection() throws Exception {
317         initPrimaryCloudProviderWithImage(Pair.create(null, CLOUD_ID1));
318 
319         final ClipData clipData = launchPickerAndFetchMedia(1);
320         final List<String> mediaIds = extractMediaIds(clipData, 1);
321 
322         assertThat(mediaIds).containsExactly(CLOUD_ID1);
323 
324         final ContentResolver resolver = mContext.getContentResolver();
325         try (Cursor c = resolver.query(
326                 clipData.getItemAt(0).getUri(),
327                 new String[] {MediaStore.MediaColumns.RELATIVE_PATH}, null, null)) {
328             assertThat(c).isNotNull();
329             assertThat(c.moveToFirst()).isTrue();
330 
331             assertThat(c.getString(c.getColumnIndex(MediaStore.MediaColumns.RELATIVE_PATH)))
332                     .isEqualTo(null);
333         }
334     }
335 
336     @Test
testCloudEventNotification()337     public void testCloudEventNotification() throws Exception {
338         // Create a placeholder local image to ensure that the picker UI is never empty.
339         // The PhotoPickerUiUtils#findItemList needs to select an item and it times out if the
340         // Picker UI is empty.
341         mUriList.addAll(createImagesAndGetUris(1, mContext.getUserId()));
342 
343         // Cloud provider isn't set
344         assertThat(MediaStore.isCurrentCloudMediaProviderAuthority(mContext.getContentResolver(),
345                         CloudProviderPrimary.AUTHORITY)).isFalse();
346         addImage(mCloudPrimaryMediaGenerator, /* localId */ null, CLOUD_ID1);
347 
348         // Notification fails because the calling cloud provider isn't enabled
349         assertThrows("Unauthorized cloud media notification", SecurityException.class,
350                 () -> MediaStore.notifyCloudMediaChangedEvent(mContext.getContentResolver(),
351                         CloudProviderPrimary.AUTHORITY, COLLECTION_1));
352 
353         // Sleep because the notification API throttles requests with a 1s delay
354         Thread.sleep(1500);
355 
356         ClipData clipData = launchPickerAndFetchMedia(1);
357         List<String> mediaIds = extractMediaIds(clipData, 1);
358 
359         assertThat(mediaIds).containsNoneIn(Collections.singletonList(CLOUD_ID1));
360 
361         // Now set the cloud provider and verify that notification succeeds
362         setCloudProvider(CloudProviderPrimary.AUTHORITY);
363         assertThat(MediaStore.isCurrentCloudMediaProviderAuthority(mContext.getContentResolver(),
364                         CloudProviderPrimary.AUTHORITY)).isTrue();
365 
366         MediaStore.notifyCloudMediaChangedEvent(mContext.getContentResolver(),
367                 CloudProviderPrimary.AUTHORITY, COLLECTION_1);
368 
369         assertThrows("Unauthorized cloud media notification", SecurityException.class,
370                 () -> MediaStore.notifyCloudMediaChangedEvent(mContext.getContentResolver(),
371                         CloudProviderSecondary.AUTHORITY, COLLECTION_1));
372 
373         // Sleep because the notification API throttles requests with a 1s delay
374         Thread.sleep(1500);
375 
376         clipData = launchPickerAndFetchMedia(1);
377         mediaIds = extractMediaIds(clipData, 1);
378 
379         assertThat(mediaIds).containsExactly(CLOUD_ID1);
380     }
381 
382     @Test
383     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU)
testStorageManagerKnowsCloudProvider()384     public void testStorageManagerKnowsCloudProvider() throws Exception {
385         final StorageManager storageManager = mContext.getSystemService(StorageManager.class);
386 
387         setCloudProvider(CloudProviderPrimary.AUTHORITY);
388         assertThat(storageManager.getCloudMediaProvider())
389                 .isEqualTo(CloudProviderPrimary.AUTHORITY);
390 
391         setCloudProvider(CloudProviderSecondary.AUTHORITY);
392         assertThat(storageManager.getCloudMediaProvider())
393                 .isEqualTo(CloudProviderSecondary.AUTHORITY);
394 
395         setCloudProvider(null);
396         assertThat(storageManager.getCloudMediaProvider()).isNull();
397     }
398 
launchPickerAndFetchMedia(int maxCount)399     private ClipData launchPickerAndFetchMedia(int maxCount) throws Exception {
400         final Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
401         intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MediaStore.getPickImagesMaxLimit());
402         mActivity.startActivityForResult(intent, REQUEST_CODE);
403 
404         return PhotoPickerCloudUtils.fetchPickerMedia(mActivity, sDevice, maxCount);
405     }
406 
initPrimaryCloudProviderWithImage(Pair<String, String>.... mediaPairs)407     private void initPrimaryCloudProviderWithImage(Pair<String, String>... mediaPairs)
408             throws Exception {
409         PhotoPickerCloudUtils.initCloudProviderWithImage(mContext, mCloudPrimaryMediaGenerator,
410                 CloudProviderPrimary.AUTHORITY, mediaPairs);
411     }
412 }
413