diff --git a/Libraries/Image/AssetSourceResolver.js b/Libraries/Image/AssetSourceResolver.js new file mode 100644 index 0000000000..3acbdc9bf4 --- /dev/null +++ b/Libraries/Image/AssetSourceResolver.js @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule AssetSourceResolver + * @flow + */ + +export type ResolvedAssetSource = { + __packager_asset: boolean, + width: number, + height: number, + uri: string, + scale: number, +}; + +import type { PackagerAsset } from 'AssetRegistry'; + +const PixelRatio = require('PixelRatio'); +const Platform = require('Platform'); + +const assetPathUtils = require('../../local-cli/bundle/assetPathUtils'); +const invariant = require('invariant'); + +/** + * Returns a path like 'assets/AwesomeModule/icon@2x.png' + */ +function getScaledAssetPath(asset): string { + var scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get()); + var scaleSuffix = scale === 1 ? '' : '@' + scale + 'x'; + var assetDir = assetPathUtils.getBasePath(asset); + return assetDir + '/' + asset.name + scaleSuffix + '.' + asset.type; +} + +/** + * Returns a path like 'drawable-mdpi/icon.png' + */ +function getAssetPathInDrawableFolder(asset): string { + var scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get()); + var drawbleFolder = assetPathUtils.getAndroidDrawableFolderName(asset, scale); + var fileName = assetPathUtils.getAndroidResourceIdentifier(asset); + return drawbleFolder + '/' + fileName + '.' + asset.type; +} + +class AssetSourceResolver { + + serverUrl: ?string; + // where the bundle is being run from + bundlePath: ?string; + // the asset to resolve + asset: PackagerAsset; + + constructor(serverUrl: ?string, bundlePath: ?string, asset: PackagerAsset) { + this.serverUrl = serverUrl; + this.bundlePath = bundlePath; + this.asset = asset; + } + + isLoadedFromServer(): boolean { + return !!this.serverUrl; + } + + isLoadedFromFileSystem(): boolean { + return !!this.bundlePath; + } + + defaultAsset(): ResolvedAssetSource { + if (this.isLoadedFromServer()) { + return this.assetServerURL(); + } + + if (Platform.OS === 'android') { + return this.isLoadedFromFileSystem() ? + this.drawableFolderInBundle() : + this.resourceIdentifierWithoutScale(); + } else { + return this.scaledAssetPathInBundle(); + } + } + + /** + * Returns an absolute URL which can be used to fetch the asset + * from the devserver + */ + assetServerURL(): ResolvedAssetSource { + invariant(!!this.serverUrl, 'need server to load from'); + return this.fromSource( + this.serverUrl + getScaledAssetPath(this.asset) + + '?platform=' + Platform.OS + '&hash=' + this.asset.hash + ); + } + + /** + * Resolves to just the scaled asset filename + * E.g. 'assets/AwesomeModule/icon@2x.png' + */ + scaledAssetPath(): ResolvedAssetSource { + return this.fromSource(getScaledAssetPath(this.asset)); + } + + /** + * Resolves to where the bundle is running from, with a scaled asset filename + * E.g. '/sdcard/bundle/assets/AwesomeModule/icon@2x.png' + */ + scaledAssetPathInBundle(): ResolvedAssetSource { + const path = this.bundlePath || ''; + return this.fromSource(path + getScaledAssetPath(this.asset)); + } + + /** + * The default location of assets bundled with the app, located by + * resource identifier + * The Android resource system picks the correct scale. + * E.g. 'assets_awesomemodule_icon' + */ + resourceIdentifierWithoutScale(): ResolvedAssetSource { + invariant(Platform.OS === 'android', 'resource identifiers work on Android'); + return this.fromSource(assetPathUtils.getAndroidResourceIdentifier(this.asset)); + } + + /** + * If the jsbundle is running from a sideload location, this resolves assets + * relative to its location + * E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png' + */ + drawableFolderInBundle(): ResolvedAssetSource { + const path = this.bundlePath || ''; + return this.fromSource( + 'file://' + path + getAssetPathInDrawableFolder(this.asset) + ); + } + + fromSource(source: string): ResolvedAssetSource { + return { + __packager_asset: true, + width: this.asset.width, + height: this.asset.height, + uri: source, + scale: AssetSourceResolver.pickScale(this.asset.scales, PixelRatio.get()), + }; + } + + static pickScale(scales: Array, deviceScale: number): number { + // Packager guarantees that `scales` array is sorted + for (var i = 0; i < scales.length; i++) { + if (scales[i] >= deviceScale) { + return scales[i]; + } + } + + // If nothing matches, device scale is larger than any available + // scales, so we return the biggest one. Unless the array is empty, + // in which case we default to 1 + return scales[scales.length - 1] || 1; + } + +} + + module.exports = AssetSourceResolver; diff --git a/Libraries/Image/__tests__/resolveAssetSource-test.js b/Libraries/Image/__tests__/resolveAssetSource-test.js index ae7ce3c5ea..e31d2f16fa 100644 --- a/Libraries/Image/__tests__/resolveAssetSource-test.js +++ b/Libraries/Image/__tests__/resolveAssetSource-test.js @@ -10,6 +10,7 @@ jest .dontMock('AssetRegistry') + .dontMock('AssetSourceResolver') .dontMock('../resolveAssetSource') .dontMock('../../../local-cli/bundle/assetPathUtils'); @@ -217,6 +218,60 @@ describe('resolveAssetSource', () => { }); }); + describe('source resolver can be customized', () => { + beforeEach(() => { + NativeModules.SourceCode.scriptURL = + 'file:///sdcard/Path/To/Simulator/main.bundle'; + Platform.OS = 'android'; + }); + + it('uses bundled source, event when js is sideloaded', () => { + resolveAssetSource.setCustomSourceTransformer( + (resolver) => resolver.resourceIdentifierWithoutScale(), + ); + expectResolvesAsset({ + __packager_asset: true, + fileSystemLocation: '/root/app/module/a', + httpServerLocation: '/assets/AwesomeModule/Subdir', + width: 100, + height: 200, + scales: [1], + hash: '5b6f00f', + name: '!@Logo#1_€', + type: 'png', + }, { + __packager_asset: true, + width: 100, + height: 200, + uri: 'awesomemodule_subdir_logo1_', + scale: 1, + }); + }); + + it('allows any customization', () => { + resolveAssetSource.setCustomSourceTransformer( + (resolver) => resolver.fromSource('TEST') + ); + expectResolvesAsset({ + __packager_asset: true, + fileSystemLocation: '/root/app/module/a', + httpServerLocation: '/assets/AwesomeModule/Subdir', + width: 100, + height: 200, + scales: [1], + hash: '5b6f00f', + name: '!@Logo#1_€', + type: 'png', + }, { + __packager_asset: true, + width: 100, + height: 200, + uri: 'TEST', + scale: 1, + }); + }); + }); + }); describe('resolveAssetSource.pickScale', () => { diff --git a/Libraries/Image/resolveAssetSource.js b/Libraries/Image/resolveAssetSource.js index e42b2d1da0..0f666cf2f7 100644 --- a/Libraries/Image/resolveAssetSource.js +++ b/Libraries/Image/resolveAssetSource.js @@ -13,23 +13,15 @@ */ 'use strict'; -export type ResolvedAssetSource = { - __packager_asset: boolean, - width: number, - height: number, - uri: string, - scale: number, -}; +import type { ResolvedAssetSource } from 'AssetSourceResolver'; -var AssetRegistry = require('AssetRegistry'); -var PixelRatio = require('PixelRatio'); -var Platform = require('Platform'); -var SourceCode = require('NativeModules').SourceCode; -var assetPathUtils = require('../../local-cli/bundle/assetPathUtils'); +const AssetRegistry = require('AssetRegistry'); +const AssetSourceResolver = require('AssetSourceResolver'); +const { SourceCode } = require('NativeModules'); -var _serverURL, _offlinePath; +let _customSourceTransformer, _serverURL, _bundleSourcePath; -function getDevServerURL() { +function getDevServerURL(): ?string { if (_serverURL === undefined) { var scriptURL = SourceCode.scriptURL; var match = scriptURL && scriptURL.match(/^https?:\/\/.*?\//); @@ -41,119 +33,60 @@ function getDevServerURL() { _serverURL = null; } } - return _serverURL; } -function getOfflinePath() { - if (_offlinePath === undefined) { +function getBundleSourcePath(): ?string { + if (_bundleSourcePath === undefined) { const scriptURL = SourceCode.scriptURL; if (!scriptURL) { // scriptURL is falsy, we have nothing to go on here - _offlinePath = ''; - return _offlinePath; + _bundleSourcePath = null; + return _bundleSourcePath; } if (scriptURL.startsWith('assets://')) { // running from within assets, no offline path to use - _offlinePath = ''; - return _offlinePath; + _bundleSourcePath = null; + return _bundleSourcePath; } if (scriptURL.startsWith('file://')) { // cut off the protocol - _offlinePath = scriptURL.substring(7, scriptURL.lastIndexOf('/') + 1); + _bundleSourcePath = scriptURL.substring(7, scriptURL.lastIndexOf('/') + 1); } else { - _offlinePath = scriptURL.substring(0, scriptURL.lastIndexOf('/') + 1); + _bundleSourcePath = scriptURL.substring(0, scriptURL.lastIndexOf('/') + 1); } } - return _offlinePath; + return _bundleSourcePath; +} + +function setCustomSourceTransformer( + transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource, +): void { + _customSourceTransformer = transformer; } /** - * Returns the path at which the asset can be found in the archive + * `source` is either a number (opaque type returned by require('./foo.png')) + * or an `ImageSource` like { uri: '' } */ -function getPathInArchive(asset) { - var offlinePath = getOfflinePath(); - if (Platform.OS === 'android') { - if (offlinePath) { - // E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png' - return 'file://' + offlinePath + getAssetPathInDrawableFolder(asset); - } - // E.g. 'assets_awesomemodule_icon' - // The Android resource system picks the correct scale. - return assetPathUtils.getAndroidResourceIdentifier(asset); - } else { - // E.g. '/assets/AwesomeModule/icon@2x.png' - return offlinePath + getScaledAssetPath(asset); - } -} - -/** - * Returns an absolute URL which can be used to fetch the asset - * from the devserver - */ -function getPathOnDevserver(devServerUrl, asset) { - return devServerUrl + getScaledAssetPath(asset) + '?platform=' + Platform.OS + - '&hash=' + asset.hash; -} - -/** - * Returns a path like 'assets/AwesomeModule/icon@2x.png' - */ -function getScaledAssetPath(asset) { - var scale = pickScale(asset.scales, PixelRatio.get()); - var scaleSuffix = scale === 1 ? '' : '@' + scale + 'x'; - var assetDir = assetPathUtils.getBasePath(asset); - return assetDir + '/' + asset.name + scaleSuffix + '.' + asset.type; -} - -/** - * Returns a path like 'drawable-mdpi/icon.png' - */ -function getAssetPathInDrawableFolder(asset) { - var scale = pickScale(asset.scales, PixelRatio.get()); - var drawbleFolder = assetPathUtils.getAndroidDrawableFolderName(asset, scale); - var fileName = assetPathUtils.getAndroidResourceIdentifier(asset); - return drawbleFolder + '/' + fileName + '.' + asset.type; -} - -function pickScale(scales: Array, deviceScale: number): number { - // Packager guarantees that `scales` array is sorted - for (var i = 0; i < scales.length; i++) { - if (scales[i] >= deviceScale) { - return scales[i]; - } - } - - // If nothing matches, device scale is larger than any available - // scales, so we return the biggest one. Unless the array is empty, - // in which case we default to 1 - return scales[scales.length - 1] || 1; -} - function resolveAssetSource(source: any): ?ResolvedAssetSource { if (typeof source === 'object') { return source; } var asset = AssetRegistry.getAssetByID(source); - if (asset) { - return assetToImageSource(asset); + if (!asset) { + return null; } - return null; -} - -function assetToImageSource(asset): ResolvedAssetSource { - var devServerURL = getDevServerURL(); - return { - __packager_asset: true, - width: asset.width, - height: asset.height, - uri: devServerURL ? getPathOnDevserver(devServerURL, asset) : getPathInArchive(asset), - scale: pickScale(asset.scales, PixelRatio.get()), - }; + const resolver = new AssetSourceResolver(getDevServerURL(), getBundleSourcePath(), asset); + if (_customSourceTransformer) { + return _customSourceTransformer(resolver); + } + return resolver.defaultAsset(); } module.exports = resolveAssetSource; -module.exports.pickScale = pickScale; +module.exports.pickScale = AssetSourceResolver.pickScale; +module.exports.setCustomSourceTransformer = setCustomSourceTransformer;