diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 00000000..c25634b0 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,70 @@ +name: Android Build + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-android: + name: Build Android (${{ matrix.app }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - app: rn + dir: playground-rn + - app: expo + dir: playground-expo + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: ${{ matrix.dir }}/node_modules + key: ${{ runner.os }}-${{ matrix.app }}-node-${{ hashFiles(format('{0}/package.json', matrix.dir)) }} + + - name: Install dependencies + working-directory: ${{ matrix.dir }} + run: | + if [ -f "pnpm-lock.yaml" ]; then + npm install -g pnpm@9 + pnpm install + else + npm install + fi + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ matrix.app }}-gradle-${{ hashFiles(format('{0}/android/gradle/wrapper/gradle-wrapper.properties', matrix.dir), format('{0}/android/build.gradle', matrix.dir)) }} + restore-keys: | + ${{ runner.os }}-${{ matrix.app }}-gradle- + + - name: Build Android app + working-directory: ${{ matrix.dir }}/android + run: ./gradlew :app:assembleRelease diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fb70e4b4..7d8c40c0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,7 +15,7 @@ concurrency: jobs: e2e-ios: name: E2E iOS (${{ matrix.app }}) - runs-on: macos-latest + runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false @@ -27,6 +27,14 @@ jobs: scheme: PlaygroundRN app_name: PlaygroundRN bundle_id: com.rnziparchive.playground.rn + runner: macos-15 + - app: expo + dir: playground-expo + workspace: RNZipArchivePlayground.xcworkspace + scheme: RNZipArchivePlayground + app_name: RNZipArchivePlayground + bundle_id: com.rnziparchive.playground.expo + runner: macos-26 steps: - name: Checkout uses: actions/checkout@v4 @@ -102,7 +110,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: maestro-debug-ios + name: maestro-debug-ios-${{ matrix.app }} path: ~/.maestro/tests/ - name: Upload iOS crash logs @@ -117,7 +125,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: ios-crash-logs + name: ios-crash-logs-${{ matrix.app }} path: crash-logs/ e2e-android: @@ -131,6 +139,9 @@ jobs: - app: rn dir: playground-rn bundle_id: com.rnziparchive.playground.rn + - app: expo + dir: playground-expo + bundle_id: com.rnziparchive.playground.expo steps: - name: Checkout uses: actions/checkout@v4 @@ -186,9 +197,19 @@ jobs: with: api-level: 29 arch: x86_64 + cores: 4 + ram-size: 4096M + heap-size: 1024M + disk-size: 4096M emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true script: | + # Expo's heavier UI can trigger a system-level ANR dialog on the + # low-memory GitHub Actions emulator. hide_error_dialogs is the + # documented AOSP global setting (Settings.Global.HIDE_ERROR_DIALOGS) + # used by CTS/Input ANR tests to suppress error dialogs on API 29+. + # Maestro still dismisses any dialog that slips through (_setup.yaml). + adb shell settings put global hide_error_dialogs 1 adb install ${{ matrix.dir }}/android/app/build/outputs/apk/release/app-release.apk maestro test --env APP_ID="${{ matrix.bundle_id }}" .maestro/flows/ci-master.yaml @@ -196,5 +217,5 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: maestro-debug-android + name: maestro-debug-android-${{ matrix.app }} path: ~/.maestro/tests/ diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 00000000..2c23943d --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -0,0 +1,80 @@ +name: iOS Build + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-ios: + name: Build iOS (${{ matrix.app }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - app: rn + dir: playground-rn + workspace: PlaygroundRN.xcworkspace + scheme: PlaygroundRN + runner: macos-15 + - app: expo + dir: playground-expo + workspace: RNZipArchivePlayground.xcworkspace + scheme: RNZipArchivePlayground + runner: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: ${{ matrix.dir }}/node_modules + key: ${{ runner.os }}-${{ matrix.app }}-node-${{ hashFiles(format('{0}/package.json', matrix.dir)) }} + + - name: Install dependencies + working-directory: ${{ matrix.dir }} + run: | + if [ -f "pnpm-lock.yaml" ]; then + npm install -g pnpm@9 + pnpm install + else + npm install + fi + + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: ${{ matrix.dir }}/ios/Pods + key: ${{ runner.os }}-${{ matrix.app }}-pods-${{ hashFiles(format('{0}/ios/Podfile.lock', matrix.dir)) }} + + - name: Install CocoaPods + working-directory: ${{ matrix.dir }}/ios + run: | + gem install cocoapods -v '>= 1.13' + pod install + + - name: Build iOS app + working-directory: ${{ matrix.dir }}/ios + run: | + xcodebuild \ + -workspace ${{ matrix.workspace }} \ + -scheme ${{ matrix.scheme }} \ + -configuration Release \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + COMPILER_INDEX_STORE_ENABLE=NO diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a125be65..c0fab6d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,13 +9,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@master + uses: actions/setup-node@v4 with: - node-version: 10.0.0 + node-version: 20 - name: Publish if version has been updated - uses: pascalgn/npm-publish-action@4f4bf159e299f65d21cd1cbd96fc5d53228036df + uses: pascalgn/npm-publish-action@1.3.9 with: # All of theses inputs are optional tag_name: "v%s" tag_message: "v%s" diff --git a/.maestro/flows/_setup.yaml b/.maestro/flows/_setup.yaml index dbbda73a..75629c16 100644 --- a/.maestro/flows/_setup.yaml +++ b/.maestro/flows/_setup.yaml @@ -66,9 +66,31 @@ appId: ${APP_ID} commands: - tapOn: "Reload" +# The GitHub Actions Android emulator (API 29, software GPU) occasionally shows +# a system-level "System UI isn't responding" ANR dialog that is unrelated to +# the app. The dialog uses a straight ASCII apostrophe; match that exactly. +# Poll for the dialog on Android while the playground screen is still hidden, +# and tap "Wait" so the loaded app stays in focus. +- runFlow: + when: + platform: Android + commands: + - repeat: + times: 60 + while: + notVisible: "RNZipArchive Playground" + commands: + - runFlow: + when: + visible: "System UI isn't responding" + commands: + - tapOn: "Wait" + - waitForAnimationToEnd: + timeout: 2000 + - extendedWaitUntil: visible: "RNZipArchive Playground" - timeout: 180000 + timeout: 10000 - runFlow: when: diff --git a/CHANGELOG.md b/CHANGELOG.md index 55af7b83..820433c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [9.0.0] - Unreleased + +### Changed (Breaking) +- Progress events are now byte-weighted per entry for `unzip`/`unzipWithPassword` on both platforms (previously byte-level with within-file granularity on Android, effectively start/end-only on iOS) +- iOS `unzip` progress events now report the zip entry name in `filePath` instead of the full destination path +- iOS `zip`/`zipWithPassword` with a files array now emits per-file progress events (previously only 0% and 100%) +- Android operations are serialized on a managed single-thread executor instead of one raw thread per call +- Android progress events are posted on the main thread; the final 100% event may now arrive after the promise resolves +- See [MIGRATION.md](./MIGRATION.md) for upgrade guidance + +### Added +- Android: Zip Slip protection — entries escaping the destination directory are rejected during extraction (`unzip`, `unzipWithPassword`, `unzipAssets`) +- Android: `ZipSecurity` utility with JUnit regression tests +- CI: Android/iOS build workflows for both playground apps; Expo added to the E2E matrix + +### Fixed +- Android: close all zip/stream handles via try-with-resources (previously leaked on error paths) +- Android: emit progress events on the main thread and flush output streams in `StreamUtil.copy` +- Android: `minSdkVersion` fallback now matches the documented API 23 minimum +- TypeScript: fix invalid default-parameter syntax in `index.d.ts`; move the `react-native` import to the top level +- iOS: nullability annotations in `RNZipArchive.h` +- iOS: per-entry `filePath` updates during unzip (previously dispatched a stale progress value) +- playground-rn: pin `react-native`, `react-native-screens`, `react-native-gesture-handler`, `react-native-safe-area-context` to match playground-expo (fixes CI codegen failure from floating `react-native-screens` 4.26.x) + ## [8.0.1] - 2026-05-19 ### Fixed diff --git a/MIGRATION.md b/MIGRATION.md index c3a6d89d..84307b3e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,48 @@ # Migration Guide +## v8.x to v9.0 + +### What's Changed + +v9.0 hardens the native implementations and aligns progress reporting across platforms. The **JavaScript API is unchanged** — all breaking changes are in native behavior that existing code may observe. + +### Breaking Changes + +| | v8.x | v9.0 | +|---|---|---| +| `unzip` progress (Android) | Byte-level, within-file granularity | Byte-weighted, per-entry granularity | +| `unzip` progress (iOS) | Effectively start/end events only | Byte-weighted, per-entry granularity | +| `unzip` event `filePath` (iOS) | Full destination path | Zip entry name (e.g. `folder/file.txt`) | +| `zip` progress with files array (iOS) | Only 0% and 100% events | Per-file progress events | +| Concurrent operations (Android) | Ran in parallel | Serialized on a single worker thread | +| Malicious/traversal zip entries (Android) | Extracted outside destination | Rejected (Zip Slip protection) | + +### Migration Steps + +#### Step 1: Check your progress-event handling + +If you subscribe to `zipArchiveProgressEvent`: + +- **Don't parse `filePath` on iOS** expecting a filesystem path for `unzip` operations — it is now the archive entry name. On Android it remains the source zip path. +- **Don't assume the final 100% event arrives before the promise resolves.** On Android, events are now posted to the main thread and the last event may land after `.then()` runs. Gate completion logic on the promise, not the event. +- Progress is byte-weighted per entry for `unzip`: with archives containing one very large file, expect the bar to jump rather than advance smoothly within that file. + +#### Step 2: Check concurrent usage (Android) + +If you kick off multiple zip/unzip operations simultaneously, they now execute one at a time in call order. Await them sequentially, or expect later calls to take longer. + +#### Step 3: Rebuild + +```bash +npm install react-native-zip-archive@^9.0.0 +cd ios && pod install && cd .. +``` + +### Need Help? + +- Check the playground apps (`playground-rn/`, `playground-expo/`) for working examples +- Open an issue on GitHub + ## v7.x to v8.0 ### What's Changed diff --git a/README.md b/README.md index 7d666e7a..e6771f62 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,13 @@ getUncompressedSize(sourcePath) Subscribe to progress events. Useful for showing a progress bar. - `progress` — value from 0 to 1 (1 = completed) -- `filePath` — the zip file path (empty on iOS for zip operations) +- `filePath` — the zip file path (on iOS, the entry being processed for unzip operations; empty for zip operations) + +Progress is reported monotonically from 0 to 1, with explicit 0% and 100% events at the start and end of each operation. The granularity depends on the operation: + +- `unzip` / `unzipWithPassword` — byte-weighted: progress reflects uncompressed bytes extracted so far, updated after each entry completes. +- `zip` / `zipWithPassword` — per-file: progress reflects the number of files compressed so far. +- `unzipAssets` (Android only) — approximate: compares bytes read to the compressed archive size. > The event is global — check `filePath` in your callback to ensure it matches the operation you care about. Remember to call `.remove()` on the returned subscription when done. diff --git a/android/build.gradle b/android/build.gradle index e31bd994..78cd7f43 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -30,7 +30,7 @@ android { } defaultConfig { - minSdkVersion safeExtGet('minSdkVersion', 21) + minSdkVersion safeExtGet('minSdkVersion', 23) targetSdkVersion safeExtGet('targetSdkVersion', 33) versionCode 1 versionName "1.0" @@ -64,4 +64,6 @@ repositories { dependencies { implementation 'com.facebook.react:react-native' implementation group: 'net.lingala.zip4j', name: 'zip4j', version: '2.11.5' + + testImplementation 'junit:junit:4.13.2' } diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 3564823f..9be37598 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -3,13 +3,16 @@ import android.content.res.AssetFileDescriptor; import android.net.Uri; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; import java.io.BufferedInputStream; @@ -22,21 +25,18 @@ import java.io.StringWriter; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.Enumeration; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.zip.ZipEntry; -//import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; -import net.lingala.zip4j.exception.ZipException; import net.lingala.zip4j.model.FileHeader; import net.lingala.zip4j.model.ZipParameters; import net.lingala.zip4j.model.enums.CompressionMethod; import net.lingala.zip4j.model.enums.CompressionLevel; import net.lingala.zip4j.model.enums.EncryptionMethod; import net.lingala.zip4j.model.enums.AesKeyStrength; -import net.lingala.zip4j.progress.ProgressMonitor; import java.nio.charset.Charset; @@ -48,6 +48,11 @@ public class RNZipArchiveModule extends NativeZipArchiveSpec { private static final String EVENT_KEY_FILENAME = "filePath"; private static final String EVENT_KEY_PROGRESS = "progress"; + private final ExecutorService executor = Executors.newSingleThreadExecutor( + r -> new Thread(r, "RNZipArchiveWorker") + ); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + public RNZipArchiveModule(ReactApplicationContext reactContext) { super(reactContext); } @@ -57,127 +62,106 @@ public String getName() { return NAME; } + @Override + public void invalidate() { + executor.shutdownNow(); + super.invalidate(); + } + + @Deprecated + @SuppressWarnings({"deprecation", "removal"}) + public void onCatalystInstanceDestroy() { + invalidate(); + } + @Override public void isPasswordProtected(final String zipFilePath, final Promise promise) { - try { - net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); - promise.resolve(zipFile.isEncrypted()); - } catch (ZipException ex) { - promise.reject("RNZipArchiveError", String.format("Unable to check for encryption due to: %s", getStackTrace(ex))); - } + executor.submit(() -> { + try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { + promise.resolve(zipFile.isEncrypted()); + } catch (Exception ex) { + promise.reject("RNZipArchiveError", String.format("Unable to check for encryption due to: %s", getStackTrace(ex))); + } + }); } @Override public void unzipWithPassword(final String zipFilePath, final String destDirectory, final String password, final Promise promise) { - new Thread(new Runnable() { - @Override - public void run() { - try { - net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); - if (zipFile.isEncrypted()) { - zipFile.setPassword(password.toCharArray()); - } else { - promise.reject("RNZipArchiveError", String.format("Zip file: %s is not password protected", zipFilePath)); - return; - } - - List fileHeaderList = zipFile.getFileHeaders(); - List extractedFileNames = new ArrayList<>(); - int totalFiles = fileHeaderList.size(); - - updateProgress(0, 1, zipFilePath); // force 0% - for (int i = 0; i < totalFiles; i++) { - FileHeader fileHeader = (FileHeader) fileHeaderList.get(i); + executor.submit(() -> { + try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { + if (zipFile.isEncrypted()) { + zipFile.setPassword(password.toCharArray()); + } else { + promise.reject("RNZipArchiveError", String.format("Zip file: %s is not password protected", zipFilePath)); + return; + } - File fout = new File(destDirectory, fileHeader.getFileName()); - String canonicalPath = fout.getCanonicalPath(); - String destDirCanonicalPath = (new File(destDirectory).getCanonicalPath()) + File.separator; + List fileHeaderList = zipFile.getFileHeaders(); + long totalBytes = Math.max(totalUncompressedSize(fileHeaderList), 1); + long extractedBytes = 0; - if (!canonicalPath.startsWith(destDirCanonicalPath)) { - throw new SecurityException(String.format("Found Zip Path Traversal Vulnerability with %s", canonicalPath)); - } + updateProgress(0, 1, zipFilePath); // force 0% + for (FileHeader fileHeader : fileHeaderList) { + ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); - if (!fileHeader.isDirectory()) { - zipFile.extractFile(fileHeader, destDirectory); - extractedFileNames.add(fileHeader.getFileName()); - } - updateProgress(i + 1, totalFiles, zipFilePath); + if (!fileHeader.isDirectory()) { + zipFile.extractFile(fileHeader, destDirectory); + extractedBytes += Math.max(fileHeader.getUncompressedSize(), 0); } - promise.resolve(destDirectory); - } catch (Exception ex) { - updateProgress(0, 1, zipFilePath); // force 0% - promise.reject("RNZipArchiveError", String.format("Failed to unzip file, due to: %s", getStackTrace(ex))); + updateProgress(extractedBytes, totalBytes, zipFilePath); } + updateProgress(1, 1, zipFilePath); // force 100% + promise.resolve(destDirectory); + } catch (Exception ex) { + updateProgress(0, 1, zipFilePath); // force 0% + promise.reject("RNZipArchiveError", String.format("Failed to unzip file, due to: %s", getStackTrace(ex))); } - }).start(); + }); } @Override public void unzip(final String zipFilePath, final String destDirectory, final String charset, final Promise promise) { - new Thread(new Runnable() { - @Override - public void run() { - // Check the file exists - try { - new File(zipFilePath); - } catch (NullPointerException e) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); - return; - } - - try { - // Find the total uncompressed size of every file in the zip, so we can - // get an accurate progress measurement - final long totalUncompressedBytes = getUncompressedSize(zipFilePath, charset); - - File destDir = new File(destDirectory); - if (!destDir.exists()) { - //noinspection ResultOfMethodCallIgnored - destDir.mkdirs(); - } - - updateProgress(0, 1, zipFilePath); // force 0% - - // We use arrays here so we can update values - // from inside the callback - final long[] extractedBytes = {0}; - final int[] lastPercentage = {0}; - - net.lingala.zip4j.ZipFile zipFile = null; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); - zipFile.setCharset(Charset.forName(charset)); - } else { - zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); - } - - ProgressMonitor progressMonitor = zipFile.getProgressMonitor(); + executor.submit(() -> { + if (zipFilePath == null) { + promise.reject("RNZipArchiveError", "Couldn't open file null. "); + return; + } + File zipFileRef = new File(zipFilePath); + if (!zipFileRef.exists()) { + promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + return; + } - zipFile.setRunInThread(true); - zipFile.extractAll(destDirectory); + try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { + File destDir = new File(destDirectory); + if (!destDir.exists()) { + //noinspection ResultOfMethodCallIgnored + destDir.mkdirs(); + } - while (!progressMonitor.getState().equals(ProgressMonitor.State.READY)) { - updateProgress(progressMonitor.getWorkCompleted(), progressMonitor.getTotalWork(), zipFilePath); + List fileHeaderList = zipFile.getFileHeaders(); + long totalBytes = Math.max(totalUncompressedSize(fileHeaderList), 1); + long extractedBytes = 0; - Thread.sleep(100); - } + updateProgress(0, 1, zipFilePath); // force 0% + for (FileHeader fileHeader : fileHeaderList) { + ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); - if (progressMonitor.getResult().equals(ProgressMonitor.Result.SUCCESS)) { - zipFile.close(); - updateProgress(1, 1, zipFilePath); // force 100% - promise.resolve(destDirectory); - } else if (progressMonitor.getResult().equals(ProgressMonitor.Result.ERROR)) { - throw new Exception("Error occurred. Error message: " + progressMonitor.getException().getMessage()); - } else if (progressMonitor.getResult().equals(ProgressMonitor.Result.CANCELLED)) { - throw new Exception("Task cancelled"); + if (!fileHeader.isDirectory()) { + zipFile.extractFile(fileHeader, destDirectory); + extractedBytes += Math.max(fileHeader.getUncompressedSize(), 0); } - } catch (Exception ex) { - updateProgress(0, 1, zipFilePath); // force 0% - promise.reject("RNZipArchiveError", "Failed to extract file " + ex.getLocalizedMessage()); + updateProgress(extractedBytes, totalBytes, zipFilePath); } + + updateProgress(1, 1, zipFilePath); // force 100% + promise.resolve(destDirectory); + } catch (Exception ex) { + updateProgress(0, 1, zipFilePath); // force 0% + promise.reject("RNZipArchiveError", "Failed to extract file " + ex.getLocalizedMessage()); } - }).start(); + }); } /** @@ -192,269 +176,255 @@ public void run() { */ @Override public void unzipAssets(final String assetsPath, final String destDirectory, final Promise promise) { - new Thread(new Runnable() { - @Override - public void run() { - InputStream assetsInputStream; - long compressedSize; - - try { - if(assetsPath.startsWith("content://")) { - var assetUri = Uri.parse(assetsPath); - var contentResolver = getReactApplicationContext().getContentResolver(); - - assetsInputStream = contentResolver.openInputStream(assetUri); - var fileDescriptor = contentResolver.openFileDescriptor(assetUri, "r"); - compressedSize = fileDescriptor.getStatSize(); - } else { - assetsInputStream = getReactApplicationContext().getAssets().open(assetsPath); - try { - AssetFileDescriptor fileDescriptor = getReactApplicationContext().getAssets().openFd(assetsPath); - compressedSize = fileDescriptor.getLength(); - } catch (IOException fdEx) { - // Asset is compressed in the APK; openFd() doesn't work for compressed assets. - // Fall back to available() as a size estimate. - compressedSize = assetsInputStream.available(); - if (compressedSize <= 0) { - compressedSize = 1; // avoid division by zero in progress math - } + executor.submit(() -> { + InputStream assetsInputStream = null; + AssetFileDescriptor fileDescriptor = null; + long compressedSize; + + try { + if (assetsPath.startsWith("content://")) { + Uri assetUri = Uri.parse(assetsPath); + android.content.ContentResolver contentResolver = getReactApplicationContext().getContentResolver(); + + assetsInputStream = contentResolver.openInputStream(assetUri); + fileDescriptor = contentResolver.openAssetFileDescriptor(assetUri, "r"); + compressedSize = fileDescriptor != null ? fileDescriptor.getLength() : 0; + if (compressedSize <= 0) { + compressedSize = 1; // avoid division by zero in progress math + } + } else { + assetsInputStream = getReactApplicationContext().getAssets().open(assetsPath); + try { + fileDescriptor = getReactApplicationContext().getAssets().openFd(assetsPath); + compressedSize = fileDescriptor.getLength(); + } catch (IOException fdEx) { + // Asset is compressed in the APK; openFd() doesn't work for compressed assets. + // Fall back to available() as a size estimate. + compressedSize = assetsInputStream.available(); + if (compressedSize <= 0) { + compressedSize = 1; // avoid division by zero in progress math } } - } catch (IOException e) { - Log.e(TAG, "Failed to open asset: " + assetsPath, e); - promise.reject("RNZipArchiveError", String.format("Asset file `%s` could not be opened: %s", assetsPath, e.getMessage())); - return; } - try { - try { - File destDir = new File(destDirectory); - if (!destDir.exists()) { - //noinspection ResultOfMethodCallIgnored - destDir.mkdirs(); - } - ZipInputStream zipIn = new ZipInputStream(assetsInputStream); - BufferedInputStream bin = new BufferedInputStream(zipIn); + if (assetsInputStream == null) { + promise.reject("RNZipArchiveError", String.format("Asset file `%s` could not be opened", assetsPath)); + return; + } - ZipEntry entry; + File destDir = new File(destDirectory); + if (!destDir.exists()) { + //noinspection ResultOfMethodCallIgnored + destDir.mkdirs(); + } - long extractedBytes = 0; - updateProgress(extractedBytes, compressedSize, assetsPath); // force 0% + try (ZipInputStream zipIn = new ZipInputStream(assetsInputStream); + BufferedInputStream bin = new BufferedInputStream(zipIn)) { - File fout; - while ((entry = zipIn.getNextEntry()) != null) { - if (entry.isDirectory()) continue; + ZipEntry entry; + long extractedBytes = 0; + updateProgress(extractedBytes, compressedSize, assetsPath); // force 0% - Log.i("rnziparchive", "Extracting: " + entry.getName()); + while ((entry = zipIn.getNextEntry()) != null) { + if (entry.isDirectory()) continue; - fout = new File(destDirectory, entry.getName()); - String canonicalPath = fout.getCanonicalPath(); - String destDirCanonicalPath = (new File(destDirectory).getCanonicalPath()) + File.separator; + Log.i("rnziparchive", "Extracting: " + entry.getName()); - if (!canonicalPath.startsWith(destDirCanonicalPath)) { - throw new SecurityException(String.format("Found Zip Path Traversal Vulnerability with %s", canonicalPath)); - } + ZipSecurity.validateExtractPath(destDirectory, entry.getName()); - if (!fout.exists()) { - //noinspection ResultOfMethodCallIgnored - (new File(fout.getParent())).mkdirs(); - } + File fout = new File(destDirectory, entry.getName()); + File parentDir = fout.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + //noinspection ResultOfMethodCallIgnored + parentDir.mkdirs(); + } - FileOutputStream out = new FileOutputStream(fout); - BufferedOutputStream Bout = new BufferedOutputStream(out); - StreamUtil.copy(bin, Bout, null); - Bout.close(); - out.close(); + try (FileOutputStream out = new FileOutputStream(fout); + BufferedOutputStream bout = new BufferedOutputStream(out)) { + StreamUtil.copy(bin, bout, null); + } - extractedBytes += entry.getCompressedSize(); + extractedBytes += entry.getCompressedSize(); - // do not let the percentage go over 99% because we want it to hit 100% only when we are sure it's finished - if(extractedBytes > compressedSize*0.99) extractedBytes = (long) (compressedSize*0.99); + // do not let the percentage go over 99% because we want it to hit 100% only when we are sure it's finished + if (extractedBytes > compressedSize * 0.99) extractedBytes = (long) (compressedSize * 0.99); - updateProgress(extractedBytes, compressedSize, entry.getName()); - } + updateProgress(extractedBytes, compressedSize, entry.getName()); + } - updateProgress(compressedSize, compressedSize, assetsPath); // force 100% + updateProgress(compressedSize, compressedSize, assetsPath); // force 100% + } - bin.close(); - zipIn.close(); - } catch (Exception ex) { - ex.printStackTrace(); - updateProgress(0, 1, assetsPath); // force 0% - throw new Exception(String.format("Couldn't extract %s", assetsPath)); + promise.resolve(destDirectory); + } catch (Exception ex) { + Log.e(TAG, "Failed to extract asset: " + assetsPath, ex); + updateProgress(0, 1, assetsPath); // force 0% + promise.reject("RNZipArchiveError", ex.getMessage()); + } finally { + if (fileDescriptor != null) { + try { + fileDescriptor.close(); + } catch (IOException ignored) { + } + } + if (assetsInputStream != null) { + try { + assetsInputStream.close(); + } catch (IOException ignored) { } - } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); - return; } - promise.resolve(destDirectory); } - }).start(); + }); } @Override public void zipFiles(final ReadableArray files, final String destDirectory, final double compressionLevel, final Promise promise) { - zip(files.toArrayList(), destDirectory, compressionLevel, promise); + final List fileList; + try { + fileList = readableArrayToStringList(files); + } catch (IllegalArgumentException ex) { + promise.reject("RNZipArchiveError", "Invalid files array: " + ex.getMessage()); + return; + } + zip(fileList, destDirectory, compressionLevel, promise); } @Override public void zipFolder(final String folder, final String destFile, final double compressionLevel, final Promise promise) { - ArrayList folderAsArrayList = new ArrayList<>(); - folderAsArrayList.add(folder); - zip(folderAsArrayList, destFile, compressionLevel, promise); + List folderAsList = new ArrayList<>(); + folderAsList.add(folder); + zip(folderAsList, destFile, compressionLevel, promise); } @Override public void zipFilesWithPassword(final ReadableArray files, final String destFile, final String password, String encryptionMethod, final double compressionLevel, Promise promise) { - zipWithPassword(files.toArrayList(), destFile, password, encryptionMethod, compressionLevel, promise); + final List fileList; + try { + fileList = readableArrayToStringList(files); + } catch (IllegalArgumentException ex) { + promise.reject("RNZipArchiveError", "Invalid files array: " + ex.getMessage()); + return; + } + zipWithPassword(fileList, destFile, password, encryptionMethod, compressionLevel, promise); } @Override public void zipFolderWithPassword(final String folder, final String destFile, final String password, String encryptionMethod, final double compressionLevel, Promise promise) { - ArrayList folderAsArrayList = new ArrayList<>(); - folderAsArrayList.add(folder); - zipWithPassword(folderAsArrayList, destFile, password, encryptionMethod, compressionLevel, promise); + List folderAsList = new ArrayList<>(); + folderAsList.add(folder); + zipWithPassword(folderAsList, destFile, password, encryptionMethod, compressionLevel, promise); } - private void zipWithPassword(final ArrayList filesOrDirectory, final String destFile, final String password, + private void zipWithPassword(final List filesOrDirectory, final String destFile, final String password, String encryptionMethod, final double compressionLevel, Promise promise) { - try{ - ZipParameters parameters = new ZipParameters(); - parameters.setCompressionMethod(CompressionMethod.DEFLATE); - parameters.setCompressionLevel(getCompressionLevel(compressionLevel)); - - String encParts[] = encryptionMethod.split("-"); - - if (password != null && !password.isEmpty()) { - parameters.setEncryptFiles(true); - if (encParts[0].equals("AES")) { - parameters.setEncryptionMethod(EncryptionMethod.AES); - if (encParts[1].equals("128")) { - parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_128); - } else if (encParts[1].equals("256")) { - parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); - } else { - parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_128); - } - } else if (encryptionMethod.equals("STANDARD")) { - parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD_VARIANT_STRONG); - Log.d(TAG, "Standard Encryption"); + try { + ZipParameters parameters = buildZipParameters(compressionLevel); + + if (password == null || password.isEmpty()) { + promise.reject("RNZipArchiveError", "Password is empty"); + return; + } + + parameters.setEncryptFiles(true); + String[] encParts = encryptionMethod.split("-"); + + if (encParts[0].equals("AES")) { + parameters.setEncryptionMethod(EncryptionMethod.AES); + if (encParts[1].equals("128")) { + parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_128); + } else if (encParts[1].equals("256")) { + parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); } else { - parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD); - Log.d(TAG, "Encryption type not supported default to Standard Encryption"); + parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_128); } + } else if ("STANDARD".equals(encryptionMethod)) { + parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD_VARIANT_STRONG); + Log.d(TAG, "Standard Encryption"); } else { - promise.reject("RNZipArchiveError", "Password is empty"); - return; + parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD); + Log.d(TAG, "Encryption type not supported default to Standard Encryption"); } processZip(filesOrDirectory, destFile, parameters, promise, password.toCharArray()); - } catch (Exception ex) { promise.reject("RNZipArchiveError", ex.getMessage()); - return; } } - private void zip(final ArrayList filesOrDirectory, final String destFile, final double compressionLevel, final Promise promise) { - try{ - ZipParameters parameters = new ZipParameters(); - parameters.setCompressionMethod(CompressionMethod.DEFLATE); - parameters.setCompressionLevel(getCompressionLevel(compressionLevel)); - + private void zip(final List filesOrDirectory, final String destFile, final double compressionLevel, final Promise promise) { + try { + ZipParameters parameters = buildZipParameters(compressionLevel); processZip(filesOrDirectory, destFile, parameters, promise, null); - } catch (Exception ex) { promise.reject("RNZipArchiveError", ex.getMessage()); - return; } } - private void processZip(final ArrayList entries, final String destFile, final ZipParameters parameters, final Promise promise, final char[] password) { - new Thread(new Runnable() { - @Override - public void run() { - try { - net.lingala.zip4j.ZipFile zipFile; - if (password != null) { - zipFile = new net.lingala.zip4j.ZipFile(destFile, password); - } else { - zipFile = new net.lingala.zip4j.ZipFile(destFile); - } + private void processZip(final List entries, final String destFile, final ZipParameters parameters, final Promise promise, final char[] password) { + executor.submit(() -> { + try (net.lingala.zip4j.ZipFile zipFile = password != null + ? new net.lingala.zip4j.ZipFile(destFile, password) + : new net.lingala.zip4j.ZipFile(destFile)) { - updateProgress(0, 100, destFile); - - int totalFiles = 0; - int fileCounter = 0; - - for (int i = 0; i < entries.size(); i++) { - File f = new File(entries.get(i).toString()); - - if (f.exists()) { - if (f.isDirectory()) { - File[] listFiles = f.listFiles(); - List files = listFiles != null ? Arrays.asList(listFiles) : new ArrayList(); - - totalFiles += files.size(); - for (int j = 0; j < files.size(); j++) { - if (files.get(j).isDirectory()) { - zipFile.addFolder(files.get(j), parameters); - } - else { - zipFile.addFile(files.get(j), parameters); - } - fileCounter += 1; - updateProgress(fileCounter, totalFiles, destFile); - } + updateProgress(0, 100, destFile); + + int totalFiles = 0; + int fileCounter = 0; + + for (int i = 0; i < entries.size(); i++) { + File f = new File(entries.get(i)); - } else { - totalFiles += 1; - zipFile.addFile(f, parameters); + if (f.exists()) { + if (f.isDirectory()) { + File[] listFiles = f.listFiles(); + List files = listFiles != null ? Arrays.asList(listFiles) : new ArrayList(); + + totalFiles += files.size(); + for (int j = 0; j < files.size(); j++) { + if (files.get(j).isDirectory()) { + zipFile.addFolder(files.get(j), parameters); + } else { + zipFile.addFile(files.get(j), parameters); + } fileCounter += 1; updateProgress(fileCounter, totalFiles, destFile); } + + } else { + totalFiles += 1; + zipFile.addFile(f, parameters); + fileCounter += 1; + updateProgress(fileCounter, totalFiles, destFile); } - else { - promise.reject("RNZipArchiveError", "File or folder does not exist"); - return; - } + } else { + promise.reject("RNZipArchiveError", "File or folder does not exist"); + return; } - updateProgress(1, 1, destFile); // force 100% - promise.resolve(destFile); - } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); - return; } + updateProgress(1, 1, destFile); // force 100% + promise.resolve(destFile); + } catch (Exception ex) { + promise.reject("RNZipArchiveError", ex.getMessage()); } - }).start(); - } - - protected void updateProgress(long extractedBytes, long totalSize, String zipFilePath) { - // Ensure progress can't overflow 1 - double progress = Math.min((double) extractedBytes / (double) totalSize, 1); - Log.d(TAG, String.format("updateProgress: %.0f%%", progress * 100)); - - WritableMap map = Arguments.createMap(); - map.putString(EVENT_KEY_FILENAME, zipFilePath); - map.putDouble(EVENT_KEY_PROGRESS, progress); - getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) - .emit(PROGRESS_EVENT_NAME, map); + }); } @Override public void getUncompressedSize(String zipFilePath, String charset, final Promise promise) { - try { - long totalSize = getUncompressedSize(zipFilePath, charset); - if (totalSize == -1) { - promise.reject("RNZipArchiveError", "Failed to get uncompressed size"); - } else { - promise.resolve((double) totalSize); + executor.submit(() -> { + try { + long totalSize = getUncompressedSize(zipFilePath, charset); + if (totalSize == -1) { + promise.reject("RNZipArchiveError", "Failed to get uncompressed size"); + } else { + promise.resolve((double) totalSize); + } + } catch (Exception e) { + promise.reject("RNZipArchiveError", "Failed to get uncompressed size: " + e.getMessage()); } - } catch (Exception e) { - promise.reject("RNZipArchiveError", "Failed to get uncompressed size: " + e.getMessage()); - } + }); } /** @@ -463,31 +433,42 @@ public void getUncompressedSize(String zipFilePath, String charset, final Promis * @return -1 on failure */ private long getUncompressedSize(String zipFilePath, String charset) { - long totalSize = 0; - try { - net.lingala.zip4j.ZipFile zipFile = null; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); - zipFile.setCharset(Charset.forName(charset)); - } else { - zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); - } - - final List files = zipFile.getFileHeaders(); - for(FileHeader it : files) { - long size = it.getUncompressedSize(); - if (size != -1) { - totalSize += size; - } - } - - zipFile.close(); + try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { + return totalUncompressedSize(zipFile.getFileHeaders()); } catch (Exception ignored) { return -1; } + } + + /** + * Sum the uncompressed sizes of the given entries, skipping entries whose size is unknown (-1). + */ + private static long totalUncompressedSize(List fileHeaders) { + long totalSize = 0; + for (FileHeader fileHeader : fileHeaders) { + long size = fileHeader.getUncompressedSize(); + if (size > 0) { + totalSize += size; + } + } return totalSize; } + private net.lingala.zip4j.ZipFile openZipFile(String zipFilePath, String charset) { + net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && charset != null) { + zipFile.setCharset(Charset.forName(charset)); + } + return zipFile; + } + + private ZipParameters buildZipParameters(double compressionLevel) { + ZipParameters parameters = new ZipParameters(); + parameters.setCompressionMethod(CompressionMethod.DEFLATE); + parameters.setCompressionLevel(getCompressionLevel(compressionLevel)); + return parameters; + } + private static CompressionLevel getCompressionLevel(double compressionLevel) { if (compressionLevel == -1) { return CompressionLevel.NORMAL; @@ -517,6 +498,42 @@ private static CompressionLevel getCompressionLevel(double compressionLevel) { } } + /** + * Convert a JS string array to a Java list, rejecting non-string elements instead of + * crashing the caller with an unchecked native type exception. + * + * @throws IllegalArgumentException if an element is not a string + */ + static List readableArrayToStringList(ReadableArray array) { + List result = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + if (array.getType(i) != ReadableType.String) { + throw new IllegalArgumentException( + "expected string at index " + i + " but got " + array.getType(i)); + } + result.add(array.getString(i)); + } + return result; + } + + protected void updateProgress(long extractedBytes, long totalSize, String zipFilePath) { + // Ensure progress can't overflow 1 + final double progress = Math.min((double) extractedBytes / (double) totalSize, 1); + Log.d(TAG, String.format("updateProgress: %.0f%%", progress * 100)); + + final WritableMap map = Arguments.createMap(); + map.putString(EVENT_KEY_FILENAME, zipFilePath); + map.putDouble(EVENT_KEY_PROGRESS, progress); + + mainHandler.post(() -> { + ReactApplicationContext context = getReactApplicationContext(); + if (context != null && context.hasActiveCatalystInstance()) { + context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(PROGRESS_EVENT_NAME, map); + } + }); + } + /** * Returns the exception stack trace as a string */ diff --git a/android/src/main/java/com/rnziparchive/StreamUtil.java b/android/src/main/java/com/rnziparchive/StreamUtil.java index 1055b7b3..d7e9624f 100644 --- a/android/src/main/java/com/rnziparchive/StreamUtil.java +++ b/android/src/main/java/com/rnziparchive/StreamUtil.java @@ -14,7 +14,13 @@ public interface ProgressCallback { void onCopyProgress(long bytesRead); } - // https://stackoverflow.com/questions/4919690/how-to-read-one-stream-into-another + /** + * Copies all bytes from {@code from} to {@code to}. + *

+ * Callers are responsible for opening and closing both streams. + * + * @return the number of bytes copied + */ public static long copy(InputStream from, OutputStream to, ProgressCallback callback) throws IOException { checkNotNull(from); checkNotNull(to); @@ -32,6 +38,7 @@ public static long copy(InputStream from, OutputStream to, ProgressCallback call callback.onCopyProgress(r); } } + to.flush(); return total; } } diff --git a/android/src/main/java/com/rnziparchive/ZipSecurity.java b/android/src/main/java/com/rnziparchive/ZipSecurity.java new file mode 100644 index 00000000..66c535c0 --- /dev/null +++ b/android/src/main/java/com/rnziparchive/ZipSecurity.java @@ -0,0 +1,35 @@ +package com.rnziparchive; + +import java.io.File; +import java.io.IOException; + +/** + * Validates zip extraction paths to prevent Zip Slip / path traversal attacks. + */ +public final class ZipSecurity { + + private ZipSecurity() { + // utility class + } + + /** + * Ensures that extracting {@code entryName} into {@code destDirectory} would not escape the + * destination directory (e.g. via {@code ../} or absolute paths). + * + * @param destDirectory the target extraction directory + * @param entryName the zip entry name + * @throws IOException if canonical paths cannot be resolved + * @throws SecurityException if the entry would be extracted outside {@code destDirectory} + */ + public static void validateExtractPath(String destDirectory, String entryName) throws IOException { + File destDir = new File(destDirectory); + File fout = new File(destDir, entryName); + + String canonicalPath = fout.getCanonicalPath(); + String destDirCanonicalPath = destDir.getCanonicalPath() + File.separator; + + if (!canonicalPath.startsWith(destDirCanonicalPath)) { + throw new SecurityException(String.format("Found Zip Path Traversal Vulnerability with %s", canonicalPath)); + } + } +} diff --git a/android/src/test/java/com/rnziparchive/ReadableArrayToStringListTest.java b/android/src/test/java/com/rnziparchive/ReadableArrayToStringListTest.java new file mode 100644 index 00000000..275cd098 --- /dev/null +++ b/android/src/test/java/com/rnziparchive/ReadableArrayToStringListTest.java @@ -0,0 +1,133 @@ +package com.rnziparchive; + +import com.facebook.react.bridge.Dynamic; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableType; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ReadableArrayToStringListTest { + + /** Minimal ReadableArray stub backed by a list of JS-like values. */ + private static class StubReadableArray implements ReadableArray { + private final List values; + + StubReadableArray(Object... values) { + this.values = Arrays.asList(values); + } + + @Override + public int size() { + return values.size(); + } + + @Override + public ReadableType getType(int index) { + Object value = values.get(index); + if (value == null) return ReadableType.Null; + if (value instanceof String) return ReadableType.String; + if (value instanceof Boolean) return ReadableType.Boolean; + if (value instanceof Number) return ReadableType.Number; + if (value instanceof ReadableMap) return ReadableType.Map; + if (value instanceof ReadableArray) return ReadableType.Array; + throw new IllegalStateException("Unsupported stub value: " + value); + } + + @Override + public String getString(int index) { + return (String) values.get(index); + } + + @Override + public boolean isNull(int index) { + return values.get(index) == null; + } + + @Override + public boolean getBoolean(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public double getDouble(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public int getInt(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public long getLong(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public ReadableArray getArray(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public ReadableMap getMap(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public Dynamic getDynamic(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public ArrayList toArrayList() { + return new ArrayList<>(values); + } + } + + @Test + public void convertsStringArray() { + List result = RNZipArchiveModule.readableArrayToStringList( + new StubReadableArray("/tmp/a.zip", "/tmp/b.zip")); + + assertEquals(Arrays.asList("/tmp/a.zip", "/tmp/b.zip"), result); + } + + @Test + public void rejectsNumberElement() { + try { + RNZipArchiveModule.readableArrayToStringList(new StubReadableArray("/tmp/a.zip", 42)); + fail("Expected IllegalArgumentException for number element"); + } catch (IllegalArgumentException ex) { + assertTrue(ex.getMessage().contains("index 1")); + } + } + + @Test + public void rejectsNullElement() { + try { + RNZipArchiveModule.readableArrayToStringList(new StubReadableArray((Object) null)); + fail("Expected IllegalArgumentException for null element"); + } catch (IllegalArgumentException ex) { + assertTrue(ex.getMessage().contains("index 0")); + } + } + + @Test + public void rejectsBooleanElement() { + try { + RNZipArchiveModule.readableArrayToStringList(new StubReadableArray(true)); + fail("Expected IllegalArgumentException for boolean element"); + } catch (IllegalArgumentException ex) { + assertTrue(ex.getMessage().contains("index 0")); + } + } +} diff --git a/android/src/test/java/com/rnziparchive/ZipSecurityTest.java b/android/src/test/java/com/rnziparchive/ZipSecurityTest.java new file mode 100644 index 00000000..d44f2ed0 --- /dev/null +++ b/android/src/test/java/com/rnziparchive/ZipSecurityTest.java @@ -0,0 +1,57 @@ +package com.rnziparchive; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ZipSecurityTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void validateExtractPath_acceptsSafeNestedEntry() throws IOException { + File dest = temporaryFolder.newFolder("dest"); + + // Should not throw + ZipSecurity.validateExtractPath(dest.getAbsolutePath(), "folder/file.txt"); + } + + @Test + public void validateExtractPath_rejectsParentDirectoryTraversal() throws IOException { + File dest = temporaryFolder.newFolder("dest"); + + try { + ZipSecurity.validateExtractPath(dest.getAbsolutePath(), "../evil.txt"); + fail("Expected SecurityException for traversal entry"); + } catch (SecurityException ex) { + assertTrue(ex.getMessage().contains("Zip Path Traversal Vulnerability")); + } + } + + @Test + public void validateExtractPath_rejectsDeepTraversal() throws IOException { + File dest = temporaryFolder.newFolder("dest"); + + try { + ZipSecurity.validateExtractPath(dest.getAbsolutePath(), "a/../../evil.txt"); + fail("Expected SecurityException for deep traversal entry"); + } catch (SecurityException ex) { + assertTrue(ex.getMessage().contains("Zip Path Traversal Vulnerability")); + } + } + + @Test + public void validateExtractPath_acceptsEntryInSubdirectoryNamedLikeTraversal() throws IOException { + File dest = temporaryFolder.newFolder("dest"); + + // A directory name that contains ".." is not traversal as long as it stays inside dest + ZipSecurity.validateExtractPath(dest.getAbsolutePath(), "foo..bar/baz.txt"); + } +} diff --git a/index.d.ts b/index.d.ts index 41133c48..73a0b167 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,30 +1,40 @@ +import { NativeEventSubscription } from "react-native"; + declare module "react-native-zip-archive" { export enum EncryptionMethods { STANDARD = "STANDARD", AES_128 = "AES-128", AES_256 = "AES-256", } - import { NativeEventSubscription } from "react-native"; + export const DEFAULT_COMPRESSION: number; export const NO_COMPRESSION: number; export const BEST_SPEED: number; export const BEST_COMPRESSION: number; + export function isPasswordProtected(source: string): Promise; + export function zip( source: string | string[], target: string, - compressionLevel: number = DEFAULT_COMPRESSION + compressionLevel?: number ): Promise; + export function zipWithPassword( source: string | string[], target: string, password: string, encryptionMethod?: EncryptionMethods, - compressionLevel: number = DEFAULT_COMPRESSION + compressionLevel?: number ): Promise; + export function unzip(source: string, target: string, charset?: string): Promise; export function unzipWithPassword(source: string, target: string, password: string): Promise; export function unzipAssets(assetPath: string, target: string): Promise; - export function subscribe(callback: ({ progress, filePath }: { progress: number, filePath: string }) => void): NativeEventSubscription; + + export function subscribe( + callback: ({ progress, filePath }: { progress: number; filePath: string }) => void + ): NativeEventSubscription; + export function getUncompressedSize(source: string, charset?: string): Promise; } diff --git a/ios/RNZipArchive.h b/ios/RNZipArchive.h index 0391e9c3..96bfa20a 100644 --- a/ios/RNZipArchive.h +++ b/ios/RNZipArchive.h @@ -13,11 +13,13 @@ #import #endif -@interface RNZipArchive : RCTEventEmitter +NS_ASSUME_NONNULL_BEGIN -@property (nonatomic) NSString *processedFilePath; +@interface RNZipArchive : RCTEventEmitter + +@property (nonatomic, copy, nullable) NSString *processedFilePath; @property (nonatomic) float progress; -@property (nonatomic, copy) void (^progressHandler)(NSUInteger entryNumber, NSUInteger total); +@property (nonatomic, copy, nullable) void (^progressHandler)(NSUInteger entryNumber, NSUInteger total); @end @@ -25,3 +27,5 @@ @interface RNZipArchive () @end #endif + +NS_ASSUME_NONNULL_END diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index acbbc98f..b72d0a67 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -60,22 +60,7 @@ - (void)unzip:(NSString *)from charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - NSError *error = nil; - - BOOL success = [SSZipArchive unzipFileAtPath:from toDestination:destinationPath preserveAttributes:NO overwrite:YES password:nil error:&error delegate:self]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - reject(@"unzip_error", [error localizedDescription], error); - } + [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; } - (void)unzipWithPassword:(NSString *)from @@ -83,13 +68,50 @@ - (void)unzipWithPassword:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; +} + +- (void)unzipFile:(NSString *)from +destinationPath:(NSString *)destinationPath + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% NSError *error = nil; - BOOL success = [SSZipArchive unzipFileAtPath:from toDestination:destinationPath preserveAttributes:NO overwrite:YES password:password error:&error delegate:self]; + // Total uncompressed size, used for byte-weighted progress. If it can't be + // determined, fall back to per-entry progress (entryNumber / total). + NSNumber *payloadSize = [SSZipArchive payloadSizeForArchiveAtPath:from error:nil]; + unsigned long long totalSize = payloadSize ? [payloadSize unsignedLongLongValue] : 0; + + __block unsigned long long extractedBytes = 0; + __weak RNZipArchive *weakSelf = self; + + BOOL success = [SSZipArchive unzipFileAtPath:from + toDestination:destinationPath + preserveAttributes:NO + overwrite:YES + nestedZipLevel:0 + password:password + error:&error + delegate:nil + progressHandler:^(NSString *entry, unz_file_info zipInfo, long entryNumber, long total) { + RNZipArchive *strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + strongSelf.processedFilePath = entry; + if (totalSize > 0) { + extractedBytes += zipInfo.uncompressed_size; + [strongSelf zipArchiveProgressEvent:extractedBytes total:totalSize]; + } else { + [strongSelf zipArchiveProgressEvent:entryNumber total:total]; + } + } + completionHandler:nil]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -145,7 +167,7 @@ - (void)zipFiles:(NSArray *)from BOOL success; [self setProgressHandler]; - success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from]; + success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from withPassword:nil progressHandler:self.progressHandler]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -205,7 +227,7 @@ - (void)zipFilesWithPassword:(NSArray *)from BOOL success; [self setProgressHandler]; // Note: withFilesAtPaths doesn't have AES class method, using password only - success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from withPassword:password]; + success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from withPassword:password progressHandler:self.progressHandler]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -263,11 +285,6 @@ - (void)zipArchiveProgressEvent:(unsigned long long)loaded total:(unsigned long [self dispatchProgessEvent:self.progress processedFilePath:self.processedFilePath]; } -- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex totalFiles:(NSInteger)totalFiles archivePath:(NSString *)archivePath unzippedFilePath:(NSString *)processedFilePath { - self.processedFilePath = processedFilePath; - [self dispatchProgessEvent:self.progress processedFilePath:self.processedFilePath]; -} - - (void)setProgressHandler { __weak RNZipArchive *weakSelf = self; self.progressHandler = ^(NSUInteger entryNumber, NSUInteger total) { diff --git a/package.json b/package.json index 6c7b67e5..5ff621a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "8.0.1", + "version": "9.0.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/playground-expo/ios/Podfile b/playground-expo/ios/Podfile index 41a1dcab..6ee5a785 100644 --- a/playground-expo/ios/Podfile +++ b/playground-expo/ios/Podfile @@ -14,6 +14,7 @@ def ccache_enabled?(podfile_properties) podfile_properties['apple.ccacheEnabled'] == 'true' end + ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' @@ -61,5 +62,21 @@ target 'RNZipArchivePlayground' do :mac_catalyst_enabled => false, :ccache_enabled => ccache_enabled?(podfile_properties), ) + + # Xcode / Swift version workaround: + # - The macos-26 runner uses Xcode 26.5 / Swift 6.3, but Expo SDK 55 pods + # are not fully Swift-6 clean upstream. Compile third-party pods in Swift + # 5 to avoid the unbounded Swift 6.2 strict-concurrency patch list. + # - react-native-screens 4.25.x gamma sources are also not Swift-6 clean, + # so keep that pod on Swift 5 as well. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['SWIFT_VERSION'] = '5.0' + config.build_settings.delete('SWIFT_STRICT_CONCURRENCY') + config.build_settings.delete('SWIFT_UPCOMING_FEATURES') + end + end + + installer.pods_project.save end end diff --git a/playground-expo/ios/Podfile.lock b/playground-expo/ios/Podfile.lock index 222049e3..08ac82e7 100644 --- a/playground-expo/ios/Podfile.lock +++ b/playground-expo/ios/Podfile.lock @@ -2167,7 +2167,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNScreens (4.25.1): + - RNScreens (4.25.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2189,9 +2189,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNScreens/common (= 4.25.1) + - RNScreens/common (= 4.25.2) - Yoga - - RNScreens/common (4.25.1): + - RNScreens/common (4.25.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2214,7 +2214,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - RNZipArchive (8.0.0): + - RNZipArchive (8.0.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2272,7 +2272,7 @@ DEPENDENCIES: - "ExpoLogBox (from `../node_modules/.pnpm/@expo+log-box@55.0.12_@expo+dom-webview@55.0.6_expo@55.0.24_react-native@0.83.9_@babel+_e793a23f021904cb90aca52b02325627/node_modules/@expo/log-box`)" - "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@55.0.25_react-native@0.83.9_@babel+core@7.29.0_@react-native-communit_087fd0e951dd02e79f8845e87a44f4c0/node_modules/expo-modules-core`)" - "ExpoModulesJSI (from `../node_modules/.pnpm/expo-modules-core@55.0.25_react-native@0.83.9_@babel+core@7.29.0_@react-native-communit_087fd0e951dd02e79f8845e87a44f4c0/node_modules/expo-modules-core`)" - - "ExpoRouter (from `../node_modules/.pnpm/expo-router@55.0.14_@expo+log-box@55.0.12_@expo+metro-runtime@55.0.11_@types+react@19.0_3e34215a2215cb436996c578e41dcd27/node_modules/expo-router/ios`)" + - "ExpoRouter (from `../node_modules/.pnpm/expo-router@55.0.14_@expo+log-box@55.0.12_@expo+metro-runtime@55.0.11_@types+react@19.0_7963b2a8fa53ad71da4d6d600e2e2b8d/node_modules/expo-router/ios`)" - "ExpoSymbols (from `../node_modules/.pnpm/expo-symbols@55.0.8_expo-font@55.0.7_expo@55.0.24_react-native@0.83.9_@babel+core@7.29._543cdd268cfc9f8eb1bffd6762f95efa/node_modules/expo-symbols/ios`)" - "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@55.1.6_expo@55.0.24/node_modules/expo-updates-interface/ios`)" - "FBLazyVector (from `../node_modules/.pnpm/react-native@0.83.9_@babel+core@7.29.0_@react-native-community+cli@20.1.3_typescript@5._a74f6cc48602ef8fdbac7889a4700499/node_modules/react-native/Libraries/FBLazyVector`)" @@ -2351,7 +2351,7 @@ DEPENDENCIES: - "ReactCommon/turbomodule/core (from `../node_modules/.pnpm/react-native@0.83.9_@babel+core@7.29.0_@react-native-community+cli@20.1.3_typescript@5._a74f6cc48602ef8fdbac7889a4700499/node_modules/react-native/ReactCommon`)" - "ReactNativeDependencies (from `../node_modules/.pnpm/react-native@0.83.9_@babel+core@7.29.0_@react-native-community+cli@20.1.3_typescript@5._a74f6cc48602ef8fdbac7889a4700499/node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)" - "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.31.2_react-native@0.83.9_@babel+core@7.29.0_@react-nativ_e1a9ea27ba9dd35d6202bd5a4e603aa1/node_modules/react-native-gesture-handler`)" - - "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.25.1_react-native@0.83.9_@babel+core@7.29.0_@react-native-commun_5fda5ce5a84bd0fa3b1b9e64a2ca5e15/node_modules/react-native-screens`)" + - "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.25.2_react-native@0.83.9_@babel+core@7.29.0_@react-native-commun_ca72db04dab7622cd2788778a38953f8/node_modules/react-native-screens`)" - "RNZipArchive (from `../node_modules/.pnpm/react-native-zip-archive@file+.._react-native@0.83.9_@babel+core@7.29.0_@react-native-c_f686999c74b0cdd6ec23ea7588293dd3/node_modules/react-native-zip-archive`)" - "Yoga (from `../node_modules/.pnpm/react-native@0.83.9_@babel+core@7.29.0_@react-native-community+cli@20.1.3_typescript@5._a74f6cc48602ef8fdbac7889a4700499/node_modules/react-native/ReactCommon/yoga`)" @@ -2408,7 +2408,7 @@ EXTERNAL SOURCES: ExpoModulesJSI: :path: "../node_modules/.pnpm/expo-modules-core@55.0.25_react-native@0.83.9_@babel+core@7.29.0_@react-native-communit_087fd0e951dd02e79f8845e87a44f4c0/node_modules/expo-modules-core" ExpoRouter: - :path: "../node_modules/.pnpm/expo-router@55.0.14_@expo+log-box@55.0.12_@expo+metro-runtime@55.0.11_@types+react@19.0_3e34215a2215cb436996c578e41dcd27/node_modules/expo-router/ios" + :path: "../node_modules/.pnpm/expo-router@55.0.14_@expo+log-box@55.0.12_@expo+metro-runtime@55.0.11_@types+react@19.0_7963b2a8fa53ad71da4d6d600e2e2b8d/node_modules/expo-router/ios" ExpoSymbols: :path: "../node_modules/.pnpm/expo-symbols@55.0.8_expo-font@55.0.7_expo@55.0.24_react-native@0.83.9_@babel+core@7.29._543cdd268cfc9f8eb1bffd6762f95efa/node_modules/expo-symbols/ios" EXUpdatesInterface: @@ -2565,7 +2565,7 @@ EXTERNAL SOURCES: RNGestureHandler: :path: "../node_modules/.pnpm/react-native-gesture-handler@2.31.2_react-native@0.83.9_@babel+core@7.29.0_@react-nativ_e1a9ea27ba9dd35d6202bd5a4e603aa1/node_modules/react-native-gesture-handler" RNScreens: - :path: "../node_modules/.pnpm/react-native-screens@4.25.1_react-native@0.83.9_@babel+core@7.29.0_@react-native-commun_5fda5ce5a84bd0fa3b1b9e64a2ca5e15/node_modules/react-native-screens" + :path: "../node_modules/.pnpm/react-native-screens@4.25.2_react-native@0.83.9_@babel+core@7.29.0_@react-native-commun_ca72db04dab7622cd2788778a38953f8/node_modules/react-native-screens" RNZipArchive: :path: "../node_modules/.pnpm/react-native-zip-archive@file+.._react-native@0.83.9_@babel+core@7.29.0_@react-native-c_f686999c74b0cdd6ec23ea7588293dd3/node_modules/react-native-zip-archive" Yoga: @@ -2673,8 +2673,8 @@ SPEC CHECKSUMS: ReactCommon: d4871ce29ec62aadda5cc8a206449f314dab9b76 ReactNativeDependencies: edcbe7f15b74de188571f8bcce3e0b0be5d29b0e RNGestureHandler: e5a2ca72bdd39bed79cb8ccb8d4ee333357dc11d - RNScreens: 6daa35235046286228e707a8ee052467756ab8f1 - RNZipArchive: 2f13e4ae46a490be0819623be17f4a4ae9f00987 + RNScreens: a594370f2eaa08a48d682ebbca014ffec4816fd3 + RNZipArchive: 647ead787c9854c3cfd2ed818bb295da9542f8d0 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c @@ -2682,6 +2682,6 @@ SPEC CHECKSUMS: SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 Yoga: 7ca324e377340c274423a58a5494b08c2927f470 -PODFILE CHECKSUM: e1de91d809569a92f2102e8995277f37df799cf9 +PODFILE CHECKSUM: 832175b355c7c0039ca038b15aae04ff53dbd317 COCOAPODS: 1.16.2 diff --git a/playground-expo/package.json b/playground-expo/package.json index f49d4768..01fc087a 100644 --- a/playground-expo/package.json +++ b/playground-expo/package.json @@ -24,7 +24,7 @@ "react-native": "0.83.9", "react-native-gesture-handler": "~2.31.2", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "~4.25.0", + "react-native-screens": "~4.25.2", "react-native-zip-archive": "file:.." }, "devDependencies": { diff --git a/playground-expo/pnpm-lock.yaml b/playground-expo/pnpm-lock.yaml index 4911caa1..71bcd395 100644 --- a/playground-expo/pnpm-lock.yaml +++ b/playground-expo/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 55.0.15(expo@55.0.24)(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) expo-router: specifier: ~55.0.14 - version: 55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + version: 55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) expo-status-bar: specifier: ~55.0.6 version: 55.0.6(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) @@ -45,8 +45,8 @@ importers: specifier: ~5.7.0 version: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) react-native-screens: - specifier: ~4.25.0 - version: 4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + specifier: ~4.25.2 + version: 4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) react-native-zip-archive: specifier: file:.. version: file:..(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) @@ -2897,8 +2897,8 @@ packages: react: '*' react-native: '*' - react-native-screens@4.25.1: - resolution: {integrity: sha512-9gAFwkzcvBrQHIQjIT+hz1gUows1hqCEkp40oj+Wh3jXo6aG8mysFom9++HDKSLaOk2rgSUTaUgKOTff9c2udQ==} + react-native-screens@4.25.2: + resolution: {integrity: sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==} peerDependencies: react: '*' react-native: '>=0.82.0' @@ -4170,7 +4170,7 @@ snapshots: ws: 8.20.1 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + expo-router: 55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) react-native: 0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0) transitivePeerDependencies: - '@expo/dom-webview' @@ -4425,7 +4425,7 @@ snapshots: react: 19.2.0 optionalDependencies: '@expo/metro-runtime': 55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) - expo-router: 55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + expo-router: 55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) react-dom: 19.2.6(react@19.2.0) transitivePeerDependencies: - supports-color @@ -5050,7 +5050,7 @@ snapshots: optionalDependencies: '@types/react': 19.0.14 - '@react-navigation/bottom-tabs@7.16.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0)': + '@react-navigation/bottom-tabs@7.16.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0)': dependencies: '@react-navigation/elements': 2.9.18(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) '@react-navigation/native': 7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) @@ -5058,7 +5058,7 @@ snapshots: react: 19.2.0 react-native: 0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0) react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) - react-native-screens: 4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) sf-symbols-typescript: 2.2.0 transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -5085,7 +5085,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.0) use-sync-external-store: 1.6.0(react@19.2.0) - '@react-navigation/native-stack@7.15.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0)': + '@react-navigation/native-stack@7.15.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0)': dependencies: '@react-navigation/elements': 2.9.18(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) '@react-navigation/native': 7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) @@ -5093,7 +5093,7 @@ snapshots: react: 19.2.0 react-native: 0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0) react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) - react-native-screens: 4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -5860,16 +5860,16 @@ snapshots: react: 19.2.0 react-native: 0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0) - expo-router@55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0): + expo-router@55.0.14(@expo/log-box@55.0.12)(@expo/metro-runtime@55.0.11)(@types/react@19.0.14)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-linking@55.0.15)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native-gesture-handler@2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0): dependencies: '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) '@expo/metro-runtime': 55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.6(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) '@expo/schema-utils': 55.0.4 '@radix-ui/react-slot': 1.2.4(@types/react@19.0.14)(react@19.2.0) '@radix-ui/react-tabs': 1.1.13(@types/react@19.0.14)(react-dom@19.2.6(react@19.2.0))(react@19.2.0) - '@react-navigation/bottom-tabs': 7.16.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/bottom-tabs': 7.16.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) '@react-navigation/native': 7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) - '@react-navigation/native-stack': 7.15.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native-stack': 7.15.1(@react-navigation/native@7.2.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) client-only: 0.0.1 debug: 4.4.3 escape-string-regexp: 4.0.0 @@ -5889,7 +5889,7 @@ snapshots: react-native: 0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0) react-native-is-edge-to-edge: 1.3.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) react-native-safe-area-context: 5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) - react-native-screens: 4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0) semver: 7.6.3 server-only: 0.0.1 sf-symbols-typescript: 2.2.0 @@ -6945,7 +6945,7 @@ snapshots: react: 19.2.0 react-native: 0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0) - react-native-screens@4.25.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0): + react-native-screens@4.25.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@types/react@19.0.14)(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 react-freeze: 1.0.4(react@19.2.0) diff --git a/playground-rn/package.json b/playground-rn/package.json index f0d4e36d..9a68a95a 100644 --- a/playground-rn/package.json +++ b/playground-rn/package.json @@ -15,11 +15,11 @@ "@react-navigation/native-stack": "^7.15.1", "base-64": "^1.0.0", "react": "19.2.0", - "react-native": "^0.83.9", + "react-native": "0.83.9", "react-native-fs": "^2.20.0", - "react-native-gesture-handler": "^2.31.2", - "react-native-safe-area-context": "^5.7.0", - "react-native-screens": "^4.25.0", + "react-native-gesture-handler": "~2.31.2", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.25.2", "react-native-zip-archive": "file:.." }, "devDependencies": {