chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+32
View File
@@ -0,0 +1,32 @@
import type { default as AssetSourceResolver } from 'react-native/Libraries/Image/AssetSourceResolver';
import { Asset, ANDROID_EMBEDDED_URL_BASE_RESOURCE } from './Asset';
import { IS_ENV_WITH_LOCAL_ASSETS } from './PlatformUtils';
import resolveAssetSource, { setCustomSourceTransformer } from './resolveAssetSource';
// Override React Native's asset resolution for `Image` components in contexts where it matters
if (IS_ENV_WITH_LOCAL_ASSETS) {
const setTransformer =
resolveAssetSource.setCustomSourceTransformer || setCustomSourceTransformer;
setTransformer(function expoAssetTransformer(resolver) {
try {
// Bundler is using the hashAssetFiles plugin if and only if the fileHashes property exists
if ('fileHashes' in resolver.asset && resolver.asset.fileHashes) {
const asset = Asset.fromMetadata(resolver.asset);
if (asset.uri.startsWith(ANDROID_EMBEDDED_URL_BASE_RESOURCE)) {
// TODO(@kitten): See https://github.com/expo/expo/commit/ec940b57a87d99ab4f1d06d87126e662c3f04f04#r155340943
// It's unclear whether this is sound since this may be our own AssetSourceResolver, which doesn't have this method
// Please compare `AssetSourceResolver` type from `react-native/Libraries/Image/AssetSourceResolver` against `./AssetSourceResolver`
return (
resolver as unknown as AssetSourceResolver
).resourceIdentifierWithoutScale() as any;
}
return resolver.fromSource(asset.downloaded ? asset.localUri! : asset.uri);
} else {
return resolver.defaultAsset();
}
} catch {
return resolver.defaultAsset();
}
});
}
+178
View File
@@ -0,0 +1,178 @@
import type { AssetDescriptor } from './Asset';
import type { AssetMetadata, AssetSource } from './AssetSources';
import * as AssetUris from './AssetUris';
import * as ImageAssets from './ImageAssets';
export class Asset {
private static byHash: Record<string, Asset | undefined> = {};
private static byUri: Record<string, Asset | undefined> = {};
public name: string;
public readonly type: string;
public readonly hash: string | null = null;
public readonly uri: string;
public localUri: string | null = null;
public width: number | null = null;
public height: number | null = null;
public downloaded: boolean = true;
constructor({ name, type, hash = null, uri, width, height }: AssetDescriptor) {
this.name = name;
this.type = type;
this.hash = hash;
this.uri = uri;
if (typeof width === 'number') {
this.width = width;
}
if (typeof height === 'number') {
this.height = height;
}
this.name ??= AssetUris.getFilename(uri);
this.type ??= AssetUris.getFileExtension(uri);
// Essentially run the contents of downloadAsync here.
if (ImageAssets.isImageType(this.type)) {
this.width = 0;
this.height = 0;
this.name = AssetUris.getFilename(this.uri);
} else {
this.name = AssetUris.getFilename(this.uri);
}
this.localUri = this.uri;
}
static loadAsync(moduleId: number | number[] | string | string[]): Promise<Asset[]> {
const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
return Promise.all(moduleIds.map((moduleId) => Asset.fromModule(moduleId).downloadAsync()));
}
static fromModule(
virtualAssetModule: number | string | { uri: string; width: number; height: number }
): Asset {
if (typeof virtualAssetModule === 'string') {
return Asset.fromURI(virtualAssetModule);
} else if (typeof virtualAssetModule === 'number') {
throw new Error(
'Cannot resolve numeric asset IDs on the server as they are non-deterministic identifiers.'
);
}
if (
typeof virtualAssetModule === 'object' &&
'uri' in virtualAssetModule &&
typeof virtualAssetModule.uri === 'string'
) {
const extension = AssetUris.getFileExtension(virtualAssetModule.uri);
return new Asset({
name: '',
type: extension.startsWith('.') ? extension.substring(1) : extension,
hash: null,
uri: virtualAssetModule.uri,
width: virtualAssetModule.width,
height: virtualAssetModule.height,
});
}
throw new Error('Unexpected asset module ID type: ' + typeof virtualAssetModule);
}
static fromMetadata(meta: AssetMetadata): Asset {
const metaHash = meta.hash;
const maybeHash = Asset.byHash[metaHash];
if (maybeHash) {
return maybeHash;
}
const { uri, hash } = selectAssetSource(meta);
const asset = new Asset({
name: meta.name,
type: meta.type,
hash,
uri,
width: meta.width,
height: meta.height,
});
Asset.byHash[metaHash] = asset;
return asset;
}
static fromURI(uri: string): Asset {
if (Asset.byUri[uri]) {
return Asset.byUri[uri];
}
// Possibly a Base64-encoded URI
let type = '';
if (uri.indexOf(';base64') > -1) {
type = uri.split(';')[0].split('/')[1];
} else {
const extension = AssetUris.getFileExtension(uri);
type = extension.startsWith('.') ? extension.substring(1) : extension;
}
const asset = new Asset({
name: '',
type,
hash: null,
uri,
});
Asset.byUri[uri] = asset;
return asset;
}
async downloadAsync(): Promise<this> {
return this;
}
}
function pickScale(scales: number[], deviceScale: number): number {
for (let i = 0; i < scales.length; i++) {
if (scales[i] >= deviceScale) {
return scales[i];
}
}
return scales[scales.length - 1] || 1;
}
/**
* Selects the best file for the given asset (ex: choosing the best scale for images) and returns
* a { uri, hash } pair for the specific asset file.
*
* If the asset isn't an image with multiple scales, the first file is selected.
*/
function selectAssetSource(meta: AssetMetadata): AssetSource {
// This logic is based on that of AssetSourceResolver, with additional support for file hashes and
// explicitly provided URIs
const scale = pickScale(meta.scales, 1);
const index = meta.scales.findIndex((s) => s === scale);
const hash = meta.fileHashes ? (meta.fileHashes[index] ?? meta.fileHashes[0]) : meta.hash;
// Allow asset processors to directly provide the URL to load
const uri = meta.fileUris ? (meta.fileUris[index] ?? meta.fileUris[0]) : meta.uri;
if (uri) {
return { uri, hash };
}
const fileScale = scale === 1 ? '' : `@${scale}x`;
const fileExtension = meta.type ? `.${encodeURIComponent(meta.type)}` : '';
const suffix = `/${encodeURIComponent(meta.name)}${fileScale}${fileExtension}`;
const params = new URLSearchParams({
platform: process.env.EXPO_OS!,
hash: meta.hash,
});
// For assets with a specified absolute URL, we use the existing origin instead of prepending the
// development server or production CDN URL origin
if (/^https?:\/\//.test(meta.httpServerLocation)) {
const uri = meta.httpServerLocation + suffix + '?' + params;
return { uri, hash };
}
// In correctly configured apps, we arrive here if the asset is locally available on disk due to
// being managed by expo-updates, and `getLocalAssetUri(hash)` must return a local URI for this
// hash. Since the asset is local, we don't have a remote URL and specify an invalid URL (an empty
// string) as a placeholder.
return { uri: '', hash };
}
+307
View File
@@ -0,0 +1,307 @@
import { getAssetByID } from '@react-native/assets-registry/registry';
import { Platform } from 'expo-modules-core';
import { AssetMetadata, selectAssetSource } from './AssetSources';
import * as AssetUris from './AssetUris';
import { downloadAsync } from './ExpoAsset';
import * as ImageAssets from './ImageAssets';
import { getLocalAssetUri } from './LocalAssets';
import { IS_ENV_WITH_LOCAL_ASSETS } from './PlatformUtils';
import resolveAssetSource from './resolveAssetSource';
// @docsMissing
export type AssetDescriptor = {
name: string;
type: string;
hash?: string | null;
uri: string;
width?: number | null;
height?: number | null;
};
type DownloadPromiseCallbacks = {
resolve: () => void;
reject: (error: Error) => void;
};
export { AssetMetadata };
/**
* Android resource URL prefix.
* @hidden
*/
export const ANDROID_EMBEDDED_URL_BASE_RESOURCE = 'file:///android_res/';
/**
* The `Asset` class represents an asset in your app. It gives metadata about the asset (such as its
* name and type) and provides facilities to load the asset data.
*/
export class Asset {
private static byHash: Record<string, Asset | undefined> = {};
private static byUri: Record<string, Asset | undefined> = {};
/**
* The name of the asset file without the extension. Also without the part from `@` onward in the
* filename (used to specify scale factor for images).
*/
public name: string;
/**
* The extension of the asset filename.
*/
public readonly type: string;
/**
* The MD5 hash of the asset's data.
*/
public readonly hash: string | null = null;
/**
* A URI that points to the asset's data on the remote server. When running the published version
* of your app, this refers to the location on Expo's asset server where Expo has stored your
* asset. When running the app from Expo CLI during development, this URI points to Expo CLI's
* server running on your computer and the asset is served directly from your computer. If you
* are not using Classic Updates (legacy), this field should be ignored as we ensure your assets
* are on device before running your application logic.
*/
public readonly uri: string;
/**
* If the asset has been downloaded (by calling [`downloadAsync()`](#downloadasync)), the
* `file://` URI pointing to the local file on the device that contains the asset data.
*/
public localUri: string | null = null;
/**
* If the asset is an image, the width of the image data divided by the scale factor. The scale
* factor is the number after `@` in the filename, or `1` if not present.
*/
public width: number | null = null;
/**
* 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.
*/
public height: number | null = null;
private downloading: boolean = false;
/**
* Whether the asset has finished downloading from a call to [`downloadAsync()`](#downloadasync).
*/
public downloaded: boolean = false;
private _downloadCallbacks: DownloadPromiseCallbacks[] = [];
constructor({ name, type, hash = null, uri, width, height }: AssetDescriptor) {
this.name = name;
this.type = type;
this.hash = hash;
this.uri = uri;
if (typeof width === 'number') {
this.width = width;
}
if (typeof height === 'number') {
this.height = height;
}
if (hash) {
this.localUri = getLocalAssetUri(hash, type);
if (this.localUri?.startsWith(ANDROID_EMBEDDED_URL_BASE_RESOURCE)) {
// Treat Android embedded resources as not downloaded state, because the uri is not direct accessible.
this.uri = this.localUri;
this.localUri = null;
} else if (this.localUri) {
this.downloaded = true;
}
}
if (Platform.OS === 'web') {
if (!name) {
this.name = AssetUris.getFilename(uri);
}
if (!type) {
this.type = AssetUris.getFileExtension(uri);
}
}
}
// @needsAudit
/**
* A helper that wraps `Asset.fromModule(module).downloadAsync` for convenience.
* @param moduleId An array of `require('path/to/file')` or external network URLs. Can also be
* just one module or URL without an Array.
* @return Returns a Promise that fulfills with an array of `Asset`s when the asset(s) has been
* saved to disk.
* @example
* ```ts
* const [{ localUri }] = await Asset.loadAsync(require('./assets/snack-icon.png'));
* ```
*/
static loadAsync(moduleId: number | number[] | string | string[]): Promise<Asset[]> {
const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
return Promise.all(moduleIds.map((moduleId) => Asset.fromModule(moduleId).downloadAsync()));
}
// @needsAudit
/**
* Returns the [`Asset`](#asset) instance representing an asset given its module or URL.
* @param virtualAssetModule The value of `require('path/to/file')` for the asset or external
* network URL
* @return The [`Asset`](#asset) instance for the asset.
*/
static fromModule(
virtualAssetModule: number | string | { uri: string; width: number; height: number }
): Asset {
if (typeof virtualAssetModule === 'string') {
return Asset.fromURI(virtualAssetModule);
}
if (
typeof virtualAssetModule === 'object' &&
'uri' in virtualAssetModule &&
typeof virtualAssetModule.uri === 'string'
) {
const extension = AssetUris.getFileExtension(virtualAssetModule.uri);
return new Asset({
name: '',
type: extension.startsWith('.') ? extension.substring(1) : extension,
hash: null,
uri: virtualAssetModule.uri,
width: virtualAssetModule.width,
height: virtualAssetModule.height,
});
}
const meta = getAssetByID(virtualAssetModule);
if (!meta) {
throw new Error(`Module "${virtualAssetModule}" is missing from the asset registry`);
}
// Outside of the managed env we need the moduleId to initialize the asset
// because resolveAssetSource depends on it
if (!IS_ENV_WITH_LOCAL_ASSETS) {
// null-check is performed above with `getAssetByID`.
const { uri } = resolveAssetSource(virtualAssetModule)!;
const asset = new Asset({
name: meta.name,
type: meta.type,
hash: meta.hash,
uri,
width: meta.width,
height: meta.height,
});
// For images backward compatibility,
// keeps localUri the same as uri for React Native's Image that
// works fine with drawable resource names.
if (Platform.OS === 'android' && !uri.includes(':') && (meta.width || meta.height)) {
asset.localUri = asset.uri;
asset.downloaded = true;
}
Asset.byHash[meta.hash] = asset;
return asset;
}
return Asset.fromMetadata(meta);
}
// @docsMissing
static fromMetadata(meta: AssetMetadata): Asset {
// The hash of the whole asset, not to be confused with the hash of a specific file returned
// from `selectAssetSource`
const metaHash = meta.hash;
const assetByHash = Asset.byHash[metaHash];
if (assetByHash) {
return assetByHash;
}
const { uri, hash } = selectAssetSource(meta);
const asset = new Asset({
name: meta.name,
type: meta.type,
hash,
uri,
width: meta.width,
height: meta.height,
});
Asset.byHash[metaHash] = asset;
return asset;
}
// @docsMissing
static fromURI(uri: string): Asset {
if (Asset.byUri[uri]) {
return Asset.byUri[uri];
}
// Possibly a Base64-encoded URI
let type = '';
if (uri.indexOf(';base64') > -1) {
type = uri.split(';')[0].split('/')[1];
} else {
const extension = AssetUris.getFileExtension(uri);
type = extension.startsWith('.') ? extension.substring(1) : extension;
}
const asset = new Asset({
name: '',
type,
hash: null,
uri,
});
Asset.byUri[uri] = asset;
return asset;
}
// @needsAudit
/**
* Downloads the asset data to a local file in the device's cache directory. Once the returned
* promise is fulfilled without error, the [`localUri`](#localuri) field of this asset points
* to a local file containing the asset data. The asset is only downloaded if an up-to-date local
* file for the asset isn't already present due to an earlier download. The downloaded `Asset`
* will be returned when the promise is resolved.
*
* > **Note:** There is no guarantee that files downloaded via `downloadAsync` persist between app sessions.
* `downloadAsync` stores files in the caches directory, so it's up to the OS to clear this folder at its
* own discretion or when the user manually purges the caches directory. Downloaded assets are stored as
* `ExponentAsset-{cacheFileId}.{extension}` within the cache directory.
* > To manually clear cached assets, you can use [`expo-file-system`](./filesystem/) to
* delete the cache directory: `Paths.cache.delete()` or use the legacy API `deleteAsync(cacheDirectory)`.
*
* @return Returns a Promise which fulfills with an `Asset` instance.
*/
async downloadAsync(): Promise<this> {
if (this.downloaded) {
return this;
}
if (this.downloading) {
await new Promise<void>((resolve, reject) => {
this._downloadCallbacks.push({ resolve, reject });
});
return this;
}
this.downloading = true;
try {
if (Platform.OS === 'web') {
if (ImageAssets.isImageType(this.type)) {
const { width, height, name } = await ImageAssets.getImageInfoAsync(this.uri);
this.width = width;
this.height = height;
this.name = name;
} else {
this.name = AssetUris.getFilename(this.uri);
}
}
this.localUri = await downloadAsync(this.uri, this.hash, this.type);
this.downloaded = true;
this._downloadCallbacks.forEach(({ resolve }) => resolve());
} catch (e: any) {
this._downloadCallbacks.forEach(({ reject }) => reject(e));
throw e;
} finally {
this.downloading = false;
this._downloadCallbacks = [];
}
return this;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import { Asset } from './Asset';
// @needsAudit
/**
* Downloads and stores one or more assets locally.
* After the assets are loaded, this hook returns a list of asset instances.
* If something went wrong when loading the assets, an error is returned.
*
* > Note, the assets are not "reloaded" when you dynamically change the asset list.
*
* @return Returns an array containing:
* - on the first position, a list of all loaded assets. If they aren't loaded yet, this value is
* `undefined`.
* - on the second position, an error which encountered when loading the assets. If there was no
* error, this value is `undefined`.
*
* @example
* ```tsx
* const [assets, error] = useAssets([require('path/to/asset.jpg'), require('path/to/other.png')]);
*
* return assets ? <Image source={assets[0]} /> : null;
* ```
*/
export function useAssets(moduleIds: number | number[]): [Asset[] | undefined, Error | undefined] {
const [assets, setAssets] = useState<Asset[]>();
const [error, setError] = useState<Error>();
useEffect(() => {
Asset.loadAsync(moduleIds).then(setAssets).catch(setError);
}, []);
return [assets, error];
}
@@ -0,0 +1,3 @@
import AssetSourceResolver from 'react-native/Libraries/Image/AssetSourceResolver';
export default AssetSourceResolver;
export * from 'react-native/Libraries/Image/AssetSourceResolver';
+91
View File
@@ -0,0 +1,91 @@
import type { PackagerAsset } from '@react-native/assets-registry/registry';
import { Platform } from 'expo-modules-core';
import { PixelRatio } from 'react-native';
import type { AssetMetadata } from './AssetSources';
export type ResolvedAssetSource = {
__packager_asset: boolean;
width?: number;
height?: number;
uri: string;
scale: number;
};
// Returns the Metro dev server-specific asset location.
function getScaledAssetPath(asset: PackagerAsset | AssetMetadata): string {
const scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get());
const scaleSuffix = scale === 1 ? '' : '@' + scale + 'x';
const type = !asset.type ? '' : `.${asset.type}`;
if (__DEV__) {
return asset.httpServerLocation + '/' + asset.name + scaleSuffix + type;
} else {
return asset.httpServerLocation.replace(/\.\.\//g, '_') + '/' + asset.name + scaleSuffix + type;
}
}
export default class AssetSourceResolver {
private readonly serverUrl: string;
// where the jsbundle is being run from
// NOTE(EvanBacon): Never defined on web.
// @ts-expect-error: Never read locally
private readonly jsbundleUrl: string | undefined | null;
// the asset to resolve
public readonly asset: PackagerAsset | AssetMetadata;
constructor(
serverUrl: string | undefined | null,
jsbundleUrl: string | undefined | null,
asset: PackagerAsset
) {
this.serverUrl = serverUrl || 'https://expo.dev';
this.jsbundleUrl = null;
this.asset = asset;
}
// Always true for web runtimes
isLoadedFromServer(): boolean {
return true;
}
// Always false for web runtimes
isLoadedFromFileSystem(): boolean {
return false;
}
defaultAsset(): ResolvedAssetSource {
return this.assetServerURL();
}
/**
* @returns absolute remote URL for the hosted asset.
*/
assetServerURL(): ResolvedAssetSource {
const fromUrl = new URL(getScaledAssetPath(this.asset), this.serverUrl);
fromUrl.searchParams.set('platform', Platform.OS);
fromUrl.searchParams.set('hash', this.asset.hash);
return this.fromSource(
// Relative on web
fromUrl.toString().replace(fromUrl.origin, '')
);
}
fromSource(source: string): ResolvedAssetSource {
return {
__packager_asset: true,
width: this.asset.width ?? undefined,
height: this.asset.height ?? undefined,
uri: source,
scale: AssetSourceResolver.pickScale(this.asset.scales, PixelRatio.get()),
};
}
static pickScale(scales: number[], deviceScale: number): number {
for (let i = 0; i < scales.length; i++) {
if (scales[i] >= deviceScale) {
return scales[i];
}
}
return scales[scales.length - 1] || 1;
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { PackagerAsset } from '@react-native/assets-registry/registry';
import { Platform } from 'expo-modules-core';
import { PixelRatio, NativeModules } from 'react-native';
import AssetSourceResolver from './AssetSourceResolver';
import { getManifest2, manifestBaseUrl } from './PlatformUtils';
// @docsMissing
export type AssetMetadata = Pick<
PackagerAsset,
'httpServerLocation' | 'name' | 'hash' | 'type' | 'scales' | 'width' | 'height'
> & {
uri?: string;
fileHashes?: string[];
fileUris?: string[];
};
export type AssetSource = {
uri: string;
hash: string;
};
/**
* Selects the best file for the given asset (ex: choosing the best scale for images) and returns
* a { uri, hash } pair for the specific asset file.
*
* If the asset isn't an image with multiple scales, the first file is selected.
*/
export function selectAssetSource(meta: AssetMetadata): AssetSource {
// This logic is based on that of AssetSourceResolver, with additional support for file hashes and
// explicitly provided URIs
const scale = AssetSourceResolver.pickScale(meta.scales, PixelRatio.get());
const index = meta.scales.findIndex((s) => s === scale);
const hash = meta.fileHashes ? (meta.fileHashes[index] ?? meta.fileHashes[0]) : meta.hash;
// Allow asset processors to directly provide the URL to load
const uri = meta.fileUris ? (meta.fileUris[index] ?? meta.fileUris[0]) : meta.uri;
if (uri) {
return { uri: resolveUri(uri), hash };
}
const fileScale = scale === 1 ? '' : `@${scale}x`;
const fileExtension = meta.type ? `.${encodeURIComponent(meta.type)}` : '';
const suffix = `/${encodeURIComponent(meta.name)}${fileScale}${fileExtension}`;
const params = new URLSearchParams({
platform: Platform.OS,
hash: meta.hash,
});
// For assets with a specified absolute URL, we use the existing origin instead of prepending the
// development server or production CDN URL origin
if (/^https?:\/\//.test(meta.httpServerLocation)) {
const uri = meta.httpServerLocation + suffix + '?' + params;
return { uri, hash };
}
// For assets during development using manifest2, we use the development server's URL origin
const manifest2 = getManifest2();
const devServerUrl = manifest2?.extra?.expoGo?.developer
? 'http://' + manifest2.extra.expoGo.debuggerHost
: null;
if (devServerUrl) {
const baseUrl = new URL(meta.httpServerLocation + suffix, devServerUrl);
baseUrl.searchParams.set('platform', Platform.OS);
baseUrl.searchParams.set('hash', meta.hash);
return {
uri: baseUrl.href,
hash,
};
}
// Temporary fallback for loading assets in Expo Go home
if (NativeModules['ExponentKernel']) {
return { uri: `https://classic-assets.eascdn.net/~assets/${encodeURIComponent(hash)}`, hash };
}
// In correctly configured apps, we arrive here if the asset is locally available on disk due to
// being managed by expo-updates, and `getLocalAssetUri(hash)` must return a local URI for this
// hash. Since the asset is local, we don't have a remote URL and specify an invalid URL (an empty
// string) as a placeholder.
return { uri: '', hash };
}
/**
* Resolves the given URI to an absolute URI. If the given URI is already an absolute URI, it is
* simply returned. Otherwise, if it is a relative URI, it is resolved relative to the manifest's
* base URI.
*/
export function resolveUri(uri: string): string {
// `manifestBaseUrl` is always an absolute URL or `null`.
return manifestBaseUrl ? new URL(uri, manifestBaseUrl).href : uri;
}
+63
View File
@@ -0,0 +1,63 @@
export function getFilename(url: string): string {
const { pathname, searchParams } = new URL(url, 'https://e');
// When attached to a dev server, we use `unstable_path` to represent the file path. This ensures
// the file name is not canonicalized by the browser.
// NOTE(EvanBacon): This is technically not tied to `__DEV__` as it's possible to use this while bundling in production
// mode.
if (__DEV__) {
if (searchParams.has('unstable_path')) {
const encodedFilePath = decodeURIComponent(searchParams.get('unstable_path')!);
return getBasename(encodedFilePath);
}
}
return getBasename(pathname);
}
function getBasename(pathname: string): string {
return pathname.substring(pathname.lastIndexOf('/') + 1);
}
export function getFileExtension(url: string): string {
const filename = getFilename(url);
const dotIndex = filename.lastIndexOf('.');
// Ignore leading dots for hidden files
return dotIndex > 0 ? filename.substring(dotIndex) : '';
}
/**
* Returns the base URL from a manifest's URL. For example, given a manifest hosted at
* https://example.com/app/manifest.json, the base URL would be https://example.com/app/. Query
* parameters and fragments also are removed.
*
* For an Expo-hosted project with a manifest hosted at https://exp.host/@user/project/index.exp, the
* base URL would be https://exp.host/@user/project.
*
* We also normalize the "exp" protocol to "http" to handle internal URLs with the Expo schemes used
* to tell the OS to open the URLs in the the Expo client.
*/
export function getManifestBaseUrl(manifestUrl: string): string {
const urlObject = new URL(manifestUrl);
let nextProtocol = urlObject.protocol;
// Change the scheme to http(s) if it is exp(s)
if (nextProtocol === 'exp:') {
nextProtocol = 'http:';
} else if (nextProtocol === 'exps:') {
nextProtocol = 'https:';
}
urlObject.protocol = nextProtocol;
// Trim filename, query parameters, and fragment, if any
const directory = urlObject.pathname.substring(0, urlObject.pathname.lastIndexOf('/') + 1);
urlObject.pathname = directory;
urlObject.search = '';
urlObject.hash = '';
// The URL spec doesn't allow for changing the protocol to `http` or `https`
// without a port set so instead, we'll just swap the protocol manually.
return urlObject.protocol !== nextProtocol
? urlObject.href.replace(urlObject.protocol, nextProtocol)
: urlObject.href;
}
+20
View File
@@ -0,0 +1,20 @@
import { requireNativeModule } from 'expo-modules-core';
const AssetModule = requireNativeModule('ExpoAsset');
/**
* Downloads the asset from the given URL to a local cache and returns the local URL of the cached
* file.
*
* If there is already a locally cached file and its MD5 hash matches the given `md5Hash` parameter,
* if present, the remote asset is not downloaded. The `hash` property is included in Metro's asset
* metadata objects when this module's `hashAssetFiles` plugin is used, which is the typical way the
* `md5Hash` parameter of this function is provided.
*/
export async function downloadAsync(
url: string,
md5Hash: string | null,
type: string
): Promise<string> {
return AssetModule.downloadAsync(url, md5Hash, type);
}
+7
View File
@@ -0,0 +1,7 @@
export async function downloadAsync(
url: string,
_hash: string | null,
_type: string
): Promise<string> {
return url;
}
+30
View File
@@ -0,0 +1,30 @@
/* eslint-env browser */
import { getFilename } from './AssetUris';
type ImageInfo = {
name: string;
width: number;
height: number;
};
export function isImageType(type: string): boolean {
return /^(jpeg|jpg|gif|png|bmp|webp|heic)$/i.test(type);
}
export function getImageInfoAsync(url: string): Promise<ImageInfo> {
if (typeof window === 'undefined') {
return Promise.resolve({ name: getFilename(url), width: 0, height: 0 });
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onerror = reject;
img.onload = () => {
resolve({
name: getFilename(url),
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.src = url;
});
}
+24
View File
@@ -0,0 +1,24 @@
import { getLocalAssets } from './PlatformUtils';
// localAssets are provided by the expo-updates module
const localAssets = getLocalAssets();
/**
* Returns the URI of a local asset from its hash, or null if the asset is not available locally
*/
export function getLocalAssetUri(hash: string, type: string | null): string | null {
const localAssetsKey = hash;
const legacyLocalAssetsKey = `${hash}.${type ?? ''}`;
switch (true) {
case localAssetsKey in localAssets: {
return localAssets[localAssetsKey];
}
case legacyLocalAssetsKey in localAssets: {
// legacy updates store assets with an extension
return localAssets[legacyLocalAssetsKey];
}
default:
return null;
}
}
+4
View File
@@ -0,0 +1,4 @@
export function getLocalAssetUri(hash: string, type: string | null): string | null {
// noop on web
return null;
}
+49
View File
@@ -0,0 +1,49 @@
import Constants from 'expo-constants';
import { requireNativeModule, requireOptionalNativeModule } from 'expo-modules-core';
// @ts-ignore -- optional interface, will gracefully degrade to `any` if not installed
import type { ExpoUpdatesModule } from 'expo-updates';
import { getManifestBaseUrl } from './AssetUris';
const ExpoUpdates = requireOptionalNativeModule<ExpoUpdatesModule>('ExpoUpdates');
const NativeExpoGoModule = (() => {
try {
return requireNativeModule('ExpoGo');
} catch {
return null;
}
})();
function isRunningInExpoGo(): boolean {
return NativeExpoGoModule != null;
}
// expo-updates (and Expo Go expo-updates override) manages assets from updates and exposes
// the ExpoUpdates.localAssets constant containing information about the assets.
const expoUpdatesIsInstalledAndEnabled = !!ExpoUpdates?.isEnabled;
const expoUpdatesIsUsingEmbeddedAssets = ExpoUpdates?.isUsingEmbeddedAssets;
// if expo-updates is installed but we're running directly from the embedded bundle, we don't want
// to override the AssetSourceResolver.
const shouldUseUpdatesAssetResolution =
expoUpdatesIsInstalledAndEnabled && !expoUpdatesIsUsingEmbeddedAssets;
// Expo Go always uses the updates module for asset resolution (local assets) since it
// overrides the expo-updates module.
export const IS_ENV_WITH_LOCAL_ASSETS = isRunningInExpoGo() || shouldUseUpdatesAssetResolution;
// Get the localAssets property from the ExpoUpdates native module so that we do
// not need to include expo-updates as a dependency of expo-asset
export function getLocalAssets(): Record<string, string> {
return ExpoUpdates?.localAssets ?? {};
}
export function getManifest2(): typeof Constants.__unsafeNoWarnManifest2 {
return Constants.__unsafeNoWarnManifest2;
}
// Compute manifest base URL if available
export const manifestBaseUrl = Constants.experienceUrl
? getManifestBaseUrl(Constants.experienceUrl)
: null;
+12
View File
@@ -0,0 +1,12 @@
export const IS_ENV_WITH_LOCAL_ASSETS = false;
export function getLocalAssets(): Record<string, string> {
return {};
}
export function getManifest2() {
return {};
}
// Compute manifest base URL if available
export const manifestBaseUrl = null;
+5
View File
@@ -0,0 +1,5 @@
export * from './Asset.server';
export function useAssets() {
return [[], undefined];
}
+4
View File
@@ -0,0 +1,4 @@
import './Asset.fx';
export * from './Asset';
export * from './AssetHooks';
@@ -0,0 +1,3 @@
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';
export default resolveAssetSource;
export * from 'react-native/Libraries/Image/resolveAssetSource';
+54
View File
@@ -0,0 +1,54 @@
import { getAssetByID } from '@react-native/assets-registry/registry';
import AssetSourceResolver, { ResolvedAssetSource } from './AssetSourceResolver';
let _customSourceTransformer: (resolver: AssetSourceResolver) => ResolvedAssetSource;
export function setCustomSourceTransformer(
transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource
): void {
_customSourceTransformer = transformer;
}
interface resolveAssetSource {
(source: any): ResolvedAssetSource | null;
setCustomSourceTransformer(
transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource
): ResolvedAssetSource;
}
/**
* `source` is either a number (opaque type returned by require('./foo.png'))
* or an `ImageSource` like { uri: '<http location || file path>' }
*/
function resolveAssetSource(source: any): ResolvedAssetSource | null {
if (typeof source === 'object') {
return source;
}
const asset = getAssetByID(source);
if (!asset) {
return null;
}
const resolver = new AssetSourceResolver(
// Doesn't matter since this is removed on web
'https://expo.dev',
null,
asset
);
if (_customSourceTransformer) {
return _customSourceTransformer(resolver);
}
return resolver.defaultAsset();
}
Object.defineProperty(resolveAssetSource, 'setCustomSourceTransformer', {
get() {
return setCustomSourceTransformer;
},
});
export default resolveAssetSource as resolveAssetSource;
export const { pickScale } = AssetSourceResolver;
@@ -0,0 +1,57 @@
declare module 'react-native/Libraries/Image/AssetSourceResolver' {
import { PackagerAsset } from '@react-native/assets/registry';
export type ResolvedAssetSource = {
__packager_asset: boolean;
width?: number | null;
height?: number | null;
uri: string;
scale: number;
};
export default class AssetSourceResolver {
serverUrl: string | null;
jsbundleUrl: string | null;
asset: PackagerAsset & { fileHashes?: string[] };
constructor(serverUrl: string | null, jsbundleUrl: string | null, asset: PackagerAsset);
isLoadedFromServer(): boolean;
isLoadedFromFileSystem(): boolean;
defaultAsset(): ResolvedAssetSource;
assetServerURL(): ResolvedAssetSource;
scaledAssetPath(): ResolvedAssetSource;
scaledAssetURLNearBundle(): ResolvedAssetSource;
resourceIdentifierWithoutScale(): ResolvedAssetSource;
drawableFolderInBundle(): ResolvedAssetSource;
fromSource(source: string): ResolvedAssetSource;
static pickScale(scales: number[], deviceScale: number): number;
}
}
declare module 'react-native/Libraries/Image/resolveAssetSource' {
import AssetSourceResolver, {
ResolvedAssetSource,
} from 'react-native/Libraries/Image/AssetSourceResolver';
export default function resolveAssetSource(source: any): ResolvedAssetSource | null;
export function setCustomSourceTransformer(
transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource
): void;
}
declare module '@react-native/assets-registry/registry' {
import type { PackagerAsset } from '@react-native/assets/registry';
export * from '@react-native/assets/registry';
// NOTE(@kitten): Custom override supported in Expo only
interface VirtualAssetModule {
uri: string;
width: number;
height: number;
}
export function getAssetByID(input: number | VirtualAssetModule): PackagerAsset | undefined;
}