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
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [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
56 changes: 50 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
zipWithPassword,
unzip,
unzipWithPassword,
listContents,
unzipAssets,
subscribe,
isPasswordProtected,
Expand Down Expand Up @@ -104,9 +105,21 @@ 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`).

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 is only supported on Android (default: `UTF-8`). On iOS it is ignored.

Expand All @@ -119,14 +132,44 @@ 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 is only supported on Android (default: `UTF-8`). On iOS it is ignored.

```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>`
Expand Down Expand Up @@ -187,8 +230,9 @@ useEffect(() => {
| `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` | ✅ | ✅ | — |
| `unzip` | ✅ | ✅ | Optional `entries` for selective extract; charset ignored on iOS |
| `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract |
| `listContents` | ✅ | ✅ | Charset ignored on iOS |
| `unzipAssets` | ❌ | ✅ | Android only |
| `isPasswordProtected` | ✅ | ✅ | — |
| `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS |
Expand Down
3 changes: 3 additions & 0 deletions RNZipArchive.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Pod::Spec.new do |s|
s.source = { :git => 'https://github.com/mockingbot/react-native-zip-archive.git', :tag => "#{s.version}"}
s.platform = :ios, '15.5'
s.preserve_paths = '*.js'
s.pod_target_xcconfig = {
'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"'
}

if defined?(install_modules_dependencies) != nil
install_modules_dependencies(s)
Expand Down
11 changes: 11 additions & 0 deletions __mocks__/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ 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)),
Expand Down
87 changes: 82 additions & 5 deletions __tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {
zipWithPassword,
unzip,
unzipWithPassword,
listContents,
unzipAssets,
isPasswordProtected,
getUncompressedSize,
Expand All @@ -25,6 +26,7 @@ describe('react-native-zip-archive API', () => {
expect(typeof zipWithPassword).toBe('function');
expect(typeof unzip).toBe('function');
expect(typeof unzipWithPassword).toBe('function');
expect(typeof listContents).toBe('function');
expect(typeof unzipAssets).toBe('function');
expect(typeof isPasswordProtected).toBe('function');
expect(typeof getUncompressedSize).toBe('function');
Expand Down Expand Up @@ -81,29 +83,104 @@ describe('react-native-zip-archive API', () => {
describe('unzip', () => {
test('unzip with default charset', async () => {
await unzip('/source.zip', '/dest');
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith('/source.zip', '/dest', 'UTF-8');
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith('/source.zip', '/dest', 'UTF-8', null);
});

test('unzip with custom charset', async () => {
await unzip('/source.zip', '/dest', 'GBK');
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith('/source.zip', '/dest', 'GBK');
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith('/source.zip', '/dest', 'GBK', null);
});

test('unzip normalizes file:// paths', async () => {
await unzip('file:///source.zip', 'file:///dest');
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith('/source.zip', '/dest', 'UTF-8');
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith('/source.zip', '/dest', 'UTF-8', null);
});

test('unzip with entries array as third arg', async () => {
await unzip('/source.zip', '/dest', ['a.txt', 'b.txt']);
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'UTF-8',
['a.txt', 'b.txt']
);
});

test('unzip with charset and entries', async () => {
await unzip('/source.zip', '/dest', 'GBK', ['a.txt']);
expect(mockRNZipArchive.unzip).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'GBK',
['a.txt']
);
});

test('unzip rejects empty entries', async () => {
await expect(unzip('/source.zip', '/dest', [])).rejects.toThrow(
'unzip: entries must be a non-empty array when provided'
);
});
});

describe('unzipWithPassword', () => {
test('unzipWithPassword calls native module', async () => {
await unzipWithPassword('/source.zip', '/dest', 'password');
expect(mockRNZipArchive.unzipWithPassword).toHaveBeenCalledWith('/source.zip', '/dest', 'password');
expect(mockRNZipArchive.unzipWithPassword).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'password',
null
);
});

test('unzipWithPassword normalizes file:// paths', async () => {
await unzipWithPassword('file:///source.zip', 'file:///dest', 'password');
expect(mockRNZipArchive.unzipWithPassword).toHaveBeenCalledWith('/source.zip', '/dest', 'password');
expect(mockRNZipArchive.unzipWithPassword).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'password',
null
);
});

test('unzipWithPassword with entries', async () => {
await unzipWithPassword('/source.zip', '/dest', 'secret', ['a.txt']);
expect(mockRNZipArchive.unzipWithPassword).toHaveBeenCalledWith(
'/source.zip',
'/dest',
'secret',
['a.txt']
);
});

test('unzipWithPassword rejects empty entries', async () => {
await expect(
unzipWithPassword('/source.zip', '/dest', 'secret', [])
).rejects.toThrow(
'unzipWithPassword: entries must be a non-empty array when provided'
);
});
});

describe('listContents', () => {
test('listContents with default charset', async () => {
const result = await listContents('/source.zip');
expect(result).toEqual([
{
path: 'hello.txt',
size: 12,
compressedSize: 10,
isDirectory: false,
isEncrypted: false,
},
]);
expect(mockRNZipArchive.listContents).toHaveBeenCalledWith('/source.zip', 'UTF-8');
});

test('listContents normalizes file:// paths', async () => {
await listContents('file:///source.zip', 'GBK');
expect(mockRNZipArchive.listContents).toHaveBeenCalledWith('/source.zip', 'GBK');
});
});

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 @@ -6,6 +6,7 @@ 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([])),
unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')),
isPasswordProtected: jest.fn(() => Promise.resolve(true)),
getUncompressedSize: jest.fn(() => Promise.resolve(1024)),
Expand Down
Loading
Loading