Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
# 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)

### 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.
- 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

### Added
- `listContents(source, charset?)` — inspect archive entries (path, sizes, directory/encrypted flags) without extracting (#365)
- 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
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ import {
unzipWithPassword,
listContents,
unzipAssets,
cancel,
subscribe,
isPasswordProtected,
getUncompressedSize,
ErrorCodes,
DEFAULT_COMPRESSION,
NO_COMPRESSION,
BEST_SPEED,
Expand Down Expand Up @@ -90,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

Expand Down Expand Up @@ -196,6 +198,39 @@ getUncompressedSize(sourcePath)
.catch((error) => console.error(error))
```

### `cancel(): Promise<void>`

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.
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions __mocks__/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
17 changes: 17 additions & 0 deletions __tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const {
unzipAssets,
isPasswordProtected,
getUncompressedSize,
cancel,
subscribe,
ErrorCodes,
DEFAULT_COMPRESSION,
NO_COMPRESSION,
BEST_SPEED,
Expand All @@ -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');
});

Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions __tests__/module-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
Loading
Loading