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