Skip to content
Draft
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
33 changes: 12 additions & 21 deletions .maestro/flows/_assets-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@ appId: ${APP_ID}
---
- runFlow:
when:
notVisible: "Assets (Android)"
notVisible: "Bundled Assets"
commands:
- tapOn: "Playground"
- scrollUntilVisible:
element:
text: "Assets (Android)"
text: "Bundled Assets"
direction: DOWN
- tapOn: "Assets (Android)"
- runFlow:
when:
visible: "Not Supported"
commands:
- assertVisible: "Not Supported"
- runFlow:
when:
visible: "Android Assets Demo"
commands:
- assertVisible: "Android Assets Demo"
- tapOn: "Unzip Assets"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible: "Extracted To"
- extendedWaitUntil:
visible: "Files:"
timeout: 10000
- assertVisible: "Files:"
- tapOn: "Bundled Assets"
- assertVisible: "Bundled Assets Demo"
- tapOn: "Unzip Assets"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible: "Extracted To"
- extendedWaitUntil:
visible: "Files:"
timeout: 10000
- assertVisible: "Files:"
9 changes: 9 additions & 0 deletions .maestro/flows/_home-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ appId: ${APP_ID}
---
- assertVisible: "Zip Operations"
- assertVisible: "Unzip Operations"
- assertVisible: "List & Selective Extract"
- scrollUntilVisible:
element:
text: "Password Protection"
direction: DOWN
- assertVisible: "Password Protection"
- scrollUntilVisible:
element:
text: "Progress Events"
direction: DOWN
- assertVisible: "Progress Events"
- scrollUntilVisible:
element:
Expand Down
56 changes: 56 additions & 0 deletions .maestro/flows/_list-contents-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
appId: ${APP_ID}
---
- runFlow:
when:
notVisible: "List & Selective Extract"
commands:
- tapOn: "Playground"
- scrollUntilVisible:
element:
text: "List & Selective Extract"
direction: DOWN
- tapOn: "List & Selective Extract"
- extendedWaitUntil:
visible: "Create Zip and List Contents"
timeout: 15000
- tapOn: "Create Zip and List Contents"
- extendedWaitUntil:
visible: "Listed Entries"
timeout: 20000
- assertVisible: "Listed Entries"
- scrollUntilVisible:
element:
text: "Contains hello.txt"
direction: DOWN
timeout: 10000
- assertVisible: "Contains hello.txt"
- assertVisible: "Contains readme.md"
- scrollUntilVisible:
element:
text: "Extract Selected Entries"
direction: DOWN
- tapOn: "Extract Selected Entries"
- extendedWaitUntil:
visible: "Selective Extract"
timeout: 20000
- assertVisible: "Selective Extract"
- scrollUntilVisible:
element:
text: "Extracted docs/guide.md"
direction: DOWN
timeout: 10000
- assertVisible: "Extracted readme.md"
- assertVisible: "Extracted docs/guide.md"
- assertVisible: "Skipped hello.txt"
- scrollUntilVisible:
element:
text: "Password Selective Extract"
direction: DOWN
- tapOn: "Password Selective Extract"
- extendedWaitUntil:
visible: "Password Selective Extract Done"
timeout: 30000
- assertVisible: "Password Selective Extract Done"
- assertVisible: "Extracted readme.md"
- assertVisible: "Skipped hello.txt"
- back
1 change: 1 addition & 0 deletions .maestro/flows/ci-master.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ appId: ${APP_ID}
- runFlow: _home-check.yaml
- runFlow: _zip-test.yaml
- runFlow: _unzip-test.yaml
- runFlow: _list-contents-test.yaml
- runFlow: _password-test.yaml
- runFlow: _progress-test.yaml
- runFlow: _assets-test.yaml
4 changes: 4 additions & 0 deletions .maestro/flows/list-contents.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
appId: ${APP_ID}
---
- runFlow: _setup.yaml
- runFlow: _list-contents-test.yaml
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## [9.4.0] - 2026-07-25

### Added
- iOS: `unzipAssets` reads archives from the main app bundle (parity with Android `assets/`) (#368)
- iOS: preserve empty directories when zipping directory items in a files array (#368)

### Changed
- iOS: non-UTF-8 `charset` arguments now reject with `ERR_UNSUPPORTED` instead of being silently ignored (#368)
- iOS: `getUncompressedSize` rejects on failure (previously resolved `-1`) for parity with Android

## [9.3.0] - 2026-07-25

### Fixed
- iOS: `zipFilesWithPassword` now honors `encryptionType` — `'STANDARD'` uses ZipCrypto instead of always writing WinZip-AES (improves server-side unzip with Node/Java tools) (#367, #333, #323)
- iOS: fsync zip output after successful `zip` / `zipWithPassword` so immediate uploads/reads see full bytes (#367)
- iOS: file-array `zip` / `zipWithPassword` now apply the requested compression level (previously always `Z_DEFAULT_COMPRESSION`)

### Added
- `scripts/validate-zip-header.js` — checks local-file and EOCD signatures for interoperability smoke tests
- README guidance for server-side unzip compatibility

## [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
- `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

## [9.0.2] - 2026-07-22

### Fixed
Expand Down
133 changes: 115 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,13 @@ import {
zipWithPassword,
unzip,
unzipWithPassword,
listContents,
unzipAssets,
cancel,
subscribe,
isPasswordProtected,
getUncompressedSize,
ErrorCodes,
DEFAULT_COMPRESSION,
NO_COMPRESSION,
BEST_SPEED,
Expand Down Expand Up @@ -104,11 +107,23 @@ zipWithPassword(sourcePath, targetPath, 'password', 'STANDARD')
.catch((error) => console.error(error))
```

### `unzip(source: string, target: string, charset?: string): Promise<string>`
### `unzip(source: string, target: string, charset?: string | string[], entries?: string[]): Promise<string>`

Unzip from source to target.
Unzip from source to target. Pass `entries` to extract only those paths; directory names match that entry and all nested children (e.g. `'docs'` extracts `docs/` and `docs/readme.md`).

> The `charset` parameter is only supported on Android (default: `UTF-8`). On iOS it is ignored.
You can pass entries as the third argument when using the default charset:

```js
unzip(sourcePath, targetPath, ['readme.md', 'docs'])
```

Or with an explicit charset:

```js
unzip(sourcePath, targetPath, 'UTF-8', ['readme.md', 'docs'])
```

> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`.

```js
const sourcePath = `${DocumentDirectoryPath}/myFile.zip`
Expand All @@ -119,21 +134,54 @@ unzip(sourcePath, targetPath, 'UTF-8')
.catch((error) => console.error(error))
```

### `unzipWithPassword(source: string, target: string, password: string): Promise<string>`
### `unzipWithPassword(source: string, target: string, password: string, entries?: string[]): Promise<string>`

Unzip a password-protected archive.
Unzip a password-protected archive. Pass `entries` to extract only those paths.

```js
unzipWithPassword(sourcePath, targetPath, 'password')
.then((path) => console.log(`unzip completed at ${path}`))
.catch((error) => console.error(error))

unzipWithPassword(sourcePath, targetPath, 'password', ['secret.txt'])
.then((path) => console.log(`selective unzip completed at ${path}`))
.catch((error) => console.error(error))
```

### `listContents(source: string, charset?: string): Promise<ZipEntry[]>`

List archive entries without extracting.

```ts
type ZipEntry = {
path: string
size: number // uncompressed size in bytes
compressedSize: number
isDirectory: boolean
isEncrypted: boolean
}
```

> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`.

```js
listContents(sourcePath)
.then((entries) => {
entries.forEach((entry) => {
console.log(entry.path, entry.size, entry.isDirectory)
})
})
.catch((error) => console.error(error))
```

### `unzipAssets(assetPath: string, target: string): Promise<string>`

Unzip a file from the Android `assets` folder. **Android only.**
Unzip a bundled archive.

- **Android:** relative path inside the APK `assets/` folder (also accepts `content://` URIs).
- **iOS:** relative path inside the main app bundle (e.g. a file copied with Xcode “Copy Bundle Resources”).

`assetPath` is the relative path inside the pre-bundled assets folder (e.g. `folder/myFile.zip`). Do not pass an absolute path.
Do not pass an absolute filesystem path.

```js
unzipAssets('./myFile.zip', DocumentDirectoryPath)
Expand All @@ -153,6 +201,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 @@ -184,22 +265,38 @@ useEffect(() => {
| Feature | iOS | Android | Notes |
|---------|-----|---------|-------|
| `zip` (folder) | ✅ | ✅ | — |
| `zip` (files array) | ✅ | ✅ | Compression level ignored on iOS |
| `zipWithPassword` (folder) | ✅ | ✅ | AES encryption supported |
| `zipWithPassword` (files array) | ⚠️ | ✅ | iOS: only `STANDARD` encryption |
| `unzip` | ✅ | ✅ | Charset ignored on iOS |
| `unzipWithPassword` | ✅ | ✅ | — |
| `unzipAssets` | ❌ | ✅ | Android only |
| `zip` (files array) | ✅ | ✅ | — |
| `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip |
| `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES |
| `unzip` | ✅ | ✅ | Optional `entries`; non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS |
| `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract |
| `listContents` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS |
| `unzipAssets` | ✅ | ✅ | Android `assets/` (+ `content://`); iOS main bundle |
| `cancel` | ✅ | ✅ | Best-effort mid-operation abort |
| `isPasswordProtected` | ✅ | ✅ | — |
| `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS |
| `getUncompressedSize` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS |
| Progress Events | ✅ | ✅ | File path empty on iOS for zip |

### Cross-Platform Notes

- **Compression levels:** Android supports 0–9 for all operations. iOS supports them only for folder operations.
- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. iOS supports AES and Standard for folders, but only Standard for file arrays.
- **Charset:** Android supports custom charsets (default UTF-8). iOS always uses UTF-8.
- **unzipAssets:** Supports `assets/` folder and `content://` URIs on Android. Not supported on iOS.
- **Compression levels:** Android supports 0–9 for all operations. iOS supports 0–9 for folder and file-array zips.
- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. On iOS, pass `'STANDARD'` (default) for ZipCrypto archives that Node `unzipper` / Java `ZipInputStream` can read; `'AES-128'` / `'AES-256'` produce WinZip-AES archives that many server tools cannot open.
- **Charset:** Android supports custom charsets (default UTF-8). iOS accepts only UTF-8; other values reject with `ERR_UNSUPPORTED`.
- **unzipAssets:** Android reads `assets/` (and `content://`). iOS reads from the main app bundle using the same relative path.
- **Empty directories:** Preserved when zipping directory contents via a files/folders array on both platforms.

### Server-side unzip interoperability

Plain (non-AES) zips created on iOS and Android are intended to open with common server unzippers (`unzip`, Node `unzipper`, Java `ZipInputStream`). Practical tips:

- Prefer `zip(...)` or `zipWithPassword(..., 'STANDARD')` when the archive will be extracted off-device.
- Avoid AES password zips if the consumer is stock Java/`unzipper` — use `'STANDARD'` instead.
- Decode URL-encoded paths (`decodeURIComponent`) before passing them in; `%20` in paths has been mistaken for corrupt archives (#333).
- After upgrading, you can sanity-check a produced file with:

```bash
node scripts/validate-zip-header.js /path/to/archive.zip
```

## Expo

Expand Down
3 changes: 3 additions & 0 deletions RNZipArchive.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Pod::Spec.new do |s|
s.dependency 'React-Core'
end
s.dependency 'SSZipArchive', '~>2.5.5'
s.pod_target_xcconfig = {
'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive" "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"'
}

s.source_files = 'ios/*.{h,m,mm}'
s.public_header_files = ['ios/RNZipArchive.h']
Expand Down
12 changes: 12 additions & 0 deletions __mocks__/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,21 @@ const mockRNZipArchive = {
zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')),
unzip: jest.fn(() => Promise.resolve('/mock/dest')),
unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')),
listContents: jest.fn(() =>
Promise.resolve([
{
path: 'hello.txt',
size: 12,
compressedSize: 10,
isDirectory: false,
isEncrypted: false,
},
])
),
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