xref: /expo/packages/expo-asset/src/Asset.ts (revision 333c3539)
1import { Platform } from 'expo-modules-core';
2import { getAssetByID } from 'react-native/Libraries/Image/AssetRegistry';
3
4import { AssetMetadata, selectAssetSource } from './AssetSources';
5import * as AssetUris from './AssetUris';
6import * as ImageAssets from './ImageAssets';
7import { getLocalAssetUri } from './LocalAssets';
8import { downloadAsync, IS_ENV_WITH_UPDATES_ENABLED } from './PlatformUtils';
9import resolveAssetSource from './resolveAssetSource';
10
11// @docsMissing
12export type AssetDescriptor = {
13  name: string;
14  type: string;
15  hash?: string | null;
16  uri: string;
17  width?: number | null;
18  height?: number | null;
19};
20
21type DownloadPromiseCallbacks = {
22  resolve: () => void;
23  reject: (error: Error) => void;
24};
25
26export { AssetMetadata };
27
28// @needsAudit
29/**
30 * The `Asset` class represents an asset in your app. It gives metadata about the asset (such as its
31 * name and type) and provides facilities to load the asset data.
32 */
33export class Asset {
34  /**
35   * @private
36   */
37  static byHash = {};
38  /**
39   * @private
40   */
41  static byUri = {};
42
43  /**
44   * The name of the asset file without the extension. Also without the part from `@` onward in the
45   * filename (used to specify scale factor for images).
46   */
47  name: string;
48  /**
49   * The extension of the asset filename.
50   */
51  type: string;
52  /**
53   * The MD5 hash of the asset's data.
54   */
55  hash: string | null = null;
56  /**
57   * A URI that points to the asset's data on the remote server. When running the published version
58   * of your app, this refers to the location on Expo's asset server where Expo has stored your
59   * asset. When running the app from Expo CLI during development, this URI points to Expo CLI's
60   * server running on your computer and the asset is served directly from your computer. If you
61   * are not using Classic Updates (legacy), this field should be ignored as we ensure your assets
62   * are on device before before running your application logic.
63   */
64  uri: string;
65  /**
66   * If the asset has been downloaded (by calling [`downloadAsync()`](#downloadasync)), the
67   * `file://` URI pointing to the local file on the device that contains the asset data.
68   */
69  localUri: string | null = null;
70  /**
71   * If the asset is an image, the width of the image data divided by the scale factor. The scale
72   * factor is the number after `@` in the filename, or `1` if not present.
73   */
74  width: number | null = null;
75  /**
76   * If the asset is an image, the height of the image data divided by the scale factor. The scale factor is the number after `@` in the filename, or `1` if not present.
77   */
78  height: number | null = null;
79  // @docsMissing
80  downloading: boolean = false;
81  // @docsMissing
82  downloaded: boolean = false;
83
84  /**
85   * @private
86   */
87  _downloadCallbacks: DownloadPromiseCallbacks[] = [];
88
89  constructor({ name, type, hash = null, uri, width, height }: AssetDescriptor) {
90    this.name = name;
91    this.type = type;
92    this.hash = hash;
93    this.uri = uri;
94
95    if (typeof width === 'number') {
96      this.width = width;
97    }
98    if (typeof height === 'number') {
99      this.height = height;
100    }
101
102    if (hash) {
103      this.localUri = getLocalAssetUri(hash, type);
104      if (this.localUri) {
105        this.downloaded = true;
106      }
107    }
108
109    if (Platform.OS === 'web') {
110      if (!name) {
111        this.name = AssetUris.getFilename(uri);
112      }
113      if (!type) {
114        this.type = AssetUris.getFileExtension(uri);
115      }
116    }
117  }
118
119  // @needsAudit
120  /**
121   * A helper that wraps `Asset.fromModule(module).downloadAsync` for convenience.
122   * @param moduleId An array of `require('path/to/file')` or external network URLs. Can also be
123   * just one module or URL without an Array.
124   * @return Returns a Promise that fulfills with an array of `Asset`s when the asset(s) has been
125   * saved to disk.
126   * @example
127   * ```ts
128   * const [{ localUri }] = await Asset.loadAsync(require('./assets/snack-icon.png'));
129   * ```
130   */
131  static loadAsync(moduleId: number | number[] | string | string[]): Promise<Asset[]> {
132    const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
133    return Promise.all(moduleIds.map((moduleId) => Asset.fromModule(moduleId).downloadAsync()));
134  }
135
136  // @needsAudit
137  /**
138   * Returns the [`Asset`](#asset) instance representing an asset given its module or URL.
139   * @param virtualAssetModule The value of `require('path/to/file')` for the asset or external
140   * network URL
141   * @return The [`Asset`](#asset) instance for the asset.
142   */
143  static fromModule(virtualAssetModule: number | string): Asset {
144    if (typeof virtualAssetModule === 'string') {
145      return Asset.fromURI(virtualAssetModule);
146    }
147
148    const meta = getAssetByID(virtualAssetModule);
149    if (!meta) {
150      throw new Error(`Module "${virtualAssetModule}" is missing from the asset registry`);
151    }
152
153    // Outside of the managed env we need the moduleId to initialize the asset
154    // because resolveAssetSource depends on it
155    if (!IS_ENV_WITH_UPDATES_ENABLED) {
156      const { uri } = resolveAssetSource(virtualAssetModule);
157      const asset = new Asset({
158        name: meta.name,
159        type: meta.type,
160        hash: meta.hash,
161        uri,
162        width: meta.width,
163        height: meta.height,
164      });
165
166      // TODO: FileSystem should probably support 'downloading' from drawable
167      // resources But for now it doesn't (it only supports raw resources) and
168      // React Native's Image works fine with drawable resource names for
169      // images.
170      if (Platform.OS === 'android' && !uri.includes(':') && (meta.width || meta.height)) {
171        asset.localUri = asset.uri;
172        asset.downloaded = true;
173      }
174
175      Asset.byHash[meta.hash] = asset;
176      return asset;
177    }
178
179    return Asset.fromMetadata(meta);
180  }
181
182  // @docsMissing
183  static fromMetadata(meta: AssetMetadata): Asset {
184    // The hash of the whole asset, not to be confused with the hash of a specific file returned
185    // from `selectAssetSource`
186    const metaHash = meta.hash;
187    if (Asset.byHash[metaHash]) {
188      return Asset.byHash[metaHash];
189    }
190
191    const { uri, hash } = selectAssetSource(meta);
192    const asset = new Asset({
193      name: meta.name,
194      type: meta.type,
195      hash,
196      uri,
197      width: meta.width,
198      height: meta.height,
199    });
200    Asset.byHash[metaHash] = asset;
201    return asset;
202  }
203
204  // @docsMissing
205  static fromURI(uri: string): Asset {
206    if (Asset.byUri[uri]) {
207      return Asset.byUri[uri];
208    }
209
210    // Possibly a Base64-encoded URI
211    let type = '';
212    if (uri.indexOf(';base64') > -1) {
213      type = uri.split(';')[0].split('/')[1];
214    } else {
215      const extension = AssetUris.getFileExtension(uri);
216      type = extension.startsWith('.') ? extension.substring(1) : extension;
217    }
218
219    const asset = new Asset({
220      name: '',
221      type,
222      hash: null,
223      uri,
224    });
225
226    Asset.byUri[uri] = asset;
227
228    return asset;
229  }
230
231  // @needsAudit
232  /**
233   * Downloads the asset data to a local file in the device's cache directory. Once the returned
234   * promise is fulfilled without error, the [`localUri`](#assetlocaluri) field of this asset points
235   * to a local file containing the asset data. The asset is only downloaded if an up-to-date local
236   * file for the asset isn't already present due to an earlier download. The downloaded `Asset`
237   * will be returned when the promise is resolved.
238   * @return Returns a Promise which fulfills with an `Asset` instance.
239   */
240  async downloadAsync(): Promise<this> {
241    if (this.downloaded) {
242      return this;
243    }
244    if (this.downloading) {
245      await new Promise<void>((resolve, reject) => {
246        this._downloadCallbacks.push({ resolve, reject });
247      });
248      return this;
249    }
250    this.downloading = true;
251
252    try {
253      if (Platform.OS === 'web') {
254        if (ImageAssets.isImageType(this.type)) {
255          const { width, height, name } = await ImageAssets.getImageInfoAsync(this.uri);
256          this.width = width;
257          this.height = height;
258          this.name = name;
259        } else {
260          this.name = AssetUris.getFilename(this.uri);
261        }
262      }
263      this.localUri = await downloadAsync(this.uri, this.hash, this.type, this.name);
264
265      this.downloaded = true;
266      this._downloadCallbacks.forEach(({ resolve }) => resolve());
267    } catch (e) {
268      this._downloadCallbacks.forEach(({ reject }) => reject(e));
269      throw e;
270    } finally {
271      this.downloading = false;
272      this._downloadCallbacks = [];
273    }
274    return this;
275  }
276}
277