expo-file-system
Version:
Provides access to the local file system on the device.
393 lines (366 loc) • 13.5 kB
text/typescript
import { uuid, type EventSubscription } from 'expo-modules-core';
import { Directory } from './Directory';
import ExpoFileSystem from './ExpoFileSystem';
import {
FileMode,
type PickFileOptions,
type PickMultipleFilesOptions,
type PickMultipleFilesResult,
type PickSingleFileOptions,
type PickSingleFileResult,
} from './File.types';
import {
type WatchEvent,
type WatchOptions,
type WatchSubscription,
} from './FileSystemWatcher.types';
import { DownloadTask, UploadTask } from './NetworkTasks';
import {
type DownloadOptions,
type DownloadProgress,
type DownloadTaskOptions,
type UploadOptions,
type UploadResult,
} from './NetworkTasks.types';
import { Paths } from './Paths';
import { FileSystemWatcher } from './internal/FileSystemWatcher';
import { FileSystemReadableStreamSource, FileSystemWritableSink } from './internal/streams';
/**
* Represents a file on the filesystem.
*
* A `File` instance can be created for any path, and does not need to exist on the filesystem during creation.
*
* The constructor accepts an array of strings that are joined to create the file URI. The first argument can also be a `Directory` instance (like `Paths.cache`) or a `File` instance (which creates a new reference to the same file).
* @example
* ```ts
* const file = new File(Paths.cache, "subdirName", "file.txt");
* ```
*/
export class File extends ExpoFileSystem.FileSystemFile implements Blob {
/**
* A static method that downloads a file from the network.
*
* On Android, the response body streams directly into the target file. If the download fails after
* it starts, a partially written file may remain at the destination. On iOS, the download first
* completes in a temporary location and the file is moved into place only after success, so no
* file is left behind when the request fails.
*
* @param url - The URL of the file to download.
* @param destination - The destination directory or file. If a directory is provided, the resulting filename will be determined based on the response headers.
* @param options - Download options. When the destination already contains a file, the promise rejects with a `DestinationAlreadyExists` error unless `options.idempotent` is set to `true`. With `idempotent: true`, the download overwrites the existing file instead of failing.
*
* @returns A promise that resolves to the downloaded file. When the server responds with
* a non-2xx HTTP status, the promise rejects with an `UnableToDownload` error whose
* message includes the status code. No file is created in that scenario.
*
* @example
* ```ts
* const file = await File.downloadFileAsync("https://example.com/image.png", new Directory(Paths.document));
* ```
*/
static downloadFileAsync: (
url: string,
destination: Directory | File,
options?: DownloadOptions
) => Promise<File>;
/**
* Opens the system file picker for selecting a single file.
*
* This overload requires `options.multipleFiles` to be `undefined` or `false`.
*
* @param options File picker options.
*/
static pickFileAsync(options?: PickSingleFileOptions): Promise<PickSingleFileResult>;
/**
* Opens the system file picker for selecting multiple files.
*
* This overload requires `options.multipleFiles` to be `true`.
*
* @param options File picker options.
*
* @example
* ```ts
* const result = await File.pickFileAsync({
* multipleFiles: true,
* mimeTypes: ['image/*', 'application/pdf'],
* });
*
* if (!result.canceled) {
* for (const file of result.result) {
* console.log(file.uri);
* }
* }
* ```
*/
static pickFileAsync(options?: PickMultipleFilesOptions): Promise<PickMultipleFilesResult>;
/**
* A static method that opens a file picker to select a single file of specified type. On iOS, it returns a temporary copy of the file leaving the original file untouched.
*
* Selecting multiple files is not supported yet.
*
* @deprecated Use `pickFileAsync({initialUri, mimeTypes: mimeType})` instead.
* @param initialUri An optional URI pointing to an initial folder on which the file picker is opened.
* @param mimeType A mime type that is used to filter out files that can be picked out.
* @returns A `File` instance or an array of `File` instances.
*/
static pickFileAsync(initialUri?: string, mimeType?: string): Promise<File | File[]>;
static async pickFileAsync(
initialUriOrOptions?: string | PickFileOptions,
mimeType?: string
): Promise<File | File[] | PickSingleFileResult | PickMultipleFilesResult> {
const { options, usingOldAPI } = parsePickFileOptions(initialUriOrOptions, mimeType);
try {
if (options.multipleFiles) {
const files = await ExpoFileSystem.pickFileAsync(options);
return { result: files.map((file) => new File(file.uri)), canceled: false };
}
const file = await ExpoFileSystem.pickFileAsync(options);
if (usingOldAPI) {
return new File(file.uri);
}
return {
result: new File(file.uri),
canceled: false,
};
} catch (e) {
if (usingOldAPI) {
throw e;
}
return {
result: null,
canceled: true,
};
}
}
/**
* Creates an instance of a file. It can be created for any path, and does not need to exist on the filesystem during creation.
*
* The constructor accepts an array of strings that are joined to create the file URI. The first argument can also be a `Directory` instance (like `Paths.cache`) or a `File` instance (which creates a new reference to the same file).
* @param uris An array of: `file:///` string URIs, `File` instances, and `Directory` instances representing an arbitrary location on the file system.
* @example
* ```ts
* const file = new File(Paths.cache, "subdirName", "file.txt");
* ```
*/
constructor(...uris: (string | File | Directory)[]) {
super(Paths.join(...uris));
this.validatePath();
}
/*
* Directory containing the file.
*/
get parentDirectory() {
return new Directory(Paths.dirname(this.uri));
}
/**
* File extension.
* @example '.png'
*/
get extension() {
return Paths.extname(this.uri);
}
/**
* File name. Includes the extension.
*/
get name() {
return Paths.basename(this.uri);
}
readableStream() {
return new ReadableStream(new FileSystemReadableStreamSource(super.open(FileMode.ReadOnly)));
}
writableStream() {
return new WritableStream<Uint8Array>(
new FileSystemWritableSink(super.open(FileMode.WriteOnly))
);
}
async arrayBuffer(): Promise<ArrayBuffer> {
const bytes = await this.bytes();
return bytes.buffer as ArrayBuffer;
}
async json(): Promise<any> {
return JSON.parse(await this.text());
}
async formData(): ReturnType<Response['formData']> {
return new Response(await this.arrayBuffer(), {
headers: this.type ? { 'Content-Type': this.type } : undefined,
}).formData();
}
stream(): ReadableStream<Uint8Array<ArrayBuffer>> {
return this.readableStream();
}
slice(start?: number, end?: number, contentType?: string): Blob {
return new Blob([this.bytesSync().slice(start, end)], { type: contentType });
}
/**
* Uploads this file to a server and starts the request immediately.
*
* The promise resolves with the HTTP response metadata and body for any completed response,
* including non-2xx status codes. It is rejected only when the file cannot be read, the
* request fails, or the upload is cancelled.
*
* @param url The URL to upload the file to.
* @param options Upload options.
* @returns A promise that resolves to the upload result.
*/
upload(url: string, options?: UploadOptions): Promise<UploadResult> {
return new UploadTask(this, url, options).uploadAsync();
}
/**
* Creates an upload task for this file without starting it.
*
* Call `uploadAsync()` on the returned task to start the upload. Use this when you need to
* inspect task state, cancel the upload, or subscribe to progress manually.
*
* @param url The URL to upload the file to.
* @param options Upload options.
* @returns An upload task that can be started with `uploadAsync()`.
*
* @example
* ```ts
* const file = new File(Paths.document, 'photo.jpg');
* const task = file.createUploadTask('https://example.com/upload', {
* uploadType: UploadType.MULTIPART,
* onProgress: ({ bytesSent, totalBytes }) => {
* console.log(`${bytesSent} / ${totalBytes}`);
* },
* });
*
* const result = await task.uploadAsync();
* ```
*/
createUploadTask(url: string, options?: UploadOptions): UploadTask {
return new UploadTask(this, url, options);
}
/**
* Creates a download task without starting it.
*
* Call `downloadAsync()` on the returned task to start the download. Use this when you need
* pause/resume support, task state, cancellation, or manual progress subscriptions.
*
* @param url The URL of the file to download.
* @param destination The destination file or directory. If a directory is provided, the
* resulting filename is determined from the response headers or URL.
* @param options Download task options.
* @returns A download task that can be started with `downloadAsync()`.
*
* @example
* ```ts
* const destination = new File(Paths.document, 'video.mp4');
* const task = File.createDownloadTask('https://example.com/video.mp4', destination, {
* onProgress: ({ bytesWritten, totalBytes }) => {
* console.log(`${bytesWritten} / ${totalBytes}`);
* },
* });
*
* const file = await task.downloadAsync();
* ```
*/
static createDownloadTask(
url: string,
destination: File | Directory,
options?: DownloadTaskOptions
): DownloadTask {
return new DownloadTask(url, destination, options);
}
/**
* Watches this file for changes on the filesystem.
*
* The watcher automatically stops when the file is deleted or renamed. To stop watching manually,
* call `remove()` on the returned subscription.
*
* @param callback Invoked when a change is detected. Receives a `WatchEvent` describing what changed.
* @param options Configuration for debouncing and filtering events.
* @return A subscription handle. Call `remove()` to stop watching.
*
* @example
* ```ts
* const file = new File(Paths.cache, 'data.json');
* const subscription = file.watch((event) => {
* console.log(`File ${event.type}`);
* });
*
* // Later, stop watching:
* subscription.remove();
* ```
*/
watch(callback: (event: WatchEvent<File>) => void, options?: WatchOptions): WatchSubscription {
return new FileSystemWatcher<File>(this.uri, callback, options, (uri) => new File(uri));
}
}
function createAbortError(reason?: string): Error {
const error = new Error(reason ?? 'The operation was aborted.');
error.name = 'AbortError';
return error;
}
// Cannot use `static` keyword in class declaration because of a runtime error.
File.downloadFileAsync = async function downloadFileAsync(
url: string,
to: File | Directory,
options?: DownloadOptions
) {
const needsUuid = options?.onProgress || options?.signal;
const downloadUuid = needsUuid ? uuid.v4() : undefined;
let subscription: EventSubscription | undefined;
let abortHandler: (() => void) | undefined;
let lastProgress: DownloadProgress | undefined;
try {
if (options?.signal?.aborted) {
throw createAbortError(options.signal.reason);
}
if (downloadUuid && options?.onProgress) {
subscription = ExpoFileSystem.addListener('downloadProgress', (event) => {
if (event.uuid === downloadUuid) {
lastProgress = event.data;
options.onProgress!(lastProgress);
}
});
}
if (downloadUuid && options?.signal) {
abortHandler = () => {
ExpoFileSystem.cancelDownloadAsync(downloadUuid);
};
options.signal.addEventListener('abort', abortHandler, { once: true });
}
const outputURI = await ExpoFileSystem.downloadFileAsync(url, to, options, downloadUuid);
const file = new File(outputURI);
const fileSize = file.size ?? 0;
if (
options?.onProgress &&
fileSize > 0 &&
(lastProgress?.bytesWritten !== fileSize || lastProgress?.totalBytes !== fileSize)
) {
options.onProgress({ bytesWritten: fileSize, totalBytes: fileSize });
}
return file;
} catch (error: any) {
if (options?.signal?.aborted) {
throw createAbortError(options.signal.reason);
}
throw error;
} finally {
subscription?.remove();
if (abortHandler && options?.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
}
};
/**
* Used to parse different APIs merged together.
* @hidden
*/
function parsePickFileOptions(
initialUriOrOptions?: string | PickFileOptions,
mimeType?: string
): { options: PickFileOptions; usingOldAPI: boolean } {
if (typeof initialUriOrOptions === 'object') {
return { options: initialUriOrOptions, usingOldAPI: false };
}
return {
options: {
initialUri: initialUriOrOptions,
mimeTypes: mimeType,
multipleFiles: false,
},
usingOldAPI: mimeType !== undefined || typeof initialUriOrOptions === 'string',
};
}