From 64090f1d27d6307239b2a1be1cd25d1b4e995262 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:01:36 +0000 Subject: [PATCH 1/3] feat: cancel() + stable error codes (#366) Rebased onto master after #369 was squash-merged so this PR no longer conflicts with the rewritten listContents history. Co-authored-by: Perry --- .github/workflows/e2e.yml | 38 ++++- CHANGELOG.md | 6 + README.md | 36 +++++ __mocks__/react-native.js | 1 + __tests__/api.test.js | 17 +++ __tests__/module-integration.test.js | 1 + .../com/rnziparchive/RNZipArchiveModule.java | 112 ++++++++++---- .../java/com/rnziparchive/ZipErrorCodes.java | 51 +++++++ .../rnziparchive/NativeZipArchiveSpec.java | 4 + .../com/rnziparchive/ZipErrorCodesTest.java | 30 ++++ index.d.ts | 17 +++ index.js | 19 +++ ios/RNZipArchive.h | 1 + ios/RNZipArchive.mm | 137 +++++++++++++++--- package.json | 2 +- specs/NativeZipArchive.ts | 1 + 16 files changed, 416 insertions(+), 57 deletions(-) create mode 100644 android/src/main/java/com/rnziparchive/ZipErrorCodes.java create mode 100644 android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7d8c40c..ce11f1e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,8 +46,23 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + set -euo pipefail + for attempt in 1 2 3; do + echo "Installing Maestro (attempt $attempt)..." + if curl -fsSL "https://get.maestro.mobile.dev" | bash \ + && test -x "$HOME/.maestro/bin/maestro"; then + break + fi + echo "Maestro install failed on attempt $attempt" + rm -rf "$HOME/.maestro" + if [ "$attempt" -eq 3 ]; then + echo "Maestro install failed after 3 attempts" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Cache node_modules uses: actions/cache@v4 @@ -159,8 +174,23 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + set -euo pipefail + for attempt in 1 2 3; do + echo "Installing Maestro (attempt $attempt)..." + if curl -fsSL "https://get.maestro.mobile.dev" | bash \ + && test -x "$HOME/.maestro/bin/maestro"; then + break + fi + echo "Maestro install failed on attempt $attempt" + rm -rf "$HOME/.maestro" + if [ "$attempt" -eq 3 ]; then + echo "Maestro install failed after 3 attempts" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Cache node_modules uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8d01c..26d8d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [9.2.0] - 2026-07-25 + +### Added +- `cancel()` — best-effort abort of the in-flight zip/unzip operation; rejects with `ERR_CANCELLED` (#366) +- Stable cross-platform error codes (`ERR_FILE_NOT_FOUND`, `ERR_WRONG_PASSWORD`, `ERR_UNSAFE_PATH`, …) and JS `ErrorCodes` map (#366) + ## [9.1.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index 61ff683..ee62ea5 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,11 @@ import { unzipWithPassword, listContents, unzipAssets, + cancel, subscribe, isPasswordProtected, getUncompressedSize, + ErrorCodes, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -196,6 +198,39 @@ getUncompressedSize(sourcePath) .catch((error) => console.error(error)) ``` +### `cancel(): Promise` + +Cancel the in-flight zip/unzip operation (best-effort). The active operation's promise rejects with `ErrorCodes.CANCELLED` (`ERR_CANCELLED`). + +```js +const unzipPromise = unzip(sourcePath, targetPath) +cancel() +unzipPromise.catch((error) => { + if (error.code === ErrorCodes.CANCELLED) { + console.log('unzip cancelled') + } +}) +``` + +### Error codes + +Native rejections use stable `error.code` values on both platforms: + +| Code | When | +|------|------| +| `ERR_FILE_NOT_FOUND` | Source missing | +| `ERR_INVALID_PATH` | Bad / null path | +| `ERR_INVALID_ARGS` | Empty password, empty entries, etc. | +| `ERR_WRONG_PASSWORD` | Password decrypt failed | +| `ERR_NOT_PASSWORD_PROTECTED` | Password API used on a plain archive | +| `ERR_CORRUPT_ARCHIVE` | Not a zip / truncated / unreadable | +| `ERR_UNSAFE_PATH` | Zip Slip / path traversal | +| `ERR_CANCELLED` | `cancel()` interrupted the operation | +| `ERR_ZIP` / `ERR_UNZIP` | Generic zip/unzip failure | +| `ERR_UNSUPPORTED` | API not available on this platform | + +Also exported as the `ErrorCodes` constant map. + ### `subscribe(callback: ({ progress: number, filePath: string }) => void): EmitterSubscription` Subscribe to progress events. Useful for showing a progress bar. @@ -234,6 +269,7 @@ useEffect(() => { | `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract | | `listContents` | ✅ | ✅ | Charset ignored on iOS | | `unzipAssets` | ❌ | ✅ | Android only | +| `cancel` | ✅ | ✅ | Best-effort mid-operation abort | | `isPasswordProtected` | ✅ | ✅ | — | | `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS | | Progress Events | ✅ | ✅ | File path empty on iOS for zip | diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index ac17fb1..22ec107 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -20,6 +20,7 @@ const mockRNZipArchive = { unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), getUncompressedSize: jest.fn(() => Promise.resolve(1024)), + cancel: jest.fn(() => Promise.resolve()), addListener: jest.fn(), removeListeners: jest.fn(), }; diff --git a/__tests__/api.test.js b/__tests__/api.test.js index d08a07b..62368f4 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -7,7 +7,9 @@ const { unzipAssets, isPasswordProtected, getUncompressedSize, + cancel, subscribe, + ErrorCodes, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -30,6 +32,7 @@ describe('react-native-zip-archive API', () => { expect(typeof unzipAssets).toBe('function'); expect(typeof isPasswordProtected).toBe('function'); expect(typeof getUncompressedSize).toBe('function'); + expect(typeof cancel).toBe('function'); expect(typeof subscribe).toBe('function'); }); @@ -40,6 +43,20 @@ describe('react-native-zip-archive API', () => { expect(BEST_COMPRESSION).toBe(9); }); + test('exports stable ErrorCodes', () => { + expect(ErrorCodes.CANCELLED).toBe('ERR_CANCELLED'); + expect(ErrorCodes.WRONG_PASSWORD).toBe('ERR_WRONG_PASSWORD'); + expect(ErrorCodes.UNSAFE_PATH).toBe('ERR_UNSAFE_PATH'); + expect(ErrorCodes.FILE_NOT_FOUND).toBe('ERR_FILE_NOT_FOUND'); + }); + + describe('cancel', () => { + test('cancel calls native module', async () => { + await cancel(); + expect(mockRNZipArchive.cancel).toHaveBeenCalled(); + }); + }); + describe('zip', () => { test('zip resolves with target path', async () => { const result = await zip('/source', '/target.zip'); diff --git a/__tests__/module-integration.test.js b/__tests__/module-integration.test.js index d1b7f87..71b1438 100644 --- a/__tests__/module-integration.test.js +++ b/__tests__/module-integration.test.js @@ -10,6 +10,7 @@ const mockRNZipArchive = { unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), getUncompressedSize: jest.fn(() => Promise.resolve(1024)), + cancel: jest.fn(() => Promise.resolve()), addListener: jest.fn(), removeListeners: jest.fn(), }; diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index df7f276..9403f17 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -53,6 +54,7 @@ public class RNZipArchiveModule extends NativeZipArchiveSpec { r -> new Thread(r, "RNZipArchiveWorker") ); private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final AtomicBoolean cancelled = new AtomicBoolean(false); public RNZipArchiveModule(ReactApplicationContext reactContext) { super(reactContext); @@ -75,13 +77,42 @@ public void onCatalystInstanceDestroy() { invalidate(); } + + private void beginOperation() { + cancelled.set(false); + } + + private boolean rejectIfCancelled(Promise promise) { + if (cancelled.get()) { + promise.reject(ZipErrorCodes.CANCELLED, "Operation cancelled"); + return true; + } + return false; + } + + private void rejectMapped(Promise promise, Exception ex, String fallback) { + if (cancelled.get()) { + promise.reject(ZipErrorCodes.CANCELLED, "Operation cancelled"); + return; + } + String message = ex.getMessage() != null ? ex.getMessage() : fallback; + promise.reject(ZipErrorCodes.mapException(ex, fallback), message); + } + + @Override + public void cancel(final Promise promise) { + cancelled.set(true); + promise.resolve(null); + } + @Override public void isPasswordProtected(final String zipFilePath, final Promise promise) { executor.submit(() -> { + beginOperation(); 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))); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -99,11 +130,12 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto return; } executor.submit(() -> { + beginOperation(); 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)); + promise.reject(ZipErrorCodes.NOT_PASSWORD_PROTECTED, String.format("Zip file: %s is not password protected", zipFilePath)); return; } @@ -113,6 +145,9 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto updateProgress(0, 1, zipFilePath); // force 0% for (FileHeader fileHeader : fileHeaderList) { + if (rejectIfCancelled(promise)) { + return; + } ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { @@ -125,7 +160,7 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto 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))); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -142,13 +177,14 @@ public void unzip(final String zipFilePath, final String destDirectory, final St return; } executor.submit(() -> { + beginOperation(); if (zipFilePath == null) { - promise.reject("RNZipArchiveError", "Couldn't open file null. "); + promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; } File zipFileRef = new File(zipFilePath); if (!zipFileRef.exists()) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "Couldn't open file " + zipFilePath + "."); return; } @@ -165,6 +201,9 @@ public void unzip(final String zipFilePath, final String destDirectory, final St updateProgress(0, 1, zipFilePath); // force 0% for (FileHeader fileHeader : fileHeaderList) { + if (rejectIfCancelled(promise)) { + return; + } ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { @@ -178,7 +217,7 @@ public void unzip(final String zipFilePath, final String destDirectory, final St promise.resolve(destDirectory); } catch (Exception ex) { updateProgress(0, 1, zipFilePath); // force 0% - promise.reject("RNZipArchiveError", "Failed to extract file " + ex.getLocalizedMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -200,12 +239,12 @@ private List optionalEntriesList(ReadableArray entries, Promise promise) try { List entryList = readableArrayToStringList(entries); if (entryList.isEmpty()) { - promise.reject("RNZipArchiveError", "entries must be a non-empty array"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "entries must be a non-empty array"); return REJECTED_ENTRIES; } return entryList; } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); return REJECTED_ENTRIES; } } @@ -213,13 +252,14 @@ private List optionalEntriesList(ReadableArray entries, Promise promise) @Override public void listContents(final String zipFilePath, final String charset, final Promise promise) { executor.submit(() -> { + beginOperation(); if (zipFilePath == null) { - promise.reject("RNZipArchiveError", "Couldn't open file null. "); + promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; } File zipFileRef = new File(zipFilePath); if (!zipFileRef.exists()) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "Couldn't open file " + zipFilePath + "."); return; } @@ -236,7 +276,7 @@ public void listContents(final String zipFilePath, final String charset, final P } promise.resolve(entries); } catch (Exception ex) { - promise.reject("RNZipArchiveError", "Failed to list contents: " + ex.getLocalizedMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -245,24 +285,25 @@ private void extractSelectedEntries(final String zipFilePath, final String destD final List wantedEntries, final String charset, final String password, final Promise promise) { executor.submit(() -> { + beginOperation(); if (zipFilePath == null) { - promise.reject("RNZipArchiveError", "Couldn't open file null. "); + promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; } File zipFileRef = new File(zipFilePath); if (!zipFileRef.exists()) { - promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "Couldn't open file " + zipFilePath + "."); return; } if (wantedEntries == null || wantedEntries.isEmpty()) { - promise.reject("RNZipArchiveError", "entries must be a non-empty array"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "entries must be a non-empty array"); return; } try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { if (password != null) { if (!zipFile.isEncrypted()) { - promise.reject("RNZipArchiveError", + promise.reject(ZipErrorCodes.NOT_PASSWORD_PROTECTED, String.format("Zip file: %s is not password protected", zipFilePath)); return; } @@ -283,7 +324,7 @@ private void extractSelectedEntries(final String zipFilePath, final String destD } if (selected.isEmpty()) { - promise.reject("RNZipArchiveError", "None of the requested entries were found in the archive"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "None of the requested entries were found in the archive"); return; } @@ -292,6 +333,9 @@ private void extractSelectedEntries(final String zipFilePath, final String destD updateProgress(0, 1, zipFilePath); for (FileHeader fileHeader : selected) { + if (rejectIfCancelled(promise)) { + return; + } ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { @@ -309,7 +353,7 @@ private void extractSelectedEntries(final String zipFilePath, final String destD promise.resolve(destDirectory); } catch (Exception ex) { updateProgress(0, 1, zipFilePath); - promise.reject("RNZipArchiveError", "Failed to extract selected files: " + ex.getLocalizedMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } }); } @@ -363,6 +407,7 @@ private static String stripTrailingSlash(String path) { @Override public void unzipAssets(final String assetsPath, final String destDirectory, final Promise promise) { executor.submit(() -> { + beginOperation(); InputStream assetsInputStream = null; AssetFileDescriptor fileDescriptor = null; long compressedSize; @@ -394,7 +439,7 @@ public void unzipAssets(final String assetsPath, final String destDirectory, fin } if (assetsInputStream == null) { - promise.reject("RNZipArchiveError", String.format("Asset file `%s` could not be opened", assetsPath)); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, String.format("Asset file `%s` could not be opened", assetsPath)); return; } @@ -412,6 +457,9 @@ public void unzipAssets(final String assetsPath, final String destDirectory, fin updateProgress(extractedBytes, compressedSize, assetsPath); // force 0% while ((entry = zipIn.getNextEntry()) != null) { + if (rejectIfCancelled(promise)) { + return; + } if (entry.isDirectory()) continue; Log.i("rnziparchive", "Extracting: " + entry.getName()); @@ -445,7 +493,7 @@ public void unzipAssets(final String assetsPath, final String destDirectory, fin } catch (Exception ex) { Log.e(TAG, "Failed to extract asset: " + assetsPath, ex); updateProgress(0, 1, assetsPath); // force 0% - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.UNZIP); } finally { if (fileDescriptor != null) { try { @@ -469,7 +517,7 @@ public void zipFiles(final ReadableArray files, final String destDirectory, fina try { fileList = readableArrayToStringList(files); } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid files array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid files array: " + ex.getMessage()); return; } zip(fileList, destDirectory, compressionLevel, promise); @@ -489,7 +537,7 @@ public void zipFilesWithPassword(final ReadableArray files, final String destFil try { fileList = readableArrayToStringList(files); } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid files array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid files array: " + ex.getMessage()); return; } zipWithPassword(fileList, destFile, password, encryptionMethod, compressionLevel, promise); @@ -509,7 +557,7 @@ private void zipWithPassword(final List filesOrDirectory, final String d ZipParameters parameters = buildZipParameters(compressionLevel); if (password == null || password.isEmpty()) { - promise.reject("RNZipArchiveError", "Password is empty"); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Password is empty"); return; } @@ -537,7 +585,7 @@ private void zipWithPassword(final List filesOrDirectory, final String d processZip(filesOrDirectory, destFile, parameters, promise, password.toCharArray()); } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.ZIP); } } @@ -546,12 +594,13 @@ private void zip(final List filesOrDirectory, final String destFile, fin ZipParameters parameters = buildZipParameters(compressionLevel); processZip(filesOrDirectory, destFile, parameters, promise, null); } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.ZIP); } } private void processZip(final List entries, final String destFile, final ZipParameters parameters, final Promise promise, final char[] password) { executor.submit(() -> { + beginOperation(); try (net.lingala.zip4j.ZipFile zipFile = password != null ? new net.lingala.zip4j.ZipFile(destFile, password) : new net.lingala.zip4j.ZipFile(destFile)) { @@ -562,6 +611,9 @@ private void processZip(final List entries, final String destFile, final int fileCounter = 0; for (int i = 0; i < entries.size(); i++) { + if (rejectIfCancelled(promise)) { + return; + } File f = new File(entries.get(i)); if (f.exists()) { @@ -571,6 +623,9 @@ private void processZip(final List entries, final String destFile, final totalFiles += files.size(); for (int j = 0; j < files.size(); j++) { + if (rejectIfCancelled(promise)) { + return; + } if (files.get(j).isDirectory()) { zipFile.addFolder(files.get(j), parameters); } else { @@ -587,12 +642,12 @@ private void processZip(final List entries, final String destFile, final updateProgress(fileCounter, totalFiles, destFile); } } else { - promise.reject("RNZipArchiveError", "File or folder does not exist"); + promise.reject(ZipErrorCodes.FILE_NOT_FOUND, "File or folder does not exist"); return; } } } catch (Exception ex) { - promise.reject("RNZipArchiveError", ex.getMessage()); + rejectMapped(promise, ex, ZipErrorCodes.ZIP); return; } updateProgress(1, 1, destFile); // force 100% @@ -603,15 +658,16 @@ private void processZip(final List entries, final String destFile, final @Override public void getUncompressedSize(String zipFilePath, String charset, final Promise promise) { executor.submit(() -> { + beginOperation(); try { long totalSize = getUncompressedSize(zipFilePath, charset); if (totalSize == -1) { - promise.reject("RNZipArchiveError", "Failed to get uncompressed size"); + promise.reject(ZipErrorCodes.CORRUPT_ARCHIVE, "Failed to get uncompressed size"); } else { promise.resolve((double) totalSize); } } catch (Exception e) { - promise.reject("RNZipArchiveError", "Failed to get uncompressed size: " + e.getMessage()); + rejectMapped(promise, e, ZipErrorCodes.UNZIP); } }); } diff --git a/android/src/main/java/com/rnziparchive/ZipErrorCodes.java b/android/src/main/java/com/rnziparchive/ZipErrorCodes.java new file mode 100644 index 0000000..80654ee --- /dev/null +++ b/android/src/main/java/com/rnziparchive/ZipErrorCodes.java @@ -0,0 +1,51 @@ +package com.rnziparchive; + +import net.lingala.zip4j.exception.ZipException; + +/** + * Stable promise rejection codes shared with the JS API and iOS implementation. + */ +public final class ZipErrorCodes { + public static final String FILE_NOT_FOUND = "ERR_FILE_NOT_FOUND"; + public static final String INVALID_PATH = "ERR_INVALID_PATH"; + public static final String INVALID_ARGS = "ERR_INVALID_ARGS"; + public static final String WRONG_PASSWORD = "ERR_WRONG_PASSWORD"; + public static final String NOT_PASSWORD_PROTECTED = "ERR_NOT_PASSWORD_PROTECTED"; + public static final String CORRUPT_ARCHIVE = "ERR_CORRUPT_ARCHIVE"; + public static final String UNSAFE_PATH = "ERR_UNSAFE_PATH"; + public static final String CANCELLED = "ERR_CANCELLED"; + public static final String BUSY = "ERR_BUSY"; + public static final String ZIP = "ERR_ZIP"; + public static final String UNZIP = "ERR_UNZIP"; + public static final String UNSUPPORTED = "ERR_UNSUPPORTED"; + + private ZipErrorCodes() { + } + + public static String mapException(Exception ex, String fallback) { + if (ex instanceof SecurityException) { + return UNSAFE_PATH; + } + if (ex instanceof ZipException) { + ZipException zipException = (ZipException) ex; + if (zipException.getType() == ZipException.Type.WRONG_PASSWORD) { + return WRONG_PASSWORD; + } + String message = zipException.getMessage(); + if (message != null) { + String lower = message.toLowerCase(); + if (lower.contains("not a zip") || lower.contains("corrupt") + || lower.contains("invalid") || lower.contains("malformed")) { + return CORRUPT_ARCHIVE; + } + if (lower.contains("password")) { + return WRONG_PASSWORD; + } + } + } + if (ex instanceof java.io.FileNotFoundException) { + return FILE_NOT_FOUND; + } + return fallback; + } +} diff --git a/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java b/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java index edea453..1572821 100644 --- a/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java +++ b/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java @@ -74,6 +74,10 @@ public NativeZipArchiveSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void unzipAssets(String source, String target, Promise promise); + @ReactMethod + @DoNotStrip + public abstract void cancel(Promise promise); + @ReactMethod @DoNotStrip public abstract void addListener(String eventName); diff --git a/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java b/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java new file mode 100644 index 0000000..ca201c7 --- /dev/null +++ b/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java @@ -0,0 +1,30 @@ +package com.rnziparchive; + +import static org.junit.Assert.assertEquals; + +import net.lingala.zip4j.exception.ZipException; + +import org.junit.Test; + +public class ZipErrorCodesTest { + + @Test + public void mapsSecurityExceptionToUnsafePath() { + assertEquals( + ZipErrorCodes.UNSAFE_PATH, + ZipErrorCodes.mapException(new SecurityException("traversal"), ZipErrorCodes.UNZIP)); + } + + @Test + public void mapsWrongPasswordZipException() { + ZipException ex = new ZipException("bad password", ZipException.Type.WRONG_PASSWORD); + assertEquals(ZipErrorCodes.WRONG_PASSWORD, ZipErrorCodes.mapException(ex, ZipErrorCodes.UNZIP)); + } + + @Test + public void mapsUnknownExceptionToFallback() { + assertEquals( + ZipErrorCodes.ZIP, + ZipErrorCodes.mapException(new RuntimeException("boom"), ZipErrorCodes.ZIP)); + } +} diff --git a/index.d.ts b/index.d.ts index 13c09af..85653d5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,6 +7,21 @@ declare module "react-native-zip-archive" { AES_256 = "AES-256", } + export const ErrorCodes: { + FILE_NOT_FOUND: "ERR_FILE_NOT_FOUND"; + INVALID_PATH: "ERR_INVALID_PATH"; + INVALID_ARGS: "ERR_INVALID_ARGS"; + WRONG_PASSWORD: "ERR_WRONG_PASSWORD"; + NOT_PASSWORD_PROTECTED: "ERR_NOT_PASSWORD_PROTECTED"; + CORRUPT_ARCHIVE: "ERR_CORRUPT_ARCHIVE"; + UNSAFE_PATH: "ERR_UNSAFE_PATH"; + CANCELLED: "ERR_CANCELLED"; + BUSY: "ERR_BUSY"; + ZIP: "ERR_ZIP"; + UNZIP: "ERR_UNZIP"; + UNSUPPORTED: "ERR_UNSUPPORTED"; + }; + export const DEFAULT_COMPRESSION: number; export const NO_COMPRESSION: number; export const BEST_SPEED: number; @@ -69,4 +84,6 @@ declare module "react-native-zip-archive" { ): NativeEventSubscription; export function getUncompressedSize(source: string, charset?: string): Promise; + + export function cancel(): Promise; } diff --git a/index.js b/index.js index f0ad7b8..831cab2 100644 --- a/index.js +++ b/index.js @@ -37,6 +37,21 @@ export const EncryptionMethods = { AES_256: "AES-256", }; +export const ErrorCodes = { + FILE_NOT_FOUND: "ERR_FILE_NOT_FOUND", + INVALID_PATH: "ERR_INVALID_PATH", + INVALID_ARGS: "ERR_INVALID_ARGS", + WRONG_PASSWORD: "ERR_WRONG_PASSWORD", + NOT_PASSWORD_PROTECTED: "ERR_NOT_PASSWORD_PROTECTED", + CORRUPT_ARCHIVE: "ERR_CORRUPT_ARCHIVE", + UNSAFE_PATH: "ERR_UNSAFE_PATH", + CANCELLED: "ERR_CANCELLED", + BUSY: "ERR_BUSY", + ZIP: "ERR_ZIP", + UNZIP: "ERR_UNZIP", + UNSUPPORTED: "ERR_UNSUPPORTED", +}; + export const DEFAULT_COMPRESSION = -1; export const NO_COMPRESSION = 0; export const BEST_SPEED = 1; @@ -168,3 +183,7 @@ export const getUncompressedSize = (source, charset = "UTF-8") => { charset ); }; + +export const cancel = () => { + return getRNZipArchive().cancel(); +}; diff --git a/ios/RNZipArchive.h b/ios/RNZipArchive.h index 96bfa20..b64f1d2 100644 --- a/ios/RNZipArchive.h +++ b/ios/RNZipArchive.h @@ -20,6 +20,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *processedFilePath; @property (nonatomic) float progress; @property (nonatomic, copy, nullable) void (^progressHandler)(NSUInteger entryNumber, NSUInteger total); +@property (nonatomic) BOOL cancelled; @end diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 8eb2cff..34de573 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -17,6 +17,35 @@ #import "RCTEventDispatcher.h" #endif +static NSString *const kZipErrFileNotFound = @"ERR_FILE_NOT_FOUND"; +static NSString *const kZipErrInvalidPath = @"ERR_INVALID_PATH"; +static NSString *const kZipErrInvalidArgs = @"ERR_INVALID_ARGS"; +static NSString *const kZipErrWrongPassword = @"ERR_WRONG_PASSWORD"; +static NSString *const kZipErrNotPasswordProtected = @"ERR_NOT_PASSWORD_PROTECTED"; +static NSString *const kZipErrCorruptArchive = @"ERR_CORRUPT_ARCHIVE"; +static NSString *const kZipErrUnsafePath = @"ERR_UNSAFE_PATH"; +static NSString *const kZipErrCancelled = @"ERR_CANCELLED"; +static NSString *const kZipErrZip = @"ERR_ZIP"; +static NSString *const kZipErrUnzip = @"ERR_UNZIP"; +static NSString *const kZipErrUnsupported = @"ERR_UNSUPPORTED"; + +@interface RNZipCancelDelegate : NSObject +@property (nonatomic, weak) RNZipArchive *owner; +@end + +@implementation RNZipCancelDelegate +- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + fileInfo:(unz_file_info)fileInfo { + (void)fileIndex; + (void)totalFiles; + (void)archivePath; + (void)fileInfo; + return self.owner != nil && !self.owner.cancelled; +} +@end + @implementation RNZipArchive { bool hasListeners; @@ -49,9 +78,30 @@ -(void)stopObserving { return @[@"zipArchiveProgressEvent"]; } +- (void)beginOperation { + self.cancelled = NO; +} + +- (BOOL)rejectIfCancelled:(RCTPromiseRejectBlock)reject { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + return YES; + } + return NO; +} + +- (void)cancel:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)reject; + self.cancelled = YES; + resolve(nil); +} + - (void)isPasswordProtected:(NSString *)file resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + (void)reject; + [self beginOperation]; BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; resolve([NSNumber numberWithBool:isPasswordProtected]); } @@ -98,10 +148,11 @@ - (void)listContents:(NSString *)source resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { (void)charset; // iOS always reads entry names as UTF-8 / Latin-1 fallback + [self beginOperation]; zipFile zip = unzOpen(source.fileSystemRepresentation); if (zip == NULL) { - reject(@"list_contents_error", @"failed to open zip file", nil); + reject(kZipErrFileNotFound, @"failed to open zip file", nil); return; } @@ -114,14 +165,14 @@ - (void)listContents:(NSString *)source ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); if (ret != UNZ_OK) { unzClose(zip); - reject(@"list_contents_error", @"failed to retrieve info for zip entry", nil); + reject(kZipErrCorruptArchive, @"failed to retrieve info for zip entry", nil); return; } char *filename = (char *)malloc(fileInfo.size_filename + 1); if (filename == NULL) { unzClose(zip); - reject(@"list_contents_error", @"out of memory while listing zip contents", nil); + reject(kZipErrUnzip, @"out of memory while listing zip contents", nil); return; } unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); @@ -206,8 +257,9 @@ - (void)unzipSelectedEntries:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; if (entries.count == 0) { - reject(@"unzip_files_error", @"entries must be a non-empty array", nil); + reject(kZipErrInvalidArgs, @"entries must be a non-empty array", nil); return; } @@ -223,14 +275,14 @@ - (void)unzipSelectedEntries:(NSString *)from attributes:nil error:&dirError]; if (dirError != nil) { - reject(@"unzip_files_error", dirError.localizedDescription, dirError); + reject(kZipErrUnzip, dirError.localizedDescription, dirError); return; } } zipFile zip = unzOpen(from.fileSystemRepresentation); if (zip == NULL) { - reject(@"unzip_files_error", @"failed to open zip file", nil); + reject(kZipErrFileNotFound, @"failed to open zip file", nil); return; } @@ -267,7 +319,7 @@ - (void)unzipSelectedEntries:(NSString *)from if (matchCount == 0) { unzClose(zip); - reject(@"unzip_files_error", @"None of the requested entries were found in the archive", nil); + reject(kZipErrInvalidArgs, @"None of the requested entries were found in the archive", nil); return; } @@ -316,6 +368,11 @@ - (void)unzipSelectedEntries:(NSString *)from } free(filename); + if ([self rejectIfCancelled:reject]) { + unzClose(zip); + return; + } + if (strPath == nil || ![self entry:strPath matchesSelection:entries]) { ret = unzGoToNextFile(zip); continue; @@ -413,9 +470,17 @@ - (void)unzipSelectedEntries:(NSString *)from if (success) { resolve(destinationPath); + } else if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); } else { NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; - reject(@"unzip_files_error", message, extractError); + NSString *code = kZipErrUnzip; + if ([message containsString:@"Zip Path Traversal"]) { + code = kZipErrUnsafePath; + } else if ([message.lowercaseString containsString:@"password"]) { + code = kZipErrWrongPassword; + } + reject(code, message, extractError); } } @@ -424,6 +489,7 @@ - (void)unzipFile:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -437,6 +503,8 @@ - (void)unzipFile:(NSString *)from __block unsigned long long extractedBytes = 0; __weak RNZipArchive *weakSelf = self; + RNZipCancelDelegate *cancelDelegate = [RNZipCancelDelegate new]; + cancelDelegate.owner = self; BOOL success = [SSZipArchive unzipFileAtPath:from toDestination:destinationPath @@ -445,7 +513,7 @@ - (void)unzipFile:(NSString *)from nestedZipLevel:0 password:password error:&error - delegate:nil + delegate:cancelDelegate progressHandler:^(NSString *entry, unz_file_info zipInfo, long entryNumber, long total) { RNZipArchive *strongSelf = weakSelf; if (strongSelf == nil) { @@ -464,11 +532,20 @@ - (void)unzipFile:(NSString *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { NSString *errorMessage = error ? [error localizedDescription] : @"unable to unzip"; - reject(@"unzip_error", errorMessage, error); + NSString *code = kZipErrUnzip; + NSString *lower = errorMessage.lowercaseString; + if ([lower containsString:@"password"]) { + code = kZipErrWrongPassword; + } else if ([lower containsString:@"failed to open zip"]) { + code = kZipErrFileNotFound; + } + reject(code, errorMessage, error); } } @@ -477,6 +554,7 @@ - (void)zipFolder:(NSString *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -495,11 +573,12 @@ - (void)zipFolder:(NSString *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } } @@ -549,6 +628,10 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath if (success) { NSUInteger total = entries.count, complete = 0; for (NSArray *entry in entries) { + if (self.cancelled) { + success = NO; + break; + } success &= [zipArchive writeFileAtPath:entry[0] withFileName:entry[1] compressionLevel:compressionLevel password:password AES:aes]; if (self.progressHandler) { complete++; @@ -565,6 +648,7 @@ - (void)zipFiles:(NSArray *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -577,11 +661,12 @@ - (void)zipFiles:(NSArray *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } } @@ -592,6 +677,7 @@ - (void)zipFolderWithPassword:(NSString *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -610,11 +696,12 @@ - (void)zipFolderWithPassword:(NSString *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } } @@ -625,6 +712,7 @@ - (void)zipFilesWithPassword:(NSArray *)from compressionLevel:(double)compressionLevel resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self beginOperation]; self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -638,11 +726,12 @@ - (void)zipFilesWithPassword:(NSArray *)from self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (success) { + if (self.cancelled) { + reject(kZipErrCancelled, @"Operation cancelled", nil); + } else if (success) { resolve(destinationPath); } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); + reject(kZipErrZip, @"unable to zip", nil); } } @@ -666,7 +755,7 @@ - (void)unzipAssets:(NSString *)source reject:(RCTPromiseRejectBlock)reject { // iOS doesn't have assets like Android, return error NSError *error = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"unzipAssets is not supported on iOS"}]; - reject(@"unzip_assets_not_supported", @"unzipAssets is not supported on iOS", error); + reject(kZipErrUnsupported, @"unzipAssets is not supported on iOS", error); } - (void)addListener:(NSString *)eventName { diff --git a/package.json b/package.json index 92c5232..e918b07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.1.0", + "version": "9.2.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/specs/NativeZipArchive.ts b/specs/NativeZipArchive.ts index 71c905d..38c24e3 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -50,6 +50,7 @@ export interface Spec extends TurboModule { ): Promise; getUncompressedSize(path: string, charset: string): Promise; unzipAssets(source: string, target: string): Promise; + cancel(): Promise; addListener(eventName: string): void; removeListeners(count: number): void; } From dbd1e11e2a3aebcbd26c94b86c57fb242f8af700 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 13:11:53 +0000 Subject: [PATCH 2/3] fix: address review comments on cancel, extract CRC, and sizes Make iOS cancel() interrupt in-flight work by running zip/unzip on a background queue instead of the serial method queue. Reset Android's cancel flag when enqueueing work so a cancel right after start is not discarded. Selective extract now checks fwrite and CRC, maps password failures to ERR_WRONG_PASSWORD, and listContents uses 64-bit zip info. Document Android STANDARD encryption as ZipCrypto. Co-authored-by: Perry --- CHANGELOG.md | 9 + README.md | 2 +- .../com/rnziparchive/RNZipArchiveModule.java | 29 +- ios/RNZipArchive.h | 2 +- ios/RNZipArchive.mm | 355 +++++++++++------- 5 files changed, 242 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d8d8a..ab51d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - `cancel()` — best-effort abort of the in-flight zip/unzip operation; rejects with `ERR_CANCELLED` (#366) - Stable cross-platform error codes (`ERR_FILE_NOT_FOUND`, `ERR_WRONG_PASSWORD`, `ERR_UNSAFE_PATH`, …) and JS `ErrorCodes` map (#366) +### Fixed +- iOS: `cancel()` now interrupts in-flight work. Zip/unzip run on a background serial queue so `cancel()` is not blocked behind the operation it is meant to stop. +- Android: reset the cancel flag when enqueueing work, not when the worker starts — `cancel()` immediately after `unzip`/`zip` is no longer discarded. +- iOS: selective extract checks `fwrite` byte counts and `unzCloseCurrentFile` CRC; wrong-password / not-protected cases map to `ERR_WRONG_PASSWORD` / `ERR_NOT_PASSWORD_PROTECTED` instead of generic `ERR_UNZIP`. +- iOS: `listContents` uses 64-bit zip entry info (`unzGetCurrentFileInfo64`) so entries ≥ 4 GiB report correct sizes. + ## [9.1.0] - 2026-07-25 ### Added @@ -13,6 +19,9 @@ - Optional `entries` on `unzip` / `unzipWithPassword` — extract only selected entry paths; directory names include nested children (#365) - Android unit tests for selective-extract entry matching +### Changed +- Android: `'STANDARD'` password encryption writes ZipCrypto (`ZIP_STANDARD`). zip4j's `ZIP_STANDARD_VARIANT_STRONG` is write-only and produced archives that common unzippers (including this library) could not extract. + ## [9.0.2] - 2026-07-22 ### Fixed diff --git a/README.md b/README.md index ee62ea5..b587455 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Zip with password protection. - `compressionLevel` is ignored on iOS when the source is a file array. **Encryption Types:** -- `'STANDARD'` — Standard ZIP encryption (default) +- `'STANDARD'` — Traditional ZIP encryption / ZipCrypto (default). This is **not** PKWARE Strong Encryption. On Android this writes zip4j `ZIP_STANDARD` so iOS and common unzip tools can decrypt the archive. - `'AES-128'` — AES 128-bit - `'AES-256'` — AES 256-bit diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 9403f17..1997847 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -82,6 +82,11 @@ private void beginOperation() { cancelled.set(false); } + private void submitWork(Runnable work) { + beginOperation(); + executor.submit(work); + } + private boolean rejectIfCancelled(Promise promise) { if (cancelled.get()) { promise.reject(ZipErrorCodes.CANCELLED, "Operation cancelled"); @@ -107,8 +112,7 @@ public void cancel(final Promise promise) { @Override public void isPasswordProtected(final String zipFilePath, final Promise promise) { - executor.submit(() -> { - beginOperation(); + submitWork(() -> { try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { promise.resolve(zipFile.isEncrypted()); } catch (Exception ex) { @@ -129,8 +133,7 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); return; } - executor.submit(() -> { - beginOperation(); + submitWork(() -> { try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { if (zipFile.isEncrypted()) { zipFile.setPassword(password.toCharArray()); @@ -176,8 +179,7 @@ public void unzip(final String zipFilePath, final String destDirectory, final St extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); return; } - executor.submit(() -> { - beginOperation(); + submitWork(() -> { if (zipFilePath == null) { promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; @@ -251,8 +253,7 @@ private List optionalEntriesList(ReadableArray entries, Promise promise) @Override public void listContents(final String zipFilePath, final String charset, final Promise promise) { - executor.submit(() -> { - beginOperation(); + submitWork(() -> { if (zipFilePath == null) { promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; @@ -284,8 +285,7 @@ public void listContents(final String zipFilePath, final String charset, final P private void extractSelectedEntries(final String zipFilePath, final String destDirectory, final List wantedEntries, final String charset, final String password, final Promise promise) { - executor.submit(() -> { - beginOperation(); + submitWork(() -> { if (zipFilePath == null) { promise.reject(ZipErrorCodes.INVALID_PATH, "Couldn't open file null."); return; @@ -406,8 +406,7 @@ private static String stripTrailingSlash(String path) { */ @Override public void unzipAssets(final String assetsPath, final String destDirectory, final Promise promise) { - executor.submit(() -> { - beginOperation(); + submitWork(() -> { InputStream assetsInputStream = null; AssetFileDescriptor fileDescriptor = null; long compressedSize; @@ -599,8 +598,7 @@ private void zip(final List filesOrDirectory, final String destFile, fin } private void processZip(final List entries, final String destFile, final ZipParameters parameters, final Promise promise, final char[] password) { - executor.submit(() -> { - beginOperation(); + submitWork(() -> { try (net.lingala.zip4j.ZipFile zipFile = password != null ? new net.lingala.zip4j.ZipFile(destFile, password) : new net.lingala.zip4j.ZipFile(destFile)) { @@ -657,8 +655,7 @@ private void processZip(final List entries, final String destFile, final @Override public void getUncompressedSize(String zipFilePath, String charset, final Promise promise) { - executor.submit(() -> { - beginOperation(); + submitWork(() -> { try { long totalSize = getUncompressedSize(zipFilePath, charset); if (totalSize == -1) { diff --git a/ios/RNZipArchive.h b/ios/RNZipArchive.h index b64f1d2..7dbc932 100644 --- a/ios/RNZipArchive.h +++ b/ios/RNZipArchive.h @@ -20,7 +20,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *processedFilePath; @property (nonatomic) float progress; @property (nonatomic, copy, nullable) void (^progressHandler)(NSUInteger entryNumber, NSUInteger total); -@property (nonatomic) BOOL cancelled; +@property (atomic) BOOL cancelled; @end diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 34de573..b154a53 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -82,6 +82,19 @@ - (void)beginOperation { self.cancelled = NO; } +- (dispatch_queue_t)workQueue { + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.mockingbot.ReactNative.ZipArchiveWorkQueue", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +- (void)runAsync:(void (^)(void))block { + dispatch_async([self workQueue], block); +} + - (BOOL)rejectIfCancelled:(RCTPromiseRejectBlock)reject { if (self.cancelled) { reject(kZipErrCancelled, @"Operation cancelled", nil); @@ -102,8 +115,10 @@ - (void)isPasswordProtected:(NSString *)file reject:(RCTPromiseRejectBlock)reject { (void)reject; [self beginOperation]; - BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; - resolve([NSNumber numberWithBool:isPasswordProtected]); + [self runAsync:^{ + BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; + resolve([NSNumber numberWithBool:isPasswordProtected]); + }]; } - (void)unzip:(NSString *)from @@ -113,16 +128,19 @@ - (void)unzip:(NSString *)from resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { (void)charset; - if (entries != nil && entries.count > 0) { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:nil - resolve:resolve - reject:reject]; - return; - } - [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; + [self beginOperation]; + [self runAsync:^{ + if (entries != nil && entries.count > 0) { + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:nil + resolve:resolve + reject:reject]; + return; + } + [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; + }]; } - (void)unzipWithPassword:(NSString *)from @@ -131,16 +149,19 @@ - (void)unzipWithPassword:(NSString *)from entries:(NSArray *)entries resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - if (entries != nil && entries.count > 0) { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:password - resolve:resolve - reject:reject]; - return; - } - [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; + [self beginOperation]; + [self runAsync:^{ + if (entries != nil && entries.count > 0) { + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:password + resolve:resolve + reject:reject]; + return; + } + [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; + }]; } - (void)listContents:(NSString *)source @@ -149,66 +170,107 @@ - (void)listContents:(NSString *)source reject:(RCTPromiseRejectBlock)reject { (void)charset; // iOS always reads entry names as UTF-8 / Latin-1 fallback [self beginOperation]; - - zipFile zip = unzOpen(source.fileSystemRepresentation); - if (zip == NULL) { - reject(kZipErrFileNotFound, @"failed to open zip file", nil); - return; - } - - NSMutableArray *entries = [NSMutableArray array]; - // Empty archives return a non-UNZ_OK code from unzGoToFirstFile; treat as an empty list. - int ret = unzGoToFirstFile(zip); - while (ret == UNZ_OK) { - unz_file_info fileInfo; - memset(&fileInfo, 0, sizeof(unz_file_info)); - ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); - if (ret != UNZ_OK) { - unzClose(zip); - reject(kZipErrCorruptArchive, @"failed to retrieve info for zip entry", nil); + [self runAsync:^{ + zipFile zip = unzOpen(source.fileSystemRepresentation); + if (zip == NULL) { + reject(kZipErrFileNotFound, @"failed to open zip file", nil); return; } - char *filename = (char *)malloc(fileInfo.size_filename + 1); - if (filename == NULL) { - unzClose(zip); - reject(kZipErrUnzip, @"out of memory while listing zip contents", nil); - return; - } - unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); - filename[fileInfo.size_filename] = '\0'; - - NSString *path = [NSString stringWithUTF8String:filename]; - if (path == nil) { - path = [[NSString alloc] initWithBytes:filename - length:fileInfo.size_filename - encoding:NSISOLatin1StringEncoding]; - } - BOOL isDirectory = NO; - if (fileInfo.size_filename > 0 && - (filename[fileInfo.size_filename - 1] == '/' || filename[fileInfo.size_filename - 1] == '\\')) { - isDirectory = YES; - } - free(filename); + NSMutableArray *entries = [NSMutableArray array]; + // Empty archives return a non-UNZ_OK code from unzGoToFirstFile; treat as an empty list. + int ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + NSString *path = nil; + unsigned long long size = 0; + unsigned long long compressedSize = 0; + BOOL isDirectory = NO; + BOOL isEncrypted = NO; + NSString *entryError = nil; + if (![self readCurrentZipEntry:zip + path:&path + size:&size + compressedSize:&compressedSize + isDirectory:&isDirectory + isEncrypted:&isEncrypted + error:&entryError]) { + unzClose(zip); + reject(kZipErrCorruptArchive, entryError ?: @"failed to retrieve info for zip entry", nil); + return; + } + + [entries addObject:@{ + @"path": path, + @"size": @((double)size), + @"compressedSize": @((double)compressedSize), + @"isDirectory": @(isDirectory), + @"isEncrypted": @(isEncrypted), + }]; - if (path == nil) { - path = @""; + ret = unzGoToNextFile(zip); } - BOOL isEncrypted = (fileInfo.flag & 1) != 0; - [entries addObject:@{ - @"path": path, - @"size": @((double)fileInfo.uncompressed_size), - @"compressedSize": @((double)fileInfo.compressed_size), - @"isDirectory": @(isDirectory), - @"isEncrypted": @(isEncrypted), - }]; + unzClose(zip); + resolve(entries); + }]; +} + +- (BOOL)readCurrentZipEntry:(unzFile)zip + path:(NSString **)outPath + size:(unsigned long long *)outSize + compressedSize:(unsigned long long *)outCompressedSize + isDirectory:(BOOL *)outIsDirectory + isEncrypted:(BOOL *)outIsEncrypted + error:(NSString **)outError { + unz_file_info64 fileInfo; + memset(&fileInfo, 0, sizeof(fileInfo)); + int ret = unzGetCurrentFileInfo64(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); + if (ret != UNZ_OK) { + if (outError != NULL) { + *outError = @"failed to retrieve info for zip entry"; + } + return NO; + } - ret = unzGoToNextFile(zip); + size_t nameLen = (size_t)fileInfo.size_filename; + char *filename = (char *)malloc(nameLen + 1); + if (filename == NULL) { + if (outError != NULL) { + *outError = @"out of memory while reading zip entry"; + } + return NO; + } + unzGetCurrentFileInfo64(zip, &fileInfo, filename, nameLen + 1, NULL, 0, NULL, 0); + filename[nameLen] = '\0'; + + NSString *path = [NSString stringWithUTF8String:filename]; + if (path == nil) { + path = [[NSString alloc] initWithBytes:filename + length:nameLen + encoding:NSISOLatin1StringEncoding]; } + BOOL isDirectory = NO; + if (nameLen > 0 && (filename[nameLen - 1] == '/' || filename[nameLen - 1] == '\\')) { + isDirectory = YES; + } + free(filename); - unzClose(zip); - resolve(entries); + if (outPath != NULL) { + *outPath = path ?: @""; + } + if (outSize != NULL) { + *outSize = (unsigned long long)fileInfo.uncompressed_size; + } + if (outCompressedSize != NULL) { + *outCompressedSize = (unsigned long long)fileInfo.compressed_size; + } + if (outIsDirectory != NULL) { + *outIsDirectory = isDirectory; + } + if (outIsEncrypted != NULL) { + *outIsEncrypted = (fileInfo.flag & 1) != 0; + } + return YES; } - (NSString *)normalizedZipPath:(NSString *)path { @@ -257,12 +319,21 @@ - (void)unzipSelectedEntries:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [self beginOperation]; + if ([self rejectIfCancelled:reject]) { + return; + } if (entries.count == 0) { reject(kZipErrInvalidArgs, @"entries must be a non-empty array", nil); return; } + if (password.length > 0 && ![SSZipArchive isFilePasswordProtectedAtPath:from]) { + reject(kZipErrNotPasswordProtected, + [NSString stringWithFormat:@"Zip file: %@ is not password protected", from], + nil); + return; + } + self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; @@ -291,28 +362,22 @@ - (void)unzipSelectedEntries:(NSString *)from NSUInteger matchCount = 0; int ret = unzGoToFirstFile(zip); while (ret == UNZ_OK) { - unz_file_info fileInfo; - memset(&fileInfo, 0, sizeof(unz_file_info)); - ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); - if (ret != UNZ_OK) { - break; - } - char *filenameBuf = (char *)malloc(fileInfo.size_filename + 1); - if (filenameBuf == NULL) { - break; - } - unzGetCurrentFileInfo(zip, &fileInfo, filenameBuf, fileInfo.size_filename + 1, NULL, 0, NULL, 0); - filenameBuf[fileInfo.size_filename] = '\0'; - NSString *path = [NSString stringWithUTF8String:filenameBuf]; - if (path == nil) { - path = [[NSString alloc] initWithBytes:filenameBuf - length:fileInfo.size_filename - encoding:NSISOLatin1StringEncoding]; + if ([self rejectIfCancelled:reject]) { + unzClose(zip); + return; } - free(filenameBuf); - if ([self entry:path matchesSelection:entries]) { + NSString *path = nil; + unsigned long long size = 0; + if ([self readCurrentZipEntry:zip + path:&path + size:&size + compressedSize:NULL + isDirectory:NULL + isEncrypted:NULL + error:NULL] && + [self entry:path matchesSelection:entries]) { matchCount += 1; - totalSize += fileInfo.uncompressed_size; + totalSize += size; } ret = unzGoToNextFile(zip); } @@ -331,49 +396,33 @@ - (void)unzipSelectedEntries:(NSString *)from unsigned long long extractedBytes = 0; BOOL success = YES; NSError *extractError = nil; + NSString *extractCode = kZipErrUnzip; ret = unzGoToFirstFile(zip); while (ret == UNZ_OK) { - unz_file_info fileInfo; - memset(&fileInfo, 0, sizeof(unz_file_info)); - ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); - if (ret != UNZ_OK) { - success = NO; - extractError = [NSError errorWithDomain:@"RNZipArchive" - code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"failed to retrieve info for zip entry"}]; - break; - } - - char *filename = (char *)malloc(fileInfo.size_filename + 1); - if (filename == NULL) { + NSString *strPath = nil; + unsigned long long uncompressedSize = 0; + BOOL isDirectory = NO; + NSString *entryError = nil; + if (![self readCurrentZipEntry:zip + path:&strPath + size:&uncompressedSize + compressedSize:NULL + isDirectory:&isDirectory + isEncrypted:NULL + error:&entryError]) { success = NO; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"out of memory while extracting"}]; + userInfo:@{NSLocalizedDescriptionKey: entryError ?: @"failed to retrieve info for zip entry"}]; break; } - unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); - filename[fileInfo.size_filename] = '\0'; - - NSString *strPath = [NSString stringWithUTF8String:filename]; - if (strPath == nil) { - strPath = [[NSString alloc] initWithBytes:filename - length:fileInfo.size_filename - encoding:NSISOLatin1StringEncoding]; - } - BOOL isDirectory = NO; - if (fileInfo.size_filename > 0 && - (filename[fileInfo.size_filename - 1] == '/' || filename[fileInfo.size_filename - 1] == '\\')) { - isDirectory = YES; - } - free(filename); if ([self rejectIfCancelled:reject]) { unzClose(zip); return; } - if (strPath == nil || ![self entry:strPath matchesSelection:entries]) { + if (strPath.length == 0 || ![self entry:strPath matchesSelection:entries]) { ret = unzGoToNextFile(zip); continue; } @@ -385,6 +434,7 @@ - (void)unzipSelectedEntries:(NSString *)from if (![self isSafeExtractPath:strPath intoDestination:destinationPath]) { success = NO; + extractCode = kZipErrUnsafePath; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @@ -400,7 +450,7 @@ - (void)unzipSelectedEntries:(NSString *)from withIntermediateDirectories:YES attributes:nil error:nil]; - extractedBytes += fileInfo.uncompressed_size; + extractedBytes += uncompressedSize; [self zipArchiveProgressEvent:extractedBytes total:totalSize]; ret = unzGoToNextFile(zip); continue; @@ -419,9 +469,12 @@ - (void)unzipSelectedEntries:(NSString *)from } if (ret != UNZ_OK) { success = NO; + extractCode = password.length > 0 ? kZipErrWrongPassword : kZipErrUnzip; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"failed to open file in zip archive"}]; + userInfo:@{NSLocalizedDescriptionKey: password.length > 0 + ? @"wrong password or failed to open encrypted zip entry" + : @"failed to open file in zip archive"}]; break; } @@ -438,27 +491,47 @@ - (void)unzipSelectedEntries:(NSString *)from unsigned char buffer[4096]; int readBytes; do { + if ([self rejectIfCancelled:reject]) { + fclose(out); + unzCloseCurrentFile(zip); + unzClose(zip); + return; + } readBytes = unzReadCurrentFile(zip, buffer, sizeof(buffer)); if (readBytes < 0) { success = NO; + extractCode = password.length > 0 ? kZipErrWrongPassword : kZipErrUnzip; extractError = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"failed to read zip entry"}]; break; } if (readBytes > 0) { - fwrite(buffer, 1, readBytes, out); + if (fwrite(buffer, 1, (size_t)readBytes, out) != (size_t)readBytes) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to write extracted file"}]; + break; + } } } while (readBytes > 0); fclose(out); - unzCloseCurrentFile(zip); + int closeRet = unzCloseCurrentFile(zip); + if (success && closeRet != UNZ_OK) { + success = NO; + extractCode = password.length > 0 ? kZipErrWrongPassword : kZipErrCorruptArchive; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to extract zip entry (wrong password or corrupt archive)"}]; + } if (!success) { break; } - extractedBytes += fileInfo.uncompressed_size; + extractedBytes += uncompressedSize; [self zipArchiveProgressEvent:extractedBytes total:totalSize]; ret = unzGoToNextFile(zip); } @@ -474,13 +547,7 @@ - (void)unzipSelectedEntries:(NSString *)from reject(kZipErrCancelled, @"Operation cancelled", nil); } else { NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; - NSString *code = kZipErrUnzip; - if ([message containsString:@"Zip Path Traversal"]) { - code = kZipErrUnsafePath; - } else if ([message.lowercaseString containsString:@"password"]) { - code = kZipErrWrongPassword; - } - reject(code, message, extractError); + reject(extractCode, message, extractError); } } @@ -489,7 +556,9 @@ - (void)unzipFile:(NSString *)from password:(NSString *)password resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [self beginOperation]; + if ([self rejectIfCancelled:reject]) { + return; + } self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -555,6 +624,7 @@ - (void)zipFolder:(NSString *)from resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -580,6 +650,7 @@ - (void)zipFolder:(NSString *)from } else { reject(kZipErrZip, @"unable to zip", nil); } + }]; } // Expands `paths` into (full path, entry name) pairs. Files keep their base @@ -649,6 +720,7 @@ - (void)zipFiles:(NSArray *)from resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -668,6 +740,7 @@ - (void)zipFiles:(NSArray *)from } else { reject(kZipErrZip, @"unable to zip", nil); } + }]; } - (void)zipFolderWithPassword:(NSString *)from @@ -678,6 +751,7 @@ - (void)zipFolderWithPassword:(NSString *)from resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -703,6 +777,7 @@ - (void)zipFolderWithPassword:(NSString *)from } else { reject(kZipErrZip, @"unable to zip", nil); } + }]; } - (void)zipFilesWithPassword:(NSArray *)from @@ -713,6 +788,7 @@ - (void)zipFilesWithPassword:(NSArray *)from resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self beginOperation]; + [self runAsync:^{ self.progress = 0.0; self.processedFilePath = @""; [self zipArchiveProgressEvent:0 total:1]; // force 0% @@ -733,12 +809,16 @@ - (void)zipFilesWithPassword:(NSArray *)from } else { reject(kZipErrZip, @"unable to zip", nil); } + }]; } - (void)getUncompressedSize:(NSString *)path charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + (void)charset; + [self beginOperation]; + [self runAsync:^{ NSError *error = nil; NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; @@ -747,6 +827,7 @@ - (void)getUncompressedSize:(NSString *)path } else { resolve(@-1); } + }]; } - (void)unzipAssets:(NSString *)source From b00cf89c55fa4c81fcec397dfe9bd986b5655efa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 14:08:30 +0000 Subject: [PATCH 3/3] fix: align selective-extract progress and password charset On iOS, emit 0% progress when selective extract fails instead of forcing 100% before reject, matching Android. On Android, do not force UTF-8 when unzipWithPassword extracts selected entries so entry names match the full unzipWithPassword path. Co-authored-by: Perry --- CHANGELOG.md | 2 ++ .../src/main/java/com/rnziparchive/RNZipArchiveModule.java | 2 +- ios/RNZipArchive.mm | 7 ++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab51d59..c7e9686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - Android: reset the cancel flag when enqueueing work, not when the worker starts — `cancel()` immediately after `unzip`/`zip` is no longer discarded. - iOS: selective extract checks `fwrite` byte counts and `unzCloseCurrentFile` CRC; wrong-password / not-protected cases map to `ERR_WRONG_PASSWORD` / `ERR_NOT_PASSWORD_PROTECTED` instead of generic `ERR_UNZIP`. - iOS: `listContents` uses 64-bit zip entry info (`unzGetCurrentFileInfo64`) so entries ≥ 4 GiB report correct sizes. +- iOS: selective extract emits 0% progress on failure (matches Android) instead of a 100% event before reject. +- Android: `unzipWithPassword` selective extract no longer forces UTF-8 charset, matching the full-extract path. ## [9.1.0] - 2026-07-25 diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 1997847..057495b 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -130,7 +130,7 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto return; } if (entryList != null) { - extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); + extractSelectedEntries(zipFilePath, destDirectory, entryList, null, password, promise); return; } submitWork(() -> { diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index b154a53..5db22f4 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -538,14 +538,15 @@ - (void)unzipSelectedEntries:(NSString *)from unzClose(zip); - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; - if (success) { + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; resolve(destinationPath); } else if (self.cancelled) { reject(kZipErrCancelled, @"Operation cancelled", nil); } else { + self.progress = 0.0; + [self zipArchiveProgressEvent:0 total:1]; NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; reject(extractCode, message, extractError); }