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