diff --git a/.editorconfig b/.editorconfig index 4d817a92..65365be6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,4 +13,3 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -max_line_length=120 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..030ef144 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.pbxproj -text +# specific for windows script files +*.bat text eol=crlf \ No newline at end of file diff --git a/.gitignore b/.gitignore index ac920977..49c3aaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,16 @@ -node_modules -workbench -*.log -# Xcode +# OSX +# .DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# build/ *.pbxuser !default.pbxuser @@ -12,25 +20,68 @@ build/ !default.mode2v3 *.perspectivev3 !default.perspectivev3 -*.xcworkspace -!default.xcworkspace xcuserdata -profile +*.xccheckout *.moved-aside DerivedData -.idea/ -# Pods - for those of you who use CocoaPods -Pods -update-test.sh -.vscode/ -android/.gradle/* -android/gradle/* -android/*.iml -android/local.properties -android/.settings -android/.project -Session.vim +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace +**/.xcode.env.local + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml +# Cocoapods +# +example/ios/Pods -# Bob +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +example/.yarn/* + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob lib/ + +# React Native Codegen +ios/generated +android/generated + +# React Native Nitro Modules +nitrogen/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5f53e875 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.19.0 diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/.watchmanconfig @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..45d257b2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..733562e3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,145 @@ +# Contributing + +Contributions are always welcome, no matter how large or small! + +We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. Before contributing, please read the [code of conduct](./CODE_OF_CONDUCT.md). + +## Development workflow + +This project is a monorepo managed using [Yarn workspaces](https://yarnpkg.com/features/workspaces). It contains the following packages: + +- The library package in the root directory. +- An example app in the `example/` directory. + +To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: + +```sh +yarn +``` + +> Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development. + +This project uses Nitro Modules. If you're not familiar with how Nitro works, make sure to check the [Nitro Modules Docs](https://nitro.margelo.com/). + +You need to run [Nitrogen](https://nitro.margelo.com/docs/nitrogen) to generate the boilerplate code required for this project. The example app will not build without this step. + +Run **Nitrogen** in following cases: + +- When you make changes to any `*.nitro.ts` files. +- When running the project for the first time (since the generated files are not committed to the repository). + +To invoke **Nitrogen**, use the following command: + +```sh +yarn nitrogen +``` + +The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. + +It is configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example app. Changes to the library's JavaScript code will be reflected in the example app without a rebuild, but native code changes will require a rebuild of the example app. + +If you want to use Android Studio or XCode to edit the native code, you can open the `example/android` or `example/ios` directories respectively in those editors. To edit the Objective-C or Swift files, open `example/ios/Fs2Example.xcworkspace` in XCode and find the source files at `Pods > Development Pods > react-native-fs2`. + +To edit the Java or Kotlin files, open `example/android` in Android studio and find the source files at `react-native-fs2` under `Android`. + +You can use various commands from the root directory to work with the project. + +To start the packager: + +```sh +yarn example start +``` + +To run the example app on Android: + +```sh +yarn example android +``` + +To run the example app on iOS: + +```sh +yarn example ios +``` + +To confirm that the app is running with the new architecture, you can check the Metro logs for a message like this: + +```sh +Running "Fs2Example" with {"fabric":true,"initialProps":{"concurrentRoot":true},"rootTag":1} +``` + +Note the `"fabric":true` and `"concurrentRoot":true` properties. + +Make sure your code passes TypeScript and ESLint. Run the following to verify: + +```sh +yarn typecheck +yarn lint +``` + +To fix formatting errors, run the following: + +```sh +yarn lint --fix +``` + +Remember to add tests for your change if possible. Run the unit tests by: + +```sh +yarn test +``` + +### Commit message convention + +We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: + +- `fix`: bug fixes, e.g. fix crash due to deprecated method. +- `feat`: new features, e.g. add new method to the module. +- `refactor`: code refactor, e.g. migrate from class components to hooks. +- `docs`: changes into documentation, e.g. add usage example for the module.. +- `test`: adding or updating tests, e.g. add integration tests using detox. +- `chore`: tooling changes, e.g. change CI config. + +Our pre-commit hooks verify that your commit message matches this format when committing. + +### Linting and tests + +[ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) + +We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. + +Our pre-commit hooks verify that the linter and tests pass when committing. + +### Publishing to npm + +We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. + +To publish new versions, run the following: + +```sh +yarn release +``` + +### Scripts + +The `package.json` file contains various scripts for common tasks: + +- `yarn`: setup project by installing dependencies. +- `yarn typecheck`: type-check files with TypeScript. +- `yarn lint`: lint files with ESLint. +- `yarn test`: run unit tests with Jest. +- `yarn example start`: start the Metro server for the example app. +- `yarn example android`: run the example app on Android. +- `yarn example ios`: run the example app on iOS. + +### Sending a pull request + +> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). + +When you're sending a pull request: + +- Prefer small pull requests focused on one change. +- Verify that linters and tests are passing. +- Review the documentation to make sure it looks good. +- Follow the pull request template when opening a pull request. +- For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..7aa421e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 Sourcetoad +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5bb73984..03104201 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # react-native-fs2 _A fork of [react-native-fs](https://github.com/itinance/react-native-fs) with a smaller footprint and fixes due to the upstream library seemingly being abandoned._ +**Now powered by [Nitro Modules](https://github.com/mrousavy/nitro)** for superior performance and type safety! 🚀 + ### Why the fork? This library intentional or not has become critical to the success of our mobile applications. We've noticed a few things that led to this fork: @@ -18,19 +20,42 @@ We debated a few paths, but we felt it best to fork the project and make some ma We will continue to support this library for as long as we use it. +## Features + +- 🚀 **High Performance**: Powered by Nitro Modules with direct JSI bindings +- 📁 **File System Operations**: Complete file system access (read, write, copy, move, etc.) +- 🌊 **File Streaming** (Beta): Efficiently handle large files with streaming API +- 📱 **MediaStore Support**: Android MediaStore integration for media files +- ⬇️ **Downloads**: Background downloads with progress tracking +- 🔒 **Type Safe**: Full TypeScript support with end-to-end type safety +- 🎯 **Cross Platform**: iOS and Android support + ### Installation ```bash npm i --save react-native-fs2 +# Peer dependency required +npm i --save react-native-nitro-modules ``` #### Supported React Native Versions | react-native-fs2 | react-native | |------------------|--------------| +| 4.x (nitro) | >=0.80 | | 3.0.x | >=0.69 | ### Changelog Changes can be found in [CHANGELOG.md](CHANGELOG.md) +### What's New in 4.x + +- **Nitro Modules Architecture**: Complete rewrite using Nitro Modules for superior performance +- **File Streaming API**: New streaming capabilities for large file operations (see [FILE_STREAM.md](./docs/FILE_STREAM.md)) +- **Better Type Safety**: End-to-end type safety from TypeScript to native code +- **ArrayBuffer Built-in**: Native ArrayBuffer support without additional dependencies +- **Backward Compatible API**: Most existing code works without changes! + +> **Note**: v4.x requires `react-native-nitro-modules` as a peer dependency. See migration notes below. + ## Usage ```ts import RNFS from 'react-native-fs2'; @@ -114,15 +139,15 @@ await RNFS.completeHandler('JobID') // readDir(dirPath: string): Promise const dirItems = await RNFS.readDir('DirPath') ``` -* Retuns an `array` of `ReadDirItem` which are items that are present in the directory +* Returns an `array` of `ReadDirItem` which are items that are present in the directory * `ReadDirItem` - * ctime: `Date | undefined` -> The creation date of the file (iOS only) - * mtime: `Date | undefined` -> The last modified date of the file + * ctime: `number | undefined` -> The creation timestamp in milliseconds (iOS only) + * mtime: `number` -> The last modified timestamp in milliseconds * name: `string` -> The name of the item * path: `string` -> The absolute path to the item * size: `number` -> Size in bytes - * isFile: () => `boolean` -> Is the file just a file? - * isDirectory: () => `boolean` -> Is the file a directory? + * isFile: `boolean` -> Is the item a file? + * isDirectory: `boolean` -> Is the item a directory? ### `readFile` @@ -134,8 +159,7 @@ const fileData = await RNFS.readFile('DirPath', 'utf8') * Optionally includes `EncodingOrOptions` with values: * `'utf8'` (default) | `'base64'` (for binary files) | `'ascii'` | `'arraybuffer'` * ...fileoptions -* Note: `arraybuffer` requires [react-native-blob-jsi-helper](https://github.com/mrousavy/react-native-blob-jsi-helper) - * `npm i react-native-blob-jsi-helper` or `yarn add react-native-blob-jsi-helper` +* Note: `arraybuffer` support is built-in via Nitro Modules (no additional dependencies required) ### `read` ```ts @@ -202,13 +226,13 @@ await RNFS.write('FileToWrite', 'ContentsToWrite', -1, 'utf8') // stat(filepath: string): Promise const fileStats = await RNFS.stat('FilePath') ``` -* Retuns an `array` of `StatResult` which are `statistics` of the `file` +* Returns a `StatResult` object with statistics of the file * `StatResult` * path: `string` -> The same as filepath argument - * ctime: `date` -> The creation date of the file - * mtime: `date` -> The last modified date of the file + * ctime: `number` -> The creation timestamp in milliseconds + * mtime: `number` -> The last modified timestamp in milliseconds * size: `number` -> Size in bytes - * mode: `number` -> UNIX file mode + * mode: `number` -> UNIX file mode (iOS only) * originalFilepath: `string` -> Android: In case of content uri this is the pointed file path, otherwise is the same as path * isFile: () => `boolean` -> Is the file just a file? * isDirectory: () => `boolean` -> Is the file a directory? @@ -262,7 +286,7 @@ if (await RNFS.isResumable('JobID')) { RNFS.resumeDownload('JobID') } ``` -* Check if the download job with this ID is resumable. +* Check if the the download job with this ID is resumable. ### `touch` ```ts @@ -278,13 +302,75 @@ await RNFS.scanFile('FilePath', Date, Date) ``` * Scan the file using [Media Scanner](https://developer.android.com/reference/android/media/MediaScannerConnection). ----- +# File Streaming API (Beta) + +React-native-fs2 now provides powerful file streaming capabilities for efficiently reading and writing large files without loading entire content into memory. + +### `createReadStream` +```ts +import { createReadStream, listenToReadStreamData, listenToReadStreamProgress, listenToReadStreamEnd } from 'react-native-fs2'; + +const stream = await createReadStream('/path/to/large-file.dat', { + bufferSize: 8192 // 8KB chunks +}); + +// Listen for data chunks +const unsubData = listenToReadStreamData(stream.streamId, (event) => { + console.log(`Chunk ${event.chunk}: ${event.data.byteLength} bytes`); +}); + +// Listen for progress +const unsubProgress = listenToReadStreamProgress(stream.streamId, (event) => { + console.log(`Progress: ${event.progress * 100}%`); +}); + +// Listen for completion +const unsubEnd = listenToReadStreamEnd(stream.streamId, (event) => { + console.log('Stream finished'); + unsubData(); + unsubProgress(); + unsubEnd(); +}); + +await stream.start(); +``` + +### `createWriteStream` +```ts +import { createWriteStream, listenToWriteStreamProgress, listenToWriteStreamFinish } from 'react-native-fs2'; + +const stream = await createWriteStream('/path/to/output-file.dat', { + append: false, + createDirectories: true +}); + +// Listen for progress +const unsubProgress = listenToWriteStreamProgress(stream.streamId, (event) => { + console.log(`Written: ${event.bytesWritten} bytes`); +}); + +// Listen for completion +const unsubFinish = listenToWriteStreamFinish(stream.streamId, (event) => { + console.log('Write completed:', event.bytesWritten, 'bytes'); + unsubProgress(); + unsubFinish(); +}); + +// Write data in chunks +await stream.write(chunk1); +await stream.write(chunk2); +await stream.close(); +``` + +**For complete streaming API documentation, see [FILE_STREAM.md](./docs/FILE_STREAM.md)** # MediaStore -_RNFS2 can now interact with the MediaStore on Android. This allows you to add, delete, and update media files in the MediaStore._ -* Inspiration for this feature came from [react-native-blob-util](https://github.com/RonRadtke/react-native-blob-util/wiki/MediaStore/) -* This feature is only available on Android targeting API 29 or higher. And may require the following permissions: +### RNFS2 can now interact with the MediaStore on Android. This allows you to add, delete, and update media files in the MediaStore. + +### Inspiration for this feature came from [react-native-blob-util](https://github.com/RonRadtke/react-native-blob-util/wiki/MediaStore/) + +### This feature is only available on Android targeting API 29 or higher. And may require the following permissions: ```xml @@ -308,11 +394,11 @@ _RNFS2 can now interact with the MediaStore on Android. This allows you to add, * Creates a new media file in the MediaStore with the given `mimeType`. This will not create a file on the filesystem, but will create a reference in the MediaStore. ```ts -// createMediaFile(fileDescriptor: FileDescriptor, mediatype: MediaCollections): Promise +// createMediaFile(fileDescription: FileDescription, mediatype: MediaCollections): Promise -const fileDescriptor = { name: 'sample', parentFolder: 'MyAppFolder', mimeType: 'image/png' } +const fileDescription = { name: 'sample', parentFolder: 'MyAppFolder', mimeType: 'image/png' } -const contentURI = await RNFS.MediaStore.createMediaFile(fileDescriptor, RNFS.MediaStore.MEDIA_IMAGE) +const contentURI = await RNFS.MediaStore.createMediaFile(fileDescription, RNFS.MediaStore.MEDIA_IMAGE) ``` ### `updateMediaFile` @@ -320,12 +406,12 @@ const contentURI = await RNFS.MediaStore.createMediaFile(fileDescriptor, RNFS.M * Updates the media file in the MediaStore ```ts -// updateMediaFile(uri: string, fileDescriptor: FileDescriptor, mediatype: MediaCollections): Promise +// updateMediaFile(uri: string, fileDescription: FileDescription, mediatype: MediaCollections): Promise const contentURI = 'content://media/external/images/media/123' -const fileDescriptor = { name: 'sample-updated-filename', parentFolder: 'MyAppFolder', mimeType: 'image/png' } +const fileDescription = { name: 'sample-updated-filename', parentFolder: 'MyAppFolder', mimeType: 'image/png' } -const contentURI = await RNFS.MediaStore.updateMediaFile(contentURI, fileDescriptor, RNFS.MediaStore.MEDIA_IMAGE) +const contentURI = await RNFS.MediaStore.updateMediaFile(contentURI, fileDescription, RNFS.MediaStore.MEDIA_IMAGE) ``` ### `writeToMediaFile` @@ -343,11 +429,11 @@ await RNFS.MediaStore.writeToMediaFile('content://media/external/images/media/12 * Copies the file at `filepath` to the MediaStore with the given `mimeType`. ```ts -// copyToMediaStore(fileDescriptor: filedescriptor, mediatype: MediaCollections, path: string): Promise +// copyToMediaStore(fileDescription: FileDescription, mediatype: MediaCollections, path: string): Promise -const fileDescriptor = { name: 'sample', parentFolder: 'MyAppFolder', mimeType: 'image/png' } +const fileDescription = { name: 'sample', parentFolder: 'MyAppFolder', mimeType: 'image/png' } -const contentURI = await RNFS.MediaStore.copyToMediaStore(fileDescriptor, RNFS.MediaStore.MEDIA_IMAGE, '/path/to/image/imageToCopy.png') +const contentURI = await RNFS.MediaStore.copyToMediaStore(fileDescription, RNFS.MediaStore.MEDIA_IMAGE, '/path/to/image/imageToCopy.png') ``` ### `queryMediaStore` @@ -355,22 +441,25 @@ const contentURI = await RNFS.MediaStore.copyToMediaStore(fileDescriptor, RNFS. * Queries the MediaStore for media files with the given `searchOptions`. ```ts -// queryMediaStore(searchOptions: MediaStoreSearchOptions): Promise +// queryMediaStore(searchOptions: MediaStoreSearchOptions): Promise -await RNFS.MediaStore.queryMediaStore({ +// Query by URI +const result = await RNFS.MediaStore.queryMediaStore({ uri: 'content://media/external/images/media/123', - fileName: '' - relativePath: '' - mediaType: RNFS.MediaStore.MEDIA_IMAGE; + mediaType: RNFS.MediaStore.MEDIA_IMAGE }) -// or -await RNFS.MediaStore.queryMediaStore({ - uri: '', - fileName: 'image.png' - relativePath: 'MyAppFolder' - mediaType: RNFS.MediaStore.MEDIA_IMAGE; +// or query by filename and path +const result = await RNFS.MediaStore.queryMediaStore({ + fileName: 'image.png', + relativePath: 'MyAppFolder', + mediaType: RNFS.MediaStore.MEDIA_IMAGE }) + +// result will be MediaStoreFile or undefined if not found +if (result) { + console.log(result.uri, result.name, result.size) +} ``` ### `deleteFromMediaStore` @@ -383,9 +472,9 @@ await RNFS.MediaStore.queryMediaStore({ await RNFS.MediaStore.deleteFromMediaStore('content://media/external/images/media/123') ``` -## FileDescriptor +## FileDescription ```ts -type FileDescriptor = { +type FileDescription = { name: string; parentFolder: string; mimeType: string @@ -395,17 +484,23 @@ type FileDescriptor = { ## MediaStoreSearchOptions ```ts type MediaStoreSearchOptions = { - uri: string; - fileName: string; - relativePath: string; + uri?: string; + fileName?: string; + relativePath?: string; mediaType: MediaCollections }; ``` -## MediaStoreQueryResult +## MediaStoreFile ```ts -type MediaStoreQueryResult = { - contentUri: string; +type MediaStoreFile = { + uri: string; + name: string; + mimeType: string; + size: number; + dateAdded?: bigint; + dateModified?: bigint; + relativePath?: string; }; ``` @@ -433,3 +528,82 @@ type MediaStoreQueryResult = { #### iOS * `LibraryDirectoryPath` - Absolute path to [NSLibraryDirectory](https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nslibrarydirectory) * `MainBundlePath` - Absolute path to main bundle directory. + +## Migrating from v3.x to v4.x + +The v4.x release brings significant improvements with minimal breaking changes. Most apps can upgrade with little to no code changes! + +### Installation + +1. **Install peer dependency:** +```bash +npm install react-native-nitro-modules +# or +yarn add react-native-nitro-modules +``` + +2. **Update react-native-fs2:** +```bash +npm install react-native-fs2@latest +# or +yarn add react-native-fs2@latest +``` + +### Breaking Changes + +#### Timestamps are now numbers +The only significant breaking change is that timestamps are now returned as numbers (milliseconds since epoch) instead of Date objects: + +```typescript +// v3.x +const items = await RNFS.readDir(path); +const date = items[0].mtime; // Date object + +// v4.x +const items = await RNFS.readDir(path); +const timestamp = items[0].mtime; // number +const date = new Date(items[0].mtime); // Convert to Date if needed +``` + +This affects: +- `readDir()` - `ctime` and `mtime` fields +- `stat()` - `ctime` and `mtime` fields + +### What Still Works + +✅ **All core file operations** - No changes required: +```typescript +await RNFS.readFile(path, 'utf8'); +await RNFS.writeFile(path, content, 'utf8'); +await RNFS.copyFile(src, dest); +await RNFS.moveFile(src, dest); +await RNFS.unlink(path); +// ... all other operations work the same! +``` + +✅ **Download API** - Backward compatible: +```typescript +const { jobId, promise } = RNFS.downloadFile({ + fromUrl: url, + toFile: path, + begin: (res) => { }, + progress: (res) => { } +}); +``` + +✅ **MediaStore** (Android) - Works the same + +### New Features to Explore + +Once migrated, you can optionally explore: + +- **File Streaming API**: For efficient large file operations (see [FILE_STREAM.md](./docs/FILE_STREAM.md)) +- **Better Performance**: Automatic via Nitro Modules architecture +- **Enhanced Type Safety**: Full TypeScript support throughout + +### Need Help? + +If you encounter issues during migration: +1. Check the [CHANGELOG.md](./CHANGELOG.md) for detailed changes +2. Review [FILE_STREAM.md](./docs/FILE_STREAM.md) for streaming API +3. Open an issue on [GitHub](https://github.com/sourcetoad/react-native-fs2/issues) diff --git a/RNFS2.podspec b/RNFS2.podspec index 73736699..ce1955c6 100644 --- a/RNFS2.podspec +++ b/RNFS2.podspec @@ -1,24 +1,33 @@ -require 'json' -pjson = JSON.parse(File.read('package.json')) +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) Pod::Spec.new do |s| - s.name = "RNFS2" - s.version = pjson["version"] - s.homepage = "https://github.com/sourcetoad/react-native-fs2" - s.summary = pjson["description"] - s.license = pjson["license"] + s.name = "RNFS2" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] s.authors = { "Johannes Lumpe" => "johannes@lum.pe", "Hagen Hübel" => "hhuebel@itinance.com", "Connor Tumbleson" => "connor@sourcetoad.com" } - s.ios.deployment_target = '12.4' - - s.source = { :git => "https://github.com/sourcetoad/react-native-fs2", :tag => "v#{s.version}" } + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/sourcetoad/react-native-fs2.git", :tag => "v#{s.version}" } s.resource_bundles = { 'RNFS_PrivacyInfo' => 'ios/PrivacyInfo.xcprivacy' } - s.source_files = "ios/*.{h,m}" - s.preserve_paths = "src/*.{js,ts}" + s.source_files = [ + "ios/**/*.{swift}", + "ios/**/*.{m,mm}", + "cpp/**/*.{hpp,cpp}", + ] + + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + + load 'nitrogen/generated/ios/RNFS2+autolinking.rb' + add_nitrogen_files(s) - s.dependency 'React-Core' + install_modules_dependencies(s) end diff --git a/RNFS2.xcodeproj/project.pbxproj b/RNFS2.xcodeproj/project.pbxproj deleted file mode 100644 index e5be34de..00000000 --- a/RNFS2.xcodeproj/project.pbxproj +++ /dev/null @@ -1,271 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - 8BF740771C033A2E0057A1E7 /* Downloader.m in Sources */ = {isa = PBXBuildFile; fileRef = 8BF740761C033A2E0057A1E7 /* Downloader.m */; }; - F1E59BDF1ADD662800ACA28A /* RNFSManager.m in Sources */ = {isa = PBXBuildFile; fileRef = F1E59BDE1ADD662800ACA28A /* RNFSManager.m */; }; - F1EB08BB1AFD0E6A008F8F2B /* NSArray+Map.m in Sources */ = {isa = PBXBuildFile; fileRef = F1EB08BA1AFD0E6A008F8F2B /* NSArray+Map.m */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - F12AFB991ADAF8F800E0535D /* CopyFiles */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "include/$(PRODUCT_NAME)"; - dstSubfolderSpec = 16; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 8BF740751C033A2E0057A1E7 /* Downloader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Downloader.h; path = ios/Downloader.h; sourceTree = ""; }; - 8BF740761C033A2E0057A1E7 /* Downloader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Downloader.m; path = ios/Downloader.m; sourceTree = ""; }; - AB23E8B62BEEA450005CB009 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ios/PrivacyInfo.xcprivacy; sourceTree = ""; }; - F12AFB9B1ADAF8F800E0535D /* libRNFS.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; name = libRNFS.a; path = libRNFS2.a; sourceTree = BUILT_PRODUCTS_DIR; }; - F1E59BDD1ADD662800ACA28A /* RNFSManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNFSManager.h; path = ios/RNFSManager.h; sourceTree = ""; }; - F1E59BDE1ADD662800ACA28A /* RNFSManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RNFSManager.m; path = ios/RNFSManager.m; sourceTree = ""; }; - F1EB08B91AFD0E6A008F8F2B /* NSArray+Map.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "NSArray+Map.h"; path = "ios/NSArray+Map.h"; sourceTree = ""; }; - F1EB08BA1AFD0E6A008F8F2B /* NSArray+Map.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "NSArray+Map.m"; path = "ios/NSArray+Map.m"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - F12AFB981ADAF8F800E0535D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - F12AFB921ADAF8F800E0535D = { - isa = PBXGroup; - children = ( - AB23E8B62BEEA450005CB009 /* PrivacyInfo.xcprivacy */, - F1EB08B91AFD0E6A008F8F2B /* NSArray+Map.h */, - F1EB08BA1AFD0E6A008F8F2B /* NSArray+Map.m */, - F1E59BDD1ADD662800ACA28A /* RNFSManager.h */, - F1E59BDE1ADD662800ACA28A /* RNFSManager.m */, - 8BF740751C033A2E0057A1E7 /* Downloader.h */, - 8BF740761C033A2E0057A1E7 /* Downloader.m */, - F12AFB9C1ADAF8F800E0535D /* Products */, - ); - sourceTree = ""; - }; - F12AFB9C1ADAF8F800E0535D /* Products */ = { - isa = PBXGroup; - children = ( - F12AFB9B1ADAF8F800E0535D /* libRNFS.a */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - F12AFB9A1ADAF8F800E0535D /* RNFS2 */ = { - isa = PBXNativeTarget; - buildConfigurationList = F12AFBAF1ADAF8F800E0535D /* Build configuration list for PBXNativeTarget "RNFS2" */; - buildPhases = ( - F12AFB971ADAF8F800E0535D /* Sources */, - F12AFB981ADAF8F800E0535D /* Frameworks */, - F12AFB991ADAF8F800E0535D /* CopyFiles */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = RNFS2; - productName = RNLocalNotification; - productReference = F12AFB9B1ADAF8F800E0535D /* libRNFS.a */; - productType = "com.apple.product-type.library.static"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - F12AFB931ADAF8F800E0535D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 0630; - ORGANIZATIONNAME = "Johannes Lumpe"; - TargetAttributes = { - F12AFB9A1ADAF8F800E0535D = { - CreatedOnToolsVersion = 6.3; - }; - }; - }; - buildConfigurationList = F12AFB961ADAF8F800E0535D /* Build configuration list for PBXProject "RNFS2" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 0; - knownRegions = ( - English, - en, - ); - mainGroup = F12AFB921ADAF8F800E0535D; - productRefGroup = F12AFB9C1ADAF8F800E0535D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - F12AFB9A1ADAF8F800E0535D /* RNFS2 */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - F12AFB971ADAF8F800E0535D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F1E59BDF1ADD662800ACA28A /* RNFSManager.m in Sources */, - F1EB08BB1AFD0E6A008F8F2B /* NSArray+Map.m in Sources */, - 8BF740771C033A2E0057A1E7 /* Downloader.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - F12AFBAD1ADAF8F800E0535D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_SYMBOLS_PRIVATE_EXTERN = NO; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - }; - name = Debug; - }; - F12AFBAE1ADAF8F800E0535D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - F12AFBB01ADAF8F800E0535D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - HEADER_SEARCH_PATHS = ( - "$(inherited)", - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, - "$(SRCROOT)/../../React/**", - "$(SRCROOT)/../react-native/React/**", - "$(SRCROOT)/node_modules/react-native/React/**", - ); - OTHER_LDFLAGS = "-ObjC"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - }; - name = Debug; - }; - F12AFBB11ADAF8F800E0535D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - HEADER_SEARCH_PATHS = ( - "$(inherited)", - /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, - "$(SRCROOT)/../../React/**", - "$(SRCROOT)/../react-native/React/**", - "$(SRCROOT)/node_modules/react-native/React/**", - ); - OTHER_LDFLAGS = "-ObjC"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - F12AFB961ADAF8F800E0535D /* Build configuration list for PBXProject "RNFS2" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F12AFBAD1ADAF8F800E0535D /* Debug */, - F12AFBAE1ADAF8F800E0535D /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - F12AFBAF1ADAF8F800E0535D /* Build configuration list for PBXNativeTarget "RNFS2" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F12AFBB01ADAF8F800E0535D /* Debug */, - F12AFBB11ADAF8F800E0535D /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = F12AFB931ADAF8F800E0535D /* Project object */; -} diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 00000000..aa2b15e3 --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,24 @@ +project(fs2) +cmake_minimum_required(VERSION 3.9.0) + +set(PACKAGE_NAME fs2) +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 20) + +# Define C++ library and add all sources +add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp) + +# Add Nitrogen specs :) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/fs2+autolinking.cmake) + +# Set up local includes +include_directories("src/main/cpp" "../cpp") + +find_library(LOG_LIB log) + +# Link all libraries together +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android # <-- Android core +) diff --git a/android/build.gradle b/android/build.gradle index 8bda82dd..9d1ef1ac 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,37 +1,129 @@ -def safeExtGet(prop, fallback) { - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['Fs2_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } } -buildscript { - // Only need dependencies when building in isolation. As a library we can assume repos from parent. - if (project == rootProject) { - repositories { - google() - mavenCentral() +def reactNativeArchitectures() { + def value = rootProject.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply from: '../nitrogen/generated/android/fs2+autolinking.gradle' + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["Fs2_" + name]).toInteger() +} + +android { + namespace "com.margelo.nitro.fs2" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared" + abiFilters (*reactNativeArchitectures()) + + buildTypes { + debug { + cppFlags "-O1 -g" + } + release { + cppFlags "-O2" + } + } + } } - dependencies { - classpath("com.android.tools.build:gradle:7.2.1") + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" } } -} -apply plugin: 'com.android.library' + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnative.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] + } -android { - compileSdkVersion safeExtGet('compileSdkVersion', 26) - buildToolsVersion safeExtGet('buildToolsVersion', '26.0.3') - - defaultConfig { - minSdkVersion safeExtGet('minSdkVersion', 19) - targetSdkVersion safeExtGet('targetSdkVersion', 26) - versionCode 1 - versionName "1.0" + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false } - lintOptions { - abortOnError false + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] } + } } +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + dependencies { - implementation 'com.facebook.react:react-native:+' + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation project(":react-native-nitro-modules") } + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..a419f72c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +Fs2_kotlinVersion=2.0.21 +Fs2_minSdkVersion=24 +Fs2_targetSdkVersion=34 +Fs2_compileSdkVersion=35 +Fs2_ndkVersion=27.1.12297006 diff --git a/android/gradlew b/android/gradlew deleted file mode 100755 index 1b6c7873..00000000 --- a/android/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat deleted file mode 100644 index 107acd32..00000000 --- a/android/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 66b50c2f..a2f47b60 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,2 @@ - - - + diff --git a/android/src/main/cpp/cpp-adapter.cpp b/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 00000000..5d64b869 --- /dev/null +++ b/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,6 @@ +#include +#include "fs2OnLoad.hpp" + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + return margelo::nitro::fs2::initialize(vm); +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/DownloadParams.kt b/android/src/main/java/com/margelo/nitro/fs2/DownloadParams.kt new file mode 100644 index 00000000..3edb463a --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/DownloadParams.kt @@ -0,0 +1,25 @@ +package com.margelo.nitro.fs2 + +import com.facebook.react.bridge.ReadableMap +import java.io.File +import java.net.URL + +class DownloadParams { + var src: URL? = null + var dest: File? = null + var headers: ReadableMap? = null + var progressInterval = 0 + var progressDivider = 0f + var readTimeout = 0 + var connectionTimeout = 0 + + // These will be populated by the callbacks received in the Fs2.downloadFile spec method + var onDownloadBegin: ((event: DownloadEventResult) -> Unit)? = null + var onDownloadProgress: ((event: DownloadEventResult) -> Unit)? = null + var onDownloadComplete: ((result: DownloadEventResult) -> Unit)? = null + var onDownloadError: ((event: DownloadEventResult) -> Unit)? = null + + var jobId: Int = 0 + var onCleanup: ((jobId: Int) -> Unit)? = + null // Callback for Fs2 to clean up the downloader instance +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/Downloader.kt b/android/src/main/java/com/margelo/nitro/fs2/Downloader.kt new file mode 100644 index 00000000..bf34deca --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/Downloader.kt @@ -0,0 +1,299 @@ +package com.margelo.nitro.fs2 + +import android.os.Build +import android.util.Log +import com.margelo.nitro.core.AnyMap +import java.io.BufferedInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import kotlin.math.roundToInt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +// AsyncTask has been replaced with Kotlin Coroutines. +class Downloader { + private val _job = SupervisorJob() // Parent job for this Downloader instance's scope + private val scope = CoroutineScope(Dispatchers.IO + _job) // Scope for this Downloader + + private var mParam: DownloadParams? = null + private var jobId: Int = 0 + private var activeDownloadJob: Job? = null // The job for the currently active download + + // Private data class to hold the outcome of the internal download operation + private data class DownloadOperationOutcome(val statusCode: Double, val bytesWritten: Double) + + fun start(params: DownloadParams) { + mParam = params + jobId = params.jobId // Assuming jobId is part of DownloadParams + + activeDownloadJob = scope.launch { + val currentParams = mParam ?: return@launch + + try { + Log.d( + "Downloader", + "Starting download for job $jobId on thread ${Thread.currentThread().name}" + ) + ensureActive() // Check if coroutine is active before starting download logic + + // The download function now returns an outcome with statusCode and bytesWritten + val outcome = download(currentParams) + + ensureActive() // Check if active before invoking completion callback + + // Construct the final DownloadResult for the callback + val completeResult = DownloadEventResult( + jobId = jobId.toDouble(), + headers = null, + contentLength = null, + statusCode = outcome.statusCode, + bytesWritten = outcome.bytesWritten, + error = null + ) + currentParams.onDownloadComplete?.invoke(completeResult) + Log.d( + "Downloader", + "Download $jobId completed successfully with status ${outcome.statusCode.toInt()}." + ) + + } catch (e: CancellationException) { + Log.i("Downloader", "Download $jobId cancelled: ${e.message}") + val errorEvent = DownloadEventResult( + jobId = jobId.toDouble(), + headers = null, + contentLength = null, + statusCode = null, + bytesWritten = null, + error = e.message ?: "Download cancelled" + ) + currentParams.onDownloadError?.invoke(errorEvent) + } catch (ex: Exception) { + Log.e("Downloader", "Download $jobId error: ${ex.message}", ex) + val errorEvent = DownloadEventResult( + jobId = jobId.toDouble(), + headers = null, + contentLength = null, + statusCode = null, + bytesWritten = null, + error = ex.message ?: "Unknown download error" + ) + currentParams.onDownloadError?.invoke(errorEvent) + } finally { + Log.d("Downloader", "Cleaning up for job $jobId via onCleanup callback.") + currentParams.onCleanup?.invoke(jobId) + } + } + } + + @Throws(Exception::class) + private suspend fun download(param: DownloadParams): DownloadOperationOutcome { + // The actual blocking operations will be wrapped in withContext(Dispatchers.IO) + return withContext(Dispatchers.IO) { + var input: InputStream? = null + var output: FileOutputStream? = null + var connection: HttpURLConnection? = null + + // These variables will determine the outcome returned by this withContext block + var outcomeStatusCode: Double = 0.0 + var outcomeBytesWritten: Double = 0.0 + + try { + coroutineContext.ensureActive() // Still uses the outer coroutine's context for cancellation + + connection = + param.src?.openConnection() as? HttpURLConnection + ?: throw Exception("Could not open connection to ${param.src}") + + param.headers?.let { headers -> + val iterator = headers.keySetIterator() + while (iterator.hasNextKey()) { + val key = iterator.nextKey() + connection!!.setRequestProperty(key, headers.getString(key)) + } + } + + connection.connectTimeout = param.connectionTimeout + connection.readTimeout = param.readTimeout + Log.d("Downloader", "Job $jobId: Connecting to ${param.src}") + connection.connect() + coroutineContext.ensureActive() + + val httpStatusCode = connection.responseCode + var lengthOfFile = getContentLength(connection) + Log.d( + "Downloader", + "Job $jobId: Initial status code $httpStatusCode, length $lengthOfFile" + ) + + var currentHttpCode = httpStatusCode + var currentConnection = connection // Use this for operations within the try block + + val isRedirect = + currentHttpCode != HttpURLConnection.HTTP_OK && + (currentHttpCode == HttpURLConnection.HTTP_MOVED_PERM || + currentHttpCode == HttpURLConnection.HTTP_MOVED_TEMP || + currentHttpCode == 307 || + currentHttpCode == 308) + + if (isRedirect) { + val redirectURL = currentConnection.getHeaderField("Location") + Log.d("Downloader", "Job $jobId: Redirecting to $redirectURL") + currentConnection.disconnect() // Disconnect the connection that redirected + + coroutineContext.ensureActive() + // Assign to the outer 'connection' variable for the finally block to correctly disconnect it + connection = URL(redirectURL).openConnection() as? HttpURLConnection + ?: throw Exception("Could not open redirected connection to $redirectURL") + connection.connectTimeout = param.connectionTimeout + connection.readTimeout = param.readTimeout + connection.connect() + coroutineContext.ensureActive() + + currentConnection = connection // Update currentConnection to the new connection + currentHttpCode = currentConnection.responseCode + lengthOfFile = getContentLength(currentConnection) + Log.d( + "Downloader", + "Job $jobId: Redirected status code $currentHttpCode, length $lengthOfFile" + ) + } + + outcomeStatusCode = currentHttpCode.toDouble() + + if (currentHttpCode in 200..299) { + Log.d("Downloader", "Job $jobId: Download starting (status $currentHttpCode).") + val responseHeaders = mutableMapOf() + currentConnection.headerFields.forEach { (key, values) -> + if (key != null && values.isNotEmpty()) { + responseHeaders[key] = values[0] + } + } + + val tempHeaderMap = AnyMap() + responseHeaders.forEach { (key, value) -> + tempHeaderMap.setString(key, value) + } + + val beginEvent = DownloadEventResult( + jobId = jobId.toDouble(), + headers = tempHeaderMap, + contentLength = lengthOfFile.toDouble(), + statusCode = null, + bytesWritten = null, + error = null + ) + param.onDownloadBegin?.invoke(beginEvent) + + input = BufferedInputStream(currentConnection.inputStream, 8 * 1024) + output = FileOutputStream(param.dest) + + val data = ByteArray(8 * 1024) + var total: Long = 0 + var count: Int + var lastProgressValue = 0.0 + var lastProgressEmitTimestamp = 0L + val hasProgressCallback = param.onDownloadProgress != null + + while (input.read(data).also { count = it } != -1) { + coroutineContext.ensureActive() + total += count + + if (hasProgressCallback) { + val progressEvent = DownloadEventResult( + jobId = jobId.toDouble(), + headers = null, + contentLength = lengthOfFile.toDouble(), + statusCode = null, + bytesWritten = total.toDouble(), + error = null + ) + var shouldInvokeProgress = false + if (param.progressInterval > 0) { + val timestamp = System.currentTimeMillis() + if (timestamp - lastProgressEmitTimestamp > param.progressInterval) { + lastProgressEmitTimestamp = timestamp + shouldInvokeProgress = true + } + } else if (param.progressDivider <= 0f) { + shouldInvokeProgress = true + } else { + if (lengthOfFile > 0) { + val progressPercentage = + ((total.toDouble() * 100) / lengthOfFile).roundToInt() + if (param.progressDivider > 0 && progressPercentage % param.progressDivider.toInt() == 0) { + if ((progressPercentage.toDouble() != lastProgressValue) || (total == lengthOfFile)) { + lastProgressValue = progressPercentage.toDouble() + shouldInvokeProgress = true + } + } else if (param.progressDivider == 0f && total == lengthOfFile) { + lastProgressValue = progressPercentage.toDouble() + shouldInvokeProgress = true + } + } + } + if (shouldInvokeProgress) { + param.onDownloadProgress?.invoke(progressEvent) + } + } + output.write(data, 0, count) + } + output.flush() + output.fd.sync() + outcomeBytesWritten = total.toDouble() + Log.d("Downloader", "Job $jobId: Finished writing $total bytes.") + } else { + Log.w( + "Downloader", + "Job $jobId: Server returned non-successful status: $currentHttpCode. Bytes written set to 0." + ) + outcomeBytesWritten = 0.0 + } + } finally { + try { + output?.close() + } catch (e: Exception) { + Log.e("Downloader", "Job $jobId: Error closing output stream", e) + } + try { + input?.close() + } catch (e: Exception) { + Log.e("Downloader", "Job $jobId: Error closing input stream", e) + } + // 'connection' here refers to the latest HttpURLConnection object that was opened (original or redirected). + connection?.disconnect() + Log.d("Downloader", "Job $jobId: Connection resources released within withContext.") + } + // This is the return value of the withContext block + DownloadOperationOutcome(outcomeStatusCode, outcomeBytesWritten) + } + } + + private fun getContentLength(connection: HttpURLConnection): Long { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connection.contentLengthLong + } else { + connection.contentLength.toLong() + } + } + + fun stop() { + Log.d("Downloader", "Attempting to stop download job $jobId") + activeDownloadJob?.cancel(CancellationException("Download stopped by user request for job $jobId")) + } + + // Call this method when the Downloader instance is no longer needed, + // typically from RNFSManager when cleaning up a download job entirely. + fun destroy() { + Log.d("Downloader", "Destroying Downloader for job $jobId, cancelling its scope.") + _job.cancel() // This cancels the SupervisorJob, and consequently the scope and any active coroutines. + } +} + diff --git a/android/src/main/java/com/margelo/nitro/fs2/Fs2.kt b/android/src/main/java/com/margelo/nitro/fs2/Fs2.kt new file mode 100644 index 00000000..8449d65f --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/Fs2.kt @@ -0,0 +1,519 @@ +package com.margelo.nitro.fs2 + +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.bridge.ReadableMap +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.ArrayBuffer +import com.margelo.nitro.core.Promise +import java.io.File +import java.io.FileNotFoundException +import java.net.URL +import java.nio.ByteBuffer + +@DoNotStrip +class Fs2() : HybridFs2Spec() { + private val reactContext = NitroModules.applicationContext!! + private val rnfsManager = RNFSManager(reactContext) + private val listeners = DownloadListeners() + + data class DownloadListeners( + val beginListeners: MutableMap Unit)?> = mutableMapOf(), + val progressListeners: MutableMap Unit)?> = mutableMapOf(), + val completeListeners: MutableMap Unit)?> = mutableMapOf(), + val errorListeners: MutableMap Unit)?> = mutableMapOf() + ) + + // Companion object to manage job IDs and active downloaders + companion object { + private val activeDownloaders = mutableMapOf() + + // Method to be called by Downloader when it finishes + fun downloaderDidFinish(jobId: Int) { + activeDownloaders.remove(jobId) + } + } + + // Cache the directory paths at initialization + override val cachesDirectoryPath: String = RNFSManager.getCachesDirectoryPath(reactContext) + override val externalCachesDirectoryPath: String = + RNFSManager.getExternalCachesDirectoryPath(reactContext) ?: "" + override val documentDirectoryPath: String = + RNFSManager.getDocumentDirectoryPath(reactContext) ?: "" + override val downloadDirectoryPath: String = RNFSManager.getDownloadDirectoryPath() + override val externalDirectoryPath: String = + RNFSManager.getExternalDirectoryPath(reactContext) ?: "" + override val externalStorageDirectoryPath: String = + RNFSManager.getExternalStorageDirectoryPath() ?: "" + override val temporaryDirectoryPath: String = + RNFSManager.getTemporaryDirectoryPath(reactContext) + override val libraryDirectoryPath: String = "" // Not available on Android + override val picturesDirectoryPath: String = RNFSManager.getPicturesDirectoryPath() + + override fun mkdir(filepath: String, options: MkdirOptions?): Promise { + return Promise.async { + try { + rnfsManager.mkdir(filepath, options) + return@async + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun moveFile(filepath: String, destPath: String): Promise { + return Promise.async { + try { + rnfsManager.moveFile(filepath, destPath) + return@async + } catch (e: Exception) { + // Adjust error message to be more specific to moveFile if needed + throw reject(filepath, e) + } + } + } + + override fun copyFile(filepath: String, destPath: String): Promise { + return Promise.async { + try { + // Directly call RNFSManager.copyFile. + // RNFSManager.copyFile (via its helpers getInputStream/getOutputStream) + // will handle: + // - Source existence check (throws ENOENT if not found after path/URI resolution) + // - Source is directory check (throws EISDIR if source is a directory) + // - Content URI resolution for both source and destination paths internally + // - Parent directory creation for destination (if destination is a direct file + // path) + // - Overwriting destination if it's an existing file + // - Failing if destination is an existing directory (as getOutputStream would fail) + rnfsManager.copyFile(filepath, destPath) + return@async + } catch (e: Exception) { + // The reject helper in Fs2.kt will catch IORejectionException from RNFSManager + // (e.g., code "ENOENT" or "EISDIR") and rethrow them appropriately. + throw reject(filepath, e) + } + } + } + + override fun unlink(filepath: String): Promise { + return Promise.async { + try { + rnfsManager.unlink(filepath) + return@async + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun exists(filepath: String): Promise { + return Promise.async { + try { + return@async rnfsManager.exists(filepath) + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun readDir(dirPath: String): Promise> { + return Promise.async { + try { + val fileStats = rnfsManager.readDir(dirPath) + val readDirItems = + fileStats + .map { stat -> + ReadDirItem( + name = stat.name, + path = stat.path, + size = stat.size.toDouble(), + isFile = (stat.type == RNFSManager.FILE_TYPE_REGULAR), + isDirectory = + (stat.type == RNFSManager.FILE_TYPE_DIRECTORY), + mtime = stat.lastModified.toDouble(), + ctime = null + ) + } + .toTypedArray() + return@async readDirItems + } catch (e: Exception) { + throw reject(dirPath, e) + } + } + } + + override fun readFile(path: String): Promise { + return Promise.async { + try { + val fileBytes = rnfsManager.readFile(path) + val byteBuffer = ByteBuffer.wrap(fileBytes) + + return@async ArrayBuffer.copy((byteBuffer)) + } catch (e: Exception) { + throw reject(path, e) + } + } + } + + override fun read(filepath: String, length: Double, position: Double): Promise { + return Promise.async { + try { + val lengthInt = length.toInt() + val positionInt = position.toInt() + + // Read bytes directly, no base64 involved + val byteArray = rnfsManager.read(filepath, lengthInt, positionInt) + + // Create and return ArrayBuffer from byte array + val byteBuffer = ByteBuffer.wrap(byteArray) + return@async ArrayBuffer.copy((byteBuffer)) + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun writeFile(path: String, data: ArrayBuffer): Promise { + val copiedBuffer: ArrayBuffer + try { + // Create a copy of the ArrayBuffer to ensure we have ownership + copiedBuffer = ArrayBuffer.copy(data) + } catch (e: Exception) { + // If copying fails, reject immediately + return Promise.rejected(reject(path, e)) + } + + return Promise.async { + try { + val byteBuffer = copiedBuffer.getBuffer(copyIfNeeded = true) + val byteArray: ByteArray + if (byteBuffer.hasArray()) { + byteArray = + byteBuffer + .array() + .copyOfRange( + byteBuffer.arrayOffset() + byteBuffer.position(), + byteBuffer.arrayOffset() + byteBuffer.limit() + ) + } else { + byteArray = ByteArray(byteBuffer.remaining()) + byteBuffer.get(byteArray) + } + + rnfsManager.writeFile(path, byteArray) + return@async + } catch (e: Exception) { + throw reject(path, e) + } + } + } + + override fun appendFile(filepath: String, data: ArrayBuffer): Promise { + val copiedBuffer: ArrayBuffer + try { + // Create a copy of the ArrayBuffer to ensure we have ownership + copiedBuffer = ArrayBuffer.copy(data) + } catch (e: Exception) { + // If copying fails, reject immediately + return Promise.rejected(reject(filepath, e)) + } + + return Promise.async { + try { + val byteBuffer = copiedBuffer.getBuffer(copyIfNeeded = true) + val byteArray: ByteArray + if (byteBuffer.hasArray()) { + byteArray = + byteBuffer + .array() + .copyOfRange( + byteBuffer.arrayOffset() + byteBuffer.position(), + byteBuffer.arrayOffset() + byteBuffer.limit() + ) + } else { + byteArray = ByteArray(byteBuffer.remaining()) + byteBuffer.get(byteArray) + } + + rnfsManager.appendFile(filepath, byteArray) + return@async + } catch (e: Exception) { + reject(filepath, e) + } + } + } + + override fun write(filepath: String, data: ArrayBuffer, position: Double?): Promise { + val copiedBuffer: ArrayBuffer + try { + // Create a copy of the ArrayBuffer to ensure we have ownership + copiedBuffer = ArrayBuffer.copy(data) + } catch (e: Exception) { + // If copying fails, reject immediately + return Promise.rejected(reject(filepath, e)) + } + + return Promise.async { + try { + val byteBuffer = copiedBuffer.getBuffer(copyIfNeeded = true) + val byteArray: ByteArray + if (byteBuffer.hasArray()) { + byteArray = + byteBuffer + .array() + .copyOfRange( + byteBuffer.arrayOffset() + byteBuffer.position(), + byteBuffer.arrayOffset() + byteBuffer.limit() + ) + } else { + byteArray = ByteArray(byteBuffer.remaining()) + byteBuffer.get(byteArray) + } + + // Write directly using the updated method, no base64 involved + rnfsManager.write(filepath, byteArray, position?.toInt() ?: -1) + return@async + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun stat(filepath: String): Promise { + return Promise.async { + try { + val fileStat = rnfsManager.stat(filepath) + return@async fileStat + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun hash(filepath: String, algorithm: HashAlgorithm): Promise { + return Promise.async { + try { + // The HashAlgorithm enum values from TypeScript will be passed as strings + // (e.g., "md5", "sha256") which is what rnfsManager.hash expects. + return@async rnfsManager.hash(filepath, algorithm.toString()) + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun touch(filepath: String, mtime: Double?, ctime: Double?): Promise { + return Promise.async { + try { + // Android only supports setting the modified time (mtime) + // We'll ignore ctime as it's not applicable on Android + if (mtime != null) { + val result = rnfsManager.touch(filepath, mtime.toLong(), null) + if (!result) { + // If the operation failed, throw an appropriate error + throw Error( + "ETOUCH: Failed to set modification time for file at path: $filepath" + ) + } + } + + return@async + } catch (e: Exception) { + throw reject(filepath, e) + } + } + } + + override fun getFSInfo(): Promise { + return Promise.async { + try { + val fsInfo = rnfsManager.getFSInfo() + // Map internal storage info to FSInfoResult. + // External storage info (fsInfo.totalSpaceEx, fsInfo.freeSpaceEx) is available + // if we decide to expand FSInfoResult in Fs2.nitro.ts later. + return@async FSInfoResult( + totalSpace = fsInfo.totalSpace.toDouble(), + freeSpace = fsInfo.freeSpace.toDouble() + ) + } catch (e: Exception) { + // Although rnfsManager.getFSInfo() doesn't declare throwing specific exceptions, + // we catch broadly here just in case of unexpected runtime issues. + throw Error("EFSINFO: Failed to get file system info: ${e.message}") + } + } + } + + override fun downloadFile( + options: DownloadFileOptions, + headers: Map? + ): Promise { + val downloadPromise:Promise = Promise() + + try { + val currentJobId = options.jobId + val params = + DownloadParams().apply { + this.jobId = currentJobId.toInt() + this.src = URL(options.fromUrl) + this.dest = File(options.toFile) + this.headers = convertHeadersToReadableMap(headers) + + // Assign callbacks directly from parameters + this.onDownloadBegin = { event -> + listeners.beginListeners[event.jobId]?.invoke(event) + } + this.onDownloadProgress = { event -> + listeners.progressListeners[event.jobId]?.invoke(event) + } + this.onDownloadComplete = { result -> + listeners.completeListeners[result.jobId]?.invoke(result) + } + this.onDownloadError = { event -> + listeners.errorListeners[event.jobId]?.invoke(event) + } + this.onCleanup = { finishedJobId -> + downloaderDidFinish(finishedJobId) + downloadPromise.resolve(finishedJobId.toDouble()) + } + } + + val downloader = Downloader() + activeDownloaders[currentJobId.toInt()] = downloader // Store the downloader instance + downloader.start(params) + } catch (e: Exception) { + val currentJobId = options.jobId + + // Handle synchronous errors during setup (e.g., invalid URL) + // Asynchronous errors during download will be reported via onDownloadError + // callback. + listeners.errorListeners[currentJobId]?.invoke( + DownloadEventResult( + jobId = currentJobId, + headers = null, + contentLength = null, + bytesWritten = null, + statusCode = null, + error = e.message ?: "Error setting up download", + ) + ) + + downloadPromise.reject(reject(options.toFile, e)) // Also rethrow for the promise rejection + } + + return downloadPromise + } + + override fun stopDownload(jobId: Double): Promise { + return Promise.async { + try { + val downloader = activeDownloaders[jobId.toInt()] + if (downloader != null) { + downloader.stop() + activeDownloaders.remove(jobId.toInt()) // Remove as it's now stopped + } else { + // Optionally log or handle if no downloader is found for the jobId + // This could mean it already completed/errored or was already stopped. + println("Fs2: No active downloader found for jobId: $jobId to stop.") + } + return@async + } catch (e: Exception) { + // Consider specific error handling for stopDownload if necessary + throw reject("jobId: $jobId", e) // Use a placeholder path for reject + } + } + } + + override fun resumeDownload(jobId: Double): Promise { + // Android's DownloadManager does not directly support pausing and resuming downloads + // in the same way iOS does. Once a download is stopped, it's typically cancelled. + // For now, we'll make this a no-op or reject, as it's marked iOS-only in Fs2.nitro.ts. + return Promise.async { + // Option 1: Reject as not supported + // throw Error("resumeDownload is not supported on Android") + + // Option 2: No-op (as it's iOS only and this maintains consistency with the .nitro.ts + // comment) + return@async // Does nothing. + } + } + + override fun isResumable(jobId: Double): Promise { + // As per resumeDownload, this is not directly applicable to Android's DownloadManager. + return Promise.async { + return@async false // Or throw an error if preferred. + } + } + + override fun listenToDownloadBegin( + jobId: Double, + onDownloadBegin: ((event: DownloadEventResult) -> Unit)? + ): () -> Unit { + listeners.beginListeners[jobId] = onDownloadBegin + return { listeners.beginListeners.remove(jobId) } + } + + override fun listenToDownloadProgress( + jobId: Double, + onDownloadProgress: ((event: DownloadEventResult) -> Unit)? + ): () -> Unit { + listeners.progressListeners[jobId] = onDownloadProgress + return { listeners.progressListeners.remove(jobId) } + } + + override fun listenToDownloadComplete( + jobId: Double, + onDownloadComplete: ((result: DownloadEventResult) -> Unit)? + ): () -> Unit { + listeners.completeListeners[jobId] = onDownloadComplete + return { listeners.completeListeners.remove(jobId) } + } + + override fun listenToDownloadError( + jobId: Double, + onDownloadError: ((event: DownloadEventResult) -> Unit)? + ): () -> Unit { + listeners.errorListeners[jobId] = onDownloadError + return { listeners.errorListeners.remove(jobId) } + } + + // iOS only: No-op on Android + override fun listenToDownloadCanBeResumed( + jobId: Double, + onDownloadCanBeResumed: ((event: DownloadEventResult) -> Unit)? + ): () -> Unit { + // No-op, Android does not support download can-be-resumed events + return {} + } + + override fun getAllExternalFilesDirs(): Promise> { + return Promise.async { throw Error("getAllExternalFilesDirs is not supported") } + } + + override fun scanFile(path: String): Promise> { + return Promise.async { throw Error("scanFile is not supported") } + } + + // Private methods + private fun reject(filepath: String, ex: Exception): Throwable { + if (ex is FileNotFoundException) { + throw Error("ENOENT: no such file or directory, open '$filepath'") + } + + if (ex is IORejectionException) { + throw Error("${ex.code}: ${ex.message}") + } + + throw Error(ex.message) + } + + // Convert Map to ReadableMap for React Native bridge + private fun convertHeadersToReadableMap(headers: Map?): ReadableMap? { + return headers?.let { headerMap -> + val writableMap = com.facebook.react.bridge.Arguments.createMap() + for ((key, value) in headerMap) { + writableMap.putString(key, value) + } + writableMap + } + } +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/Fs2Package.kt b/android/src/main/java/com/margelo/nitro/fs2/Fs2Package.kt new file mode 100644 index 00000000..3534e11e --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/Fs2Package.kt @@ -0,0 +1,22 @@ +package com.margelo.nitro.fs2 + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider + +class Fs2Package : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { HashMap() } + } + + companion object { + init { + System.loadLibrary("fs2") + } + } +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/Fs2Stream.kt b/android/src/main/java/com/margelo/nitro/fs2/Fs2Stream.kt new file mode 100644 index 00000000..70d05e42 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/Fs2Stream.kt @@ -0,0 +1,584 @@ +package com.margelo.nitro.fs2 + +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.Promise +import com.margelo.nitro.core.ArrayBuffer +import com.margelo.nitro.fs2.utils.Fs2Util +import com.margelo.nitro.fs2.utils.BufferPool +import com.margelo.nitro.fs2.utils.StreamError + +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.RandomAccessFile +import java.io.FileOutputStream +import java.util.concurrent.BlockingQueue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue +import java.util.UUID + +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock + +class Fs2Stream() : HybridFs2StreamSpec() { + // Stream state data classes + private data class ReadStreamState( + val file: File, + val options: ReadStreamOptions?, + var isActive: Boolean = false, + var position: Long = 0L, + var job: Job? = null, + val pauseMutex: kotlinx.coroutines.sync.Mutex = kotlinx.coroutines.sync.Mutex(locked = false) // Unlocked = active, Locked = paused + ) + + private data class WriteStreamState( + val file: File, + val options: WriteStreamOptions?, + var isActive: Boolean = false, + var position: Long = 0L, + var job: Job? = null, + var hasError: Boolean = false + ) + + // Stream handle maps + private val readStreams = ConcurrentHashMap() + private val writeStreams = ConcurrentHashMap() + + // Coroutine scope for stream operations + private val streamScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Event listener maps (for demonstration, not yet emitting events) + private val readStreamDataListeners = ConcurrentHashMap Unit>() + private val readStreamProgressListeners = + ConcurrentHashMap Unit>() + private val readStreamEndListeners = ConcurrentHashMap Unit>() + private val readStreamErrorListeners = + ConcurrentHashMap Unit>() + private val writeStreamProgressListeners = + ConcurrentHashMap Unit>() + private val writeStreamFinishListeners = + ConcurrentHashMap Unit>() + private val writeStreamErrorListeners = + ConcurrentHashMap Unit>() + + // Write stream: queue for incoming writes + private data class WriteRequest( + val data: ByteArray?, + val isString: Boolean = false, + val isEnd: Boolean = false + ) + + // Add reference to RNFSManager and context + private val reactContext = NitroModules.applicationContext!! + + // Add buffer pool instance + private val bufferPool = BufferPool() + + // Helper to open InputStream for reading (file or content URI) + private fun openInputStream(path: String, start: Long = 0L): InputStream { + val uri = Fs2Util.getFileUri(path) + try { + if ("content" == uri.scheme) { + val input = reactContext.contentResolver.openInputStream(uri) + ?: throw StreamError.NotFound(path) + if (start > 0) input.skip(start) + return input + } else { + val filePath = Fs2Util.getOriginalFilepath(reactContext, path) + val file = File(filePath) + if (!file.canRead()) throw StreamError.AccessDenied(path) + val raf = RandomAccessFile(filePath, "r") + raf.seek(start) + return object : InputStream() { + override fun read(): Int = raf.read() + override fun read(b: ByteArray, off: Int, len: Int): Int = raf.read(b, off, len) + override fun close() = raf.close() + } + } + } catch (e: SecurityException) { + throw StreamError.AccessDenied(path) + } catch (e: IOException) { + throw StreamError.IOError(e.message ?: "I/O error") + } + } + + // Helper to open OutputStream for writing (file or content URI) + private fun openOutputStream(path: String, append: Boolean): OutputStream { + val uri = Fs2Util.getFileUri(path) + try { + if ("content" == uri.scheme) { + val output = reactContext.contentResolver.openOutputStream(uri, if (append) "wa" else "w") + ?: throw StreamError.NotFound(path) + return output + } else { + val filePath = Fs2Util.getOriginalFilepath(reactContext, path) + val file = File(filePath) + + if (file.exists()) { + if (!file.canWrite()) throw StreamError.AccessDenied(path) + } else { + val parentDir = file.parentFile + if (parentDir != null && !parentDir.canWrite()) { + throw StreamError.AccessDenied(path) + } + } + + return FileOutputStream(filePath, append) + } + } catch (e: SecurityException) { + throw StreamError.AccessDenied(path) + } catch (e: IOException) { + throw StreamError.IOError(e.message ?: "I/O error") + } + } + + override fun createReadStream( + path: String, + options: ReadStreamOptions? + ): Promise { + return Promise.async { + val file = File(path) + if (!file.exists() || !file.isFile) { + throw StreamError.NotFound(path) + } + val streamId = UUID.randomUUID().toString() + val state = ReadStreamState(file, options) + readStreams[streamId] = state + return@async ReadStreamHandle(streamId) + } + } + + override fun createWriteStream( + path: String, + options: WriteStreamOptions? + ): Promise { + return Promise.async { + val file = File(path) + if (options?.createDirectories == true) { + file.parentFile?.mkdirs() + } + val streamId = UUID.randomUUID().toString() + val outputStream = openOutputStream(path, options?.append == true) + val queue = LinkedBlockingQueue() + val state = WriteStreamState(file, options, isActive = true) + val impl = WriteStreamStateImpl(state, outputStream, queue) + writeStreams[streamId] = impl + impl.state.job = streamScope.launch { + var bytesWritten = 0L + try { + writeLoop@ while (true) { + val req = impl.queue.take() + if (req.isEnd) break@writeLoop + req.data?.let { data -> + impl.outputStream.write(data) + impl.state.position += data.size + bytesWritten += data.size + writeStreamProgressListeners[streamId]?.invoke( + WriteStreamProgressEvent( + streamId = streamId, + bytesWritten = bytesWritten, + lastChunkSize = data.size.toLong() + ) + ) + } + } + } catch (e: SecurityException) { + impl.state.hasError = true + writeStreamErrorListeners[streamId]?.invoke( + WriteStreamErrorEvent( + streamId = streamId, + error = StreamError.AccessDenied(state.file.path).message ?: "Access denied", + code = null + ) + ) + } catch (e: IOException) { + impl.state.hasError = true + writeStreamErrorListeners[streamId]?.invoke( + WriteStreamErrorEvent( + streamId = streamId, + error = StreamError.IOError(e.message ?: "I/O error").message ?: "I/O error", + code = null + ) + ) + } catch (e: Exception) { + impl.state.hasError = true + val error = when (e) { + is StreamError -> e + else -> StreamError.IOError(e.message ?: "Unknown error") + } + writeStreamErrorListeners[streamId]?.invoke( + WriteStreamErrorEvent( + streamId = streamId, + error = error.message ?: "Unknown error", + code = null + ) + ) + } finally { + try { + impl.outputStream.close() + } catch (_: Exception) { + } + impl.state.isActive = false + } + } + return@async WriteStreamHandle(streamId) + } + } + + // --- Read Stream Control --- + override fun startReadStream(streamId: String): Promise { + return Promise.async { + val state = + readStreams[streamId] ?: throw StreamError.InvalidStream(streamId) + if (state.isActive) return@async + state.isActive = true + + // Only create new job if none exists or previous one is completed + if (state.job == null || state.job?.isActive == false) { + state.job = streamScope.launch { + val bufferSize = state.options?.bufferSize ?: BufferPool.DEFAULT_BUFFER_SIZE + val start = state.options?.start ?: 0L + val end = state.options?.end + var position = start + var chunk = 0L + val fileLength = state.file.length() + var bytesReadTotal = 0L + try { + state.position = position + openInputStream(state.file.path, start).use { inputStream -> + var buffer = bufferPool.acquire(bufferSize.toInt()) + try { + readLoop@ while (true) { + // Wait if paused - acquire lock briefly to check, then suspend if needed + state.pauseMutex.withLock { + // Just checking pause state, lock will be released after this block + } + + // Perform I/O without holding the lock + val bytesToRead = if (end != null) { + val remaining = end - position + 1 + if (remaining <= 0) break@readLoop + minOf(bufferSize.toLong(), remaining).toInt() + } else bufferSize.toInt() + + val read = inputStream.read(buffer, 0, bytesToRead) + if (read == -1) break@readLoop + + val data = buffer.copyOf(read) + + readStreamDataListeners[streamId]?.invoke( + ReadStreamDataEvent( + streamId = streamId, + data = ArrayBuffer.copy(java.nio.ByteBuffer.wrap(data)), + chunk = chunk, + position = position + ) + ) + + position += read + state.position = position + bytesReadTotal += read + chunk++ + + readStreamProgressListeners[streamId]?.invoke( + ReadStreamProgressEvent( + streamId = streamId, + bytesRead = bytesReadTotal, + totalBytes = fileLength, + progress = bytesReadTotal.toDouble() / fileLength.toDouble() + ) + ) + + if (end != null && position > end) break@readLoop + } + } finally { + bufferPool.release(buffer) + } + } + readStreamEndListeners[streamId]?.invoke( + ReadStreamEndEvent( + streamId = streamId, + bytesRead = bytesReadTotal, + success = true + ) + ) + } catch (e: SecurityException) { + val error = StreamError.AccessDenied(state.file.path) + readStreamErrorListeners[streamId]?.invoke( + ReadStreamErrorEvent( + streamId = streamId, + error = error.message ?: "Access denied", + code = null + ) + ) + } catch (e: IOException) { + val error = StreamError.IOError(e.message ?: "I/O error") + readStreamErrorListeners[streamId]?.invoke( + ReadStreamErrorEvent( + streamId = streamId, + error = error.message ?: "I/O error", + code = null + ) + ) + } catch (e: Exception) { + val error = when (e) { + is StreamError -> e + else -> StreamError.IOError(e.message ?: "Unknown error") + } + readStreamErrorListeners[streamId]?.invoke( + ReadStreamErrorEvent( + streamId = streamId, + error = error.message ?: "Unknown error", + code = null + ) + ) + } finally { + state.isActive = false + state.job = null + readStreams.remove(streamId) + readStreamDataListeners.remove(streamId) + readStreamProgressListeners.remove(streamId) + readStreamEndListeners.remove(streamId) + readStreamErrorListeners.remove(streamId) + } + } + } + } + } + + override fun pauseReadStream(streamId: String): Promise { + return Promise.async { + val state = + readStreams[streamId] ?: throw Exception("ENOENT: No such read stream: $streamId") + if (!state.isActive) return@async + + // Use tryLock to avoid deadlock - locks mutex to pause stream + state.pauseMutex.tryLock() + state.isActive = false + } + } + + override fun resumeReadStream(streamId: String): Promise { + return Promise.async { + val state = + readStreams[streamId] ?: throw Exception("ENOENT: No such read stream: $streamId") + if (state.isActive) return@async + + // Safely unlock mutex to resume stream - check if locked first + if (state.pauseMutex.isLocked) { + try { + state.pauseMutex.unlock() + } catch (e: IllegalStateException) { + // Mutex might have been unlocked by another coroutine, ignore + } + } + state.isActive = true + } + } + + override fun closeReadStream(streamId: String): Promise { + return Promise.async { + val state = readStreams.remove(streamId) + ?: throw Exception("ENOENT: No such read stream: $streamId") + state.job?.cancel() + readStreamDataListeners.remove(streamId) + readStreamProgressListeners.remove(streamId) + readStreamEndListeners.remove(streamId) + readStreamErrorListeners.remove(streamId) + } + } + + override fun isReadStreamActive(streamId: String): Promise { + return Promise.async { + val state = + readStreams[streamId] ?: throw Exception("ENOENT: No such read stream: $streamId") + return@async state.isActive + } + } + + // --- Write Stream Control --- + override fun writeToStream(streamId: String, data: ArrayBuffer): Promise { + val copiedBuffer: ArrayBuffer + try { + copiedBuffer = ArrayBuffer.copy(data) + } catch (e: Exception) { + return Promise.rejected(StreamError.BufferError("Failed to copy ArrayBuffer: ${e.message}")) + } + + return Promise.async { + val impl = writeStreams[streamId] ?: throw StreamError.InvalidStream(streamId) + if (!impl.state.isActive) throw StreamError.StreamInactive(streamId) + val bytes = copiedBuffer.getBuffer(true).let { buf -> + if (buf.hasArray()) { + buf.array().copyOfRange( + buf.arrayOffset() + buf.position(), + buf.arrayOffset() + buf.limit() + ) + } else { + ByteArray(buf.remaining()).also { buf.get(it) } + } + } + impl.queue.add(WriteRequest(bytes)) + impl.state.job?.let { if (!it.isActive) throw StreamError.StreamInactive(streamId) } + } + } + + override fun flushWriteStream(streamId: String): Promise { + return Promise.async { + val impl = + writeStreams[streamId] ?: throw Exception("ENOENT: No such write stream: $streamId") + impl.outputStream.flush() + } + } + + override fun closeWriteStream(streamId: String): Promise { + return Promise.async { + val impl = writeStreams.remove(streamId) ?: throw StreamError.InvalidStream(streamId) + + // Signal end to prevent new writes and wait for pending writes to complete + impl.state.isActive = false + impl.queue.add(WriteRequest(null, isEnd = true)) + + // Wait for background job to finish processing + impl.state.job?.join() + + try { + impl.outputStream.close() + } catch (_: Exception) { + } + + writeStreamFinishListeners[streamId]?.invoke( + WriteStreamFinishEvent( + streamId = streamId, + bytesWritten = impl.state.position, + success = !impl.state.hasError + ) + ) + + writeStreamProgressListeners.remove(streamId) + writeStreamFinishListeners.remove(streamId) + writeStreamErrorListeners.remove(streamId) + } + } + + override fun isWriteStreamActive(streamId: String): Promise { + return Promise.async { + val impl = + writeStreams[streamId] ?: throw Exception("ENOENT: No such write stream: $streamId") + return@async impl.state.isActive + } + } + + override fun getWriteStreamPosition(streamId: String): Promise { + return Promise.async { + val impl = + writeStreams[streamId] ?: throw Exception("ENOENT: No such write stream: $streamId") + return@async impl.state.position + } + } + + override fun endWriteStream(streamId: String): Promise { + return Promise.async { + val impl = + writeStreams[streamId] ?: throw Exception("ENOENT: No such write stream: $streamId") + + // Mark the stream as finished (no more writes) + impl.state.isActive = false + + // Enqueue an 'end' marker to unblock the write job + impl.queue.add(WriteRequest(null, isEnd = true)) + + // Wait for the background job to finish + impl.state.job?.join() + + // Now cleanup (remove from map, close file, emit finish) + writeStreams.remove(streamId) + + try { + impl.outputStream.flush() + } catch (_: Exception) { + } + try { + impl.outputStream.close() + } catch (_: Exception) { + } + + writeStreamFinishListeners[streamId]?.invoke( + WriteStreamFinishEvent( + streamId = streamId, + bytesWritten = impl.state.position, + success = !impl.state.hasError + ) + ) + + writeStreamProgressListeners.remove(streamId) + writeStreamFinishListeners.remove(streamId) + writeStreamErrorListeners.remove(streamId) + } + } + + // --- Event Listener Registration --- + override fun listenToReadStreamData( + streamId: String, + onData: (event: ReadStreamDataEvent) -> Unit + ): () -> Unit { + readStreamDataListeners[streamId] = onData + return { readStreamDataListeners.remove(streamId) } + } + + override fun listenToReadStreamProgress( + streamId: String, + onProgress: (event: ReadStreamProgressEvent) -> Unit + ): () -> Unit { + readStreamProgressListeners[streamId] = onProgress + return { readStreamProgressListeners.remove(streamId) } + } + + override fun listenToReadStreamEnd( + streamId: String, + onEnd: (event: ReadStreamEndEvent) -> Unit + ): () -> Unit { + readStreamEndListeners[streamId] = onEnd + return { readStreamEndListeners.remove(streamId) } + } + + override fun listenToReadStreamError( + streamId: String, + onError: (event: ReadStreamErrorEvent) -> Unit + ): () -> Unit { + readStreamErrorListeners[streamId] = onError + return { readStreamErrorListeners.remove(streamId) } + } + + override fun listenToWriteStreamProgress( + streamId: String, + onProgress: (event: WriteStreamProgressEvent) -> Unit + ): () -> Unit { + writeStreamProgressListeners[streamId] = onProgress + return { writeStreamProgressListeners.remove(streamId) } + } + + override fun listenToWriteStreamFinish( + streamId: String, + onFinish: (event: WriteStreamFinishEvent) -> Unit + ): () -> Unit { + writeStreamFinishListeners[streamId] = onFinish + return { writeStreamFinishListeners.remove(streamId) } + } + + override fun listenToWriteStreamError( + streamId: String, + onError: (event: WriteStreamErrorEvent) -> Unit + ): () -> Unit { + writeStreamErrorListeners[streamId] = onError + return { writeStreamErrorListeners.remove(streamId) } + } + + // WriteStreamStateImpl: extends WriteStreamState with queue and outputStream + private class WriteStreamStateImpl( + val state: WriteStreamState, + val outputStream: OutputStream, + val queue: BlockingQueue + ) +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/IORejectionException.kt b/android/src/main/java/com/margelo/nitro/fs2/IORejectionException.kt new file mode 100644 index 00000000..c47bb86a --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/IORejectionException.kt @@ -0,0 +1,3 @@ +package com.margelo.nitro.fs2 + +class IORejectionException(val code: String, message: String) : Exception(message) diff --git a/android/src/main/java/com/margelo/nitro/fs2/MediaStore.kt b/android/src/main/java/com/margelo/nitro/fs2/MediaStore.kt new file mode 100644 index 00000000..713a9388 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/MediaStore.kt @@ -0,0 +1,106 @@ +package com.margelo.nitro.fs2 + +import com.margelo.nitro.core.Promise +import androidx.core.net.toUri + +class MediaStore(): HybridMediaStoreSpec() { + private val mediaStoreManager = RNFSMediaStoreManager() + + private fun reject(context: String, ex: Exception): Throwable { + // You can expand this for more specific error types as needed + throw Error(ex.message ?: "Error in MediaStore operation: $context") + } + + override fun mediaStoreCreateFile( + fileDescription: FileDescription, + mediaCollection: MediaCollectionType + ): Promise { + return Promise.async { + try { + val uri = mediaStoreManager.createMediaFile(fileDescription, mediaCollection) + return@async uri.toString() + } catch (e: Exception) { + throw reject(fileDescription.name, e) + } + } + } + + override fun mediaStoreUpdateFile( + uri: String, + fileDescription: FileDescription, + mediaCollection: MediaCollectionType + ): Promise { + return Promise.async { + try { + val updated = mediaStoreManager.updateMediaFile(uri.toUri(), fileDescription, mediaCollection) + if (updated) { + return@async uri + } else { + throw Error("Failed to update file: $uri") + } + } catch (e: Exception) { + throw reject(uri, e) + } + } + } + + override fun mediaStoreWriteToFile(uri: String, sourceFilePath: String): Promise { + return Promise.async { + try { + val success = mediaStoreManager.writeToMediaFile(uri.toUri(), sourceFilePath) + if (success) { + return@async + } else { + throw Error("Failed to write to file: $uri") + } + } catch (e: Exception) { + throw reject(uri, e) + } + } + } + + override fun mediaStoreCopyFromFile( + sourceFilePath: String, + fileDescription: FileDescription, + mediaCollection: MediaCollectionType + ): Promise { + return Promise.async { + try { + val uri = mediaStoreManager.copyToMediaStore(fileDescription, mediaCollection, sourceFilePath) + return@async uri.toString() + } catch (e: Exception) { + throw reject(sourceFilePath, e) + } + } + } + + override fun mediaStoreQueryFile(searchOptions: MediaStoreSearchOptions): Promise { + val queryPromise:Promise = Promise() + + try { + val file = mediaStoreManager.query(searchOptions) + + if (file != null) { + queryPromise.resolve(file) + } else { + println("File not found: ${searchOptions.fileName}") + queryPromise.reject(Error("File not found: ${searchOptions.fileName}")) + } + } catch (e: Exception) { + throw reject(searchOptions.fileName ?: "query", e) + } + + return queryPromise + } + + override fun mediaStoreDeleteFile(uri: String): Promise { + return Promise.async { + try { + val deleted = mediaStoreManager.delete(uri.toUri()) + return@async deleted + } catch (e: Exception) { + throw reject(uri, e) + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/margelo/nitro/fs2/RNFSFileTransformer.kt b/android/src/main/java/com/margelo/nitro/fs2/RNFSFileTransformer.kt new file mode 100644 index 00000000..6c29b1a2 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/RNFSFileTransformer.kt @@ -0,0 +1,13 @@ +package com.margelo.nitro.fs2 + +object RNFSFileTransformer { + interface FileTransformer { + fun onWriteFile(data: ByteArray): ByteArray + fun onReadFile( + data: ByteArray + ): ByteArray // Retained as per original, though usage not seen in manager + } + + @JvmStatic // If it needs to be accessed from Java as a static field + var sharedFileTransformer: FileTransformer? = null +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/RNFSManager.kt b/android/src/main/java/com/margelo/nitro/fs2/RNFSManager.kt new file mode 100644 index 00000000..48f550bc --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/RNFSManager.kt @@ -0,0 +1,370 @@ +package com.margelo.nitro.fs2 + +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.provider.MediaStore +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.RandomAccessFile +import java.security.MessageDigest +import androidx.core.net.toUri +import com.facebook.react.bridge.ReactApplicationContext +import com.margelo.nitro.fs2.utils.Fs2Util + +class RNFSManager(private val context: ReactApplicationContext) { + companion object { + const val FILE_TYPE_REGULAR = 0 + const val FILE_TYPE_DIRECTORY = 1 + + // Directory path providers (can be enhanced or made more granular) + fun getDocumentDirectoryPath(context: ReactApplicationContext): String = context.filesDir.absolutePath + fun getTemporaryDirectoryPath(context: ReactApplicationContext): String = context.cacheDir.absolutePath + fun getPicturesDirectoryPath(): String = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + .absolutePath + fun getCachesDirectoryPath(context: ReactApplicationContext): String = context.cacheDir.absolutePath + fun getDownloadDirectoryPath(): String = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .absolutePath + fun getExternalStorageDirectoryPath(): String? = + Environment.getExternalStorageDirectory()?.absolutePath + fun getExternalDirectoryPath(context: ReactApplicationContext): String? = + context.getExternalFilesDir(null)?.absolutePath + fun getExternalCachesDirectoryPath(context: ReactApplicationContext): String? = + context.externalCacheDir?.absolutePath + } + + private fun getFileUri(filepath: String, isDirectoryAllowed: Boolean = false): Uri { + val uri = filepath.toUri() + if (uri.scheme == null) { + val file = File(filepath) + if (!isDirectoryAllowed && file.isDirectory) { + throw IORejectionException( + "EISDIR", + "EISDIR: illegal operation on a directory, read '$filepath'" + ) + } + return "file://$filepath".toUri() + } + return uri + } + + private fun getOriginalFilepath(filepath: String, isDirectoryAllowed: Boolean = false): String { + val uri = getFileUri(filepath, isDirectoryAllowed) + var originalFilepath = filepath + if ("content" == uri.scheme) { + try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + originalFilepath = cursor.getString(columnIndex) + } + } + } catch (e: IllegalArgumentException) { + // Ignored in original code + } + } + return originalFilepath + } + + private fun getInputStream(filepath: String): InputStream { + val uri = getFileUri(filepath) + try { + return context.contentResolver.openInputStream(uri) + ?: throw IORejectionException( + "ENOENT", + "ENOENT: could not open an input stream for '$filepath'" + ) + } catch (ex: FileNotFoundException) { + throw IORejectionException("ENOENT", "ENOENT: ${ex.message}, open '$filepath'") + } + } + + private fun getWriteAccessByAPILevel(): String { + return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) "w" else "rwt" + } + + private fun getOutputStream(filepath: String, append: Boolean): OutputStream { + val uri = getFileUri(filepath) + try { + return context.contentResolver.openOutputStream( + uri, + if (append) "wa" else getWriteAccessByAPILevel() + ) + ?: throw IORejectionException( + "ENOENT", + "ENOENT: could not open an output stream for '$filepath'" + ) + } catch (ex: FileNotFoundException) { + throw IORejectionException("ENOENT", "ENOENT: ${ex.message}, open '$filepath'") + } + } + + private fun getInputStreamBytes(inputStream: InputStream): ByteArray { + ByteArrayOutputStream().use { byteBuffer -> + val buffer = ByteArray(1024) + var len: Int + while (inputStream.read(buffer).also { len = it } != -1) { + byteBuffer.write(buffer, 0, len) + } + return byteBuffer.toByteArray() + } + } + + fun writeFile(filepath: String, data: ByteArray) { + getOutputStream(filepath, false).use { outputStream -> outputStream.write(data) } + } + + fun appendFile(filepath: String, data: ByteArray) { + getOutputStream(filepath, true).use { outputStream -> outputStream.write(data) } + } + + fun write(filepath: String, data: ByteArray, position: Int) { + if (position < 0) { // Append + getOutputStream(filepath, true).use { outputStream -> outputStream.write(data) } + } else { + val file = File(getOriginalFilepath(filepath, false)) + + // Ensure parent directories exist + val parent = file.parentFile + if (parent != null && !parent.exists()) { + parent.mkdirs() + } + + // Create file if it doesn't exist + if (!file.exists()) { + file.createNewFile() + } + + RandomAccessFile(file, "rw").use { randomAccessFile -> + randomAccessFile.seek(position.toLong()) + randomAccessFile.write(data) + } + } + } + + fun exists(filepath: String): Boolean { + // Let exceptions propagate to be handled by the caller (Fs2.kt) + val file = File(getOriginalFilepath(filepath, true)) + return file.exists() + } + + fun readFile(filepath: String): ByteArray { + getInputStream(filepath).use { inputStream -> + return getInputStreamBytes(inputStream) + } + } + + fun read(filepath: String, length: Int, position: Int): ByteArray { + getInputStream(filepath).use { inputStream -> + val buffer = ByteArray(length) + inputStream.skip(position.toLong()) + val bytesRead = inputStream.read(buffer, 0, length) + + // If we read fewer bytes than requested, return a truncated array + return if (bytesRead < length) buffer.copyOf(bytesRead) else buffer + } + } + + fun hash(filepath: String, algorithm: String): String { + val algorithms = + mapOf( + "md5" to "MD5", + "sha1" to "SHA-1", + "sha224" to "SHA-224", + "sha256" to "SHA-256", + "sha384" to "SHA-384", + "sha512" to "SHA-512" + ) + + if (!algorithms.containsKey(algorithm.lowercase())) { + throw IllegalArgumentException("Invalid hash algorithm: $algorithm") + } + + val file = File(getOriginalFilepath(filepath, false)) + + if (file.isDirectory) { + throw IORejectionException( + "EISDIR", + "EISDIR: illegal operation on a directory, read '$filepath'" + ) + } + if (!file.exists()) { + throw IORejectionException( + "ENOENT", + "ENOENT: no such file or directory, open '$filepath'" + ) + } + + val md = MessageDigest.getInstance(algorithms[algorithm.lowercase()]) + FileInputStream(file).use { inputStream -> // Use the file path directly for FileInputStream + val buffer = ByteArray(1024 * 10) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + md.update(buffer, 0, read) + } + } + + val hexString = StringBuilder() + for (digestByte in md.digest()) { + hexString.append(String.format("%02x", digestByte)) + } + return hexString.toString() + } + + fun moveFile(filepath: String, destPath: String) { + val inFile = + File(getOriginalFilepath(filepath, false)) // Use original path for file operations + val outFile = + File(getOriginalFilepath(destPath, false)) // Use original path for file operations + + if (outFile.exists()) { // Added check to prevent overwriting an existing file by renameTo + if (!outFile.delete()) { + throw IOException("Failed to delete existing destination file: $destPath") + } + } + + if (!inFile.renameTo(outFile)) { + copyFile(filepath, destPath) // Original paths from parameters + if (!inFile.delete()) { + // Log or throw a more specific error if deletion after copy fails + // For simplicity, let's assume it mostly works or the copy is sufficient + } + } + } + + fun copyFile(filepath: String, destPath: String) { + getInputStream(filepath).use { input -> + getOutputStream(destPath, false).use { output -> + val buffer = ByteArray(1024) + var length: Int + while (input.read(buffer).also { length = it } > 0) { + output.write(buffer, 0, length) + } + } + } + } + + data class FileStat( + val name: String, + val path: String, + val size: Long, + val type: Int, // FILE_TYPE_REGULAR or FILE_TYPE_DIRECTORY + val lastModified: Long // mtime in seconds + ) + + fun readDir(directoryPath: String): List { + val dir = + File( + getOriginalFilepath(directoryPath, true) + ) // Use original path for directory listing + if (!dir.exists()) throw IORejectionException( + "ENOENT", + "Folder does not exist: $directoryPath" + ) + if (!dir.isDirectory) + throw IORejectionException("ENOTDIR", "Path is not a directory: $directoryPath") + + return dir.listFiles()?.map { childFile -> + FileStat( + name = childFile.name, + path = childFile.absolutePath, + size = childFile.length(), + type = if (childFile.isDirectory) FILE_TYPE_DIRECTORY else FILE_TYPE_REGULAR, + lastModified = childFile.lastModified() / 1000 + ) + } + ?: emptyList() + } + + fun stat(filepath: String): NativeStatResult { + val originalPath = getOriginalFilepath(filepath, true) + val file = File(originalPath) + + if (!file.exists()) throw IORejectionException("ENOENT", "File does not exist: $filepath") + + return NativeStatResult( + ctime = (file.lastModified() / 1000).toDouble(), + mtime = (file.lastModified() / 1000).toDouble(), + size = file.length().toDouble(), + type = if (file.isDirectory) StatResultType.DIRECTORY else StatResultType.FILE, + originalFilepath = originalPath, + mode = null, + ) + } + + fun unlink(filepath: String) { + val file = File(getOriginalFilepath(filepath, true)) + if (!file.exists()) throw IORejectionException("ENOENT", "File does not exist: $filepath") + deleteRecursive(file) + } + + private fun deleteRecursive(fileOrDirectory: File) { + if (fileOrDirectory.isDirectory) { + fileOrDirectory.listFiles()?.forEach { child -> deleteRecursive(child) } + } + if (!fileOrDirectory.delete()) { + // Optionally throw an error if deletion fails + // Log.w("RNFSManager", "Failed to delete: ${fileOrDirectory.absolutePath}") + } + } + + fun mkdir(filepath: String, options: MkdirOptions?): Unit { + val file = File(filepath) + file.mkdirs() // Attempt to create directory and necessary parents. + + // throw an error if the directory could not be created + if (!file.exists() || !file.isDirectory) { // Also check if it's actually a directory + throw IOException("Directory could not be created or path is not a directory: $filepath") + } + } + + data class FSInfo( + val totalSpace: Long, // Internal storage total space + val freeSpace: Long, // Internal storage free space + val totalSpaceEx: Long, // External storage total space (if available) + val freeSpaceEx: Long // External storage free space (if available) + ) + + // For StatFs methods + fun getFSInfo(): FSInfo { + val internalPath = Environment.getDataDirectory() + val stat = StatFs(internalPath.path) + val totalSpace = stat.totalBytes + val freeSpace = stat.freeBytes + + var totalSpaceEx = 0L + var freeSpaceEx = 0L + + if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) { + try { + val externalPath = Environment.getExternalStorageDirectory() + if (externalPath != null) { + val statEx = StatFs(externalPath.path) + totalSpaceEx = statEx.totalBytes + freeSpaceEx = statEx.freeBytes + } + } catch (e: Exception) { + // External storage might not be available or accessible + // Log.w("RNFSManager", "Could not get external storage info: ${e.message}") + } + } + + return FSInfo(totalSpace, freeSpace, totalSpaceEx, freeSpaceEx) + } + + fun touch(filepath: String, mtime: Long, ctime: Long? = null): Boolean { + // Java File API only supports setting lastModified time (mtime). + // ctime (creation time or change time) is not directly settable. + // We'll use mtime for lastModified. + val file = File(getOriginalFilepath(filepath, false)) // Use original path + return file.setLastModified(mtime * 1000) // Original was in seconds, convert to ms + } +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/RNFSMediaStoreManager.kt b/android/src/main/java/com/margelo/nitro/fs2/RNFSMediaStoreManager.kt new file mode 100644 index 00000000..fa435416 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/RNFSMediaStoreManager.kt @@ -0,0 +1,267 @@ +package com.margelo.nitro.fs2 + +import android.app.RecoverableSecurityException +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.FileUtils +import android.provider.MediaStore +import android.util.Log +import androidx.core.net.toUri +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream + +import com.margelo.nitro.NitroModules + +class RNFSMediaStoreManager { + private val context = NitroModules.applicationContext + ?: throw IllegalStateException("NitroModules.applicationContext is null") + + companion object { + private const val BUFFER_SIZE = 10240 + private fun getMediaUri(mt: MediaCollectionType): Uri? { + return when (mt) { + MediaCollectionType.AUDIO -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + + MediaCollectionType.VIDEO -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + else MediaStore.Video.Media.EXTERNAL_CONTENT_URI + + MediaCollectionType.IMAGE -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + MediaCollectionType.DOWNLOAD -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + else null + } + } + + private fun getRelativePath(mt: MediaCollectionType): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return when (mt) { + MediaCollectionType.AUDIO -> Environment.DIRECTORY_MUSIC + MediaCollectionType.VIDEO -> Environment.DIRECTORY_MOVIES + MediaCollectionType.IMAGE -> Environment.DIRECTORY_PICTURES + MediaCollectionType.DOWNLOAD -> Environment.DIRECTORY_DOWNLOADS + } + } else { + throw UnsupportedOperationException("Android version not supported") + } + } + } + + fun createMediaFile(file: FileDescription, mediaType: MediaCollectionType): Uri { + val resolver = context.contentResolver + val fileDetails = + ContentValues().apply { + put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000) + put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis() / 1000) + put(MediaStore.MediaColumns.MIME_TYPE, file.mimeType) + put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + getRelativePath(mediaType) + '/' + file.parentFolder + ) + } + val mediaUri = + getMediaUri(mediaType) + ?: throw IOException("Failed to get MediaStore URI for type $mediaType") + return resolver.insert(mediaUri, fileDetails) + ?: throw IOException("File could not be created in MediaStore") + } + + fun updateMediaFile( + fileUri: Uri, + file: FileDescription, + mediaType: MediaCollectionType + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + throw UnsupportedOperationException("Android version not supported") + val resolver = context.contentResolver + val fileDetails = + ContentValues().apply { + put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis() / 1000) + put(MediaStore.MediaColumns.MIME_TYPE, file.mimeType) + put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + getRelativePath(mediaType) + '/' + file.parentFolder + ) + } + return try { + val rowsUpdated = resolver.update(fileUri, fileDetails, null, null) + rowsUpdated > 0 + } catch (e: SecurityException) { + if (e is RecoverableSecurityException) { + throw SecurityException("App needs user permission to modify this file: ${e.message}") + } else { + throw SecurityException("SecurityException occurred during update: ${e.message}") + } + } + } + + fun writeToMediaFile(fileUri: Uri, filePath: String, transformFile: Boolean = false): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + throw UnsupportedOperationException("Android version not supported") + val resolver = context.contentResolver + try { + val src = File(filePath) + if (!src.exists()) throw IOException("No such file ('$filePath')") + val descr = + resolver.openFileDescriptor(fileUri, "w") + ?: throw IOException("Failed to open file descriptor") + + FileInputStream(src).use { fin -> + FileOutputStream(descr.fileDescriptor).use { out -> + if (transformFile) { + val bytes = fin.readBytes() + // Implement transformation logic if needed + out.write(bytes) // No transformation in this version + } else { + FileUtils.copy(fin, out) + } + } + } + + return true + } catch (e: Exception) { + throw IOException("Failed to write file: ${e.message}", e) + } + } + + fun copyToMediaStore(file: FileDescription, mediaType: MediaCollectionType, path: String): Uri { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + throw UnsupportedOperationException("Android version not supported") + val resolver = context.contentResolver + val srcFile = File(path) + if (!srcFile.exists()) throw IOException("No such file ('$path')") + val fileUri = createMediaFile(file, mediaType) + try { + val pendingValues = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 1) } + if (resolver.update(fileUri, pendingValues, null, null) == 0) { + cleanupMediaStoreEntry(fileUri, resolver) + throw IOException( + "Failed to mark media file as pending (0 rows updated). Original entry cleaned up." + ) + } + val writeSuccessful = writeToMediaFile(fileUri, path, false) + if (writeSuccessful) { + val commitValues = + ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + if (resolver.update(fileUri, commitValues, null, null) > 0) { + return fileUri + } else { + cleanupMediaStoreEntry(fileUri, resolver) + throw IOException( + "Failed to commit media file (unmark as pending - 0 rows updated). Entry with data cleaned up." + ) + } + } else { + cleanupMediaStoreEntry(fileUri, resolver) + throw IOException("Failed to write file to MediaStore.") + } + } catch (e: Exception) { + cleanupMediaStoreEntry(fileUri, resolver) + throw IOException("Unexpected error during copyToMediaStore: ${e.message}", e) + } + } + + fun query(query: MediaStoreSearchOptions): MediaStoreFile? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + throw UnsupportedOperationException("Android version not supported") + var cursor: Cursor? = null + + try { + val resolver = context.contentResolver + val queryUri:String = query.uri ?: "" + val queryMediaType: String = query.mediaType.toString() + val queryFilename: String = query.fileName ?: "" + val queryRelativePath: String = query.relativePath ?: "" + + val mediaCollectionType = try { + MediaCollectionType.valueOf(queryMediaType) + } catch (e: IllegalArgumentException) { + Log.e("RNFS2", "Invalid media type provided: $queryMediaType") + return null + } + + val mediaURI = + if (queryUri.isNotEmpty()) queryUri.toUri() + else getMediaUri(mediaCollectionType) + val projection = + arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.DATE_ADDED, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.RELATIVE_PATH + ) + val selection: String? + val selectionArgs: Array? + if (queryUri.isEmpty()) { + val relativePath = getRelativePath(mediaCollectionType) + selection = + MediaStore.MediaColumns.DISPLAY_NAME + + " = ? AND " + + MediaStore.MediaColumns.RELATIVE_PATH + + " = ?" + selectionArgs = arrayOf(queryFilename, "$relativePath/${queryRelativePath}/") + } else { + selection = null + selectionArgs = null + } + cursor = resolver.query(mediaURI!!, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + val name = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) + val mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)) + val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)) + val dateAdded = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)) + val dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)) + val relativePath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)) + val fileUri = Uri.withAppendedPath(mediaURI, id.toString()) + return MediaStoreFile( + uri = fileUri.toString(), + name = name, + mimeType = mimeType, + size = size.toDouble(), + dateAdded = dateAdded, + dateModified = dateModified, + relativePath = relativePath + ) + } + return null + } finally { + cursor?.close() + } + } + + fun delete(fileUri: Uri): Boolean { + val resolver = context.contentResolver + return resolver.delete(fileUri, null, null) > 0 + } + + private fun cleanupMediaStoreEntry(fileUri: Uri, resolver: ContentResolver) { + try { + resolver.delete(fileUri, null, null) + } catch (deleteError: Exception) { + Log.e("RNFS2", "Failed to cleanup MediaStore entry: ${deleteError.message}") + } + } +} diff --git a/android/src/main/java/com/margelo/nitro/fs2/utils/BufferPool.kt b/android/src/main/java/com/margelo/nitro/fs2/utils/BufferPool.kt new file mode 100644 index 00000000..7301ec66 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/utils/BufferPool.kt @@ -0,0 +1,97 @@ +package com.margelo.nitro.fs2.utils + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicInteger + +/** + * A thread-safe pool of byte arrays for efficient memory reuse. + * @param bufferSize The size of each buffer in the pool + * @param maxPoolSize The maximum number of buffers to keep in the pool + */ +class BufferPool( + private val bufferSize: Int = DEFAULT_BUFFER_SIZE, + private val maxPoolSize: Int = DEFAULT_MAX_POOL_SIZE +) { + private val pool = ConcurrentLinkedQueue() + private val currentPoolSize = AtomicInteger(0) + + /** + * Acquires a buffer from the pool or creates a new one if none are available. + * @param requestedSize The desired buffer size. If larger than bufferSize, a new buffer will be created. + * @return A byte array of at least the requested size + */ + fun acquire(requestedSize: Int = bufferSize): ByteArray { + if (requestedSize > bufferSize) { + return ByteArray(requestedSize) + } + + val pooledBuffer = pool.poll() + return if (pooledBuffer != null) { + currentPoolSize.decrementAndGet() + pooledBuffer + } else { + ByteArray(bufferSize) + } + } + + /** + * Returns a buffer to the pool if there's room, otherwise lets it be garbage collected. + * @param buffer The buffer to return to the pool + */ + fun release(buffer: ByteArray) { + if (buffer.size != bufferSize) { + return // Wrong size, let it be garbage collected + } + + // Only add to pool if we haven't exceeded the limit + if (currentPoolSize.get() < maxPoolSize && pool.offer(buffer)) { + currentPoolSize.incrementAndGet() + } + // If pool.offer() fails or we're at capacity, let buffer be GC'd + } + + /** + * Clears all buffers from the pool. + */ + fun clear() { + pool.clear() + currentPoolSize.set(0) + } + + /** + * Returns the current number of buffers available in the pool. + */ + fun availableBuffers(): Int = currentPoolSize.get() + + /** + * Returns pool statistics for monitoring. + */ + fun getStats(): PoolStats = PoolStats( + bufferSize = bufferSize, + maxPoolSize = maxPoolSize, + currentPoolSize = currentPoolSize.get(), + poolUtilization = currentPoolSize.get().toDouble() / maxPoolSize + ) + + data class PoolStats( + val bufferSize: Int, + val maxPoolSize: Int, + val currentPoolSize: Int, + val poolUtilization: Double + ) + + companion object { + const val DEFAULT_BUFFER_SIZE = 8192 // 8KB + const val DEFAULT_MAX_POOL_SIZE = 25 + + /** + * Creates a pool optimized for small frequent reads (similar to Node.js behavior) + */ + fun createSmallBufferPool(): BufferPool = BufferPool(4096, 50) + + /** + * Creates a pool optimized for large file operations + */ + fun createLargeBufferPool(): BufferPool = BufferPool(65536, 10) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/margelo/nitro/fs2/utils/Fs2Util.kt b/android/src/main/java/com/margelo/nitro/fs2/utils/Fs2Util.kt new file mode 100644 index 00000000..129c9f86 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/utils/Fs2Util.kt @@ -0,0 +1,43 @@ +package com.margelo.nitro.fs2.utils + +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import androidx.core.net.toUri +import java.io.File +import com.margelo.nitro.fs2.IORejectionException + +object Fs2Util { + fun getFileUri(filepath: String, isDirectoryAllowed: Boolean = false): Uri { + val uri = filepath.toUri() + if (uri.scheme == null) { + val file = File(filepath) + if (!isDirectoryAllowed && file.isDirectory) { + throw IORejectionException( + "EISDIR", + "EISDIR: illegal operation on a directory, read '$filepath'" + ) + } + return "file://$filepath".toUri() + } + return uri + } + + fun getOriginalFilepath(context: Context, filepath: String, isDirectoryAllowed: Boolean = false): String { + val uri = getFileUri(filepath, isDirectoryAllowed) + var originalFilepath = filepath + if ("content" == uri.scheme) { + try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + originalFilepath = cursor.getString(columnIndex) + } + } + } catch (e: IllegalArgumentException) { + // Ignored in original code + } + } + return originalFilepath + } +} \ No newline at end of file diff --git a/android/src/main/java/com/margelo/nitro/fs2/utils/StreamError.kt b/android/src/main/java/com/margelo/nitro/fs2/utils/StreamError.kt new file mode 100644 index 00000000..aa2ba002 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/fs2/utils/StreamError.kt @@ -0,0 +1,31 @@ +package com.margelo.nitro.fs2.utils + +sealed class StreamError : Exception() { + data class NotFound(val path: String) : StreamError() { + override val message: String = "ENOENT: File does not exist: $path" + } + + data class AccessDenied(val path: String) : StreamError() { + override val message: String = "EACCES: Permission denied: $path" + } + + data class IOError(override val message: String) : StreamError() + + data class StorageError(val reason: String) : StreamError() { + override val message: String = "Storage error: $reason" + } + + data class StreamClosed(val streamId: String) : StreamError() { + override val message: String = "EPIPE: Stream is closed: $streamId" + } + + data class StreamInactive(val streamId: String) : StreamError() { + override val message: String = "EPIPE: Stream is not active: $streamId" + } + + data class InvalidStream(val streamId: String) : StreamError() { + override val message: String = "ENOENT: No such stream: $streamId" + } + + data class BufferError(override val message: String) : StreamError() +} \ No newline at end of file diff --git a/android/src/main/java/com/rnfs2/DownloadParams.java b/android/src/main/java/com/rnfs2/DownloadParams.java deleted file mode 100644 index a7b4c40d..00000000 --- a/android/src/main/java/com/rnfs2/DownloadParams.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.rnfs2; - -import java.io.File; -import java.net.URL; -import java.util.*; - -import com.facebook.react.bridge.ReadableMap; - -public class DownloadParams { - public interface OnTaskCompleted { - void onTaskCompleted(DownloadResult res); - } - - public interface OnDownloadBegin { - void onDownloadBegin(int statusCode, long contentLength, Map headers); - } - - public interface OnDownloadProgress { - void onDownloadProgress(long contentLength, long bytesWritten); - } - - public URL src; - public File dest; - public ReadableMap headers; - public int progressInterval; - public float progressDivider; - public int readTimeout; - public int connectionTimeout; - public OnTaskCompleted onTaskCompleted; - public OnDownloadBegin onDownloadBegin; - public OnDownloadProgress onDownloadProgress; -} diff --git a/android/src/main/java/com/rnfs2/DownloadResult.java b/android/src/main/java/com/rnfs2/DownloadResult.java deleted file mode 100644 index 8b042aed..00000000 --- a/android/src/main/java/com/rnfs2/DownloadResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.rnfs2; - -public class DownloadResult { - public int statusCode; - public long bytesWritten; - public Exception exception; -} diff --git a/android/src/main/java/com/rnfs2/Downloader.java b/android/src/main/java/com/rnfs2/Downloader.java deleted file mode 100644 index f568e918..00000000 --- a/android/src/main/java/com/rnfs2/Downloader.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.rnfs2; - -import java.io.FileOutputStream; -import java.io.BufferedInputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URL; -import java.net.HttpURLConnection; -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; - -import android.util.Log; - -import android.os.AsyncTask; - -import com.facebook.react.bridge.ReadableMapKeySetIterator; - -public class Downloader extends AsyncTask { - private DownloadParams mParam; - private AtomicBoolean mAbort = new AtomicBoolean(false); - DownloadResult res; - - protected DownloadResult doInBackground(DownloadParams... params) { - mParam = params[0]; - res = new DownloadResult(); - - new Thread(new Runnable() { - public void run() { - try { - download(mParam, res); - mParam.onTaskCompleted.onTaskCompleted(res); - } catch (Exception ex) { - res.exception = ex; - mParam.onTaskCompleted.onTaskCompleted(res); - } - } - }).start(); - - return res; - } - - private void download(DownloadParams param, DownloadResult res) throws Exception { - InputStream input = null; - FileOutputStream output = null; - HttpURLConnection connection = null; - - try { - connection = (HttpURLConnection)param.src.openConnection(); - - ReadableMapKeySetIterator iterator = param.headers.keySetIterator(); - - while (iterator.hasNextKey()) { - String key = iterator.nextKey(); - String value = param.headers.getString(key); - connection.setRequestProperty(key, value); - } - - connection.setConnectTimeout(param.connectionTimeout); - connection.setReadTimeout(param.readTimeout); - connection.connect(); - - int statusCode = connection.getResponseCode(); - long lengthOfFile = getContentLength(connection); - - boolean isRedirect = ( - statusCode != HttpURLConnection.HTTP_OK && - ( - statusCode == HttpURLConnection.HTTP_MOVED_PERM || - statusCode == HttpURLConnection.HTTP_MOVED_TEMP || - statusCode == 307 || - statusCode == 308 - ) - ); - - if (isRedirect) { - String redirectURL = connection.getHeaderField("Location"); - connection.disconnect(); - - connection = (HttpURLConnection) new URL(redirectURL).openConnection(); - connection.setConnectTimeout(5000); - connection.connect(); - - statusCode = connection.getResponseCode(); - lengthOfFile = getContentLength(connection); - } - if(statusCode >= 200 && statusCode < 300) { - Map> headers = connection.getHeaderFields(); - - Map headersFlat = new HashMap<>(); - - for (Map.Entry> entry : headers.entrySet()) { - String headerKey = entry.getKey(); - String valueKey = entry.getValue().get(0); - - if (headerKey != null && valueKey != null) { - headersFlat.put(headerKey, valueKey); - } - } - - if (mParam.onDownloadBegin != null) { - mParam.onDownloadBegin.onDownloadBegin(statusCode, lengthOfFile, headersFlat); - } - - input = new BufferedInputStream(connection.getInputStream(), 8 * 1024); - output = new FileOutputStream(param.dest); - - byte data[] = new byte[8 * 1024]; - long total = 0; - int count; - double lastProgressValue = 0; - long lastProgressEmitTimestamp = 0; - boolean hasProgressCallback = mParam.onDownloadProgress != null; - - while ((count = input.read(data)) != -1) { - if (mAbort.get()) throw new Exception("Download has been aborted"); - - total += count; - - if (hasProgressCallback) { - if (param.progressInterval > 0) { - long timestamp = System.currentTimeMillis(); - if (timestamp - lastProgressEmitTimestamp > param.progressInterval) { - lastProgressEmitTimestamp = timestamp; - publishProgress(new long[]{lengthOfFile, total}); - } - } else if (param.progressDivider <= 0) { - publishProgress(new long[]{lengthOfFile, total}); - } else { - double progress = Math.round(((double) total * 100) / lengthOfFile); - if (progress % param.progressDivider == 0) { - if ((progress != lastProgressValue) || (total == lengthOfFile)) { - Log.d("Downloader", "EMIT: " + String.valueOf(progress) + ", TOTAL:" + String.valueOf(total)); - lastProgressValue = progress; - publishProgress(new long[]{lengthOfFile, total}); - } - } - } - } - - output.write(data, 0, count); - } - - output.flush(); - output.getFD().sync(); - res.bytesWritten = total; - } - res.statusCode = statusCode; - } finally { - if (output != null) output.close(); - if (input != null) input.close(); - if (connection != null) connection.disconnect(); - } - } - - private long getContentLength(HttpURLConnection connection){ - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - return connection.getContentLengthLong(); - } - return connection.getContentLength(); - } - - protected void stop() { - mAbort.set(true); - } - - @Override - protected void onProgressUpdate(long[]... values) { - super.onProgressUpdate(values); - if (mParam.onDownloadProgress != null) { - mParam.onDownloadProgress.onDownloadProgress(values[0][0], values[0][1]); - } - } - - protected void onPostExecute(Exception ex) { - - } -} diff --git a/android/src/main/java/com/rnfs2/IORejectionException.java b/android/src/main/java/com/rnfs2/IORejectionException.java deleted file mode 100644 index e243c15e..00000000 --- a/android/src/main/java/com/rnfs2/IORejectionException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.rnfs2; - -class IORejectionException extends Exception { - private String code; - - public IORejectionException(String code, String message) { - super(message); - this.code = code; - } - - public String getCode() { - return code; - } -} diff --git a/android/src/main/java/com/rnfs2/RNFSFileTransformer.java b/android/src/main/java/com/rnfs2/RNFSFileTransformer.java deleted file mode 100644 index a95bc32b..00000000 --- a/android/src/main/java/com/rnfs2/RNFSFileTransformer.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.rnfs2; - -public class RNFSFileTransformer { - public interface FileTransformer { - public byte[] onWriteFile(byte[] data); - public byte[] onReadFile(byte[] data); - } - - public static RNFSFileTransformer.FileTransformer sharedFileTransformer; -} diff --git a/android/src/main/java/com/rnfs2/RNFSManager.java b/android/src/main/java/com/rnfs2/RNFSManager.java deleted file mode 100755 index 494f4830..00000000 --- a/android/src/main/java/com/rnfs2/RNFSManager.java +++ /dev/null @@ -1,665 +0,0 @@ -package com.rnfs2; - -import android.database.Cursor; -import android.net.Uri; -import android.os.Environment; -import android.os.StatFs; -import android.provider.MediaStore; -import android.util.Base64; -import android.util.SparseArray; -import android.media.MediaScannerConnection; - -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableArray; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.module.annotations.ReactModule; -import com.facebook.react.modules.core.RCTNativeAppEventEmitter; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.RandomAccessFile; -import java.net.URL; -import java.security.MessageDigest; -import java.util.HashMap; -import java.util.Map; - -@ReactModule(name = RNFSManager.MODULE_NAME) -public class RNFSManager extends ReactContextBaseJavaModule { - - static final String MODULE_NAME = "RNFSManager"; - - private static final String RNFSDocumentDirectoryPath = "RNFSDocumentDirectoryPath"; - private static final String RNFSExternalDirectoryPath = "RNFSExternalDirectoryPath"; - private static final String RNFSExternalStorageDirectoryPath = "RNFSExternalStorageDirectoryPath"; - private static final String RNFSPicturesDirectoryPath = "RNFSPicturesDirectoryPath"; - private static final String RNFSDownloadDirectoryPath = "RNFSDownloadDirectoryPath"; - private static final String RNFSTemporaryDirectoryPath = "RNFSTemporaryDirectoryPath"; - private static final String RNFSCachesDirectoryPath = "RNFSCachesDirectoryPath"; - private static final String RNFSExternalCachesDirectoryPath = "RNFSExternalCachesDirectoryPath"; - private static final String RNFSDocumentDirectory = "RNFSDocumentDirectory"; - - private static final String RNFSFileTypeRegular = "RNFSFileTypeRegular"; - private static final String RNFSFileTypeDirectory = "RNFSFileTypeDirectory"; - - private SparseArray downloaders = new SparseArray<>(); - - private final ReactApplicationContext reactContext; - - public RNFSManager(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - } - - @Override - public String getName() { - return MODULE_NAME; - } - - private Uri getFileUri(String filepath, boolean isDirectoryAllowed) throws IORejectionException { - Uri uri = Uri.parse(filepath); - if (uri.getScheme() == null) { - // No prefix, assuming that provided path is absolute path to file - File file = new File(filepath); - if (!isDirectoryAllowed && file.isDirectory()) { - throw new IORejectionException("EISDIR", "EISDIR: illegal operation on a directory, read '" + filepath + "'"); - } - uri = Uri.parse("file://" + filepath); - } - return uri; - } - - private String getOriginalFilepath(String filepath, boolean isDirectoryAllowed) throws IORejectionException { - Uri uri = getFileUri(filepath, isDirectoryAllowed); - String originalFilepath = filepath; - if (uri.getScheme().equals("content")) { - try { - Cursor cursor = reactContext.getContentResolver().query(uri, null, null, null, null); - if (cursor.moveToFirst()) { - originalFilepath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)); - } - cursor.close(); - } catch (IllegalArgumentException ignored) { - } - } - return originalFilepath; - } - - private InputStream getInputStream(String filepath) throws IORejectionException { - Uri uri = getFileUri(filepath, false); - InputStream stream; - try { - stream = reactContext.getContentResolver().openInputStream(uri); - } catch (FileNotFoundException ex) { - throw new IORejectionException("ENOENT", "ENOENT: " + ex.getMessage() + ", open '" + filepath + "'"); - } - if (stream == null) { - throw new IORejectionException("ENOENT", "ENOENT: could not open an input stream for '" + filepath + "'"); - } - return stream; - } - - private String getWriteAccessByAPILevel() { - return android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P ? "w" : "rwt"; - } - - private OutputStream getOutputStream(String filepath, boolean append) throws IORejectionException { - Uri uri = getFileUri(filepath, false); - OutputStream stream; - try { - stream = reactContext.getContentResolver().openOutputStream(uri, append ? "wa" : getWriteAccessByAPILevel()); - } catch (FileNotFoundException ex) { - throw new IORejectionException("ENOENT", "ENOENT: " + ex.getMessage() + ", open '" + filepath + "'"); - } - if (stream == null) { - throw new IORejectionException("ENOENT", "ENOENT: could not open an output stream for '" + filepath + "'"); - } - return stream; - } - - private static byte[] getInputStreamBytes(InputStream inputStream) throws IOException { - byte[] bytesResult; - ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream(); - int bufferSize = 1024; - byte[] buffer = new byte[bufferSize]; - try { - int len; - while ((len = inputStream.read(buffer)) != -1) { - byteBuffer.write(buffer, 0, len); - } - bytesResult = byteBuffer.toByteArray(); - } finally { - try { - byteBuffer.close(); - } catch (IOException ignored) { - } - } - return bytesResult; - } - - @ReactMethod - public void writeFile(String filepath, String base64Content, ReadableMap options, Promise promise) { - try { - byte[] bytes = Base64.decode(base64Content, Base64.DEFAULT); - - OutputStream outputStream = getOutputStream(filepath, false); - outputStream.write(bytes); - outputStream.close(); - - promise.resolve(null); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void appendFile(String filepath, String base64Content, Promise promise) { - try { - byte[] bytes = Base64.decode(base64Content, Base64.DEFAULT); - - OutputStream outputStream = getOutputStream(filepath, true); - outputStream.write(bytes); - outputStream.close(); - - promise.resolve(null); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void write(String filepath, String base64Content, int position, Promise promise) { - try { - byte[] bytes = Base64.decode(base64Content, Base64.DEFAULT); - - if (position < 0) { - OutputStream outputStream = getOutputStream(filepath, true); - outputStream.write(bytes); - outputStream.close(); - } else { - RandomAccessFile file = new RandomAccessFile(filepath, "rw"); - file.seek(position); - file.write(bytes); - file.close(); - } - - promise.resolve(null); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void exists(String filepath, Promise promise) { - try { - File file = new File(filepath); - promise.resolve(file.exists()); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void readFile(String filepath, Promise promise) { - try (InputStream inputStream = getInputStream(filepath)) { - byte[] inputData = getInputStreamBytes(inputStream); - String base64Content = Base64.encodeToString(inputData, Base64.NO_WRAP); - - promise.resolve(base64Content); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void read(String filepath, int length, int position, Promise promise) { - try (InputStream inputStream = getInputStream(filepath)) { - byte[] buffer = new byte[length]; - inputStream.skip(position); - int bytesRead = inputStream.read(buffer, 0, length); - - String base64Content = Base64.encodeToString(buffer, 0, bytesRead, Base64.NO_WRAP); - - promise.resolve(base64Content); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void hash(String filepath, String algorithm, Promise promise) { - try { - Map algorithms = new HashMap<>(); - - algorithms.put("md5", "MD5"); - algorithms.put("sha1", "SHA-1"); - algorithms.put("sha224", "SHA-224"); - algorithms.put("sha256", "SHA-256"); - algorithms.put("sha384", "SHA-384"); - algorithms.put("sha512", "SHA-512"); - - if (!algorithms.containsKey(algorithm)) throw new Exception("Invalid hash algorithm"); - - File file = new File(filepath); - - if (file.isDirectory()) { - rejectFileIsDirectory(promise); - return; - } - - if (!file.exists()) { - rejectFileNotFound(promise, filepath); - return; - } - - MessageDigest md = MessageDigest.getInstance(algorithms.get(algorithm)); - - FileInputStream inputStream = new FileInputStream(filepath); - byte[] buffer = new byte[1024 * 10]; // 10 KB Buffer - - int read; - while ((read = inputStream.read(buffer)) != -1) { - md.update(buffer, 0, read); - } - - StringBuilder hexString = new StringBuilder(); - for (byte digestByte : md.digest()) - hexString.append(String.format("%02x", digestByte)); - - promise.resolve(hexString.toString()); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - @ReactMethod - public void moveFile(String filepath, String destPath, ReadableMap options, Promise promise) { - try { - File inFile = new File(filepath); - - if (!inFile.renameTo(new File(destPath))) { - copyFile(filepath, destPath); - inFile.delete(); - } - - promise.resolve(true); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void copyFile(String filepath, String destPath, ReadableMap options, Promise promise) { - try { - copyFile(filepath, destPath); - - promise.resolve(null); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - private void copyFile(String filepath, String destPath) throws IOException, IORejectionException { - try (InputStream in = getInputStream(filepath)) { - try (OutputStream out = getOutputStream(destPath, false)) { - byte[] buffer = new byte[1024]; - int length; - while ((length = in.read(buffer)) > 0) { - out.write(buffer, 0, length); - } - } - } - } - - @ReactMethod - public void readDir(String directory, Promise promise) { - try { - File file = new File(directory); - - if (!file.exists()) throw new Exception("Folder does not exist"); - - File[] files = file.listFiles(); - - WritableArray fileMaps = Arguments.createArray(); - - for (File childFile : files) { - WritableMap fileMap = Arguments.createMap(); - - fileMap.putDouble("mtime", (double) childFile.lastModified() / 1000); - fileMap.putString("name", childFile.getName()); - fileMap.putString("path", childFile.getAbsolutePath()); - fileMap.putDouble("size", (double) childFile.length()); - fileMap.putInt("type", childFile.isDirectory() ? 1 : 0); - - fileMaps.pushMap(fileMap); - } - - promise.resolve(fileMaps); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, directory, ex); - } - } - - @ReactMethod - public void stat(String filepath, Promise promise) { - try { - String originalFilepath = getOriginalFilepath(filepath, true); - File file = new File(originalFilepath); - - if (!file.exists()) throw new Exception("File does not exist"); - - WritableMap statMap = Arguments.createMap(); - statMap.putInt("ctime", (int) (file.lastModified() / 1000)); - statMap.putInt("mtime", (int) (file.lastModified() / 1000)); - statMap.putDouble("size", (double) file.length()); - statMap.putInt("type", file.isDirectory() ? 1 : 0); - statMap.putString("originalFilepath", originalFilepath); - - promise.resolve(statMap); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void unlink(String filepath, Promise promise) { - try { - File file = new File(filepath); - - if (!file.exists()) throw new Exception("File does not exist"); - - DeleteRecursive(file); - - promise.resolve(null); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - private void DeleteRecursive(File fileOrDirectory) { - if (fileOrDirectory.isDirectory()) { - for (File child : fileOrDirectory.listFiles()) { - DeleteRecursive(child); - } - } - - fileOrDirectory.delete(); - } - - @ReactMethod - public void mkdir(String filepath, ReadableMap options, Promise promise) { - try { - File file = new File(filepath); - - file.mkdirs(); - - boolean exists = file.exists(); - - if (!exists) throw new Exception("Directory could not be created"); - - promise.resolve(null); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - private void sendEvent(ReactContext reactContext, String eventName, WritableMap params) { - reactContext - .getJSModule(RCTNativeAppEventEmitter.class) - .emit(eventName, params); - } - - @ReactMethod - public void downloadFile(final ReadableMap options, final Promise promise) { - try { - File file = new File(options.getString("toFile")); - URL url = new URL(options.getString("fromUrl")); - final int jobId = options.getInt("jobId"); - ReadableMap headers = options.getMap("headers"); - int progressInterval = options.getInt("progressInterval"); - int progressDivider = options.getInt("progressDivider"); - int readTimeout = options.getInt("readTimeout"); - int connectionTimeout = options.getInt("connectionTimeout"); - boolean hasBeginCallback = options.getBoolean("hasBeginCallback"); - boolean hasProgressCallback = options.getBoolean("hasProgressCallback"); - - DownloadParams params = new DownloadParams(); - - params.src = url; - params.dest = file; - params.headers = headers; - params.progressInterval = progressInterval; - params.progressDivider = progressDivider; - params.readTimeout = readTimeout; - params.connectionTimeout = connectionTimeout; - - params.onTaskCompleted = new DownloadParams.OnTaskCompleted() { - public void onTaskCompleted(DownloadResult res) { - if (res.exception == null) { - WritableMap infoMap = Arguments.createMap(); - - infoMap.putInt("jobId", jobId); - infoMap.putInt("statusCode", res.statusCode); - infoMap.putDouble("bytesWritten", (double)res.bytesWritten); - - promise.resolve(infoMap); - } else { - reject(promise, options.getString("toFile"), res.exception); - } - } - }; - - if (hasBeginCallback) { - params.onDownloadBegin = new DownloadParams.OnDownloadBegin() { - public void onDownloadBegin(int statusCode, long contentLength, Map headers) { - WritableMap headersMap = Arguments.createMap(); - - for (Map.Entry entry : headers.entrySet()) { - headersMap.putString(entry.getKey(), entry.getValue()); - } - - WritableMap data = Arguments.createMap(); - - data.putInt("jobId", jobId); - data.putInt("statusCode", statusCode); - data.putDouble("contentLength", (double)contentLength); - data.putMap("headers", headersMap); - - sendEvent(getReactApplicationContext(), "DownloadBegin", data); - } - }; - } - - if (hasProgressCallback) { - params.onDownloadProgress = new DownloadParams.OnDownloadProgress() { - public void onDownloadProgress(long contentLength, long bytesWritten) { - WritableMap data = Arguments.createMap(); - - data.putInt("jobId", jobId); - data.putDouble("contentLength", (double)contentLength); - data.putDouble("bytesWritten", (double)bytesWritten); - - sendEvent(getReactApplicationContext(), "DownloadProgress", data); - } - }; - } - - Downloader downloader = new Downloader(); - - downloader.execute(params); - - this.downloaders.put(jobId, downloader); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, options.getString("toFile"), ex); - } - } - - @ReactMethod - public void stopDownload(int jobId) { - Downloader downloader = this.downloaders.get(jobId); - - if (downloader != null) { - downloader.stop(); - } - } - - @ReactMethod - public void pathForBundle(String bundleNamed, Promise promise) { - // TODO: Not sure what equivalent would be? - } - - @ReactMethod - public void pathForGroup(String bundleNamed, Promise promise) { - // TODO: Not sure what equivalent would be? - } - - @ReactMethod - public void getFSInfo(Promise promise) { - File path = Environment.getDataDirectory(); - StatFs stat = new StatFs(path.getPath()); - StatFs statEx = new StatFs(Environment.getExternalStorageDirectory().getPath()); - long totalSpace = stat.getTotalBytes(); - long freeSpace = stat.getFreeBytes(); - long totalSpaceEx = statEx.getTotalBytes(); - long freeSpaceEx = statEx.getFreeBytes(); - - WritableMap info = Arguments.createMap(); - info.putDouble("totalSpace", (double) totalSpace); // Int32 too small, must use Double - info.putDouble("freeSpace", (double) freeSpace); - info.putDouble("totalSpaceEx", (double) totalSpaceEx); - info.putDouble("freeSpaceEx", (double) freeSpaceEx); - promise.resolve(info); - } - - @ReactMethod - public void touch(String filepath, double mtime, double ctime, Promise promise) { - try { - File file = new File(filepath); - promise.resolve(file.setLastModified((long) mtime)); - } catch (Exception ex) { - ex.printStackTrace(); - reject(promise, filepath, ex); - } - } - - @ReactMethod - public void getAllExternalFilesDirs(Promise promise){ - File[] allExternalFilesDirs = this.getReactApplicationContext().getExternalFilesDirs(null); - WritableArray fs = Arguments.createArray(); - for (File f : allExternalFilesDirs) { - if (f != null) { - fs.pushString(f.getAbsolutePath()); - } - } - promise.resolve(fs); - } - - @ReactMethod - public void scanFile(String path, final Promise promise) { - MediaScannerConnection.scanFile(this.getReactApplicationContext(), - new String[]{path}, - null, - new MediaScannerConnection.MediaScannerConnectionClient() { - @Override - public void onMediaScannerConnected() {} - @Override - public void onScanCompleted(String path, Uri uri) { - promise.resolve(path); - } - } - ); - } - - // Required for rn built in EventEmitter Calls. - @ReactMethod - public void addListener(String eventName) { - - } - - @ReactMethod - public void removeListeners(Integer count) { - - } - - private void reject(Promise promise, String filepath, Exception ex) { - if (ex instanceof FileNotFoundException) { - rejectFileNotFound(promise, filepath); - return; - } - if (ex instanceof IORejectionException) { - IORejectionException ioRejectionException = (IORejectionException) ex; - promise.reject(ioRejectionException.getCode(), ioRejectionException.getMessage()); - return; - } - - promise.reject(null, ex.getMessage()); - } - - private void rejectFileNotFound(Promise promise, String filepath) { - promise.reject("ENOENT", "ENOENT: no such file or directory, open '" + filepath + "'"); - } - - private void rejectFileIsDirectory(Promise promise) { - promise.reject("EISDIR", "EISDIR: illegal operation on a directory, read"); - } - - @Override - public Map getConstants() { - final Map constants = new HashMap<>(); - - constants.put(RNFSDocumentDirectory, 0); - constants.put(RNFSDocumentDirectoryPath, this.getReactApplicationContext().getFilesDir().getAbsolutePath()); - constants.put(RNFSTemporaryDirectoryPath, this.getReactApplicationContext().getCacheDir().getAbsolutePath()); - constants.put(RNFSPicturesDirectoryPath, Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getAbsolutePath()); - constants.put(RNFSCachesDirectoryPath, this.getReactApplicationContext().getCacheDir().getAbsolutePath()); - constants.put(RNFSDownloadDirectoryPath, Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath()); - constants.put(RNFSFileTypeRegular, 0); - constants.put(RNFSFileTypeDirectory, 1); - - File externalStorageDirectory = Environment.getExternalStorageDirectory(); - if (externalStorageDirectory != null) { - constants.put(RNFSExternalStorageDirectoryPath, externalStorageDirectory.getAbsolutePath()); - } else { - constants.put(RNFSExternalStorageDirectoryPath, null); - } - - File externalDirectory = this.getReactApplicationContext().getExternalFilesDir(null); - if (externalDirectory != null) { - constants.put(RNFSExternalDirectoryPath, externalDirectory.getAbsolutePath()); - } else { - constants.put(RNFSExternalDirectoryPath, null); - } - - File externalCachesDirectory = this.getReactApplicationContext().getExternalCacheDir(); - if (externalCachesDirectory != null) { - constants.put(RNFSExternalCachesDirectoryPath, externalCachesDirectory.getAbsolutePath()); - } else { - constants.put(RNFSExternalCachesDirectoryPath, null); - } - - return constants; - } -} diff --git a/android/src/main/java/com/rnfs2/RNFSMediaStoreManager.java b/android/src/main/java/com/rnfs2/RNFSMediaStoreManager.java deleted file mode 100644 index c2a36fea..00000000 --- a/android/src/main/java/com/rnfs2/RNFSMediaStoreManager.java +++ /dev/null @@ -1,480 +0,0 @@ -package com.rnfs2; - -import android.app.RecoverableSecurityException; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.os.ParcelFileDescriptor; -import android.provider.MediaStore; -import android.os.FileUtils; -import android.util.Log; - -import androidx.annotation.RequiresApi; - -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableArray; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.module.annotations.ReactModule; - -import com.rnfs2.Utils.FileDescription; -import com.rnfs2.Utils.MediaStoreQuery; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -@ReactModule(name = RNFSMediaStoreManager.MODULE_NAME) -public class RNFSMediaStoreManager extends ReactContextBaseJavaModule { - - static final String MODULE_NAME = "RNFSMediaStoreManager"; - private final ReactApplicationContext reactContext; - - public enum MediaType { - Audio, - Image, - Video, - Download, - } - - private static final String RNFSMediaStoreTypeAudio = MediaType.Audio.toString(); - private static final String RNFSMediaStoreTypeImage = MediaType.Image.toString(); - private static final String RNFSMediaStoreTypeVideo = MediaType.Video.toString(); - private static final String RNFSMediaStoreTypeDownload = MediaType.Download.toString(); - - public RNFSMediaStoreManager(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - } - - @Override - public String getName() { - return MODULE_NAME; - } - - private static Uri getMediaUri(MediaType mt) { - Uri res = null; - if (mt == MediaType.Audio) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - res = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); - } else { - res = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; - } - } else if (mt == MediaType.Video) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - res = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); - } else { - res = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; - } - } else if (mt == MediaType.Image) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - res = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); - } else { - res = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - } - } else if (mt == MediaType.Download) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - res = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); - } - } - - return res; - } - - private static String getRelativePath(MediaType mt, ReactApplicationContext ctx) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (mt == MediaType.Audio) { - return Environment.DIRECTORY_MUSIC; - } - - if (mt == MediaType.Video) { - return Environment.DIRECTORY_MOVIES; - } - - if (mt == MediaType.Image) { - return Environment.DIRECTORY_PICTURES; - } - - if (mt == MediaType.Download) { - return Environment.DIRECTORY_DOWNLOADS; - } - - return Environment.DIRECTORY_DOWNLOADS; - } else { - // throw error not supported - return null; - } - } - - @ReactMethod - public void createMediaFile(ReadableMap filedata, String mediaType, Promise promise) { - if (!(filedata.hasKey("name") && filedata.hasKey("parentFolder") && filedata.hasKey("mimeType"))) { - promise.reject("RNFS2.createMediaFile", "Invalid filedata: " + filedata.toString()); - return; - } - - if (mediaType == null) { - promise.reject("RNFS2.createMediaFile", "Invalid mediatype"); - } - - FileDescription file = new FileDescription(filedata.getString("name"), filedata.getString("mimeType"), filedata.getString("parentFolder")); - Uri res = createNewMediaFile(file, MediaType.valueOf(mediaType), promise, reactContext); - - if (res != null) { - promise.resolve(res.toString()); - } else { - promise.reject("RNFS2.createMediaFile", "File could not be created"); - } - } - - @ReactMethod void updateMediaFile(String fileUri, ReadableMap filedata, String mediaType, Promise promise) { - if (!(filedata.hasKey("name") && filedata.hasKey("parentFolder") && filedata.hasKey("mimeType"))) { - promise.reject("RNFS2.updateMediaFile", "Invalid filedata: " + filedata.toString()); - return; - } - - if (mediaType == null) { - promise.reject("RNFS2.updateMediaFile", "Invalid mediatype"); - return; - } - - FileDescription file = new FileDescription(filedata.getString("name"), filedata.getString("mimeType"), filedata.getString("parentFolder")); - Uri fileuri = Uri.parse(fileUri); - boolean res = updateExistingMediaFile(fileuri, file, MediaType.valueOf(mediaType), promise, reactContext); - if (res) { - promise.resolve("Success"); - } - } - - @ReactMethod - public void writeToMediaFile(String fileUri, String path, boolean transformFile, Promise promise) { - boolean res = writeToMediaFile(Uri.parse(fileUri), path, transformFile, false, promise, reactContext); - if (res) { - promise.resolve("Success"); - } - } - - @ReactMethod - public void copyToMediaStore(ReadableMap filedata, String mediaType, String path, Promise promise) { - if (!(filedata.hasKey("name") && filedata.hasKey("parentFolder") && filedata.hasKey("mimeType"))) { - promise.reject("RNFS2.copyToMediaStore", "Invalid filedata: " + filedata.toString()); - return; - } - - if (mediaType == null) { - promise.reject("RNFS2.copyToMediaStore", "Invalid mediatype"); - return; - } - - if (path == null) { - promise.reject("RNFS2.copyToMediaStore", "Invalid path"); - return; - } - - try { - File srcFile = new File(path); - if (!srcFile.exists()) { - promise.reject("RNFS2.copyToMediaStore", "No such file ('" + path + "')"); - return; - } - } catch (Exception e) { - promise.reject("RNFS2.copyToMediaStore", "Error accessing source file: " + e.getMessage(), e); - return; - } - - ContentResolver resolver = reactContext.getContentResolver(); - Uri fileUri = null; - - try { - FileDescription fileDesc = new FileDescription(filedata.getString("name"), filedata.getString("mimeType"), filedata.getString("parentFolder")); - - fileUri = createNewMediaFile(fileDesc, MediaType.valueOf(mediaType), promise, reactContext); - - if (fileUri == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - promise.reject("RNFS2.copyToMediaStore", "Failed to create initial media file entry (null URI from createNewMediaFile on Q+)."); - } - return; - } - - ContentValues pendingValues = new ContentValues(); - pendingValues.put(MediaStore.MediaColumns.IS_PENDING, 1); - if (resolver.update(fileUri, pendingValues, null, null) == 0) { - cleanupMediaStoreEntry(fileUri, resolver); - promise.reject("RNFS2.copyToMediaStore", "Failed to mark media file as pending (0 rows updated). Original entry cleaned up."); - return; - } - - boolean writeSuccessful = writeToMediaFile(fileUri, path, false, true, promise, reactContext); - - if (writeSuccessful) { - ContentValues commitValues = new ContentValues(); - commitValues.put(MediaStore.MediaColumns.IS_PENDING, 0); - if (resolver.update(fileUri, commitValues, null, null) > 0) { - promise.resolve(fileUri.toString()); - } else { - cleanupMediaStoreEntry(fileUri, resolver); - promise.reject("RNFS2.copyToMediaStore", "Failed to commit media file (unmark as pending - 0 rows updated). Entry with data cleaned up."); - } - } - // If writeSuccessful is false, writeToMediaFile has already rejected and handled cleanup. - - } catch (Exception e) { - if (fileUri != null) { - cleanupMediaStoreEntry(fileUri, resolver); - } - promise.reject("RNFS2.copyToMediaStore", "Unexpected error during copyToMediaStore: " + e.getMessage(), e); - } - } - - @ReactMethod - public void query(ReadableMap query, Promise promise) { - try { - MediaStoreQuery mediaStoreQuery = new MediaStoreQuery(query.getString("uri"), query.getString("fileName"), query.getString("relativePath"), query.getString("mediaType")); - WritableMap queryResult = query(mediaStoreQuery, promise, reactContext); - promise.resolve(queryResult); - } catch (Exception e) { - promise.reject("RNFS2.query", "Error checking file existence: " + e.getMessage()); - } - } - - @ReactMethod - public void delete(String fileUri, Promise promise) { - try { - Uri uri = Uri.parse(fileUri); - ContentResolver resolver = reactContext.getContentResolver(); - int res = resolver.delete(uri, null, null); - if (res > 0) { - promise.resolve(true); - } else { - promise.resolve(false); - } - } catch (Exception e) { - promise.reject("RNFS2.delete", "Error deleting file: " + e.getMessage()); - } - } - - private Uri createNewMediaFile(FileDescription file, MediaType mediaType, Promise promise, ReactApplicationContext ctx) { - // Add a specific media item. - Context appCtx = reactContext.getApplicationContext(); - ContentResolver resolver = appCtx.getContentResolver(); - - ContentValues fileDetails = new ContentValues(); - String relativePath = getRelativePath(mediaType, ctx); - String mimeType = file.mimeType; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - fileDetails.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000); - fileDetails.put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis() / 1000); - fileDetails.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); - fileDetails.put(MediaStore.MediaColumns.DISPLAY_NAME, file.name); - fileDetails.put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath + '/' + file.parentFolder); - - Uri mediauri = getMediaUri(mediaType); - - try { - // Keeps a handle to the new file's URI in case we need to modify it later. - return resolver.insert(mediauri, fileDetails); - } catch (Exception e) { - return null; - } - } else { - // throw error not supported - promise.reject("RNFS2.createNewMediaFile", "Android version not supported"); - } - - return null; - } - - private boolean updateExistingMediaFile(Uri fileUri, FileDescription file, MediaType mediaType, Promise promise, ReactApplicationContext ctx) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - try { - Context appCtx = ctx.getApplicationContext(); - ContentResolver resolver = appCtx.getContentResolver(); - - ContentValues fileDetails = new ContentValues(); - String relativePath = getRelativePath(mediaType, ctx); - String mimeType = file.mimeType; - - fileDetails.put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis() / 1000); - fileDetails.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); - fileDetails.put(MediaStore.MediaColumns.DISPLAY_NAME, file.name); - fileDetails.put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath + '/' + file.parentFolder); - - int rowsUpdated = 0; - try { - rowsUpdated = resolver.update(fileUri, fileDetails, null, null); - } catch (SecurityException securityException) { - if (securityException instanceof RecoverableSecurityException) { - promise.reject("ERR_RECOVERABLE_SECURITY", "App needs user permission to modify this file." + securityException.getMessage()); - } else { - promise.reject("ERR_SECURITY_EXCEPTION", "SecurityException occurred during update: " + securityException.getMessage()); - } - - return false; - } - return rowsUpdated > 0; - } catch (Exception e) { - promise.reject("RNFS2.updateExistingMediaFile", "Error updating file: " + e.getMessage(), e); - return false; - } - } else { - promise.reject("RNFS2.updateExistingMediaFile", "Android version not supported"); - return false; - } - } - - private void cleanupMediaStoreEntry(Uri fileUri, ContentResolver resolver) { - try { - resolver.delete(fileUri, null, null); - } catch (Exception deleteError) { - Log.e("RNFS2", "Failed to cleanup MediaStore entry: " + deleteError.getMessage()); - } - } - - private boolean writeToMediaFile(Uri fileUri, String filePath, boolean transformFile, boolean shouldCleanupOnFailure, Promise promise, ReactApplicationContext ctx) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Context appCtx = ctx.getApplicationContext(); - ContentResolver resolver = appCtx.getContentResolver(); - OutputStream stream = null; - - try { - if (fileUri == null) { - promise.reject("RNFS2.createMediaFile", "Invalid file URI"); - return false; - } - - File src = new File(filePath); - if (!src.exists()) { - promise.reject("ENOENT", "No such file ('" + filePath + "')"); - return false; - } - - ParcelFileDescriptor descr = appCtx.getContentResolver().openFileDescriptor(fileUri, "w"); - if (descr == null) { - promise.reject("RNFS2.createMediaFile", "Failed to open file descriptor"); - return false; - } - - try (descr; FileInputStream fin = new FileInputStream(src); FileOutputStream out = new FileOutputStream(descr.getFileDescriptor())) { - if (transformFile) { - int length = (int) src.length(); - byte[] bytes = new byte[length]; - fin.read(bytes); - if (RNFSFileTransformer.sharedFileTransformer == null) { - throw new IllegalStateException("Write to media file with transform was specified but the shared file transformer is not set"); - } - byte[] transformedBytes = RNFSFileTransformer.sharedFileTransformer.onWriteFile(bytes); - out.write(transformedBytes); - } else { - FileUtils.copy(fin, out); - } - } - - stream = resolver.openOutputStream(fileUri); - - if (stream == null) { - promise.reject(new IOException("Failed to get output stream.")); - return false; - } - } catch (Exception e) { - if (shouldCleanupOnFailure) { - cleanupMediaStoreEntry(fileUri, resolver); - } - - promise.reject("RNFS2.createMediaFile", "Failed to write file: " + e.getMessage()); - return false; - } finally { - if (stream != null) { - try { - stream.close(); - } catch (IOException e) { - Log.e("RNFS2", "Failed to close output stream: " + e.getMessage()); - } - } - } - - return true; - } else { - promise.reject("RNFS2.createMediaFile", "Android version not supported"); - return false; - } - } - - private WritableMap query(MediaStoreQuery query, Promise promise, ReactApplicationContext ctx) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Cursor cursor = null; - try { - Context appCtx = ctx.getApplicationContext(); - ContentResolver resolver = appCtx.getContentResolver(); - WritableMap queryResultsMap = Arguments.createMap(); - - Uri mediaURI = !Objects.equals(query.uri, "") ? Uri.parse(query.uri) : getMediaUri(MediaType.valueOf(query.mediaType)); - String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.RELATIVE_PATH}; - - String selection = null; - String[] selectionArgs = null; - - if (Objects.equals(query.uri, "")) { - String relativePath = getRelativePath(MediaType.valueOf(query.mediaType), ctx); - selection = MediaStore.MediaColumns.DISPLAY_NAME + " = ? AND " + MediaStore.MediaColumns.RELATIVE_PATH + " = ?"; - selectionArgs = new String[]{query.fileName, relativePath + '/' + query.relativePath + '/'}; - } - - // query the media store - cursor = resolver.query(mediaURI, projection, selection, selectionArgs, null); - - if (cursor != null && cursor.moveToFirst()) { - int idColumnIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); - long id = cursor.getLong(idColumnIndex); - - Uri contentUri = Uri.withAppendedPath(mediaURI, String.valueOf(id)); - - queryResultsMap.putString("contentUri", contentUri.toString()); - - promise.resolve(queryResultsMap); - } else { - promise.resolve(null); - } - - return queryResultsMap; - } catch (Exception e) { - return null; - } finally { - if (cursor != null) { - cursor.close(); - } - } - } else { - // throw error not supported - promise.reject("RNFS2.exists", "Android version not supported"); - return null; - } - } - - @Override - public Map getConstants() { - final Map constants = new HashMap<>(); - - constants.put(RNFSMediaStoreTypeAudio, RNFSMediaStoreTypeAudio); - constants.put(RNFSMediaStoreTypeImage, RNFSMediaStoreTypeImage); - constants.put(RNFSMediaStoreTypeVideo, RNFSMediaStoreTypeVideo); - constants.put(RNFSMediaStoreTypeDownload, RNFSMediaStoreTypeDownload); - - return constants; - } -} diff --git a/android/src/main/java/com/rnfs2/RNFSPackage.java b/android/src/main/java/com/rnfs2/RNFSPackage.java deleted file mode 100644 index 12301525..00000000 --- a/android/src/main/java/com/rnfs2/RNFSPackage.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.rnfs2; - -import androidx.annotation.NonNull; - -import com.facebook.react.ReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.uimanager.ViewManager; - -import java.util.Arrays; -import java.util.List; - -public class RNFSPackage implements ReactPackage { - - @NonNull - @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Arrays.asList( - new RNFSManager(reactContext), - new RNFSMediaStoreManager(reactContext) - ); - } - - @NonNull - @Override - public List createViewManagers(@NonNull ReactApplicationContext reactContext) { - return Arrays.asList(); - } -} diff --git a/android/src/main/java/com/rnfs2/Utils/FileDescription.java b/android/src/main/java/com/rnfs2/Utils/FileDescription.java deleted file mode 100644 index eaae7250..00000000 --- a/android/src/main/java/com/rnfs2/Utils/FileDescription.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.rnfs2.Utils; - -public class FileDescription { - public String name; - public String parentFolder; - public String mimeType; - - public FileDescription(String n, String mT, String pF) { - name = n; - parentFolder = pF != null ? pF : ""; - mimeType = mT; - } - - public String getFullPath() { - return parentFolder + "/" + MimeType.getFullFileName(name, mimeType); - } -} diff --git a/android/src/main/java/com/rnfs2/Utils/MediaStoreQuery.java b/android/src/main/java/com/rnfs2/Utils/MediaStoreQuery.java deleted file mode 100644 index 02909978..00000000 --- a/android/src/main/java/com/rnfs2/Utils/MediaStoreQuery.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.rnfs2.Utils; - -public class MediaStoreQuery { - public String uri; - public String fileName; - public String relativePath; - public String mediaType; - - public MediaStoreQuery(String contentURI, String contentFileName, String contentRelativePath, String contentMediaType) { - uri = contentURI != null ? contentURI : ""; - fileName = contentFileName != null ? contentFileName : ""; - relativePath = contentRelativePath != null ? contentRelativePath : ""; - mediaType = contentMediaType; - } -} diff --git a/android/src/main/java/com/rnfs2/Utils/MimeType.java b/android/src/main/java/com/rnfs2/Utils/MimeType.java deleted file mode 100644 index 8a8308fa..00000000 --- a/android/src/main/java/com/rnfs2/Utils/MimeType.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.rnfs2.Utils; - -import android.webkit.MimeTypeMap; - -public class MimeType { - static String UNKNOWN = "*/*"; - static String BINARY_FILE = "application/octet-stream"; - static String IMAGE = "image/*"; - static String AUDIO = "audio/*"; - static String VIDEO = "video/*"; - static String TEXT = "text/*"; - static String FONT = "font/*"; - static String APPLICATION = "application/*"; - static String CHEMICAL = "chemical/*"; - static String MODEL = "model/*"; - - /** - * * Given `name` = `ABC` AND `mimeType` = `video/mp4`, then return `ABC.mp4` - * * Given `name` = `ABC` AND `mimeType` = `null`, then return `ABC` - * * Given `name` = `ABC.mp4` AND `mimeType` = `video/mp4`, then return `ABC.mp4` - * - * @param name can have file extension or not - */ - - public static String getFullFileName(String name, String mimeType) { - // Prior to API 29, MimeType.BINARY_FILE has no file extension - String ext = MimeType.getExtensionFromMimeType(mimeType); - if ((ext == null || ext.isEmpty()) || name.endsWith("." + ext)) return name; - else { - String fn = name + "." + ext; - if (fn.endsWith(".")) return stripEnd(fn, "."); - else return fn; - } - } - - /** - * Some mime types return no file extension on older API levels. This function adds compatibility accross API levels. - * - * @see this.getExtensionFromMimeTypeOrFileName - */ - - public static String getExtensionFromMimeType(String mimeType) { - if (mimeType != null) { - if (mimeType.equals(BINARY_FILE)) return "bin"; - else return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); - } else return ""; - } - - /** - * @see this.getExtensionFromMimeType - */ - public static String getExtensionFromMimeTypeOrFileName(String mimeType, String filename) { - if (mimeType == null || mimeType.equals(UNKNOWN)) return substringAfterLast(filename, "."); - else return getExtensionFromMimeType(mimeType); - } - - /** - * Some file types return no mime type on older API levels. This function adds compatibility across API levels. - */ - public static String getMimeTypeFromExtension(String fileExtension) { - if (fileExtension.equals("bin")) return BINARY_FILE; - else { - String mt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension); - if (mt != null) return mt; - else return UNKNOWN; - } - } - - public static String stripEnd(String str, String stripChars) { - if (str == null || stripChars == null) { - return str; - } - int end = str.length(); - while (end != 0 && stripChars.indexOf(str.charAt(end - 1)) != -1) { - end--; - } - return str.substring(0, end); - } - - public static String substringAfterLast(String str, String separator) { - if (str == null) { - return null; - } else if (str.isEmpty()) { - return ""; - } else { - int pos = str.lastIndexOf(separator); - if (pos == -1 || pos == str.length() - 1) { - return ""; - } - return str.substring(pos + 1); - } - } -} diff --git a/babel.config.js b/babel.config.js index f7b3da3b..0c05fd69 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,12 @@ module.exports = { - presets: ['module:@react-native/babel-preset'], + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], }; diff --git a/docs/FILE_STREAM.md b/docs/FILE_STREAM.md new file mode 100644 index 00000000..124d8e8f --- /dev/null +++ b/docs/FILE_STREAM.md @@ -0,0 +1,433 @@ +# File Stream API + +React-native-fs2-nitro provides powerful file streaming capabilities for reading and writing large files efficiently. The stream API uses Nitro's callback system to handle events without blocking the JavaScript thread. + +## Overview + +File streams are ideal for handling large files where loading entire content into memory would be inefficient or cause memory issues. The streaming API provides: + +- **Read Streams**: Read files in chunks with progress callbacks +- **Write Streams**: Write files in chunks with progress callbacks +- **Memory Efficient**: Process large files without loading entire content into memory +- **Event-Driven**: Use Nitro callbacks for real-time progress updates +- **Cross-Platform**: Consistent API across iOS and Android + +> **Note:** The stream API is now binary-only. All encoding/decoding (e.g., UTF-8, Base64) must be handled in JavaScript. Native code only receives and returns `ArrayBuffer`. + +## Read Stream API + +### `createReadStream(path: string, options?: ReadStreamOptions): Promise` + +Creates a read stream for efficiently reading large files in chunks. + +**Parameters:** +- `path`: File path to read from +- `options`: Optional configuration for the read stream + +**Returns:** Promise that resolves to a `ReadStreamHandle` + +#### ReadStreamOptions + +```typescript +interface ReadStreamOptions { + bufferSize?: number; // Buffer size in bytes (default: 4096) + start?: number; // Start position in bytes (default: 0) + end?: number; // End position in bytes (default: file end) +} +``` + +#### ReadStreamHandle + +```typescript +interface ReadStreamHandle { + streamId: string; + + // Start reading the stream + start(): Promise; + + // Pause the stream + pause(): Promise; + + // Resume the stream + resume(): Promise; + + // Close the stream and cleanup resources + close(): Promise; + + // Check if stream is active + isActive(): Promise; +} +``` + +### Stream Event Listeners + +```typescript +// Listen for data chunks +function listenToReadStreamData( + streamId: string, + onData: (event: ReadStreamDataEvent) => void +): () => void; + +// Listen for stream completion +function listenToReadStreamEnd( + streamId: string, + onEnd: (event: ReadStreamEndEvent) => void +): () => void; + +// Listen for stream errors +function listenToReadStreamError( + streamId: string, + onError: (event: ReadStreamErrorEvent) => void +): () => void; + +// Listen for stream progress +function listenToReadStreamProgress( + streamId: string, + onProgress: (event: ReadStreamProgressEvent) => void +): () => void; +``` + +#### Event Types + +```typescript +interface ReadStreamDataEvent { + streamId: string; + data: ArrayBuffer; // Raw data chunk + chunk: number; // Chunk number (0-based) + position: number; // Current position in file +} + +interface ReadStreamProgressEvent { + streamId: string; + bytesRead: number; // Total bytes read so far + totalBytes: number; // Total file size + progress: number; // Progress as percentage (0-100) +} + +interface ReadStreamEndEvent { + streamId: string; + bytesRead: number; // Total bytes read + success: boolean; +} + +interface ReadStreamErrorEvent { + streamId: string; + error: string; // Error message + code?: string; // Error code +} +``` + +### Read Stream Example + +```typescript +import { Fs2, concatenateArrayBuffers, listenToReadStreamData, listenToReadStreamProgress, listenToReadStreamEnd, listenToReadStreamError } from 'react-native-fs2-nitro'; + +async function readLargeFile() { + try { + // Create read stream + const stream = await Fs2.createReadStream('/path/to/large-file.dat', { + bufferSize: 8192 // 8KB chunks + }); + + let totalData = new ArrayBuffer(0); + + // Listen for data chunks + const unsubscribeData = listenToReadStreamData( + stream.streamId, + (event) => { + console.log(`Received chunk ${event.chunk}, ${event.data.byteLength} bytes`); + // Accumulate data (for small files) or process chunk immediately + totalData = concatenateArrayBuffers(totalData, event.data); + } + ); + + // Listen for progress updates + const unsubscribeProgress = listenToReadStreamProgress( + stream.streamId, + (event) => { + console.log(`Progress: ${(event.progress * 100).toFixed(1)}% (${event.bytesRead}/${event.totalBytes})`); + } + ); + + // Listen for completion + const unsubscribeEnd = listenToReadStreamEnd( + stream.streamId, + (event) => { + console.log('Stream finished successfully'); + unsubscribeData(); + unsubscribeProgress(); + unsubscribeEnd(); + } + ); + + // Listen for errors + const unsubscribeError = listenToReadStreamError( + stream.streamId, + (event) => { + console.error('Stream error:', event.error); + unsubscribeData(); + unsubscribeProgress(); + unsubscribeEnd(); + unsubscribeError(); + } + ); + + // Start reading + await stream.start(); + + } catch (error) { + console.error('Failed to create read stream:', error); + } +} +``` + +## Write Stream API + +### `createWriteStream(path: string, options?: WriteStreamOptions): Promise` + +Creates a write stream for efficiently writing large files in chunks. + +**Parameters:** +- `path`: File path to write to +- `options`: Optional configuration for the write stream + +**Returns:** Promise that resolves to a `WriteStreamHandle` + +#### WriteStreamOptions + +```typescript +interface WriteStreamOptions { + append?: boolean; // Append to existing file (default: false) + bufferSize?: number; // Internal buffer size (default: 4096) + createDirectories?: boolean; // Create parent directories if needed (default: true) +} +``` + +#### WriteStreamHandle + +```typescript +interface WriteStreamHandle { + streamId: string; + + // Write data chunk to stream + write(data: ArrayBuffer): Promise; + + // Flush any buffered data + flush(): Promise; + + // Close the stream and finish writing + close(): Promise; + + // Check if stream is active + isActive(): Promise; + + // Get current write position + getPosition(): Promise; + + // End the stream (alias for close) + end(): Promise; +} +``` + +### Stream Event Listeners + +```typescript +// Listen for write progress +function listenToWriteStreamProgress( + streamId: string, + onProgress: (event: WriteStreamProgressEvent) => void +): () => void; + +// Listen for write completion +function listenToWriteStreamFinish( + streamId: string, + onFinish: (event: WriteStreamFinishEvent) => void +): () => void; + +// Listen for write errors +function listenToWriteStreamError( + streamId: string, + onError: (event: WriteStreamErrorEvent) => void +): () => void; +``` + +#### Event Types + +```typescript +interface WriteStreamProgressEvent { + streamId: string; + bytesWritten: number; // Total bytes written so far + lastChunkSize: number; // Size of last written chunk +} + +interface WriteStreamFinishEvent { + streamId: string; + bytesWritten: number; // Total bytes written + success: boolean; +} + +interface WriteStreamErrorEvent { + streamId: string; + error: string; // Error message + code?: string; // Error code +} +``` + +### Write Stream Example + +```typescript +import { Fs2, listenToWriteStreamProgress, listenToWriteStreamFinish, listenToWriteStreamError } from 'react-native-fs2-nitro'; + +async function writeLargeFile() { + try { + // Create write stream + const stream = await Fs2.createWriteStream('/path/to/output-file.dat', { + append: false, + createDirectories: true + }); + + // Listen for progress + const unsubscribeProgress = listenToWriteStreamProgress( + stream.streamId, + (event) => { + console.log(`Written: ${event.bytesWritten} bytes`); + } + ); + + // Listen for completion + const unsubscribeFinish = listenToWriteStreamFinish( + stream.streamId, + (event) => { + console.log('Write completed:', event.bytesWritten, 'bytes'); + unsubscribeProgress(); + unsubscribeFinish(); + } + ); + + // Listen for errors + const unsubscribeError = listenToWriteStreamError( + stream.streamId, + (event) => { + console.error('Write error:', event.error); + unsubscribeProgress(); + unsubscribeFinish(); + unsubscribeError(); + } + ); + + // Write data in chunks + const chunkSize = 8192; + const totalData = generateLargeData(); // Your data source + + for (let i = 0; i < totalData.byteLength; i += chunkSize) { + const chunk = totalData.slice(i, Math.min(i + chunkSize, totalData.byteLength)); + await stream.write(chunk); + } + + // Finish writing + await stream.close(); + + } catch (error) { + console.error('Failed to create write stream:', error); + } +} +``` + +## Utility Functions + +### Text and Binary Processing with Streams + +```typescript +import { readStream, writeStream } from 'react-native-fs2-nitro'; + +// Read a file as a string (text mode) +async function readTextFile(filePath: string, encoding: 'utf8' = 'utf8') { + const content = await readStream(filePath, encoding); + // content is a string +} + +// Read a file as binary (default) +async function readBinaryFile(filePath: string) { + const buffer = await readStream(filePath); // default is 'arraybuffer' + // buffer is an ArrayBuffer +} + +// Write a string to a file (text mode) +async function writeTextFile(filePath: string, text: string, encoding: 'utf8' = 'utf8') { + await writeStream(filePath, text, encoding); +} + +// Write binary data to a file (default) +async function writeBinaryFile(filePath: string, buffer: ArrayBuffer) { + await writeStream(filePath, buffer); // default is 'arraybuffer' +} +``` + +### File Copy with Progress + +```typescript +import { copyFileWithProgress } from 'react-native-fs2-nitro'; + +async function copyFileWithProgressExample( + sourcePath: string, + destPath: string, + onProgress?: (progress: number) => void +) { + await copyFileWithProgress(sourcePath, destPath, { bufferSize: 16384, onProgress }); +} +``` + +## Performance Tips + +1. **Buffer Size**: Choose appropriate buffer sizes based on your use case: + - Small files (< 1MB): 4KB - 8KB buffers + - Medium files (1-100MB): 16KB - 64KB buffers + - Large files (> 100MB): 64KB - 256KB buffers + +2. **Memory Management**: Always unsubscribe from event listeners to prevent memory leaks + +3. **Error Handling**: Always handle both stream errors and promise rejections + +4. **Threading**: Stream operations run on background threads, keeping the UI responsive + +5. **Encoding**: All encoding/decoding is handled in JavaScript. Native code only deals with `ArrayBuffer`. + +## Error Codes + +Common error codes you may encounter: + +- `ENOENT`: File not found +- `EACCES`: Permission denied +- `EISDIR`: Path is a directory +- `ENOSPC`: No space left on device +- `EMFILE`: Too many open files +- `STREAM_CLOSED`: Stream was closed unexpectedly +- `STREAM_ERROR`: General stream operation error + +## Migration from blob-util + +If migrating from react-native-blob-util's stream API: + +```typescript +// blob-util style +ReactNativeBlobUtil.fs.readStream(path, encoding, bufferSize) + .then(stream => { + stream.open(); + stream.onData(chunk => { /* handle chunk */ }); + stream.onEnd(() => { /* handle end */ }); + }); + +// fs2-nitro style +const stream = await Fs2.createReadStream(path, { bufferSize }); +const unsubscribeData = Fs2.listenToReadStreamData(stream.streamId, event => { + /* handle event.data */ +}); + +const unsubscribeEnd = Fs2.listenToReadStreamEnd(stream.streamId, () => { + /* handle end */ +}); + +await stream.start(); +``` + +The new API provides better type safety, automatic memory management, and consistent cross-platform behavior. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..84f2a4d1 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,41 @@ +import { fixupConfigRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import prettier from 'eslint-plugin-prettier'; +import { defineConfig } from 'eslint/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default defineConfig([ + { + extends: fixupConfigRules(compat.extends('@react-native', 'prettier')), + plugins: { prettier }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'prettier/prettier': [ + 'error', + { + quoteProps: 'consistent', + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + useTabs: false, + }, + ], + }, + }, + { + ignores: [ + 'node_modules/', + 'lib/' + ], + }, +]); diff --git a/example/_bundle/config b/example/.bundle/config similarity index 100% rename from example/_bundle/config rename to example/.bundle/config diff --git a/example/.eslintrc.js b/example/.eslintrc.js deleted file mode 100644 index 187894b6..00000000 --- a/example/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: '@react-native', -}; diff --git a/example/.gitignore b/example/.gitignore deleted file mode 100644 index 5d21102e..00000000 --- a/example/.gitignore +++ /dev/null @@ -1,66 +0,0 @@ -# OSX -# -.DS_Store - -# Xcode -# -build/ -*.pbxuser -!default.pbxuser -*.mode1v3 -!default.mode1v3 -*.mode2v3 -!default.mode2v3 -*.perspectivev3 -!default.perspectivev3 -xcuserdata -*.xccheckout -*.moved-aside -DerivedData -*.hmap -*.ipa -*.xcuserstate -ios/.xcode.env.local - -# Android/IntelliJ -# -.idea -.gradle -local.properties -*.iml -*.hprof -.cxx/ -*.keystore -!debug.keystore -.kotlin/ - -# node.js -# -node_modules/ -npm-debug.log -yarn-error.log - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the -# screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/ - -**/fastlane/report.xml -**/fastlane/Preview.html -**/fastlane/screenshots -**/fastlane/test_output - -# Bundle artifact -*.jsbundle - -# Ruby / CocoaPods -/ios/Pods/ -/vendor/bundle/ - -# Temporary files created by Metro to check the health of the file watcher -.metro-health-check* - -# testing -/coverage diff --git a/example/.prettierrc.js b/example/.prettierrc.js deleted file mode 100644 index 2b540746..00000000 --- a/example/.prettierrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - arrowParens: 'avoid', - bracketSameLine: true, - bracketSpacing: false, - singleQuote: true, - trailingComma: 'all', -}; diff --git a/example/.watchmanconfig b/example/.watchmanconfig index 9e26dfee..0967ef42 100644 --- a/example/.watchmanconfig +++ b/example/.watchmanconfig @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/example/App/utils.tsx b/example/App/utils.tsx deleted file mode 100644 index 5f2a5f57..00000000 --- a/example/App/utils.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import {Platform, PermissionsAndroid} from 'react-native'; -import RNFS from 'react-native-fs2'; - -export const getTestFolder = () => (Platform.OS === 'ios' ? RNFS.DocumentDirectoryPath : RNFS.DownloadDirectoryPath); -export const getFolderText = () => { - if (Platform.OS === 'ios') { - return 'DocumentDirectory'; - } - - return 'DownloadDirectory'; -}; - -export const requestAndroidPermission = async () => { - return PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, { - title: 'Example Read/Write Storage Permission', - message: 'Example App needs read/write access to your phone storage to save files', - buttonNeutral: 'Ask Me Later', - buttonNegative: 'Cancel', - buttonPositive: 'OK', - }); -}; diff --git a/example/Gemfile.lock b/example/Gemfile.lock index f968c90f..9ee27373 100644 --- a/example/Gemfile.lock +++ b/example/Gemfile.lock @@ -5,10 +5,18 @@ GEM base64 nkf rexml - activesupport (7.0.8.4) + activesupport (7.1.5.1) + base64 + benchmark (>= 0.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) @@ -20,10 +28,10 @@ GEM benchmark (0.4.0) bigdecimal (3.1.9) claide (1.1.0) - cocoapods (1.14.3) + cocoapods (1.15.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.14.3) + cocoapods-core (= 1.15.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -38,7 +46,7 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.14.3) + cocoapods-core (1.15.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -59,21 +67,22 @@ GEM cocoapods-try (1.2.0) colored2 (3.1.2) concurrent-ruby (1.3.3) + connection_pool (2.5.3) + drb (2.2.1) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) - ffi (1.17.0) - ffi (1.17.0-arm64-darwin) - ffi (1.17.0-x86_64-linux) + ffi (1.17.2) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.14.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.7.2) + json (2.12.0) logger (1.7.0) - minitest (5.24.1) + minitest (5.25.5) molinillo (0.8.0) mutex_m (0.3.0) nanaimo (0.3.0) @@ -81,24 +90,23 @@ GEM netrc (0.11.0) nkf (0.2.0) public_suffix (4.0.7) - rexml (3.3.9) + rexml (3.4.1) ruby-macho (2.5.1) + securerandom (0.3.2) typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.25.0) + xcodeproj (1.25.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) + rexml (>= 3.3.6, < 4.0) PLATFORMS - arm64-darwin-22 ruby - x86_64-linux DEPENDENCIES activesupport (>= 6.1.7.5, != 7.1.0) @@ -111,7 +119,7 @@ DEPENDENCIES xcodeproj (< 1.26.0) RUBY VERSION - ruby 2.7.7p221 + ruby 2.7.6p219 BUNDLED WITH - 2.4.22 + 2.3.11 diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..3e2c3f85 --- /dev/null +++ b/example/README.md @@ -0,0 +1,97 @@ +This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). + +# Getting Started + +> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. + +## Step 1: Start Metro + +First, you will need to run **Metro**, the JavaScript build tool for React Native. + +To start the Metro dev server, run the following command from the root of your React Native project: + +```sh +# Using npm +npm start + +# OR using Yarn +yarn start +``` + +## Step 2: Build and run your app + +With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: + +### Android + +```sh +# Using npm +npm run android + +# OR using Yarn +yarn android +``` + +### iOS + +For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). + +The first time you create a new project, run the Ruby bundler to install CocoaPods itself: + +```sh +bundle install +``` + +Then, and every time you update your native dependencies, run: + +```sh +bundle exec pod install +``` + +For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). + +```sh +# Using npm +npm run ios + +# OR using Yarn +yarn ios +``` + +If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. + +This is one way to run your app — you can also build it directly from Android Studio or Xcode. + +## Step 3: Modify your app + +Now that you have successfully run the app, let's make changes! + +Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). + +When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: + +- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). +- **iOS**: Press R in iOS Simulator. + +## Congratulations! :tada: + +You've successfully run and modified your React Native App. :partying_face: + +### Now what? + +- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). +- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + +# Learn More + +To learn more about React Native, take a look at the following resources: + +- [React Native Website](https://reactnative.dev) - learn more about React Native. +- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. +- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. +- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. +- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. diff --git a/example/__tests__/App-test.tsx b/example/__tests__/App-test.tsx deleted file mode 100644 index ae874684..00000000 --- a/example/__tests__/App-test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ReactTestRenderer from 'react-test-renderer'; -import App from '../App'; - -test('renders correctly', async () => { - await ReactTestRenderer.act(() => { - ReactTestRenderer.create(); - }); -}); diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 9b377e9a..b54e996a 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -8,14 +8,14 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '..' - // root = file("../") - // The folder where the react-native NPM package is. Default is ../node_modules/react-native - // reactNativeDir = file("../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen - // codegenDir = file("../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js - // cliFile = file("../node_modules/react-native/cli.js") + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js + // cliFile = file("../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to @@ -74,13 +74,12 @@ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' android { ndkVersion rootProject.ext.ndkVersion - buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion - namespace "com.example"; + namespace "fs2.example" defaultConfig { - applicationId "com.example" + applicationId "fs2.example" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 @@ -108,11 +107,10 @@ android { } } - - dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index ced5aabf..00000000 --- a/example/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 915addb5..dd019023 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,30 +1,27 @@ - + - - - + - - - - - - - - + android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize" + android:exported="true"> + + + + + + + \ No newline at end of file diff --git a/example/android/app/src/main/java/com/example/MainActivity.kt b/example/android/app/src/main/java/fs2/example/MainActivity.kt similarity index 90% rename from example/android/app/src/main/java/com/example/MainActivity.kt rename to example/android/app/src/main/java/fs2/example/MainActivity.kt index 35dff7f9..b9bd1363 100644 --- a/example/android/app/src/main/java/com/example/MainActivity.kt +++ b/example/android/app/src/main/java/fs2/example/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example +package fs2.example import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate @@ -11,7 +11,7 @@ class MainActivity : ReactActivity() { * Returns the name of the main component registered from JavaScript. This is used to schedule * rendering of the component. */ - override fun getMainComponentName(): String = "example" + override fun getMainComponentName(): String = "Fs2Example" /** * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] diff --git a/example/android/app/src/main/java/com/example/MainApplication.kt b/example/android/app/src/main/java/fs2/example/MainApplication.kt similarity index 75% rename from example/android/app/src/main/java/com/example/MainApplication.kt rename to example/android/app/src/main/java/fs2/example/MainApplication.kt index 33ac4a77..b704ebae 100644 --- a/example/android/app/src/main/java/com/example/MainApplication.kt +++ b/example/android/app/src/main/java/fs2/example/MainApplication.kt @@ -1,16 +1,14 @@ -package com.example +package fs2.example import android.app.Application import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost -import com.facebook.react.soloader.OpenSourceMergedSoMapping -import com.facebook.soloader.SoLoader class MainApplication : Application(), ReactApplication { @@ -35,10 +33,6 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() - SoLoader.init(this, OpenSourceMergedSoMapping) - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - load() - } + loadReactNative(this) } } diff --git a/example/android/app/src/main/res/drawable/rn_edit_text_material.xml b/example/android/app/src/main/res/drawable/rn_edit_text_material.xml index 650a08a9..5c25e728 100644 --- a/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +++ b/example/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -21,7 +21,7 @@ > -