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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ Options objects (`{ signal }`, `{ entries }`, `{ compressionLevel }`) are JS-sid

- **Threading:** Android zip/unzip on a single-thread executor (FIFO). iOS on a background serial queue. Never block the UI/main thread with archive I/O. `cancel()` must not wait behind the operation it should abort.
- **Encryption default:** `'STANDARD'` = ZipCrypto (interop with Node/Java/`unzip`). AES = WinZip-AES; many server tools cannot open AES zips — default to STANDARD for off-device consumers.
- **Charset:** Android may honor non-UTF-8; iOS must reject non-UTF-8 with `ERR_UNSUPPORTED` (except where docs say charset is ignored, e.g. `getUncompressedSize` on iOS).
- **Progress:** Emit monotonic 0→1 with explicit start/end. Shape: `{ progress, filePath }`. Treat cross-platform divergence as a bug.
- **Charset:** Android may honor non-UTF-8; an invalid charset name rejects with `ERR_UNSUPPORTED`. iOS must reject non-UTF-8 with `ERR_UNSUPPORTED` (except where docs say charset is ignored, e.g. `getUncompressedSize` on iOS).
- **Progress:** Emit monotonic 0→1 with explicit start/end. Shape: `{ progress, filePath }`. Android `zip` counts work units before the first event. `unzipAssets` uses bytes copied when `ZipInputStream` reports compressed size `-1`, so progress cannot go negative. Treat cross-platform divergence as a bug.
- **Durability:** After a successful zip, fsync the archive before resolving (iOS `fsync`, Android `FileDescriptor.sync`) so a following read or upload sees the full file.
- **Missing sources:** Reject with `ERR_FILE_NOT_FOUND` when the archive path is missing (`unzip`, `unzipWithPassword`, `listContents`, `isPasswordProtected`, `getUncompressedSize`). Do not collapse that case into `ERR_UNZIP` / `ERR_CORRUPT_ARCHIVE`.

## Testing commands

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

### Fixed
- Android: `zip` / `zipWithPassword` progress stays monotonic. The work total is counted before the first event, so a mix of files and folders no longer moves progress backwards.
- Android: `unzipAssets` progress stays within 0–1 when an entry's compressed size is unknown (`ZipInputStream` reports `-1`). Directory entries in asset archives are created, and traversal paths are rejected.
- Android: full `unzip` / `unzipWithPassword` create directory entries, including empty directories (same as iOS and selective extract). An entry that resolves to the destination directory itself is accepted; sibling prefixes such as `dest-evil` stay rejected.
- Missing archives reject with `ERR_FILE_NOT_FOUND` from Android `unzipWithPassword`, `isPasswordProtected`, and `getUncompressedSize`, and from iOS `getUncompressedSize`.
- Android: an invalid charset name rejects with `ERR_UNSUPPORTED`. A bare `AES` encryption method (or any unknown method) no longer throws; `AES` is AES-128 and unknown methods stay ZipCrypto.
- Android: successful `zip` / `zipWithPassword` fsync the archive before resolving (iOS already did).
- JS: an `AbortSignal` that aborts before the abort listener is attached rejects with `ERR_CANCELLED` and does not start native work or cancel a different in-flight operation.

## [9.5.2] - 2026-09-28

### Changed
Expand Down
11 changes: 11 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ Working examples: [playground-expo](./playground-expo/) and [playground-rn](./pl

Old-arch proof is compile + link on RN **0.81.6** (`.github/workflows/old-arch.yml`), not device Maestro. See the [README matrix](./README.md#old-architecture-rn-070081).

## Unreleased

Native behavior fixes. JavaScript call sites are unchanged. Rebuild the native app after upgrading.

- **Android zip progress** stays monotonic, including when `zip` is given a mix of files and folders. The work total is counted before the first event.
- **Android `unzipAssets` progress** stays within 0–1 when an entry's compressed size is unknown.
- **Android full `unzip` / `unzipWithPassword`** create directory entries, including empty directories. iOS and selective extract already did.
- **Missing archives** reject with `ERR_FILE_NOT_FOUND` from Android `unzipWithPassword`, `isPasswordProtected`, and `getUncompressedSize`, and from iOS `getUncompressedSize`.
- **Android `zip` / `zipWithPassword`** fsync the archive before the promise resolves. iOS already did.
- **`AbortSignal`:** if the signal aborts before the listener is attached, the call rejects with `ERR_CANCELLED` and does not start native work.

## v9.5.1

Android old-architecture load fix. JavaScript call sites are unchanged. Native rebuild required.
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Zip a folder (`string`) or files/folders (`string[]`) to `target`.

- Single file: `zip([file], target)`.
- Array items may be directories; contents are added recursively (entry paths relative to that directory; empty dirs preserved).
- Progress events run from 0 to 1 and do not move backwards. The archive is flushed to disk before the promise resolves.
- Third arg: compression level (`0`–`9`, or constants below) or `{ compressionLevel, signal }`.

```js
Expand Down Expand Up @@ -159,7 +160,7 @@ await zipWithPassword(sourceDir, targetZip, 'password', {

### `unzip(source, target, charsetOrEntriesOrOptions?, entries?)`

Extract an archive. Optional `entries` extracts only those paths (directories include nested children).
Extract an archive. Optional `entries` extracts only those paths (directories include nested children). Directory entries are created, including empty directories.

```js
await unzip(source, target)
Expand Down Expand Up @@ -222,9 +223,10 @@ subscribe(({ progress, filePath }) => { /* progress 0…1 */ })
```

- Event is **global** — match `filePath` to your operation, then call `.remove()`.
- Progress moves from 0 to 1 and does not go backwards.
- `unzip` / `unzipWithPassword`: byte-weighted after each entry.
- `zip` / `zipWithPassword`: per-file.
- `unzipAssets` (Android): approximate vs compressed size.
- `zip` / `zipWithPassword`: per immediate file or folder. The total is counted before the first tick.
- `unzipAssets` (Android): approximate vs compressed size. An entry whose compressed size is unknown counts the bytes copied, and the value stays within 0–1.

### Error codes

Expand Down
19 changes: 19 additions & 0 deletions __tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ describe('react-native-zip-archive API', () => {
code: ErrorCodes.CANCELLED,
});
expect(mockRNZipArchive.zipFolder).not.toHaveBeenCalled();
expect(mockRNZipArchive.cancel).not.toHaveBeenCalled();
});

test('zip signal that aborts while the listener is attached does not start work', async () => {
const signal = {
aborted: false,
addEventListener() {
this.aborted = true;
},
removeEventListener() {},
};
await expect(
zip('/source', '/target.zip', { signal })
).rejects.toMatchObject({
name: 'ZipError',
code: ErrorCodes.CANCELLED,
});
expect(mockRNZipArchive.zipFolder).not.toHaveBeenCalled();
expect(mockRNZipArchive.cancel).not.toHaveBeenCalled();
});

test('zip abort mid-flight calls cancel', async () => {
Expand Down
49 changes: 49 additions & 0 deletions __tests__/zip-archive.e2e.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const { zip, ErrorCodes } = require('../index');
const { mockRNZipArchive } = require('react-native');

/**
* Public JS API, end to end through zip() → withAbort → the native module.
* Native zip is mocked; the assertions are about whether this process starts
* or cancels work.
*/
describe('zip abort end to end', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('signal that aborts while the listener is attached does not start native work', async () => {
const signal = {
aborted: false,
addEventListener() {
this.aborted = true;
},
removeEventListener() {},
};

await expect(zip('/source', '/target.zip', { signal })).rejects.toMatchObject({
name: 'ZipError',
code: ErrorCodes.CANCELLED,
});
expect(mockRNZipArchive.zipFolder).not.toHaveBeenCalled();
expect(mockRNZipArchive.cancel).not.toHaveBeenCalled();
});

test('abort after native work has started cancels that operation', async () => {
let resolveNative;
mockRNZipArchive.zipFolder.mockReturnValueOnce(
new Promise((resolve) => {
resolveNative = resolve;
})
);
const controller = new AbortController();
const pending = zip('/source', '/target.zip', { signal: controller.signal });
await Promise.resolve();

controller.abort();

await expect(pending).rejects.toMatchObject({ code: ErrorCodes.CANCELLED });
expect(mockRNZipArchive.zipFolder).toHaveBeenCalledTimes(1);
expect(mockRNZipArchive.cancel).toHaveBeenCalledTimes(1);
resolveNative('/mock/path.zip');
});
});
Loading
Loading