xref: /expo/apps/test-suite/tests/MediaLibrary.js (revision 8a424beb)
1import { Asset } from 'expo-asset';
2import * as MediaLibrary from 'expo-media-library';
3import { Platform } from 'react-native';
4
5import { waitFor } from './helpers';
6import * as TestUtils from '../TestUtils';
7import { isDeviceFarm } from '../utils/Environment';
8
9export const name = 'MediaLibrary';
10
11const FILES = [
12  require('../assets/icons/app.png'),
13  require('../assets/icons/loading.png'),
14  require('../assets/black-128x256.png'),
15  require('../assets/big_buck_bunny.mp4'),
16];
17
18const WAIT_TIME = 1000;
19const IMG_NUMBER = 3;
20const VIDEO_NUMBER = 1;
21const F_SIZE = IMG_NUMBER + VIDEO_NUMBER;
22// const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video];
23const DEFAULT_MEDIA_TYPES = [MediaLibrary.MediaType.photo];
24const DEFAULT_PAGE_SIZE = 20;
25const ASSET_KEYS = [
26  'id',
27  'filename',
28  'uri',
29  'mediaType',
30  'width',
31  'height',
32  'creationTime',
33  'modificationTime',
34  'duration',
35  Platform.OS === 'ios' ? 'mediaSubtypes' : 'albumId',
36];
37
38const INFO_KEYS = [
39  'localUri',
40  'location',
41  'exif',
42  ...(Platform !== 'ios' ? [] : ['orientation', 'isFavorite']),
43];
44
45const ALBUM_KEYS = [
46  'id',
47  'title',
48  'assetCount',
49  ...(Platform !== 'ios'
50    ? []
51    : ['type', 'startTime', 'endTime', 'approximateLocation', 'locationNames']),
52];
53
54const GET_ASSETS_KEYS = ['assets', 'endCursor', 'hasNextPage', 'totalCount'];
55const ALBUM_NAME = 'Expo Test-Suite Album #1';
56const SECOND_ALBUM_NAME = 'Expo Test-Suite Album #2';
57const WRONG_NAME = 'wertyuiopdfghjklvbnhjnftyujn';
58const WRONG_ID = '1234567890';
59
60// We don't want to move files to the albums on Android R or higher, because it requires a user confirmation.
61const shouldCopyAssets = Platform.OS === 'android' && Platform.Version >= 30;
62
63async function getFiles() {
64  return await Asset.loadAsync(FILES);
65}
66
67async function getAssets(files) {
68  return await Promise.all(
69    files.map(({ localUri }) => {
70      return MediaLibrary.createAssetAsync(localUri);
71    })
72  );
73}
74
75async function createAlbum(assets, name) {
76  const album = await MediaLibrary.createAlbumAsync(name, assets[0], shouldCopyAssets);
77  if (assets.length > 1) {
78    await MediaLibrary.addAssetsToAlbumAsync(assets.slice(1), album, shouldCopyAssets);
79  }
80  return album;
81}
82
83async function checkIfThrows(f) {
84  try {
85    await f();
86  } catch {
87    return true;
88  }
89
90  return false;
91}
92
93function timeoutWrapper(fun, time) {
94  return new Promise((resolve) => {
95    setTimeout(() => {
96      fun();
97      resolve(null);
98    }, time);
99  });
100}
101
102export async function test(t) {
103  const shouldSkipTestsRequiringPermissions =
104    (await TestUtils.shouldSkipTestsRequiringPermissionsAsync()) && isDeviceFarm();
105  const describeWithPermissions = shouldSkipTestsRequiringPermissions ? t.xdescribe : t.describe;
106  // On iOS and on android R or higher, some actions require user confirmation. For those actions, we set the timeout to 30 seconds.
107  const TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT =
108    (Platform.OS === 'android' && Platform.Version >= 30) || Platform.OS === 'ios'
109      ? 30 * 1000
110      : t.jasmine.DEFAULT_TIMEOUT_INTERVAL;
111
112  describeWithPermissions('MediaLibrary', async () => {
113    let files;
114    let permissions;
115
116    const checkIfAllPermissionsWereGranted = () => {
117      if (Platform.OS === 'ios') {
118        return permissions.accessPrivileges === 'all';
119      }
120      return permissions.granted;
121    };
122
123    const oldIt = t.it;
124    t.it = (name, fn, timeout) =>
125      oldIt(
126        name,
127        async () => {
128          if (checkIfAllPermissionsWereGranted()) {
129            await fn();
130          }
131        },
132        timeout
133      );
134
135    t.beforeAll(async () => {
136      files = await getFiles();
137      permissions = await MediaLibrary.requestPermissionsAsync();
138      if (!checkIfAllPermissionsWereGranted()) {
139        console.warn('Tests were skipped - not enough permissions to run them.');
140      }
141    });
142
143    t.describe('With default assets', async () => {
144      let testAssets;
145      let album;
146
147      async function initializeDefaultAssetsAsync() {
148        testAssets = await getAssets(files);
149        album = await MediaLibrary.getAlbumAsync(ALBUM_NAME);
150        if (album == null) {
151          album = await createAlbum(testAssets, ALBUM_NAME);
152        } else {
153          await MediaLibrary.addAssetsToAlbumAsync(testAssets, album, shouldCopyAssets);
154        }
155      }
156
157      async function cleanupAsync() {
158        if (checkIfAllPermissionsWereGranted()) {
159          await MediaLibrary.deleteAssetsAsync(testAssets);
160          await MediaLibrary.deleteAlbumsAsync(album);
161        }
162      }
163
164      t.beforeAll(async () => {
165        // NOTE(2020-06-03): The `initializeAsync` function is flaky on Android; often the
166        // `addAssetsToAlbumAsync` method call inside of `createAlbum` will fail with the error
167        // "Could not get all of the requested assets". Usually retrying a few times works, so we do
168        // that programmatically here.
169        let error;
170        for (let i = 0; i < 3; i++) {
171          try {
172            await initializeDefaultAssetsAsync();
173            return;
174          } catch (e) {
175            error = e;
176            console.log('Error initializing MediaLibrary tests, trying again', e.message);
177            await cleanupAsync();
178            await waitFor(1000);
179          }
180        }
181        // if we get here, just throw
182        throw error;
183      }, TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT);
184
185      t.afterAll(async () => {
186        await cleanupAsync();
187      }, TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT);
188
189      t.describe('Every return value has proper shape', async () => {
190        t.it('createAssetAsync', () => {
191          const keys = Object.keys(testAssets[0]);
192          ASSET_KEYS.forEach((key) => t.expect(keys).toContain(key));
193        });
194
195        t.it('getAssetInfoAsync', async () => {
196          const { assets } = await MediaLibrary.getAssetsAsync();
197          const value = await MediaLibrary.getAssetInfoAsync(assets[0]);
198          const keys = Object.keys(value);
199          INFO_KEYS.forEach((key) => t.expect(keys).toContain(key));
200        });
201
202        t.it('getAlbumAsync', async () => {
203          const value = await MediaLibrary.getAlbumAsync(ALBUM_NAME);
204          const keys = Object.keys(value);
205          ALBUM_KEYS.forEach((key) => t.expect(keys).toContain(key));
206        });
207
208        t.it('getAssetsAsync', async () => {
209          const value = await MediaLibrary.getAssetsAsync();
210          const keys = Object.keys(value);
211          GET_ASSETS_KEYS.forEach((key) => t.expect(keys).toContain(key));
212        });
213      });
214
215      t.describe('Small tests', async () => {
216        t.it('Function getAlbums returns test album', async () => {
217          const albums = await MediaLibrary.getAlbumsAsync();
218          t.expect(albums.filter((elem) => elem.id === album.id).length).toBe(1);
219        });
220
221        t.it('getAlbum returns test album', async () => {
222          const otherAlbum = await MediaLibrary.getAlbumAsync(ALBUM_NAME);
223          t.expect(otherAlbum.title).toBe(album.title);
224          t.expect(otherAlbum.id).toBe(album.id);
225          t.expect(otherAlbum.assetCount).toBe(F_SIZE);
226        });
227
228        t.it('getAlbum with not existing album', async () => {
229          const album = await MediaLibrary.getAlbumAsync(WRONG_NAME);
230          t.expect(album).toBeNull();
231        });
232
233        t.it('getAssetInfo with not existing id', async () => {
234          const asset = await MediaLibrary.getAssetInfoAsync(WRONG_ID);
235          t.expect(asset).toBeNull();
236        });
237
238        t.it(
239          'saveToLibraryAsync should throw when the provided path does not contain an extension',
240          async () => {
241            t.expect(
242              await checkIfThrows(() => MediaLibrary.saveToLibraryAsync('/test/file'))
243            ).toBeTruthy();
244          }
245        );
246
247        t.it(
248          'createAssetAsync should throw when the provided path does not contain an extension',
249          async () => {
250            t.expect(
251              await checkIfThrows(() => MediaLibrary.createAssetAsync('/test/file'))
252            ).toBeTruthy();
253          }
254        );
255
256        // On both platforms assets should perserve their id. On iOS it's native behaviour,
257        // but on Android it should be implemented (but it isn't)
258        // t.it("After createAlbum and addAssetsTo album all assets have the same id", async () => {
259        //   await Promise.all(testAssets.map(async asset => {
260        //     const info = await MediaLibrary.getAssetInfoAsync(asset);
261        //     t.expect(info.id).toBe(asset.id);
262        //   }));
263        // });
264      });
265
266      t.describe('getAssetsAsync', async () => {
267        t.it('No arguments', async () => {
268          const options = {};
269          const { assets } = await MediaLibrary.getAssetsAsync(options);
270          t.expect(assets.length).toBeLessThanOrEqual(DEFAULT_PAGE_SIZE);
271          t.expect(assets.length).toBeGreaterThanOrEqual(IMG_NUMBER);
272          assets.forEach((asset) => t.expect(DEFAULT_MEDIA_TYPES).toContain(asset.mediaType));
273        });
274
275        t.it('album', async () => {
276          const options = { album };
277          const { assets } = await MediaLibrary.getAssetsAsync(options);
278          t.expect(assets.length).toBe(IMG_NUMBER);
279          assets.forEach((asset) => t.expect(DEFAULT_MEDIA_TYPES).toContain(asset.mediaType));
280          if (Platform.OS === 'android')
281            assets.forEach((asset) => t.expect(asset.albumId).toBe(album.id));
282        });
283
284        t.it('first, after', async () => {
285          const options = { first: 2, album };
286          {
287            const { assets, endCursor, hasNextPage, totalCount } =
288              await MediaLibrary.getAssetsAsync(options);
289            t.expect(assets.length).toBe(2);
290            t.expect(totalCount).toBe(IMG_NUMBER);
291            t.expect(hasNextPage).toBeTruthy();
292            assets.forEach((asset) => t.expect(DEFAULT_MEDIA_TYPES).toContain(asset.mediaType));
293            options.after = endCursor;
294          }
295          {
296            const { assets, hasNextPage, totalCount } = await MediaLibrary.getAssetsAsync(options);
297            t.expect(assets.length).toBe(IMG_NUMBER - 2);
298            t.expect(totalCount).toBe(IMG_NUMBER);
299            t.expect(hasNextPage).toBeFalsy();
300          }
301        });
302
303        t.it('mediaType: video', async () => {
304          const mediaType = MediaLibrary.MediaType.video;
305          const options = { mediaType, album };
306          const { assets } = await MediaLibrary.getAssetsAsync(options);
307          assets.forEach((asset) => t.expect(asset.mediaType).toBe(mediaType));
308          t.expect(assets.length).toBe(1);
309        });
310
311        t.it('mediaType: photo', async () => {
312          const mediaType = MediaLibrary.MediaType.photo;
313          const options = { mediaType, album };
314          const { assets } = await MediaLibrary.getAssetsAsync(options);
315          t.expect(assets.length).toBe(IMG_NUMBER);
316          assets.forEach((asset) => t.expect(asset.mediaType).toBe(mediaType));
317        });
318
319        t.it('check size - photo', async () => {
320          const mediaType = MediaLibrary.MediaType.photo;
321          const options = { mediaType, album };
322          const { assets } = await MediaLibrary.getAssetsAsync(options);
323          t.expect(assets.length).toBe(IMG_NUMBER);
324          assets.forEach((asset) => {
325            t.expect(asset.width).not.toEqual(0);
326            t.expect(asset.height).not.toEqual(0);
327          });
328        });
329
330        t.it('check size - video', async () => {
331          const mediaType = MediaLibrary.MediaType.video;
332          const options = { mediaType, album };
333          const { assets } = await MediaLibrary.getAssetsAsync(options);
334          t.expect(assets.length).toBe(VIDEO_NUMBER);
335          assets.forEach((asset) => {
336            t.expect(asset.width).not.toEqual(0);
337            t.expect(asset.height).not.toEqual(0);
338          });
339        });
340
341        t.it('supports sorting in ascending order', async () => {
342          // Get some assets with the largest height.
343          const { assets } = await MediaLibrary.getAssetsAsync({
344            sortBy: [[MediaLibrary.SortBy.height, false]],
345          });
346
347          // Set the first and last items in the list
348          const first = assets[0].height;
349          const last = assets[assets.length - 1].height;
350
351          // Repeat assets request but reverse the order.
352          const { assets: ascendingAssets } = await MediaLibrary.getAssetsAsync({
353            sortBy: [[MediaLibrary.SortBy.height, true]],
354          });
355
356          // Set the first and last items in the new list
357          const ascFirst = ascendingAssets[0].height;
358          const ascLast = ascendingAssets[assets.length - 1].height;
359
360          t.expect(ascFirst).toBe(last);
361          t.expect(ascLast).toBe(first);
362        });
363
364        t.it('supports getting assets from specified time range', async () => {
365          const assetsToCheck = 7;
366
367          // Get some assets with the biggest creation time.
368          const { assets } = await MediaLibrary.getAssetsAsync({
369            first: assetsToCheck,
370            sortBy: MediaLibrary.SortBy.creationTime,
371          });
372
373          // Set time range based on the newest and oldest creation times.
374          const createdAfter = assets[assets.length - 1].creationTime;
375          const createdBefore = assets[0].creationTime;
376
377          // Repeat assets request but with the time range.
378          const { assets: filteredAssets } = await MediaLibrary.getAssetsAsync({
379            first: assetsToCheck,
380            sortBy: MediaLibrary.SortBy.creationTime,
381            createdAfter,
382            createdBefore,
383          });
384
385          // We can't get more assets than previously, but they could be equal if there are multiple assets with the same timestamp.
386          t.expect(filteredAssets.length).toBeLessThanOrEqual(assets.length);
387
388          // Check if every asset was created within the time range.
389          for (const asset of filteredAssets) {
390            t.expect(asset.creationTime).toBeLessThanOrEqual(createdBefore);
391            t.expect(asset.creationTime).toBeGreaterThanOrEqual(createdAfter);
392          }
393        });
394      });
395
396      t.describe('getAssetInfoAsync', async () => {
397        t.it('shouldDownloadFromNetwork: false, for photos', async () => {
398          const mediaType = MediaLibrary.MediaType.photo;
399          const options = { mediaType, album };
400          const { assets } = await MediaLibrary.getAssetsAsync(options);
401          const value = await MediaLibrary.getAssetInfoAsync(assets[0], {
402            shouldDownloadFromNetwork: false,
403          });
404          const keys = Object.keys(value);
405
406          const expectedExtraKeys = Platform.select({
407            ios: ['isNetworkAsset'],
408            default: [],
409          });
410          expectedExtraKeys.forEach((key) => t.expect(keys).toContain(key));
411          if (Platform.OS === 'ios') {
412            t.expect(value['isNetworkAsset']).toBe(false);
413          }
414        });
415
416        t.it('shouldDownloadFromNetwork: true, for photos', async () => {
417          const mediaType = MediaLibrary.MediaType.photo;
418          const options = { mediaType, album };
419          const { assets } = await MediaLibrary.getAssetsAsync(options);
420          const value = await MediaLibrary.getAssetInfoAsync(assets[0], {
421            shouldDownloadFromNetwork: true,
422          });
423          const keys = Object.keys(value);
424
425          const expectedExtraKeys = Platform.select({
426            ios: ['isNetworkAsset'],
427            default: [],
428          });
429          expectedExtraKeys.forEach((key) => t.expect(keys).not.toContain(key));
430        });
431
432        t.it('shouldDownloadFromNetwork: false, for videos', async () => {
433          const mediaType = MediaLibrary.MediaType.video;
434          const options = { mediaType, album };
435          const { assets } = await MediaLibrary.getAssetsAsync(options);
436          const value = await MediaLibrary.getAssetInfoAsync(assets[0], {
437            shouldDownloadFromNetwork: false,
438          });
439          const keys = Object.keys(value);
440
441          const expectedExtraKeys = Platform.select({
442            ios: ['isNetworkAsset'],
443            default: [],
444          });
445          expectedExtraKeys.forEach((key) => t.expect(keys).toContain(key));
446          if (Platform.OS === 'ios') {
447            t.expect(value['isNetworkAsset']).toBe(false);
448          }
449        });
450
451        t.it('shouldDownloadFromNetwork: true, for videos', async () => {
452          const mediaType = MediaLibrary.MediaType.video;
453          const options = { mediaType, album };
454          const { assets } = await MediaLibrary.getAssetsAsync(options);
455          const value = await MediaLibrary.getAssetInfoAsync(assets[0], {
456            shouldDownloadFromNetwork: true,
457          });
458          const keys = Object.keys(value);
459
460          const expectedExtraKeys = Platform.select({
461            ios: ['isNetworkAsset'],
462            default: [],
463          });
464          expectedExtraKeys.forEach((key) => t.expect(keys).not.toContain(key));
465        });
466      });
467    });
468
469    t.describe('Delete tests', async () => {
470      t.it(
471        'deleteAssetsAsync',
472        async () => {
473          const assets = await getAssets(files);
474          const result = await MediaLibrary.deleteAssetsAsync(assets);
475          const deletedAssets = await Promise.all(
476            assets.map(async (asset) => await MediaLibrary.getAssetInfoAsync(asset))
477          );
478          t.expect(result).toEqual(true);
479          t.expect(assets.length).not.toEqual(0);
480          t.expect(deletedAssets.length).toEqual(assets.length);
481          deletedAssets.forEach((deletedAsset) => t.expect(deletedAsset).toBeNull);
482        },
483        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
484      );
485
486      t.it(
487        'deleteAlbumsAsync',
488        async () => {
489          const assets = await getAssets([files[0]]);
490          const album = await createAlbum(assets, ALBUM_NAME);
491
492          const result = await MediaLibrary.deleteAlbumsAsync(album, true);
493          t.expect(result).toEqual(true);
494          const deletedAlbum = await MediaLibrary.getAlbumAsync(ALBUM_NAME);
495          t.expect(deletedAlbum).toBeNull();
496
497          if (shouldCopyAssets) {
498            await MediaLibrary.deleteAssetsAsync(assets);
499          }
500        },
501        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
502      );
503
504      t.it(
505        'deleteManyAlbums',
506        async () => {
507          const assets = await getAssets(files.slice(0, 2));
508          let firstAlbum = await MediaLibrary.createAlbumAsync(
509            ALBUM_NAME,
510            assets[0],
511            shouldCopyAssets
512          );
513
514          let secondAlbum = await MediaLibrary.createAlbumAsync(
515            SECOND_ALBUM_NAME,
516            assets[1],
517            shouldCopyAssets
518          );
519
520          await MediaLibrary.deleteAlbumsAsync([firstAlbum, secondAlbum], true);
521          firstAlbum = await MediaLibrary.getAlbumAsync(ALBUM_NAME);
522          secondAlbum = await MediaLibrary.getAlbumAsync(SECOND_ALBUM_NAME);
523          t.expect(firstAlbum).toBeNull();
524          t.expect(secondAlbum).toBeNull();
525
526          if (!shouldCopyAssets) {
527            const firstAsset = await MediaLibrary.getAssetInfoAsync(assets[0]);
528            const secondAsset = await MediaLibrary.getAssetInfoAsync(assets[1]);
529            t.expect(firstAsset).toBeNull();
530            t.expect(secondAsset).toBeNull();
531          } else {
532            await MediaLibrary.deleteAssetsAsync(assets);
533          }
534        },
535        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
536      );
537    });
538
539    t.describe('Listeners', async () => {
540      const createdAssets = [];
541
542      t.afterAll(async () => {
543        if (createdAssets) {
544          await MediaLibrary.deleteAssetsAsync(createdAssets);
545        }
546      }, TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT);
547
548      t.it(
549        'addAsset calls listener',
550        async () => {
551          const spy = t.jasmine.createSpy('addAsset spy', () => {});
552          const remove = MediaLibrary.addListener(spy);
553          const asset = await MediaLibrary.createAssetAsync(files[0].localUri);
554
555          t.expect(asset).not.toBeNull();
556          await timeoutWrapper(() => t.expect(spy).toHaveBeenCalled(), WAIT_TIME);
557
558          remove.remove();
559          createdAssets.push(asset);
560        },
561        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
562      );
563
564      t.it(
565        'remove listener',
566        async () => {
567          const spy = t.jasmine.createSpy('remove spy', () => {});
568          const subscription = MediaLibrary.addListener(spy);
569          subscription.remove();
570          const asset = await MediaLibrary.createAssetAsync(files[0].localUri);
571
572          t.expect(asset).not.toBeNull();
573          await timeoutWrapper(() => t.expect(spy).not.toHaveBeenCalled(), WAIT_TIME);
574
575          createdAssets.push(asset);
576        },
577        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
578      );
579
580      t.it(
581        'deleteListener calls listener',
582        async () => {
583          const spy = t.jasmine.createSpy('deleteAsset spy', () => {});
584          const asset = await MediaLibrary.createAssetAsync(files[0].localUri);
585          const subscription = MediaLibrary.addListener(spy);
586
587          t.expect(asset).not.toBeNull();
588          await MediaLibrary.deleteAssetsAsync(asset);
589          await timeoutWrapper(() => t.expect(spy).toHaveBeenCalled(), WAIT_TIME);
590          subscription.remove();
591        },
592        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
593      );
594
595      t.it(
596        'removeAllListeners',
597        async () => {
598          const spy = t.jasmine.createSpy('removeAll', () => {});
599          MediaLibrary.addListener(spy);
600          MediaLibrary.removeAllListeners();
601
602          const asset = await MediaLibrary.createAssetAsync(files[0].localUri);
603          t.expect(asset).not.toBeNull();
604          await timeoutWrapper(() => t.expect(spy).not.toHaveBeenCalled(), WAIT_TIME);
605
606          createdAssets.push(asset);
607        },
608        TIMEOUT_WHEN_USER_NEEDS_TO_INTERACT
609      );
610    });
611  });
612}
613