From 3e4affe9c580999001aaf0fbc585b6a7e61a3ef6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:37:30 +0000 Subject: [PATCH 01/10] feat: add listContents and selective unzip APIs (#365) Expose listContents, unzipFiles, and unzipFilesWithPassword on iOS and Android so apps can inspect archives and extract only selected entries without switching to another library. Co-authored-by: Perry --- .maestro/flows/_home-check.yaml | 1 + CHANGELOG.md | 8 + README.md | 54 +++ __mocks__/react-native.js | 13 + __tests__/api.test.js | 73 ++++ __tests__/module-integration.test.js | 3 + .../com/rnziparchive/RNZipArchiveModule.java | 168 +++++++++ .../EntryMatchesSelectionTest.java | 50 +++ index.d.ts | 24 ++ index.js | 32 ++ ios/RNZipArchive.mm | 356 ++++++++++++++++++ package.json | 2 +- playground-expo/app/_layout.tsx | 1 + playground-expo/app/index.tsx | 1 + playground-expo/app/list-contents.tsx | 191 ++++++++++ playground-rn/src/App.tsx | 3 + playground-rn/src/screens/HomeScreen.tsx | 2 + .../src/screens/ListContentsScreen.tsx | 191 ++++++++++ specs/NativeZipArchive.ts | 21 ++ 19 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java create mode 100644 playground-expo/app/list-contents.tsx create mode 100644 playground-rn/src/screens/ListContentsScreen.tsx diff --git a/.maestro/flows/_home-check.yaml b/.maestro/flows/_home-check.yaml index 45f5f5d7..dcd7204d 100644 --- a/.maestro/flows/_home-check.yaml +++ b/.maestro/flows/_home-check.yaml @@ -2,6 +2,7 @@ appId: ${APP_ID} --- - assertVisible: "Zip Operations" - assertVisible: "Unzip Operations" +- assertVisible: "List & Selective Extract" - assertVisible: "Password Protection" - assertVisible: "Progress Events" - scrollUntilVisible: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4371c7..2eadca0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [9.1.0] - 2026-07-25 + +### Added +- `listContents(source, charset?)` — inspect archive entries (path, sizes, directory/encrypted flags) without extracting (#365) +- `unzipFiles(source, target, entries, charset?)` — extract only selected entry paths; directory names include nested children (#365) +- `unzipFilesWithPassword(source, target, entries, password)` — selective extract for password-protected archives (#365) +- Android unit tests for selective-extract entry matching + ## [9.0.2] - 2026-07-22 ### Fixed diff --git a/README.md b/README.md index 4ee731b7..bb2f14ea 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ import { zipWithPassword, unzip, unzipWithPassword, + unzipFiles, + unzipFilesWithPassword, + listContents, unzipAssets, subscribe, isPasswordProtected, @@ -129,6 +132,54 @@ unzipWithPassword(sourcePath, targetPath, 'password') .catch((error) => console.error(error)) ``` +### `listContents(source: string, charset?: string): Promise` + +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)) +``` + +### `unzipFiles(source: string, target: string, entries: string[], charset?: string): Promise` + +Extract only the listed entry 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. + +```js +unzipFiles(sourcePath, targetPath, ['readme.md', 'docs']) + .then((path) => console.log(`selective unzip completed at ${path}`)) + .catch((error) => console.error(error)) +``` + +### `unzipFilesWithPassword(source: string, target: string, entries: string[], password: string): Promise` + +Selective extract for a password-protected archive. + +```js +unzipFilesWithPassword(sourcePath, targetPath, ['secret.txt'], 'password') + .then((path) => console.log(`selective unzip completed at ${path}`)) + .catch((error) => console.error(error)) +``` + ### `unzipAssets(assetPath: string, target: string): Promise` Unzip a file from the Android `assets` folder. **Android only.** @@ -189,6 +240,9 @@ useEffect(() => { | `zipWithPassword` (files array) | ⚠️ | ✅ | iOS: only `STANDARD` encryption | | `unzip` | ✅ | ✅ | Charset ignored on iOS | | `unzipWithPassword` | ✅ | ✅ | — | +| `listContents` | ✅ | ✅ | Charset ignored on iOS | +| `unzipFiles` | ✅ | ✅ | Charset ignored on iOS | +| `unzipFilesWithPassword` | ✅ | ✅ | — | | `unzipAssets` | ❌ | ✅ | Android only | | `isPasswordProtected` | ✅ | ✅ | — | | `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS | diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index b4076191..16fe7d98 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -6,6 +6,19 @@ const mockRNZipArchive = { zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')), unzip: jest.fn(() => Promise.resolve('/mock/dest')), unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFiles: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFilesWithPassword: 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)), diff --git a/__tests__/api.test.js b/__tests__/api.test.js index 54559c52..3077fc09 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -3,6 +3,9 @@ const { zipWithPassword, unzip, unzipWithPassword, + unzipFiles, + unzipFilesWithPassword, + listContents, unzipAssets, isPasswordProtected, getUncompressedSize, @@ -25,6 +28,9 @@ describe('react-native-zip-archive API', () => { expect(typeof zipWithPassword).toBe('function'); expect(typeof unzip).toBe('function'); expect(typeof unzipWithPassword).toBe('function'); + expect(typeof unzipFiles).toBe('function'); + expect(typeof unzipFilesWithPassword).toBe('function'); + expect(typeof listContents).toBe('function'); expect(typeof unzipAssets).toBe('function'); expect(typeof isPasswordProtected).toBe('function'); expect(typeof getUncompressedSize).toBe('function'); @@ -107,6 +113,73 @@ describe('react-native-zip-archive API', () => { }); }); + 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'); + }); + }); + + describe('unzipFiles', () => { + test('unzipFiles with default charset', async () => { + await unzipFiles('/source.zip', '/dest', ['a.txt', 'b.txt']); + expect(mockRNZipArchive.unzipFiles).toHaveBeenCalledWith( + '/source.zip', + '/dest', + ['a.txt', 'b.txt'], + 'UTF-8' + ); + }); + + test('unzipFiles rejects empty entries', async () => { + await expect(unzipFiles('/source.zip', '/dest', [])).rejects.toThrow( + 'unzipFiles requires a non-empty entries array' + ); + }); + + test('unzipFiles normalizes file:// paths', async () => { + await unzipFiles('file:///source.zip', 'file:///dest', ['a.txt'], 'GBK'); + expect(mockRNZipArchive.unzipFiles).toHaveBeenCalledWith( + '/source.zip', + '/dest', + ['a.txt'], + 'GBK' + ); + }); + }); + + describe('unzipFilesWithPassword', () => { + test('unzipFilesWithPassword calls native module', async () => { + await unzipFilesWithPassword('/source.zip', '/dest', ['a.txt'], 'secret'); + expect(mockRNZipArchive.unzipFilesWithPassword).toHaveBeenCalledWith( + '/source.zip', + '/dest', + ['a.txt'], + 'secret' + ); + }); + + test('unzipFilesWithPassword rejects empty entries', async () => { + await expect( + unzipFilesWithPassword('/source.zip', '/dest', [], 'secret') + ).rejects.toThrow('unzipFilesWithPassword requires a non-empty entries array'); + }); + }); + describe('isPasswordProtected', () => { test('isPasswordProtected coerces to boolean', async () => { mockRNZipArchive.isPasswordProtected.mockResolvedValueOnce(1); diff --git a/__tests__/module-integration.test.js b/__tests__/module-integration.test.js index 22cf1b29..dd3a6dca 100644 --- a/__tests__/module-integration.test.js +++ b/__tests__/module-integration.test.js @@ -6,6 +6,9 @@ const mockRNZipArchive = { zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')), unzip: jest.fn(() => Promise.resolve('/mock/dest')), unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFiles: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFilesWithPassword: 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)), diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 104ea7ef..05514b5e 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -12,6 +12,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; @@ -164,6 +165,173 @@ public void unzip(final String zipFilePath, final String destDirectory, final St }); } + @Override + public void listContents(final String zipFilePath, final String charset, final Promise promise) { + executor.submit(() -> { + if (zipFilePath == null) { + promise.reject("RNZipArchiveError", "Couldn't open file null. "); + return; + } + File zipFileRef = new File(zipFilePath); + if (!zipFileRef.exists()) { + promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + return; + } + + try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { + WritableArray entries = Arguments.createArray(); + for (FileHeader fileHeader : zipFile.getFileHeaders()) { + WritableMap entry = Arguments.createMap(); + entry.putString("path", fileHeader.getFileName()); + entry.putDouble("size", Math.max(fileHeader.getUncompressedSize(), 0)); + entry.putDouble("compressedSize", Math.max(fileHeader.getCompressedSize(), 0)); + entry.putBoolean("isDirectory", fileHeader.isDirectory()); + entry.putBoolean("isEncrypted", fileHeader.isEncrypted()); + entries.pushMap(entry); + } + promise.resolve(entries); + } catch (Exception ex) { + promise.reject("RNZipArchiveError", "Failed to list contents: " + ex.getLocalizedMessage()); + } + }); + } + + @Override + public void unzipFiles(final String zipFilePath, final String destDirectory, + final ReadableArray entries, final String charset, final Promise promise) { + final List entryList; + try { + entryList = readableArrayToStringList(entries); + } catch (IllegalArgumentException ex) { + promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + return; + } + extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); + } + + @Override + public void unzipFilesWithPassword(final String zipFilePath, final String destDirectory, + final ReadableArray entries, final String password, + final Promise promise) { + final List entryList; + try { + entryList = readableArrayToStringList(entries); + } catch (IllegalArgumentException ex) { + promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + return; + } + extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); + } + + private void extractSelectedEntries(final String zipFilePath, final String destDirectory, + final List wantedEntries, final String charset, + final String password, final Promise promise) { + executor.submit(() -> { + if (zipFilePath == null) { + promise.reject("RNZipArchiveError", "Couldn't open file null. "); + return; + } + File zipFileRef = new File(zipFilePath); + if (!zipFileRef.exists()) { + promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + return; + } + if (wantedEntries == null || wantedEntries.isEmpty()) { + promise.reject("RNZipArchiveError", "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", + String.format("Zip file: %s is not password protected", zipFilePath)); + return; + } + zipFile.setPassword(password.toCharArray()); + } + + File destDir = new File(destDirectory); + if (!destDir.exists()) { + //noinspection ResultOfMethodCallIgnored + destDir.mkdirs(); + } + + List selected = new ArrayList<>(); + for (FileHeader fileHeader : zipFile.getFileHeaders()) { + if (entryMatchesSelection(fileHeader.getFileName(), wantedEntries)) { + selected.add(fileHeader); + } + } + + if (selected.isEmpty()) { + promise.reject("RNZipArchiveError", "None of the requested entries were found in the archive"); + return; + } + + long totalBytes = Math.max(totalUncompressedSize(selected), 1); + long extractedBytes = 0; + + updateProgress(0, 1, zipFilePath); + for (FileHeader fileHeader : selected) { + ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); + + if (!fileHeader.isDirectory()) { + zipFile.extractFile(fileHeader, destDirectory, ZipSecurity.createExtractParameters()); + extractedBytes += Math.max(fileHeader.getUncompressedSize(), 0); + } else { + File dir = new File(destDirectory, fileHeader.getFileName()); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + } + updateProgress(extractedBytes, totalBytes, zipFilePath); + } + + updateProgress(1, 1, zipFilePath); + promise.resolve(destDirectory); + } catch (Exception ex) { + updateProgress(0, 1, zipFilePath); + promise.reject("RNZipArchiveError", "Failed to extract selected files: " + ex.getLocalizedMessage()); + } + }); + } + + /** + * Returns true when {@code entryName} should be extracted for the given selection list. + * Matches exact paths, directory prefixes ({@code foo/} or {@code foo}), and strips a + * trailing slash from either side before comparing. + */ + static boolean entryMatchesSelection(String entryName, List wantedEntries) { + if (entryName == null || wantedEntries == null) { + return false; + } + String normalizedEntry = stripTrailingSlash(entryName.replace('\\', '/')); + for (String wanted : wantedEntries) { + if (wanted == null || wanted.isEmpty()) { + continue; + } + String normalizedWanted = stripTrailingSlash(wanted.replace('\\', '/')); + if (normalizedEntry.equals(normalizedWanted)) { + return true; + } + if (normalizedEntry.startsWith(normalizedWanted + "/")) { + return true; + } + } + return false; + } + + private static String stripTrailingSlash(String path) { + if (path == null || path.isEmpty()) { + return path; + } + int end = path.length(); + while (end > 0 && path.charAt(end - 1) == '/') { + end--; + } + return path.substring(0, end); + } + /** * Extract a zip held in the assets directory. *

diff --git a/android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java b/android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java new file mode 100644 index 00000000..2b0ecf31 --- /dev/null +++ b/android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java @@ -0,0 +1,50 @@ +package com.rnziparchive; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Test; + +public class EntryMatchesSelectionTest { + + @Test + public void matchesExactFile() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "hello.txt", Collections.singletonList("hello.txt"))); + } + + @Test + public void matchesExactFileIgnoringTrailingSlashOnWanted() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "dir", Collections.singletonList("dir/"))); + } + + @Test + public void matchesDirectoryPrefix() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "docs/readme.md", Collections.singletonList("docs"))); + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "docs/readme.md", Collections.singletonList("docs/"))); + } + + @Test + public void doesNotMatchUnrelatedPrefix() { + assertFalse(RNZipArchiveModule.entryMatchesSelection( + "documentation/readme.md", Collections.singletonList("docs"))); + } + + @Test + public void matchesAnyOfMultipleWantedEntries() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "b.txt", Arrays.asList("a.txt", "b.txt"))); + } + + @Test + public void normalizesBackslashes() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "folder\\file.txt", Collections.singletonList("folder/file.txt"))); + } +} diff --git a/index.d.ts b/index.d.ts index 73a0b167..e80d4150 100644 --- a/index.d.ts +++ b/index.d.ts @@ -32,6 +32,30 @@ declare module "react-native-zip-archive" { export function unzipWithPassword(source: string, target: string, password: string): Promise; export function unzipAssets(assetPath: string, target: string): Promise; + export type ZipEntry = { + path: string; + size: number; + compressedSize: number; + isDirectory: boolean; + isEncrypted: boolean; + }; + + export function listContents(source: string, charset?: string): Promise; + + export function unzipFiles( + source: string, + target: string, + entries: string[], + charset?: string + ): Promise; + + export function unzipFilesWithPassword( + source: string, + target: string, + entries: string[], + password: string + ): Promise; + export function subscribe( callback: ({ progress, filePath }: { progress: number; filePath: string }) => void ): NativeEventSubscription; diff --git a/index.js b/index.js index e6488e74..57c46fd7 100644 --- a/index.js +++ b/index.js @@ -64,6 +64,38 @@ export const unzipWithPassword = (source, target, password) => { ); }; +export const listContents = (source, charset = "UTF-8") => { + return getRNZipArchive().listContents(normalizeFilePath(source), charset); +}; + +export const unzipFiles = (source, target, entries, charset = "UTF-8") => { + if (!Array.isArray(entries) || entries.length === 0) { + return Promise.reject( + new Error("unzipFiles requires a non-empty entries array") + ); + } + return getRNZipArchive().unzipFiles( + normalizeFilePath(source), + normalizeFilePath(target), + entries, + charset + ); +}; + +export const unzipFilesWithPassword = (source, target, entries, password) => { + if (!Array.isArray(entries) || entries.length === 0) { + return Promise.reject( + new Error("unzipFilesWithPassword requires a non-empty entries array") + ); + } + return getRNZipArchive().unzipFilesWithPassword( + normalizeFilePath(source), + normalizeFilePath(target), + entries, + password + ); +}; + export const zipWithPassword = ( source, target, diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 2ba09151..55adee00 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,6 +7,7 @@ // #import "RNZipArchive.h" +#import "unzip.h" #import #if __has_include() @@ -71,6 +72,361 @@ - (void)unzipWithPassword:(NSString *)from [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; } +- (void)listContents:(NSString *)source + charset:(NSString *)charset + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)charset; // iOS always reads entry names as UTF-8 / Latin-1 fallback + + zipFile zip = unzOpen(source.fileSystemRepresentation); + if (zip == NULL) { + reject(@"list_contents_error", @"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(@"list_contents_error", @"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); + 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); + + if (path == nil) { + path = @""; + } + + BOOL isEncrypted = (fileInfo.flag & 1) != 0; + [entries addObject:@{ + @"path": path, + @"size": @((double)fileInfo.uncompressed_size), + @"compressedSize": @((double)fileInfo.compressed_size), + @"isDirectory": @(isDirectory), + @"isEncrypted": @(isEncrypted), + }]; + + ret = unzGoToNextFile(zip); + } + + unzClose(zip); + resolve(entries); +} + +- (NSString *)normalizedZipPath:(NSString *)path { + NSString *normalized = [path stringByReplacingOccurrencesOfString:@"\\" withString:@"/"]; + while ([normalized hasSuffix:@"/"] && normalized.length > 0) { + normalized = [normalized substringToIndex:normalized.length - 1]; + } + return normalized; +} + +- (BOOL)entry:(NSString *)entryName matchesSelection:(NSArray *)wantedEntries { + if (entryName == nil || wantedEntries.count == 0) { + return NO; + } + NSString *normalizedEntry = [self normalizedZipPath:entryName]; + for (NSString *wanted in wantedEntries) { + if (wanted.length == 0) { + continue; + } + NSString *normalizedWanted = [self normalizedZipPath:wanted]; + if ([normalizedEntry isEqualToString:normalizedWanted]) { + return YES; + } + NSString *prefix = [normalizedWanted stringByAppendingString:@"/"]; + if ([normalizedEntry hasPrefix:prefix]) { + return YES; + } + } + return NO; +} + +- (BOOL)isSafeExtractPath:(NSString *)entryName intoDestination:(NSString *)destinationPath { + if (entryName.length == 0) { + return NO; + } + NSString *fullPath = [destinationPath stringByAppendingPathComponent:entryName]; + NSString *standardizedDest = [[destinationPath stringByStandardizingPath] stringByAppendingString:@"/"]; + NSString *standardizedFull = [fullPath stringByStandardizingPath]; + return [standardizedFull hasPrefix:standardizedDest] || + [standardizedFull isEqualToString:[destinationPath stringByStandardizingPath]]; +} + +- (void)unzipFiles:(NSString *)from + destinationPath:(NSString *)destinationPath + entries:(NSArray *)entries + charset:(NSString *)charset + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)charset; + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:nil + resolve:resolve + reject:reject]; +} + +- (void)unzipFilesWithPassword:(NSString *)from + destinationPath:(NSString *)destinationPath + entries:(NSArray *)entries + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:password + resolve:resolve + reject:reject]; +} + +- (void)unzipSelectedEntries:(NSString *)from + destinationPath:(NSString *)destinationPath + entries:(NSArray *)entries + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (entries.count == 0) { + reject(@"unzip_files_error", @"entries must be a non-empty array", nil); + return; + } + + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *dirError = nil; + if (![fileManager fileExistsAtPath:destinationPath]) { + [fileManager createDirectoryAtPath:destinationPath + withIntermediateDirectories:YES + attributes:nil + error:&dirError]; + if (dirError != nil) { + reject(@"unzip_files_error", dirError.localizedDescription, dirError); + return; + } + } + + zipFile zip = unzOpen(from.fileSystemRepresentation); + if (zip == NULL) { + reject(@"unzip_files_error", @"failed to open zip file", nil); + return; + } + + // First pass: compute total uncompressed size of matching entries for progress. + unsigned long long totalSize = 0; + 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]; + } + free(filenameBuf); + if ([self entry:path matchesSelection:entries]) { + matchCount += 1; + totalSize += fileInfo.uncompressed_size; + } + ret = unzGoToNextFile(zip); + } + + if (matchCount == 0) { + unzClose(zip); + reject(@"unzip_files_error", @"None of the requested entries were found in the archive", nil); + return; + } + + if (totalSize == 0) { + totalSize = 1; + } + + // Second pass: extract matching entries. + unsigned long long extractedBytes = 0; + BOOL success = YES; + NSError *extractError = nil; + 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) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"out of memory while extracting"}]; + 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 (strPath == nil || ![self entry:strPath matchesSelection:entries]) { + ret = unzGoToNextFile(zip); + continue; + } + + if ([strPath hasPrefix:@"__MACOSX/"]) { + ret = unzGoToNextFile(zip); + continue; + } + + if (![self isSafeExtractPath:strPath intoDestination:destinationPath]) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Found Zip Path Traversal Vulnerability with %@", strPath]}]; + break; + } + + self.processedFilePath = strPath; + NSString *fullPath = [destinationPath stringByAppendingPathComponent:strPath]; + + if (isDirectory) { + [fileManager createDirectoryAtPath:fullPath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + extractedBytes += fileInfo.uncompressed_size; + [self zipArchiveProgressEvent:extractedBytes total:totalSize]; + ret = unzGoToNextFile(zip); + continue; + } + + NSString *parentDir = [fullPath stringByDeletingLastPathComponent]; + [fileManager createDirectoryAtPath:parentDir + withIntermediateDirectories:YES + attributes:nil + error:nil]; + + if (password.length > 0) { + ret = unzOpenCurrentFilePassword(zip, [password cStringUsingEncoding:NSUTF8StringEncoding]); + } else { + ret = unzOpenCurrentFile(zip); + } + if (ret != UNZ_OK) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to open file in zip archive"}]; + break; + } + + FILE *out = fopen(fullPath.fileSystemRepresentation, "wb"); + if (out == NULL) { + unzCloseCurrentFile(zip); + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to write extracted file"}]; + break; + } + + unsigned char buffer[4096]; + int readBytes; + do { + readBytes = unzReadCurrentFile(zip, buffer, sizeof(buffer)); + if (readBytes < 0) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to read zip entry"}]; + break; + } + if (readBytes > 0) { + fwrite(buffer, 1, readBytes, out); + } + } while (readBytes > 0); + + fclose(out); + unzCloseCurrentFile(zip); + + if (!success) { + break; + } + + extractedBytes += fileInfo.uncompressed_size; + [self zipArchiveProgressEvent:extractedBytes total:totalSize]; + ret = unzGoToNextFile(zip); + } + + unzClose(zip); + + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + + if (success) { + resolve(destinationPath); + } else { + NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; + reject(@"unzip_files_error", message, extractError); + } +} + - (void)unzipFile:(NSString *)from destinationPath:(NSString *)destinationPath password:(NSString *)password diff --git a/package.json b/package.json index 71a7a799..92c52327 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.0.2", + "version": "9.1.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/playground-expo/app/_layout.tsx b/playground-expo/app/_layout.tsx index 787ba018..5fb87aa7 100644 --- a/playground-expo/app/_layout.tsx +++ b/playground-expo/app/_layout.tsx @@ -10,6 +10,7 @@ export default function RootLayout() { + diff --git a/playground-expo/app/index.tsx b/playground-expo/app/index.tsx index 8fbbb64f..e2aa351e 100644 --- a/playground-expo/app/index.tsx +++ b/playground-expo/app/index.tsx @@ -13,6 +13,7 @@ import { Link } from 'expo-router'; const DEMOS = [ { href: '/zip' as const, title: 'Zip Operations', desc: 'Zip folders & files with compression levels' }, { href: '/unzip' as const, title: 'Unzip Operations', desc: 'Extract archives with charset support' }, + { href: '/list-contents' as const, title: 'List & Selective Extract', desc: 'Inspect entries and extract a subset' }, { href: '/password' as const, title: 'Password Protection', desc: 'AES & standard encryption demos' }, { href: '/progress' as const, title: 'Progress Events', desc: 'Real-time zip/unzip progress' }, { href: '/benchmark' as const, title: 'Benchmarks', desc: 'Compare compression levels & speed' }, diff --git a/playground-expo/app/list-contents.tsx b/playground-expo/app/list-contents.tsx new file mode 100644 index 00000000..8ada5711 --- /dev/null +++ b/playground-expo/app/list-contents.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; +import { ResultCard } from '../components/ResultCard'; +import { CodePreview } from '../components/CodePreview'; +import { ensureDir, listFiles } from '../utils/fileSystem'; +import { + zip, + listContents, + unzipFiles, + type ZipEntry, +} from 'react-native-zip-archive'; + +const SOURCE_CODE = `import { listContents, unzipFiles } from 'react-native-zip-archive'; + +const entries = await listContents(sourceZip); +await unzipFiles(sourceZip, outputFolder, ['readme.md']); +`; + +export default function ListContentsScreen() { + const [loading, setLoading] = useState(false); + const [entries, setEntries] = useState([]); + const [extractedFiles, setExtractedFiles] = useState([]); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + const [zipPath, setZipPath] = useState(''); + + const createDemoZip = async () => { + const dir = FileSystem.documentDirectory + 'demo-list/'; + await ensureDir(dir); + await ensureDir(dir + 'docs/'); + await FileSystem.writeAsStringAsync(dir + 'hello.txt', 'Hello from zip!'); + await FileSystem.writeAsStringAsync(dir + 'readme.md', '# Demo'); + await FileSystem.writeAsStringAsync(dir + 'docs/guide.md', '# Guide'); + const target = FileSystem.documentDirectory + 'demo-list-sample.zip'; + try { + await FileSystem.deleteAsync(target, { idempotent: true }); + } catch {} + await zip(dir, target, 0); + return target; + }; + + const handleList = async () => { + setLoading(true); + setError(''); + setEntries([]); + setExtractedFiles([]); + setResult(''); + try { + const path = await createDemoZip(); + setZipPath(path); + const listed = await listContents(path); + setEntries(listed); + setResult(`Listed ${listed.length} entries`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + const handleSelective = async () => { + if (!zipPath) return; + setLoading(true); + setError(''); + setExtractedFiles([]); + try { + const out = FileSystem.documentDirectory + 'selective/' + Date.now() + '/'; + await ensureDir(out); + const path = await unzipFiles(zipPath, out, ['readme.md', 'docs']); + const files = await listFiles(out); + setExtractedFiles(files); + setResult(`Selective extract to ${path}`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + return ( + + Demo: List Contents + + {loading ? ( + + ) : ( + Create Zip & List Contents + )} + + + Demo: Selective Extract + + {loading ? ( + + ) : ( + Extract readme.md + docs/ + )} + + + {result ? ( + + {result} + {entries.length > 0 && ( + <> + Entries: + {entries.map((entry) => ( + + • {entry.path} + {entry.isDirectory ? '/' : ''} ({entry.size} B) + + ))} + + )} + {extractedFiles.length > 0 && ( + <> + Extracted: + {extractedFiles.map((f) => ( + + • {f} + + ))} + + )} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingTop: 16, + }, + section: { + fontSize: 15, + fontWeight: '700', + marginTop: 16, + marginBottom: 8, + }, + actionBtn: { + backgroundColor: '#007AFF', + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + disabledBtn: { + backgroundColor: '#A1A1AA', + }, + actionText: { + color: '#fff', + fontWeight: '700', + }, + mono: { + fontFamily: 'Courier', + fontSize: 12, + color: '#1C1C1E', + }, + subHeading: { + marginTop: 8, + fontWeight: '700', + fontSize: 13, + }, + fileItem: { + fontSize: 13, + color: '#3A3A3C', + marginLeft: 4, + }, +}); diff --git a/playground-rn/src/App.tsx b/playground-rn/src/App.tsx index 2c0223b3..a37d29e8 100644 --- a/playground-rn/src/App.tsx +++ b/playground-rn/src/App.tsx @@ -5,6 +5,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import HomeScreen from './screens/HomeScreen'; import ZipScreen from './screens/ZipScreen'; import UnzipScreen from './screens/UnzipScreen'; +import ListContentsScreen from './screens/ListContentsScreen'; import PasswordScreen from './screens/PasswordScreen'; import ProgressScreen from './screens/ProgressScreen'; import BenchmarkScreen from './screens/BenchmarkScreen'; @@ -14,6 +15,7 @@ export type RootStackParamList = { Home: undefined; Zip: undefined; Unzip: undefined; + ListContents: undefined; Password: undefined; Progress: undefined; Benchmark: undefined; @@ -30,6 +32,7 @@ function App(): React.JSX.Element { + diff --git a/playground-rn/src/screens/HomeScreen.tsx b/playground-rn/src/screens/HomeScreen.tsx index e3f0ab39..df8cebe5 100644 --- a/playground-rn/src/screens/HomeScreen.tsx +++ b/playground-rn/src/screens/HomeScreen.tsx @@ -15,6 +15,7 @@ type RootStackParamList = { Home: undefined; Zip: undefined; Unzip: undefined; + ListContents: undefined; Password: undefined; Progress: undefined; Benchmark: undefined; @@ -26,6 +27,7 @@ type NavigationProp = NativeStackNavigationProp; const DEMOS = [ { screen: 'Zip' as const, title: 'Zip Operations', desc: 'Zip folders & files with compression levels' }, { screen: 'Unzip' as const, title: 'Unzip Operations', desc: 'Extract archives with charset support' }, + { screen: 'ListContents' as const, title: 'List & Selective Extract', desc: 'Inspect entries and extract a subset' }, { screen: 'Password' as const, title: 'Password Protection', desc: 'AES & standard encryption demos' }, { screen: 'Progress' as const, title: 'Progress Events', desc: 'Real-time zip/unzip progress' }, { screen: 'Benchmark' as const, title: 'Benchmarks', desc: 'Compare compression levels & speed' }, diff --git a/playground-rn/src/screens/ListContentsScreen.tsx b/playground-rn/src/screens/ListContentsScreen.tsx new file mode 100644 index 00000000..d9364574 --- /dev/null +++ b/playground-rn/src/screens/ListContentsScreen.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import RNFS from 'react-native-fs'; +import { ResultCard } from '../components/ResultCard'; +import { CodePreview } from '../components/CodePreview'; +import { ensureDir, listFiles } from '../utils/fileSystem'; +import { + zip, + listContents, + unzipFiles, + type ZipEntry, +} from 'react-native-zip-archive'; + +const SOURCE_CODE = `import { listContents, unzipFiles } from 'react-native-zip-archive'; + +const entries = await listContents(sourceZip); +await unzipFiles(sourceZip, outputFolder, ['readme.md']); +`; + +export default function ListContentsScreen() { + const [loading, setLoading] = useState(false); + const [entries, setEntries] = useState([]); + const [extractedFiles, setExtractedFiles] = useState([]); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + const [zipPath, setZipPath] = useState(''); + + const createDemoZip = async () => { + const dir = RNFS.DocumentDirectoryPath + '/demo-list/'; + await ensureDir(dir); + await ensureDir(dir + 'docs/'); + await RNFS.writeFile(dir + 'hello.txt', 'Hello from zip!', 'utf8'); + await RNFS.writeFile(dir + 'readme.md', '# Demo', 'utf8'); + await RNFS.writeFile(dir + 'docs/guide.md', '# Guide', 'utf8'); + const target = RNFS.DocumentDirectoryPath + '/demo-list-sample.zip'; + try { + if (await RNFS.exists(target)) await RNFS.unlink(target); + } catch {} + await zip(dir, target, 0); + return target; + }; + + const handleList = async () => { + setLoading(true); + setError(''); + setEntries([]); + setExtractedFiles([]); + setResult(''); + try { + const path = await createDemoZip(); + setZipPath(path); + const listed = await listContents(path); + setEntries(listed); + setResult(`Listed ${listed.length} entries`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + const handleSelective = async () => { + if (!zipPath) return; + setLoading(true); + setError(''); + setExtractedFiles([]); + try { + const out = RNFS.DocumentDirectoryPath + '/selective/' + Date.now() + '/'; + await ensureDir(out); + const path = await unzipFiles(zipPath, out, ['readme.md', 'docs']); + const files = await listFiles(out); + setExtractedFiles(files); + setResult(`Selective extract to ${path}`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + return ( + + Demo: List Contents + + {loading ? ( + + ) : ( + Create Zip & List Contents + )} + + + Demo: Selective Extract + + {loading ? ( + + ) : ( + Extract readme.md + docs/ + )} + + + {result ? ( + + {result} + {entries.length > 0 && ( + <> + Entries: + {entries.map((entry) => ( + + • {entry.path} + {entry.isDirectory ? '/' : ''} ({entry.size} B) + + ))} + + )} + {extractedFiles.length > 0 && ( + <> + Extracted: + {extractedFiles.map((f) => ( + + • {f} + + ))} + + )} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingTop: 16, + }, + section: { + fontSize: 15, + fontWeight: '700', + marginTop: 16, + marginBottom: 8, + }, + actionBtn: { + backgroundColor: '#007AFF', + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + disabledBtn: { + backgroundColor: '#A1A1AA', + }, + actionText: { + color: '#fff', + fontWeight: '700', + }, + mono: { + fontFamily: 'Courier', + fontSize: 12, + color: '#1C1C1E', + }, + subHeading: { + marginTop: 8, + fontWeight: '700', + fontSize: 13, + }, + fileItem: { + fontSize: 13, + color: '#3A3A3C', + marginLeft: 4, + }, +}); diff --git a/specs/NativeZipArchive.ts b/specs/NativeZipArchive.ts index 5d653722..b5580604 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -1,6 +1,14 @@ import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; +export type ZipEntry = { + path: string; + size: number; + compressedSize: number; + isDirectory: boolean; + isEncrypted: boolean; +}; + export interface Spec extends TurboModule { isPasswordProtected(file: string): Promise; unzip(from: string, destinationPath: string, charset: string): Promise; @@ -9,6 +17,19 @@ export interface Spec extends TurboModule { destinationPath: string, password: string ): Promise; + unzipFiles( + from: string, + destinationPath: string, + entries: string[], + charset: string + ): Promise; + unzipFilesWithPassword( + from: string, + destinationPath: string, + entries: string[], + password: string + ): Promise; + listContents(source: string, charset: string): Promise; zipFolder( from: string, destinationPath: string, From dfc9a72d220bb65ea857294fae97632dce1235b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:39:51 +0000 Subject: [PATCH 02/10] feat: add cancel() and stable error codes (#366) Allow aborting in-flight zip/unzip work and reject with consistent ERR_* codes on iOS and Android, including ERR_CANCELLED. Co-authored-by: Perry --- 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 +++++++ .../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 + 14 files changed, 378 insertions(+), 53 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/CHANGELOG.md b/CHANGELOG.md index 2eadca0b..9e78bc56 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 bb2f14ea..343e3231 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,11 @@ import { unzipFilesWithPassword, listContents, unzipAssets, + cancel, subscribe, isPasswordProtected, getUncompressedSize, + ErrorCodes, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -204,6 +206,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. @@ -244,6 +279,7 @@ useEffect(() => { | `unzipFiles` | ✅ | ✅ | Charset ignored on iOS | | `unzipFilesWithPassword` | ✅ | ✅ | — | | `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 16fe7d98..f86b9048 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -22,6 +22,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 3077fc09..cc342771 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -9,7 +9,9 @@ const { unzipAssets, isPasswordProtected, getUncompressedSize, + cancel, subscribe, + ErrorCodes, DEFAULT_COMPRESSION, NO_COMPRESSION, BEST_SPEED, @@ -34,6 +36,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'); }); @@ -44,6 +47,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 dd3a6dca..e037f251 100644 --- a/__tests__/module-integration.test.js +++ b/__tests__/module-integration.test.js @@ -12,6 +12,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 05514b5e..31497212 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); } }); } @@ -90,11 +121,12 @@ public void isPasswordProtected(final String zipFilePath, final Promise promise) public void unzipWithPassword(final String zipFilePath, final String destDirectory, final String password, final Promise promise) { 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; } @@ -104,6 +136,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()) { @@ -116,7 +151,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); } }); } @@ -124,13 +159,14 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto @Override public void unzip(final String zipFilePath, final String destDirectory, 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; } @@ -147,6 +183,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()) { @@ -160,7 +199,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); } }); } @@ -168,13 +207,14 @@ public void unzip(final String zipFilePath, final String destDirectory, final St @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; } @@ -191,7 +231,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); } }); } @@ -203,7 +243,7 @@ public void unzipFiles(final String zipFilePath, final String destDirectory, try { entryList = readableArrayToStringList(entries); } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); return; } extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); @@ -217,7 +257,7 @@ public void unzipFilesWithPassword(final String zipFilePath, final String destDi try { entryList = readableArrayToStringList(entries); } catch (IllegalArgumentException ex) { - promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); return; } extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); @@ -227,24 +267,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; } @@ -265,7 +306,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; } @@ -274,6 +315,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()) { @@ -291,7 +335,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); } }); } @@ -345,6 +389,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; @@ -376,7 +421,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; } @@ -394,6 +439,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()); @@ -427,7 +475,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 { @@ -451,7 +499,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); @@ -471,7 +519,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); @@ -491,7 +539,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; } @@ -517,7 +565,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); } } @@ -526,12 +574,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)) { @@ -542,6 +591,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()) { @@ -551,6 +603,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 { @@ -567,12 +622,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% @@ -583,15 +638,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 00000000..80654eec --- /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/test/java/com/rnziparchive/ZipErrorCodesTest.java b/android/src/test/java/com/rnziparchive/ZipErrorCodesTest.java new file mode 100644 index 00000000..ca201c7a --- /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 e80d4150..44d15a83 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; @@ -61,4 +76,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 57c46fd7..df9c7169 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; @@ -159,3 +174,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 96bfa20a..b64f1d28 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 55adee00..cb71429e 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]); } @@ -77,10 +127,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; } @@ -93,14 +144,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); @@ -214,8 +265,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; } @@ -231,14 +283,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; } @@ -275,7 +327,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; } @@ -324,6 +376,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; @@ -421,9 +478,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); } } @@ -432,6 +497,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% @@ -445,6 +511,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 @@ -453,7 +521,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) { @@ -472,11 +540,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); } } @@ -485,6 +562,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% @@ -503,11 +581,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); } } @@ -557,6 +636,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++; @@ -573,6 +656,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% @@ -585,11 +669,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); } } @@ -600,6 +685,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% @@ -618,11 +704,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); } } @@ -633,6 +720,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% @@ -646,11 +734,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); } } @@ -674,7 +763,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 92c52327..e918b076 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 b5580604..56c4005e 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -56,6 +56,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 66aeb4970fd84d82ece04a11d99bc268ad4d07c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:41:12 +0000 Subject: [PATCH 03/10] fix(ios): improve zip interoperability for server unzippers (#367) Honor STANDARD vs AES for file-array password zips, apply compression levels to file arrays, and fsync output so immediate uploads see full archives. Add a zip header validation script and README guidance. Co-authored-by: Perry --- CHANGELOG.md | 11 ++++++ README.md | 23 +++++++++--- ios/RNZipArchive.mm | 67 ++++++++++++++++++++++++++++++---- package.json | 2 +- scripts/validate-zip-header.js | 64 ++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 13 deletions(-) create mode 100755 scripts/validate-zip-header.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e78bc56..f147284f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [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 diff --git a/README.md b/README.md index 343e3231..96eac9d8 100644 --- a/README.md +++ b/README.md @@ -270,9 +270,9 @@ 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 | +| `zip` (files array) | ✅ | ✅ | — | +| `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | +| `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES | | `unzip` | ✅ | ✅ | Charset ignored on iOS | | `unzipWithPassword` | ✅ | ✅ | — | | `listContents` | ✅ | ✅ | Charset ignored on iOS | @@ -286,11 +286,24 @@ useEffect(() => { ### 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. +- **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 always uses UTF-8. - **unzipAssets:** Supports `assets/` folder and `content://` URIs on Android. Not supported on iOS. +### 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 This library **requires an Expo Development Build** and does not work in Expo Go because it includes custom native code. See [playground-expo](./playground-expo/) for a working Expo Development Build example. diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index cb71429e..3dc73116 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -9,6 +9,8 @@ #import "RNZipArchive.h" #import "unzip.h" #import +#import +#import #if __has_include() #import @@ -573,10 +575,13 @@ - (void)zipFolder:(NSString *)from success = [SSZipArchive createZipFileAtPath:destinationPath withContentsOfDirectory:from keepParentDirectory:NO - compressionLevel:compressionLevel + compressionLevel:[self zlibCompressionLevel:compressionLevel] password:nil AES:NO progressHandler:self.progressHandler]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -622,6 +627,37 @@ - (void)zipFolder:(NSString *)from return entries; } +- (int)zlibCompressionLevel:(double)compressionLevel { + // Map JS compression constants onto zlib levels. Negative values mean default. + if (compressionLevel < 0) { + return Z_DEFAULT_COMPRESSION; + } + if (compressionLevel > 9) { + return Z_BEST_COMPRESSION; + } + return (int)compressionLevel; +} + +- (BOOL)usesAESForEncryptionType:(NSString *)encryptionType { + // Empty / STANDARD → traditional ZipCrypto for maximum server-side compatibility. + // AES-128 / AES-256 → WinZip AES (many Java/Node unzippers cannot read this). + return encryptionType.length > 0 && ![encryptionType isEqualToString:@"STANDARD"]; +} + +/** + * Flush zip bytes to durable storage before resolving. Callers that upload or hash + * the archive immediately after `zip(...)` otherwise risk reading a partial file + * (see #323 / #355-class races). + */ +- (void)synchronizeZipFileAtPath:(NSString *)path { + int fd = open(path.fileSystemRepresentation, O_RDONLY); + if (fd < 0) { + return; + } + fsync(fd); + close(fd); +} + - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath paths:(NSArray *)paths compressionLevel:(int)compressionLevel @@ -647,6 +683,9 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath } } success &= [zipArchive close]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } } return success; } @@ -664,7 +703,13 @@ - (void)zipFiles:(NSArray *)from BOOL success; [self setProgressHandler]; - success = [self writeZipEntriesToPath:destinationPath paths:from compressionLevel:Z_DEFAULT_COMPRESSION password:nil AES:NO]; + // Honor the requested compression level (previously ignored for file arrays) and + // never enable AES for plaintext zips — both matter for Node/Java unzippers (#333, #323). + success = [self writeZipEntriesToPath:destinationPath + paths:from + compressionLevel:[self zlibCompressionLevel:compressionLevel] + password:nil + AES:NO]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -692,14 +737,17 @@ - (void)zipFolderWithPassword:(NSString *)from BOOL success; [self setProgressHandler]; - BOOL useAES = encryptionType && [encryptionType length] > 0 && ![encryptionType isEqualToString:@"STANDARD"]; + BOOL useAES = [self usesAESForEncryptionType:encryptionType]; success = [SSZipArchive createZipFileAtPath:destinationPath withContentsOfDirectory:from keepParentDirectory:NO - compressionLevel:compressionLevel + compressionLevel:[self zlibCompressionLevel:compressionLevel] password:password AES:useAES progressHandler:self.progressHandler]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -727,9 +775,14 @@ - (void)zipFilesWithPassword:(NSArray *)from BOOL success; [self setProgressHandler]; - // Note: entries are written with AES:YES, matching the previous behavior of - // createZipFileAtPath:withFilesAtPaths: (which routes through AES:YES writes) - success = [self writeZipEntriesToPath:destinationPath paths:from compressionLevel:Z_DEFAULT_COMPRESSION password:password AES:YES]; + // Prefer STANDARD (ZipCrypto) unless the caller explicitly requests AES. + // Always-on AES was a common source of "works on device, fails on server" reports. + BOOL useAES = [self usesAESForEncryptionType:encryptionType]; + success = [self writeZipEntriesToPath:destinationPath + paths:from + compressionLevel:[self zlibCompressionLevel:compressionLevel] + password:password + AES:useAES]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% diff --git a/package.json b/package.json index e918b076..d68ed07b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.2.0", + "version": "9.3.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/scripts/validate-zip-header.js b/scripts/validate-zip-header.js new file mode 100755 index 00000000..93ad91af --- /dev/null +++ b/scripts/validate-zip-header.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Lightweight ZIP interoperability check for archives produced by this library. + * + * Validates: + * - local file header signature 0x04034b50 + * - end-of-central-directory signature 0x06054b50 + * + * Usage: node scripts/validate-zip-header.js [more.zip ...] + */ +const fs = require('fs'); + +const LOCAL_FILE_HEADER = 0x04034b50; +const END_OF_CENTRAL_DIR = 0x06054b50; + +function readUInt32LE(buf, offset) { + return buf.readUInt32LE(offset); +} + +function validateZip(filePath) { + const buf = fs.readFileSync(filePath); + if (buf.length < 22) { + throw new Error(`${filePath}: file too small to be a zip (${buf.length} bytes)`); + } + + const localSig = readUInt32LE(buf, 0); + if (localSig !== LOCAL_FILE_HEADER) { + throw new Error( + `${filePath}: bad local header signature 0x${localSig.toString(16)} (expected 0x04034b50)` + ); + } + + // EOCD is at the end; comment can make it earlier. Scan last 64KiB. + const scanFrom = Math.max(0, buf.length - 65557); + let eocd = -1; + for (let i = buf.length - 22; i >= scanFrom; i--) { + if (readUInt32LE(buf, i) === END_OF_CENTRAL_DIR) { + eocd = i; + break; + } + } + if (eocd < 0) { + throw new Error(`${filePath}: end-of-central-directory signature not found`); + } + + console.log(`OK ${filePath} (local=0x04034b50, eocd@${eocd})`); +} + +const files = process.argv.slice(2); +if (files.length === 0) { + console.error('Usage: node scripts/validate-zip-header.js ...'); + process.exit(2); +} + +let failed = false; +for (const file of files) { + try { + validateZip(file); + } catch (err) { + console.error(String(err.message || err)); + failed = true; + } +} +process.exit(failed ? 1 : 0); From 71d86bf3f2f59dd2c512c56f2539504081099e68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:43:00 +0000 Subject: [PATCH 04/10] feat: close iOS/Android platform parity gaps (#368) Add iOS unzipAssets from the main bundle, preserve empty directories in file-array zips, reject non-UTF-8 charset on iOS, and ship sample.zip in both playground iOS bundles. Co-authored-by: Perry --- .maestro/flows/_assets-test.yaml | 33 ++---- CHANGELOG.md | 10 ++ README.md | 22 ++-- ios/RNZipArchive.mm | 105 +++++++++++++++--- package.json | 2 +- playground-expo/app/assets.tsx | 22 +--- playground-expo/app/index.tsx | 2 +- playground-expo/assets/sample.zip | Bin 0 -> 398 bytes .../project.pbxproj | 4 + .../ios/RNZipArchivePlayground/sample.zip | Bin 0 -> 398 bytes .../PlaygroundRN.xcodeproj/project.pbxproj | 4 + playground-rn/ios/PlaygroundRN/sample.zip | Bin 0 -> 398 bytes playground-rn/src/screens/AssetsScreen.tsx | 22 +--- playground-rn/src/screens/HomeScreen.tsx | 2 +- 14 files changed, 148 insertions(+), 80 deletions(-) create mode 100644 playground-expo/assets/sample.zip create mode 100644 playground-expo/ios/RNZipArchivePlayground/sample.zip create mode 100644 playground-rn/ios/PlaygroundRN/sample.zip diff --git a/.maestro/flows/_assets-test.yaml b/.maestro/flows/_assets-test.yaml index 731bcdcf..b07726b2 100644 --- a/.maestro/flows/_assets-test.yaml +++ b/.maestro/flows/_assets-test.yaml @@ -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:" diff --git a/CHANGELOG.md b/CHANGELOG.md index f147284f..c4cd7479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 diff --git a/README.md b/README.md index 96eac9d8..78a5a9e0 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,12 @@ unzipFilesWithPassword(sourcePath, targetPath, ['secret.txt'], 'password') ### `unzipAssets(assetPath: string, target: string): Promise` -Unzip a file from the Android `assets` folder. **Android only.** +Unzip a bundled archive. -`assetPath` is the relative path inside the pre-bundled assets folder (e.g. `folder/myFile.zip`). Do not pass an absolute path. +- **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”). + +Do not pass an absolute filesystem path. ```js unzipAssets('./myFile.zip', DocumentDirectoryPath) @@ -273,23 +276,24 @@ useEffect(() => { | `zip` (files array) | ✅ | ✅ | — | | `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | | `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES | -| `unzip` | ✅ | ✅ | Charset ignored on iOS | +| `unzip` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | | `unzipWithPassword` | ✅ | ✅ | — | -| `listContents` | ✅ | ✅ | Charset ignored on iOS | -| `unzipFiles` | ✅ | ✅ | Charset ignored on iOS | +| `listContents` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | +| `unzipFiles` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | | `unzipFilesWithPassword` | ✅ | ✅ | — | -| `unzipAssets` | ❌ | ✅ | Android only | +| `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 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 always uses UTF-8. -- **unzipAssets:** Supports `assets/` folder and `content://` URIs on Android. Not supported on iOS. +- **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 diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 3dc73116..adbc4ef8 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -108,11 +108,34 @@ - (void)isPasswordProtected:(NSString *)file resolve([NSNumber numberWithBool:isPasswordProtected]); } +- (BOOL)isUtf8Charset:(NSString *)charset { + if (charset == nil || charset.length == 0) { + return YES; + } + NSString *normalized = [[charset stringByReplacingOccurrencesOfString:@"-" withString:@""] + lowercaseString]; + return [normalized isEqualToString:@"utf8"]; +} + +- (BOOL)rejectIfUnsupportedCharset:(NSString *)charset + reject:(RCTPromiseRejectBlock)reject { + if ([self isUtf8Charset:charset]) { + return NO; + } + reject(kZipErrUnsupported, + [NSString stringWithFormat:@"charset '%@' is not supported on iOS (UTF-8 only)", charset], + nil); + return YES; +} + - (void)unzip:(NSString *)from destinationPath:(NSString *)destinationPath charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; } @@ -128,7 +151,9 @@ - (void)listContents:(NSString *)source charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)charset; // iOS always reads entry names as UTF-8 / Latin-1 fallback + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } [self beginOperation]; zipFile zip = unzOpen(source.fileSystemRepresentation); @@ -238,7 +263,9 @@ - (void)unzipFiles:(NSString *)from charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)charset; + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } [self unzipSelectedEntries:from destinationPath:destinationPath entries:entries @@ -595,10 +622,11 @@ - (void)zipFolder:(NSString *)from } } -// Expands `paths` into (full path, entry name) pairs. Files keep their base -// name; directory contents are added recursively with entry names relative to -// the listed directory (e.g. "a.txt", "sub/b.txt"), matching Android's -// zip(string[]) behavior (#339). Directory entries themselves are not written. +// Expands `paths` into (full path, entry name, kind) triples. +// kind is @"file" or @"dir". Files keep their base name; directory contents are +// added recursively with entry names relative to the listed directory +// (e.g. "a.txt", "sub/b.txt"), matching Android's zip(string[]) behavior (#339). +// Empty directories are preserved as directory entries for Android parity (#368). // Returns nil if any path does not exist. - (NSArray *> *)expandedZipEntries:(NSArray *)paths { NSFileManager *fileManager = [[NSFileManager alloc] init]; @@ -609,7 +637,7 @@ - (void)zipFolder:(NSString *)from return nil; } if (!isDirectory) { - [entries addObject:@[path, path.lastPathComponent]]; + [entries addObject:@[path, path.lastPathComponent, @"file"]]; continue; } NSDirectoryEnumerator *enumerator = [fileManager enumeratorAtPath:path]; @@ -619,9 +647,13 @@ - (void)zipFolder:(NSString *)from BOOL childIsDirectory = NO; [fileManager fileExistsAtPath:fullPath isDirectory:&childIsDirectory]; if (childIsDirectory) { + NSArray *children = [fileManager contentsOfDirectoryAtPath:fullPath error:nil]; + if (children.count == 0) { + [entries addObject:@[fullPath, relativePath, @"dir"]]; + } continue; } - [entries addObject:@[fullPath, relativePath]]; + [entries addObject:@[fullPath, relativePath, @"file"]]; } } return entries; @@ -676,7 +708,16 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath success = NO; break; } - success &= [zipArchive writeFileAtPath:entry[0] withFileName:entry[1] compressionLevel:compressionLevel password:password AES:aes]; + NSString *kind = entry.count > 2 ? entry[2] : @"file"; + if ([kind isEqualToString:@"dir"]) { + success &= [zipArchive writeFolderAtPath:entry[0] withFolderName:entry[1] withPassword:password]; + } else { + success &= [zipArchive writeFileAtPath:entry[0] + withFileName:entry[1] + compressionLevel:compressionLevel + password:password + AES:aes]; + } if (self.progressHandler) { complete++; self.progressHandler(complete, total); @@ -800,13 +841,16 @@ - (void)getUncompressedSize:(NSString *)path charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } NSError *error = nil; NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; if (error == nil) { resolve(wantedFileSize); } else { - resolve(@-1); + reject(kZipErrCorruptArchive, error.localizedDescription ?: @"Failed to get uncompressed size", error); } } @@ -814,9 +858,44 @@ - (void)unzipAssets:(NSString *)source target:(NSString *)target resolve:(RCTPromiseResolveBlock)resolve 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(kZipErrUnsupported, @"unzipAssets is not supported on iOS", error); + // Android reads from the APK assets/ folder. On iOS, map the same relative + // path onto the main app bundle so playground/docs can share one API (#368). + if (source.length == 0) { + reject(kZipErrInvalidArgs, @"asset path must not be empty", nil); + return; + } + + NSString *normalized = source; + while ([normalized hasPrefix:@"./"]) { + normalized = [normalized substringFromIndex:2]; + } + if ([normalized hasPrefix:@"/"]) { + normalized = [normalized substringFromIndex:1]; + } + + NSString *bundleRoot = [[NSBundle mainBundle] bundlePath]; + NSString *assetPath = [bundleRoot stringByAppendingPathComponent:normalized]; + if (![[NSFileManager defaultManager] fileExistsAtPath:assetPath]) { + // Also try pathForResource for files copied as bundle resources without folders. + NSString *resourceName = normalized.stringByDeletingPathExtension.lastPathComponent; + NSString *resourceExt = normalized.pathExtension; + NSString *resourceDir = normalized.stringByDeletingLastPathComponent; + if (resourceDir.length == 0) { + resourceDir = nil; + } + assetPath = [[NSBundle mainBundle] pathForResource:resourceName + ofType:resourceExt.length ? resourceExt : nil + inDirectory:resourceDir]; + } + + if (assetPath.length == 0 || ![[NSFileManager defaultManager] fileExistsAtPath:assetPath]) { + reject(kZipErrFileNotFound, + [NSString stringWithFormat:@"Asset file `%@` could not be opened from the app bundle", source], + nil); + return; + } + + [self unzipFile:assetPath destinationPath:target password:nil resolve:resolve reject:reject]; } - (void)addListener:(NSString *)eventName { diff --git a/package.json b/package.json index d68ed07b..10f2ab3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.3.0", + "version": "9.4.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/playground-expo/app/assets.tsx b/playground-expo/app/assets.tsx index ea555ebe..0e66f68e 100644 --- a/playground-expo/app/assets.tsx +++ b/playground-expo/app/assets.tsx @@ -44,26 +44,14 @@ export default function AssetsScreen() { } }; - if (Platform.OS !== 'android') { - return ( - - - - unzipAssets is only available on Android. iOS apps should use the main bundle or other asset mechanisms. - - - - - ); - } - return ( - Android Assets Demo + Bundled Assets Demo - Unzip a pre-bundled asset file (sample.zip) from the Android assets folder. - Make sure sample.zip exists in{' '} - android/app/src/main/assets/. + Unzip a pre-bundled sample.zip + {Platform.OS === 'android' + ? ' from android/app/src/main/assets/.' + : ' from the iOS app bundle (Copy Bundle Resources).'} diff --git a/playground-expo/app/index.tsx b/playground-expo/app/index.tsx index e2aa351e..c7bd72f0 100644 --- a/playground-expo/app/index.tsx +++ b/playground-expo/app/index.tsx @@ -17,7 +17,7 @@ const DEMOS = [ { href: '/password' as const, title: 'Password Protection', desc: 'AES & standard encryption demos' }, { href: '/progress' as const, title: 'Progress Events', desc: 'Real-time zip/unzip progress' }, { href: '/benchmark' as const, title: 'Benchmarks', desc: 'Compare compression levels & speed' }, - { href: '/assets' as const, title: 'Assets (Android)', desc: 'Unzip bundled assets on Android' }, + { href: '/assets' as const, title: 'Bundled Assets', desc: 'Unzip Android assets / iOS bundle resources' }, ]; export default function HomeScreen() { diff --git a/playground-expo/assets/sample.zip b/playground-expo/assets/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6b085575ba507e32369ee1b7ca89ce84556cd4d GIT binary patch literal 398 zcmWIWW@h1H009@<%`spGln`N%VJJ?_EyzjLt;#IWP0r6NNzE%M)(;KgWMICqm^%xE zODnh;7+JnDGBB`!v<0A;mSA+MM+_(m!m>C_%Sg@1$=55XD8Xl}2S|}ZT2X$k0>njn zKo@ZZcr!A|G2?Q)1js!=Ai(g}5kzD63M<4b7~ViN8Py{YlYw4lSkmZ+!(=3H;j)62 Q4dg5)AlwV2cY`<#02(+~asU7T literal 0 HcmV?d00001 diff --git a/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj b/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj index 4c3440ff..494f9ff8 100644 --- a/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj +++ b/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; DBAA7EA6CD08B42E8510EF79 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = EFB54E4CD17F3FB2EC627D4B /* PrivacyInfo.xcprivacy */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + A1B2C3D45E6F708192A3B4C5 /* sample.zip in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D35E6F708192A3B4C5 /* sample.zip */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +31,7 @@ F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = RNZipArchivePlayground/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* RNZipArchivePlayground-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "RNZipArchivePlayground-Bridging-Header.h"; path = "RNZipArchivePlayground/RNZipArchivePlayground-Bridging-Header.h"; sourceTree = ""; }; F3CD0FA6E823EC0EFCAB415A /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-RNZipArchivePlayground/ExpoModulesProvider.swift"; sourceTree = ""; }; + A1B2C3D35E6F708192A3B4C5 /* sample.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; name = sample.zip; path = RNZipArchivePlayground/sample.zip; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +64,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, EFB54E4CD17F3FB2EC627D4B /* PrivacyInfo.xcprivacy */, + A1B2C3D35E6F708192A3B4C5 /* sample.zip */, ); name = RNZipArchivePlayground; sourceTree = ""; @@ -198,6 +201,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, DBAA7EA6CD08B42E8510EF79 /* PrivacyInfo.xcprivacy in Resources */, + A1B2C3D45E6F708192A3B4C5 /* sample.zip in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/playground-expo/ios/RNZipArchivePlayground/sample.zip b/playground-expo/ios/RNZipArchivePlayground/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6b085575ba507e32369ee1b7ca89ce84556cd4d GIT binary patch literal 398 zcmWIWW@h1H009@<%`spGln`N%VJJ?_EyzjLt;#IWP0r6NNzE%M)(;KgWMICqm^%xE zODnh;7+JnDGBB`!v<0A;mSA+MM+_(m!m>C_%Sg@1$=55XD8Xl}2S|}ZT2X$k0>njn zKo@ZZcr!A|G2?Q)1js!=Ai(g}5kzD63M<4b7~ViN8Py{YlYw4lSkmZ+!(=3H;j)62 Q4dg5)AlwV2cY`<#02(+~asU7T literal 0 HcmV?d00001 diff --git a/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj b/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj index 3ecbe55a..22699e98 100644 --- a/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj +++ b/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97523AD4E6E87C2182825509 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = E5EBC47BA2EB7F034C1ACD13 /* main.m */; }; DB69DB97B85F813FDF0E001E /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = B21341DD1A1109A08737F2E2 /* AppDelegate.mm */; }; E0B8E392EC6F22AC59BCBB21 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + F7C3A1B25E9D4A0F8C2B6D11 /* sample.zip in Resources */ = {isa = PBXBuildFile; fileRef = F7C3A1B15E9D4A0F8C2B6D11 /* sample.zip */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,6 +29,7 @@ D55102F37F12E8BE1D6159A2 /* libPods-PlaygroundRN.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-PlaygroundRN.a"; sourceTree = BUILT_PRODUCTS_DIR; }; E5EBC47BA2EB7F034C1ACD13 /* main.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = main.m; path = PlaygroundRN/main.m; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F7C3A1B15E9D4A0F8C2B6D11 /* sample.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; name = sample.zip; path = PlaygroundRN/sample.zip; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,6 +51,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + F7C3A1B15E9D4A0F8C2B6D11 /* sample.zip */, ); name = PlaygroundRN; sourceTree = ""; @@ -166,6 +169,7 @@ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, E0B8E392EC6F22AC59BCBB21 /* PrivacyInfo.xcprivacy in Resources */, + F7C3A1B25E9D4A0F8C2B6D11 /* sample.zip in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/playground-rn/ios/PlaygroundRN/sample.zip b/playground-rn/ios/PlaygroundRN/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6b085575ba507e32369ee1b7ca89ce84556cd4d GIT binary patch literal 398 zcmWIWW@h1H009@<%`spGln`N%VJJ?_EyzjLt;#IWP0r6NNzE%M)(;KgWMICqm^%xE zODnh;7+JnDGBB`!v<0A;mSA+MM+_(m!m>C_%Sg@1$=55XD8Xl}2S|}ZT2X$k0>njn zKo@ZZcr!A|G2?Q)1js!=Ai(g}5kzD63M<4b7~ViN8Py{YlYw4lSkmZ+!(=3H;j)62 Q4dg5)AlwV2cY`<#02(+~asU7T literal 0 HcmV?d00001 diff --git a/playground-rn/src/screens/AssetsScreen.tsx b/playground-rn/src/screens/AssetsScreen.tsx index fe85fc80..5d7671d2 100644 --- a/playground-rn/src/screens/AssetsScreen.tsx +++ b/playground-rn/src/screens/AssetsScreen.tsx @@ -44,26 +44,14 @@ export default function AssetsScreen() { } }; - if (Platform.OS !== 'android') { - return ( - - - - unzipAssets is only available on Android. iOS apps should use the main bundle or other asset mechanisms. - - - - - ); - } - return ( - Android Assets Demo + Bundled Assets Demo - Unzip a pre-bundled asset file (sample.zip) from the Android assets folder. - Make sure sample.zip exists in{' '} - android/app/src/main/assets/. + Unzip a pre-bundled sample.zip + {Platform.OS === 'android' + ? ' from android/app/src/main/assets/.' + : ' from the iOS app bundle (Copy Bundle Resources).'} diff --git a/playground-rn/src/screens/HomeScreen.tsx b/playground-rn/src/screens/HomeScreen.tsx index df8cebe5..ebf65148 100644 --- a/playground-rn/src/screens/HomeScreen.tsx +++ b/playground-rn/src/screens/HomeScreen.tsx @@ -31,7 +31,7 @@ const DEMOS = [ { screen: 'Password' as const, title: 'Password Protection', desc: 'AES & standard encryption demos' }, { screen: 'Progress' as const, title: 'Progress Events', desc: 'Real-time zip/unzip progress' }, { screen: 'Benchmark' as const, title: 'Benchmarks', desc: 'Compare compression levels & speed' }, - { screen: 'Assets' as const, title: 'Assets (Android)', desc: 'Unzip bundled assets on Android' }, + { screen: 'Assets' as const, title: 'Bundled Assets', desc: 'Unzip Android assets / iOS bundle resources' }, ]; export default function HomeScreen() { From 4951f62d723be98a78585eedb28a01503501d916 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 02:00:28 +0000 Subject: [PATCH 05/10] fix: restore platform parity CI Co-authored-by: Perry --- .maestro/flows/_home-check.yaml | 8 ++++++++ RNZipArchive.podspec | 3 +++ ios/RNZipArchive.mm | 4 ++++ playground-expo/app/assets.tsx | 1 - playground-rn/src/screens/AssetsScreen.tsx | 1 - 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.maestro/flows/_home-check.yaml b/.maestro/flows/_home-check.yaml index dcd7204d..ed300a52 100644 --- a/.maestro/flows/_home-check.yaml +++ b/.maestro/flows/_home-check.yaml @@ -3,7 +3,15 @@ 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: diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index 78135b42..88efbd78 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -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'] diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index adbc4ef8..ace63037 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,7 +7,11 @@ // #import "RNZipArchive.h" +#if __has_include() +#import +#else #import "unzip.h" +#endif #import #import #import diff --git a/playground-expo/app/assets.tsx b/playground-expo/app/assets.tsx index 0e66f68e..04103b96 100644 --- a/playground-expo/app/assets.tsx +++ b/playground-expo/app/assets.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - View, Text, StyleSheet, ScrollView, diff --git a/playground-rn/src/screens/AssetsScreen.tsx b/playground-rn/src/screens/AssetsScreen.tsx index 5d7671d2..11c4a758 100644 --- a/playground-rn/src/screens/AssetsScreen.tsx +++ b/playground-rn/src/screens/AssetsScreen.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - View, Text, StyleSheet, ScrollView, From 9c79fcbf32871d8b0c36524af649576c757d9459 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 02:17:59 +0000 Subject: [PATCH 06/10] fix(ios): include SSZipArchive minizip compat header Co-authored-by: Perry --- ios/RNZipArchive.mm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index ace63037..c62cfe9b 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,8 +7,10 @@ // #import "RNZipArchive.h" -#if __has_include() -#import +#if __has_include() +#import +#elif __has_include("mz_compat.h") +#import "mz_compat.h" #else #import "unzip.h" #endif From 8fb89366830ac86be6cc72f19b85dbbe6bac043b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 01:13:59 +0000 Subject: [PATCH 07/10] refactor: fold selective extract into unzip APIs (#365) Remove unzipFiles/unzipFilesWithPassword and add an optional entries argument to unzip and unzipWithPassword instead, matching the existing zip overload style. Co-authored-by: Perry --- CHANGELOG.md | 3 +- README.md | 58 ++++----- __mocks__/react-native.js | 2 - __tests__/api.test.js | 114 +++++++++--------- __tests__/module-integration.test.js | 2 - .../com/rnziparchive/RNZipArchiveModule.java | 74 +++++++----- .../rnziparchive/NativeZipArchiveSpec.java | 13 +- index.d.ts | 40 +++--- index.js | 73 ++++++----- ios/RNZipArchive.mm | 51 +++----- playground-expo/app/list-contents.tsx | 8 +- .../src/screens/ListContentsScreen.tsx | 8 +- specs/NativeZipArchive.ts | 18 +-- 13 files changed, 241 insertions(+), 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cd7479..5be3f3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,7 @@ ### Added - `listContents(source, charset?)` — inspect archive entries (path, sizes, directory/encrypted flags) without extracting (#365) -- `unzipFiles(source, target, entries, charset?)` — extract only selected entry paths; directory names include nested children (#365) -- `unzipFilesWithPassword(source, target, entries, password)` — selective extract for password-protected archives (#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 diff --git a/README.md b/README.md index 78a5a9e0..bda67512 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,6 @@ import { zipWithPassword, unzip, unzipWithPassword, - unzipFiles, - unzipFilesWithPassword, listContents, unzipAssets, cancel, @@ -109,11 +107,23 @@ zipWithPassword(sourcePath, targetPath, 'password', 'STANDARD') .catch((error) => console.error(error)) ``` -### `unzip(source: string, target: string, charset?: string): Promise` +### `unzip(source: string, target: string, charset?: string | string[], entries?: string[]): Promise` -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` @@ -124,14 +134,18 @@ unzip(sourcePath, targetPath, 'UTF-8') .catch((error) => console.error(error)) ``` -### `unzipWithPassword(source: string, target: string, password: string): Promise` +### `unzipWithPassword(source: string, target: string, password: string, entries?: string[]): Promise` -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` @@ -148,7 +162,7 @@ type ZipEntry = { } ``` -> The `charset` parameter is only supported on Android (default: `UTF-8`). On iOS it is ignored. +> 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) @@ -160,28 +174,6 @@ listContents(sourcePath) .catch((error) => console.error(error)) ``` -### `unzipFiles(source: string, target: string, entries: string[], charset?: string): Promise` - -Extract only the listed entry 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. - -```js -unzipFiles(sourcePath, targetPath, ['readme.md', 'docs']) - .then((path) => console.log(`selective unzip completed at ${path}`)) - .catch((error) => console.error(error)) -``` - -### `unzipFilesWithPassword(source: string, target: string, entries: string[], password: string): Promise` - -Selective extract for a password-protected archive. - -```js -unzipFilesWithPassword(sourcePath, targetPath, ['secret.txt'], 'password') - .then((path) => console.log(`selective unzip completed at ${path}`)) - .catch((error) => console.error(error)) -``` - ### `unzipAssets(assetPath: string, target: string): Promise` Unzip a bundled archive. @@ -276,11 +268,9 @@ useEffect(() => { | `zip` (files array) | ✅ | ✅ | — | | `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | | `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES | -| `unzip` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | -| `unzipWithPassword` | ✅ | ✅ | — | +| `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 | -| `unzipFiles` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | -| `unzipFilesWithPassword` | ✅ | ✅ | — | | `unzipAssets` | ✅ | ✅ | Android `assets/` (+ `content://`); iOS main bundle | | `cancel` | ✅ | ✅ | Best-effort mid-operation abort | | `isPasswordProtected` | ✅ | ✅ | — | diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index f86b9048..22ec107c 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -6,8 +6,6 @@ const mockRNZipArchive = { zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')), unzip: jest.fn(() => Promise.resolve('/mock/dest')), unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), - unzipFiles: jest.fn(() => Promise.resolve('/mock/dest')), - unzipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), listContents: jest.fn(() => Promise.resolve([ { diff --git a/__tests__/api.test.js b/__tests__/api.test.js index cc342771..62368f43 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -3,8 +3,6 @@ const { zipWithPassword, unzip, unzipWithPassword, - unzipFiles, - unzipFilesWithPassword, listContents, unzipAssets, isPasswordProtected, @@ -30,8 +28,6 @@ describe('react-native-zip-archive API', () => { expect(typeof zipWithPassword).toBe('function'); expect(typeof unzip).toBe('function'); expect(typeof unzipWithPassword).toBe('function'); - expect(typeof unzipFiles).toBe('function'); - expect(typeof unzipFilesWithPassword).toBe('function'); expect(typeof listContents).toBe('function'); expect(typeof unzipAssets).toBe('function'); expect(typeof isPasswordProtected).toBe('function'); @@ -104,29 +100,83 @@ 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' + ); }); }); @@ -151,52 +201,6 @@ describe('react-native-zip-archive API', () => { }); }); - describe('unzipFiles', () => { - test('unzipFiles with default charset', async () => { - await unzipFiles('/source.zip', '/dest', ['a.txt', 'b.txt']); - expect(mockRNZipArchive.unzipFiles).toHaveBeenCalledWith( - '/source.zip', - '/dest', - ['a.txt', 'b.txt'], - 'UTF-8' - ); - }); - - test('unzipFiles rejects empty entries', async () => { - await expect(unzipFiles('/source.zip', '/dest', [])).rejects.toThrow( - 'unzipFiles requires a non-empty entries array' - ); - }); - - test('unzipFiles normalizes file:// paths', async () => { - await unzipFiles('file:///source.zip', 'file:///dest', ['a.txt'], 'GBK'); - expect(mockRNZipArchive.unzipFiles).toHaveBeenCalledWith( - '/source.zip', - '/dest', - ['a.txt'], - 'GBK' - ); - }); - }); - - describe('unzipFilesWithPassword', () => { - test('unzipFilesWithPassword calls native module', async () => { - await unzipFilesWithPassword('/source.zip', '/dest', ['a.txt'], 'secret'); - expect(mockRNZipArchive.unzipFilesWithPassword).toHaveBeenCalledWith( - '/source.zip', - '/dest', - ['a.txt'], - 'secret' - ); - }); - - test('unzipFilesWithPassword rejects empty entries', async () => { - await expect( - unzipFilesWithPassword('/source.zip', '/dest', [], 'secret') - ).rejects.toThrow('unzipFilesWithPassword requires a non-empty entries array'); - }); - }); - describe('isPasswordProtected', () => { test('isPasswordProtected coerces to boolean', async () => { mockRNZipArchive.isPasswordProtected.mockResolvedValueOnce(1); diff --git a/__tests__/module-integration.test.js b/__tests__/module-integration.test.js index e037f251..71b14381 100644 --- a/__tests__/module-integration.test.js +++ b/__tests__/module-integration.test.js @@ -6,8 +6,6 @@ const mockRNZipArchive = { zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')), unzip: jest.fn(() => Promise.resolve('/mock/dest')), unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), - unzipFiles: jest.fn(() => Promise.resolve('/mock/dest')), - unzipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), listContents: jest.fn(() => Promise.resolve([])), unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 31497212..a9c3238c 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -119,7 +119,16 @@ public void isPasswordProtected(final String zipFilePath, final Promise promise) @Override public void unzipWithPassword(final String zipFilePath, final String destDirectory, - final String password, final Promise promise) { + final String password, final ReadableArray entries, + final Promise promise) { + final List entryList = optionalEntriesList(entries, promise); + if (entryList == REJECTED_ENTRIES) { + return; + } + if (entryList != null) { + extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); + return; + } executor.submit(() -> { beginOperation(); try (net.lingala.zip4j.ZipFile zipFile = new net.lingala.zip4j.ZipFile(zipFilePath)) { @@ -157,7 +166,16 @@ public void unzipWithPassword(final String zipFilePath, final String destDirecto } @Override - public void unzip(final String zipFilePath, final String destDirectory, final String charset, final Promise promise) { + public void unzip(final String zipFilePath, final String destDirectory, final String charset, + final ReadableArray entries, final Promise promise) { + final List entryList = optionalEntriesList(entries, promise); + if (entryList == REJECTED_ENTRIES) { + return; + } + if (entryList != null) { + extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); + return; + } executor.submit(() -> { beginOperation(); if (zipFilePath == null) { @@ -204,6 +222,33 @@ public void unzip(final String zipFilePath, final String destDirectory, final St }); } + /** + * Sentinel meaning {@link #optionalEntriesList} already rejected the promise. + * Distinct from {@code null} (full extract) and a non-empty list (selective). + */ + private static final List REJECTED_ENTRIES = new ArrayList<>(); + + /** + * @return {@code null} for full extract, a non-empty list for selective extract, + * or {@link #REJECTED_ENTRIES} when the promise was already rejected. + */ + private List optionalEntriesList(ReadableArray entries, Promise promise) { + if (entries == null || entries.size() == 0) { + return null; + } + try { + List entryList = readableArrayToStringList(entries); + if (entryList.isEmpty()) { + promise.reject(ZipErrorCodes.INVALID_ARGS, "entries must be a non-empty array"); + return REJECTED_ENTRIES; + } + return entryList; + } catch (IllegalArgumentException ex) { + promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); + return REJECTED_ENTRIES; + } + } + @Override public void listContents(final String zipFilePath, final String charset, final Promise promise) { executor.submit(() -> { @@ -236,31 +281,6 @@ public void listContents(final String zipFilePath, final String charset, final P }); } - @Override - public void unzipFiles(final String zipFilePath, final String destDirectory, - final ReadableArray entries, final String charset, final Promise promise) { - final List entryList; - try { - entryList = readableArrayToStringList(entries); - } catch (IllegalArgumentException ex) { - promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); - return; - } - extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); - } - - @Override - public void unzipFilesWithPassword(final String zipFilePath, final String destDirectory, - final ReadableArray entries, final String password, - final Promise promise) { - final List entryList; - try { - entryList = readableArrayToStringList(entries); - } catch (IllegalArgumentException ex) { - promise.reject(ZipErrorCodes.INVALID_ARGS, "Invalid entries array: " + ex.getMessage()); - return; - } - extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); } private void extractSelectedEntries(final String zipFilePath, final String destDirectory, diff --git a/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java b/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java index c502d48d..15728212 100644 --- a/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java +++ b/android/src/paper/java/com/rnziparchive/NativeZipArchiveSpec.java @@ -20,6 +20,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.turbomodule.core.interfaces.TurboModule; import javax.annotation.Nonnull; +import javax.annotation.Nullable; public abstract class NativeZipArchiveSpec extends ReactContextBaseJavaModule implements ReactModuleWithSpec, TurboModule { public static final String NAME = "RNZipArchive"; @@ -39,11 +40,15 @@ public NativeZipArchiveSpec(ReactApplicationContext reactContext) { @ReactMethod @DoNotStrip - public abstract void unzip(String from, String destinationPath, String charset, Promise promise); + public abstract void unzip(String from, String destinationPath, String charset, @Nullable ReadableArray entries, Promise promise); @ReactMethod @DoNotStrip - public abstract void unzipWithPassword(String from, String destinationPath, String password, Promise promise); + public abstract void unzipWithPassword(String from, String destinationPath, String password, @Nullable ReadableArray entries, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void listContents(String source, String charset, Promise promise); @ReactMethod @DoNotStrip @@ -69,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/index.d.ts b/index.d.ts index 44d15a83..85653d56 100644 --- a/index.d.ts +++ b/index.d.ts @@ -43,8 +43,30 @@ declare module "react-native-zip-archive" { compressionLevel?: number ): Promise; - export function unzip(source: string, target: string, charset?: string): Promise; - export function unzipWithPassword(source: string, target: string, password: string): Promise; + /** + * Unzip an archive. Pass `entries` to extract only those paths + * (directories include nested children). When using `entries`, you may + * omit charset (`unzip(src, dest, ['a.txt'])`) or pass it explicitly + * (`unzip(src, dest, 'GBK', ['a.txt'])`). + */ + export function unzip( + source: string, + target: string, + charset?: string | string[], + entries?: string[] + ): Promise; + + /** + * Unzip a password-protected archive. Pass `entries` to extract only + * those paths (directories include nested children). + */ + export function unzipWithPassword( + source: string, + target: string, + password: string, + entries?: string[] + ): Promise; + export function unzipAssets(assetPath: string, target: string): Promise; export type ZipEntry = { @@ -57,20 +79,6 @@ declare module "react-native-zip-archive" { export function listContents(source: string, charset?: string): Promise; - export function unzipFiles( - source: string, - target: string, - entries: string[], - charset?: string - ): Promise; - - export function unzipFilesWithPassword( - source: string, - target: string, - entries: string[], - password: string - ): Promise; - export function subscribe( callback: ({ progress, filePath }: { progress: number; filePath: string }) => void ): NativeEventSubscription; diff --git a/index.js b/index.js index df9c7169..831cab26 100644 --- a/index.js +++ b/index.js @@ -57,11 +57,38 @@ export const NO_COMPRESSION = 0; export const BEST_SPEED = 1; export const BEST_COMPRESSION = 9; -export const unzip = (source, target, charset = "UTF-8") => { +function resolveUnzipArgs(charsetOrEntries, entries) { + let charset = "UTF-8"; + let selected = entries; + if (Array.isArray(charsetOrEntries)) { + selected = charsetOrEntries; + } else if (charsetOrEntries != null && charsetOrEntries !== "") { + charset = charsetOrEntries; + } + if (selected !== undefined && selected !== null) { + if (!Array.isArray(selected) || selected.length === 0) { + return { + error: new Error( + "unzip: entries must be a non-empty array when provided" + ), + }; + } + } else { + selected = null; + } + return { charset, entries: selected }; +} + +export const unzip = (source, target, charsetOrEntries = "UTF-8", entries) => { + const resolved = resolveUnzipArgs(charsetOrEntries, entries); + if (resolved.error) { + return Promise.reject(resolved.error); + } return getRNZipArchive().unzip( normalizeFilePath(source), normalizeFilePath(target), - charset + resolved.charset, + resolved.entries ); }; @@ -71,11 +98,21 @@ export const isPasswordProtected = (source) => { .then((isEncrypted) => !!isEncrypted); }; -export const unzipWithPassword = (source, target, password) => { +export const unzipWithPassword = (source, target, password, entries) => { + if (entries !== undefined && entries !== null) { + if (!Array.isArray(entries) || entries.length === 0) { + return Promise.reject( + new Error( + "unzipWithPassword: entries must be a non-empty array when provided" + ) + ); + } + } return getRNZipArchive().unzipWithPassword( normalizeFilePath(source), normalizeFilePath(target), - password + password, + entries ?? null ); }; @@ -83,34 +120,6 @@ export const listContents = (source, charset = "UTF-8") => { return getRNZipArchive().listContents(normalizeFilePath(source), charset); }; -export const unzipFiles = (source, target, entries, charset = "UTF-8") => { - if (!Array.isArray(entries) || entries.length === 0) { - return Promise.reject( - new Error("unzipFiles requires a non-empty entries array") - ); - } - return getRNZipArchive().unzipFiles( - normalizeFilePath(source), - normalizeFilePath(target), - entries, - charset - ); -}; - -export const unzipFilesWithPassword = (source, target, entries, password) => { - if (!Array.isArray(entries) || entries.length === 0) { - return Promise.reject( - new Error("unzipFilesWithPassword requires a non-empty entries array") - ); - } - return getRNZipArchive().unzipFilesWithPassword( - normalizeFilePath(source), - normalizeFilePath(target), - entries, - password - ); -}; - export const zipWithPassword = ( source, target, diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index c62cfe9b..5e37f384 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -137,19 +137,39 @@ - (BOOL)rejectIfUnsupportedCharset:(NSString *)charset - (void)unzip:(NSString *)from destinationPath:(NSString *)destinationPath charset:(NSString *)charset + entries:(NSArray *)entries resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { if ([self rejectIfUnsupportedCharset:charset reject:reject]) { return; } + 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 destinationPath:(NSString *)destinationPath password:(NSString *)password + 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]; } @@ -263,37 +283,6 @@ - (BOOL)isSafeExtractPath:(NSString *)entryName intoDestination:(NSString *)dest [standardizedFull isEqualToString:[destinationPath stringByStandardizingPath]]; } -- (void)unzipFiles:(NSString *)from - destinationPath:(NSString *)destinationPath - entries:(NSArray *)entries - charset:(NSString *)charset - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject { - if ([self rejectIfUnsupportedCharset:charset reject:reject]) { - return; - } - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:nil - resolve:resolve - reject:reject]; -} - -- (void)unzipFilesWithPassword:(NSString *)from - destinationPath:(NSString *)destinationPath - entries:(NSArray *)entries - password:(NSString *)password - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:password - resolve:resolve - reject:reject]; -} - - (void)unzipSelectedEntries:(NSString *)from destinationPath:(NSString *)destinationPath entries:(NSArray *)entries diff --git a/playground-expo/app/list-contents.tsx b/playground-expo/app/list-contents.tsx index 8ada5711..44807ae2 100644 --- a/playground-expo/app/list-contents.tsx +++ b/playground-expo/app/list-contents.tsx @@ -13,14 +13,14 @@ import { ensureDir, listFiles } from '../utils/fileSystem'; import { zip, listContents, - unzipFiles, + unzip, type ZipEntry, } from 'react-native-zip-archive'; -const SOURCE_CODE = `import { listContents, unzipFiles } from 'react-native-zip-archive'; +const SOURCE_CODE = `import { listContents, unzip } from 'react-native-zip-archive'; const entries = await listContents(sourceZip); -await unzipFiles(sourceZip, outputFolder, ['readme.md']); +await unzip(sourceZip, outputFolder, ['readme.md']); `; export default function ListContentsScreen() { @@ -73,7 +73,7 @@ export default function ListContentsScreen() { try { const out = FileSystem.documentDirectory + 'selective/' + Date.now() + '/'; await ensureDir(out); - const path = await unzipFiles(zipPath, out, ['readme.md', 'docs']); + const path = await unzip(zipPath, out, ['readme.md', 'docs']); const files = await listFiles(out); setExtractedFiles(files); setResult(`Selective extract to ${path}`); diff --git a/playground-rn/src/screens/ListContentsScreen.tsx b/playground-rn/src/screens/ListContentsScreen.tsx index d9364574..9fe875a2 100644 --- a/playground-rn/src/screens/ListContentsScreen.tsx +++ b/playground-rn/src/screens/ListContentsScreen.tsx @@ -13,14 +13,14 @@ import { ensureDir, listFiles } from '../utils/fileSystem'; import { zip, listContents, - unzipFiles, + unzip, type ZipEntry, } from 'react-native-zip-archive'; -const SOURCE_CODE = `import { listContents, unzipFiles } from 'react-native-zip-archive'; +const SOURCE_CODE = `import { listContents, unzip } from 'react-native-zip-archive'; const entries = await listContents(sourceZip); -await unzipFiles(sourceZip, outputFolder, ['readme.md']); +await unzip(sourceZip, outputFolder, ['readme.md']); `; export default function ListContentsScreen() { @@ -73,7 +73,7 @@ export default function ListContentsScreen() { try { const out = RNFS.DocumentDirectoryPath + '/selective/' + Date.now() + '/'; await ensureDir(out); - const path = await unzipFiles(zipPath, out, ['readme.md', 'docs']); + const path = await unzip(zipPath, out, ['readme.md', 'docs']); const files = await listFiles(out); setExtractedFiles(files); setResult(`Selective extract to ${path}`); diff --git a/specs/NativeZipArchive.ts b/specs/NativeZipArchive.ts index 56c4005e..38c24e3e 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -11,23 +11,17 @@ export type ZipEntry = { export interface Spec extends TurboModule { isPasswordProtected(file: string): Promise; - unzip(from: string, destinationPath: string, charset: string): Promise; - unzipWithPassword( - from: string, - destinationPath: string, - password: string - ): Promise; - unzipFiles( + unzip( from: string, destinationPath: string, - entries: string[], - charset: string + charset: string, + entries?: Array | null ): Promise; - unzipFilesWithPassword( + unzipWithPassword( from: string, destinationPath: string, - entries: string[], - password: string + password: string, + entries?: Array | null ): Promise; listContents(source: string, charset: string): Promise; zipFolder( From d9ab262229aeebe80cc619e1012751daacf5d983 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 01:33:40 +0000 Subject: [PATCH 08/10] test(e2e): cover listContents and selective unzip flows Add Maestro coverage for listing archive entries, selective unzip via entries, and password selective unzip. Playground demos now surface assertable Listed Entries / Selective Extract / Password Selective Extract results including files that were intentionally skipped. Co-authored-by: Perry --- .maestro/flows/_list-contents-test.yaml | 52 +++++++++ .maestro/flows/ci-master.yaml | 1 + .maestro/flows/list-contents.yaml | 4 + e2e/README.md | 3 + playground-expo/app/list-contents.tsx | 102 ++++++++++++++++-- .../src/screens/ListContentsScreen.tsx | 102 ++++++++++++++++-- 6 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 .maestro/flows/_list-contents-test.yaml create mode 100644 .maestro/flows/list-contents.yaml diff --git a/.maestro/flows/_list-contents-test.yaml b/.maestro/flows/_list-contents-test.yaml new file mode 100644 index 00000000..9c9e1f02 --- /dev/null +++ b/.maestro/flows/_list-contents-test.yaml @@ -0,0 +1,52 @@ +appId: ${APP_ID} +--- +- runFlow: + when: + notVisible: "List & Selective Extract" + commands: + - tapOn: "Playground" +- scrollUntilVisible: + element: + text: "List & Selective Extract" + direction: DOWN +- tapOn: "List & Selective Extract" +- assertVisible: "Create Zip & List Contents" +- tapOn: "Create Zip & List Contents" +- waitForAnimationToEnd: + timeout: 15000 +- extendedWaitUntil: + visible: "Listed Entries" + timeout: 20000 +- assertVisible: "Listed Entries" +- assertVisible: "hello.txt" +- assertVisible: "readme.md" +- scrollUntilVisible: + element: + text: "Extract readme.md + docs/" + direction: DOWN +- tapOn: "Extract readme.md + docs/" +- waitForAnimationToEnd: + timeout: 15000 +- extendedWaitUntil: + visible: "Selective Extract" + timeout: 20000 +- assertVisible: "Selective Extract" +- assertVisible: "readme.md" +- assertVisible: "docs/guide.md" +- assertVisible: "Not extracted:" +- assertVisible: "hello.txt" +- scrollUntilVisible: + element: + text: "Extract readme.md with Password" + direction: DOWN +- tapOn: "Extract readme.md with Password" +- waitForAnimationToEnd: + timeout: 20000 +- extendedWaitUntil: + visible: "Password Selective Extract" + timeout: 30000 +- assertVisible: "Password Selective Extract" +- assertVisible: "readme.md" +- assertVisible: "Not extracted:" +- assertVisible: "hello.txt" +- back diff --git a/.maestro/flows/ci-master.yaml b/.maestro/flows/ci-master.yaml index a2e834ff..22ac4b0c 100644 --- a/.maestro/flows/ci-master.yaml +++ b/.maestro/flows/ci-master.yaml @@ -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 diff --git a/.maestro/flows/list-contents.yaml b/.maestro/flows/list-contents.yaml new file mode 100644 index 00000000..1da6d211 --- /dev/null +++ b/.maestro/flows/list-contents.yaml @@ -0,0 +1,4 @@ +appId: ${APP_ID} +--- +- runFlow: _setup.yaml +- runFlow: _list-contents-test.yaml diff --git a/e2e/README.md b/e2e/README.md index 4f78ffcc..5bf59888 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -61,8 +61,11 @@ For other platforms, see the [Maestro installation guide](https://maestro.mobile |------|-------------| | `home.yaml` | Verifies the home screen loads with all demo cards | | `zip-and-unzip.yaml` | Tests zipping a sample folder and unzipping it | +| `list-contents.yaml` | Tests `listContents`, selective `unzip(..., entries)`, and selective `unzipWithPassword(..., entries)` | | `password.yaml` | Tests creating a password-protected zip and extracting it | | `progress.yaml` | Tests subscribing to progress events during a large zip operation | +| `assets.yaml` | Tests unzipping a bundled archive via `unzipAssets` | +| `ci-master.yaml` | Full CI suite (home + zip + unzip + list/selective + password + progress + assets) | ## Writing New Flows diff --git a/playground-expo/app/list-contents.tsx b/playground-expo/app/list-contents.tsx index 44807ae2..b2110cf0 100644 --- a/playground-expo/app/list-contents.tsx +++ b/playground-expo/app/list-contents.tsx @@ -9,29 +9,34 @@ import { import * as FileSystem from 'expo-file-system/legacy'; import { ResultCard } from '../components/ResultCard'; import { CodePreview } from '../components/CodePreview'; -import { ensureDir, listFiles } from '../utils/fileSystem'; +import { ensureDir, listFilesRecursive } from '../utils/fileSystem'; import { zip, + zipWithPassword, listContents, unzip, + unzipWithPassword, type ZipEntry, } from 'react-native-zip-archive'; -const SOURCE_CODE = `import { listContents, unzip } from 'react-native-zip-archive'; +const SOURCE_CODE = `import { listContents, unzip, unzipWithPassword } from 'react-native-zip-archive'; const entries = await listContents(sourceZip); -await unzip(sourceZip, outputFolder, ['readme.md']); +await unzip(sourceZip, outputFolder, ['readme.md', 'docs']); +await unzipWithPassword(sourceZip, outputFolder, 'secret', ['readme.md']); `; export default function ListContentsScreen() { const [loading, setLoading] = useState(false); const [entries, setEntries] = useState([]); const [extractedFiles, setExtractedFiles] = useState([]); + const [resultTitle, setResultTitle] = useState('Result'); const [result, setResult] = useState(''); + const [notExtracted, setNotExtracted] = useState([]); const [error, setError] = useState(''); const [zipPath, setZipPath] = useState(''); - const createDemoZip = async () => { + const createPlainDemoZip = async () => { const dir = FileSystem.documentDirectory + 'demo-list/'; await ensureDir(dir); await ensureDir(dir + 'docs/'); @@ -46,17 +51,42 @@ export default function ListContentsScreen() { return target; }; + const createPasswordDemoZip = async () => { + const dir = FileSystem.documentDirectory + 'demo-list-pw/'; + await ensureDir(dir); + await ensureDir(dir + 'docs/'); + await FileSystem.writeAsStringAsync(dir + 'hello.txt', 'Hello from zip!'); + await FileSystem.writeAsStringAsync(dir + 'readme.md', '# Demo'); + await FileSystem.writeAsStringAsync(dir + 'docs/guide.md', '# Guide'); + const target = FileSystem.documentDirectory + 'demo-list-password.zip'; + try { + await FileSystem.deleteAsync(target, { idempotent: true }); + } catch {} + await zipWithPassword(dir, target, 'secret', 'STANDARD', 0); + return target; + }; + + const summarizeExtraction = (files: string[]) => { + const normalized = files.map((f) => f.replace(/\\/g, '/')); + const missing = ['hello.txt'].filter( + (name) => !normalized.some((f) => f === name || f.endsWith('/' + name)) + ); + return { files: normalized, missing }; + }; + const handleList = async () => { setLoading(true); setError(''); setEntries([]); setExtractedFiles([]); + setNotExtracted([]); setResult(''); try { - const path = await createDemoZip(); + const path = await createPlainDemoZip(); setZipPath(path); const listed = await listContents(path); setEntries(listed); + setResultTitle('Listed Entries'); setResult(`Listed ${listed.length} entries`); } catch (e: any) { setError(e?.message || String(e)); @@ -70,12 +100,15 @@ export default function ListContentsScreen() { setLoading(true); setError(''); setExtractedFiles([]); + setNotExtracted([]); try { const out = FileSystem.documentDirectory + 'selective/' + Date.now() + '/'; await ensureDir(out); const path = await unzip(zipPath, out, ['readme.md', 'docs']); - const files = await listFiles(out); + const { files, missing } = summarizeExtraction(await listFilesRecursive(out)); setExtractedFiles(files); + setNotExtracted(missing); + setResultTitle('Selective Extract'); setResult(`Selective extract to ${path}`); } catch (e: any) { setError(e?.message || String(e)); @@ -84,10 +117,39 @@ export default function ListContentsScreen() { } }; + const handlePasswordSelective = async () => { + setLoading(true); + setError(''); + setEntries([]); + setExtractedFiles([]); + setNotExtracted([]); + setResult(''); + try { + const source = await createPasswordDemoZip(); + const out = FileSystem.documentDirectory + 'selective-pw/' + Date.now() + '/'; + await ensureDir(out); + const path = await unzipWithPassword(source, out, 'secret', ['readme.md']); + const { files, missing } = summarizeExtraction(await listFilesRecursive(out)); + setExtractedFiles(files); + setNotExtracted(missing); + setResultTitle('Password Selective Extract'); + setResult(`Password selective extract to ${path}`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + return ( Demo: List Contents - + {loading ? ( ) : ( @@ -109,8 +171,22 @@ export default function ListContentsScreen() { )} + Demo: Password Selective Extract + + {loading ? ( + + ) : ( + Extract readme.md with Password + )} + + {result ? ( - + {result} {entries.length > 0 && ( <> @@ -133,6 +209,16 @@ export default function ListContentsScreen() { ))} )} + {notExtracted.length > 0 && ( + <> + Not extracted: + {notExtracted.map((f) => ( + + • {f} + + ))} + + )} ) : null} diff --git a/playground-rn/src/screens/ListContentsScreen.tsx b/playground-rn/src/screens/ListContentsScreen.tsx index 9fe875a2..71b26d13 100644 --- a/playground-rn/src/screens/ListContentsScreen.tsx +++ b/playground-rn/src/screens/ListContentsScreen.tsx @@ -9,29 +9,34 @@ import { import RNFS from 'react-native-fs'; import { ResultCard } from '../components/ResultCard'; import { CodePreview } from '../components/CodePreview'; -import { ensureDir, listFiles } from '../utils/fileSystem'; +import { ensureDir, listFilesRecursive } from '../utils/fileSystem'; import { zip, + zipWithPassword, listContents, unzip, + unzipWithPassword, type ZipEntry, } from 'react-native-zip-archive'; -const SOURCE_CODE = `import { listContents, unzip } from 'react-native-zip-archive'; +const SOURCE_CODE = `import { listContents, unzip, unzipWithPassword } from 'react-native-zip-archive'; const entries = await listContents(sourceZip); -await unzip(sourceZip, outputFolder, ['readme.md']); +await unzip(sourceZip, outputFolder, ['readme.md', 'docs']); +await unzipWithPassword(sourceZip, outputFolder, 'secret', ['readme.md']); `; export default function ListContentsScreen() { const [loading, setLoading] = useState(false); const [entries, setEntries] = useState([]); const [extractedFiles, setExtractedFiles] = useState([]); + const [resultTitle, setResultTitle] = useState('Result'); const [result, setResult] = useState(''); + const [notExtracted, setNotExtracted] = useState([]); const [error, setError] = useState(''); const [zipPath, setZipPath] = useState(''); - const createDemoZip = async () => { + const createPlainDemoZip = async () => { const dir = RNFS.DocumentDirectoryPath + '/demo-list/'; await ensureDir(dir); await ensureDir(dir + 'docs/'); @@ -46,17 +51,42 @@ export default function ListContentsScreen() { return target; }; + const createPasswordDemoZip = async () => { + const dir = RNFS.DocumentDirectoryPath + '/demo-list-pw/'; + await ensureDir(dir); + await ensureDir(dir + 'docs/'); + await RNFS.writeFile(dir + 'hello.txt', 'Hello from zip!', 'utf8'); + await RNFS.writeFile(dir + 'readme.md', '# Demo', 'utf8'); + await RNFS.writeFile(dir + 'docs/guide.md', '# Guide', 'utf8'); + const target = RNFS.DocumentDirectoryPath + '/demo-list-password.zip'; + try { + if (await RNFS.exists(target)) await RNFS.unlink(target); + } catch {} + await zipWithPassword(dir, target, 'secret', 'STANDARD', 0); + return target; + }; + + const summarizeExtraction = (files: string[]) => { + const normalized = files.map((f) => f.replace(/\\/g, '/')); + const missing = ['hello.txt'].filter( + (name) => !normalized.some((f) => f === name || f.endsWith('/' + name)) + ); + return { files: normalized, missing }; + }; + const handleList = async () => { setLoading(true); setError(''); setEntries([]); setExtractedFiles([]); + setNotExtracted([]); setResult(''); try { - const path = await createDemoZip(); + const path = await createPlainDemoZip(); setZipPath(path); const listed = await listContents(path); setEntries(listed); + setResultTitle('Listed Entries'); setResult(`Listed ${listed.length} entries`); } catch (e: any) { setError(e?.message || String(e)); @@ -70,12 +100,15 @@ export default function ListContentsScreen() { setLoading(true); setError(''); setExtractedFiles([]); + setNotExtracted([]); try { const out = RNFS.DocumentDirectoryPath + '/selective/' + Date.now() + '/'; await ensureDir(out); const path = await unzip(zipPath, out, ['readme.md', 'docs']); - const files = await listFiles(out); + const { files, missing } = summarizeExtraction(await listFilesRecursive(out)); setExtractedFiles(files); + setNotExtracted(missing); + setResultTitle('Selective Extract'); setResult(`Selective extract to ${path}`); } catch (e: any) { setError(e?.message || String(e)); @@ -84,10 +117,39 @@ export default function ListContentsScreen() { } }; + const handlePasswordSelective = async () => { + setLoading(true); + setError(''); + setEntries([]); + setExtractedFiles([]); + setNotExtracted([]); + setResult(''); + try { + const source = await createPasswordDemoZip(); + const out = RNFS.DocumentDirectoryPath + '/selective-pw/' + Date.now() + '/'; + await ensureDir(out); + const path = await unzipWithPassword(source, out, 'secret', ['readme.md']); + const { files, missing } = summarizeExtraction(await listFilesRecursive(out)); + setExtractedFiles(files); + setNotExtracted(missing); + setResultTitle('Password Selective Extract'); + setResult(`Password selective extract to ${path}`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + return ( Demo: List Contents - + {loading ? ( ) : ( @@ -109,8 +171,22 @@ export default function ListContentsScreen() { )} + Demo: Password Selective Extract + + {loading ? ( + + ) : ( + Extract readme.md with Password + )} + + {result ? ( - + {result} {entries.length > 0 && ( <> @@ -133,6 +209,16 @@ export default function ListContentsScreen() { ))} )} + {notExtracted.length > 0 && ( + <> + Not extracted: + {notExtracted.map((f) => ( + + • {f} + + ))} + + )} ) : null} From 4d548286f2a74a0575f7fe2e86925c52739481e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 02:20:37 +0000 Subject: [PATCH 09/10] fix: restore Android module braces and harden list E2E Remove an extra closing brace left from the selective-extract cherry-pick that broke Android compilation on stacked PRs. Make the list/selective Maestro flow assert stable accessibility labels and summary markers. Co-authored-by: Perry --- .maestro/flows/_list-contents-test.yaml | 48 +++++++------- .../com/rnziparchive/RNZipArchiveModule.java | 2 - playground-expo/app/list-contents.tsx | 62 +++++++++++-------- .../src/screens/ListContentsScreen.tsx | 62 +++++++++++-------- 4 files changed, 100 insertions(+), 74 deletions(-) diff --git a/.maestro/flows/_list-contents-test.yaml b/.maestro/flows/_list-contents-test.yaml index 9c9e1f02..3a73348d 100644 --- a/.maestro/flows/_list-contents-test.yaml +++ b/.maestro/flows/_list-contents-test.yaml @@ -10,43 +10,47 @@ appId: ${APP_ID} text: "List & Selective Extract" direction: DOWN - tapOn: "List & Selective Extract" -- assertVisible: "Create Zip & List Contents" -- tapOn: "Create Zip & List Contents" -- waitForAnimationToEnd: +- extendedWaitUntil: + visible: "Create Zip and List Contents" timeout: 15000 +- tapOn: "Create Zip and List Contents" - extendedWaitUntil: visible: "Listed Entries" timeout: 20000 - assertVisible: "Listed Entries" -- assertVisible: "hello.txt" -- assertVisible: "readme.md" - scrollUntilVisible: element: - text: "Extract readme.md + docs/" + text: "Contains hello.txt" direction: DOWN -- tapOn: "Extract readme.md + docs/" -- waitForAnimationToEnd: - timeout: 15000 + 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" -- assertVisible: "readme.md" -- assertVisible: "docs/guide.md" -- assertVisible: "Not extracted:" -- assertVisible: "hello.txt" - scrollUntilVisible: element: - text: "Extract readme.md with Password" + text: "Extracted docs/guide.md" direction: DOWN -- tapOn: "Extract readme.md with Password" -- waitForAnimationToEnd: - timeout: 20000 + 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" + visible: "Password Selective Extract Done" timeout: 30000 -- assertVisible: "Password Selective Extract" -- assertVisible: "readme.md" -- assertVisible: "Not extracted:" -- assertVisible: "hello.txt" +- assertVisible: "Password Selective Extract Done" +- assertVisible: "Extracted readme.md" +- assertVisible: "Skipped hello.txt" - back diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index a9c3238c..d5b8b0ad 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -281,8 +281,6 @@ 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) { diff --git a/playground-expo/app/list-contents.tsx b/playground-expo/app/list-contents.tsx index b2110cf0..9766705d 100644 --- a/playground-expo/app/list-contents.tsx +++ b/playground-expo/app/list-contents.tsx @@ -87,7 +87,8 @@ export default function ListContentsScreen() { const listed = await listContents(path); setEntries(listed); setResultTitle('Listed Entries'); - setResult(`Listed ${listed.length} entries`); + const names = listed.map((e) => e.path.replace(/\\/g, '/')).join(', '); + setResult(`Listed ${listed.length} entries: ${names}`); } catch (e: any) { setError(e?.message || String(e)); } finally { @@ -132,7 +133,7 @@ export default function ListContentsScreen() { const { files, missing } = summarizeExtraction(await listFilesRecursive(out)); setExtractedFiles(files); setNotExtracted(missing); - setResultTitle('Password Selective Extract'); + setResultTitle('Password Selective Extract Done'); setResult(`Password selective extract to ${path}`); } catch (e: any) { setError(e?.message || String(e)); @@ -141,6 +142,16 @@ export default function ListContentsScreen() { } }; + const entryMarkers = entries.flatMap((entry) => { + const path = entry.path.replace(/\\/g, '/'); + const markers = [`Contains ${path}`]; + const base = path.split('/').pop(); + if (base && base !== path) { + markers.push(`Contains ${base}`); + } + return markers; + }); + return ( Demo: List Contents @@ -153,7 +164,7 @@ export default function ListContentsScreen() { {loading ? ( ) : ( - Create Zip & List Contents + Create Zip and List Contents )} @@ -167,7 +178,7 @@ export default function ListContentsScreen() { {loading ? ( ) : ( - Extract readme.md + docs/ + Extract Selected Entries )} @@ -181,13 +192,18 @@ export default function ListContentsScreen() { {loading ? ( ) : ( - Extract readme.md with Password + Password Selective Extract )} {result ? ( {result} + {entryMarkers.map((marker) => ( + + {marker} + + ))} {entries.length > 0 && ( <> Entries: @@ -199,26 +215,16 @@ export default function ListContentsScreen() { ))} )} - {extractedFiles.length > 0 && ( - <> - Extracted: - {extractedFiles.map((f) => ( - - • {f} - - ))} - - )} - {notExtracted.length > 0 && ( - <> - Not extracted: - {notExtracted.map((f) => ( - - • {f} - - ))} - - )} + {extractedFiles.map((f) => ( + + Extracted {f} + + ))} + {notExtracted.map((f) => ( + + Skipped {f} + + ))} ) : null} @@ -274,4 +280,10 @@ const styles = StyleSheet.create({ color: '#3A3A3C', marginLeft: 4, }, + marker: { + fontSize: 13, + color: '#1C1C1E', + fontWeight: '600', + marginTop: 4, + }, }); diff --git a/playground-rn/src/screens/ListContentsScreen.tsx b/playground-rn/src/screens/ListContentsScreen.tsx index 71b26d13..7e3d3e9c 100644 --- a/playground-rn/src/screens/ListContentsScreen.tsx +++ b/playground-rn/src/screens/ListContentsScreen.tsx @@ -87,7 +87,8 @@ export default function ListContentsScreen() { const listed = await listContents(path); setEntries(listed); setResultTitle('Listed Entries'); - setResult(`Listed ${listed.length} entries`); + const names = listed.map((e) => e.path.replace(/\\/g, '/')).join(', '); + setResult(`Listed ${listed.length} entries: ${names}`); } catch (e: any) { setError(e?.message || String(e)); } finally { @@ -132,7 +133,7 @@ export default function ListContentsScreen() { const { files, missing } = summarizeExtraction(await listFilesRecursive(out)); setExtractedFiles(files); setNotExtracted(missing); - setResultTitle('Password Selective Extract'); + setResultTitle('Password Selective Extract Done'); setResult(`Password selective extract to ${path}`); } catch (e: any) { setError(e?.message || String(e)); @@ -141,6 +142,16 @@ export default function ListContentsScreen() { } }; + const entryMarkers = entries.flatMap((entry) => { + const path = entry.path.replace(/\\/g, '/'); + const markers = [`Contains ${path}`]; + const base = path.split('/').pop(); + if (base && base !== path) { + markers.push(`Contains ${base}`); + } + return markers; + }); + return ( Demo: List Contents @@ -153,7 +164,7 @@ export default function ListContentsScreen() { {loading ? ( ) : ( - Create Zip & List Contents + Create Zip and List Contents )} @@ -167,7 +178,7 @@ export default function ListContentsScreen() { {loading ? ( ) : ( - Extract readme.md + docs/ + Extract Selected Entries )} @@ -181,13 +192,18 @@ export default function ListContentsScreen() { {loading ? ( ) : ( - Extract readme.md with Password + Password Selective Extract )} {result ? ( {result} + {entryMarkers.map((marker) => ( + + {marker} + + ))} {entries.length > 0 && ( <> Entries: @@ -199,26 +215,16 @@ export default function ListContentsScreen() { ))} )} - {extractedFiles.length > 0 && ( - <> - Extracted: - {extractedFiles.map((f) => ( - - • {f} - - ))} - - )} - {notExtracted.length > 0 && ( - <> - Not extracted: - {notExtracted.map((f) => ( - - • {f} - - ))} - - )} + {extractedFiles.map((f) => ( + + Extracted {f} + + ))} + {notExtracted.map((f) => ( + + Skipped {f} + + ))} ) : null} @@ -274,4 +280,10 @@ const styles = StyleSheet.create({ color: '#3A3A3C', marginLeft: 4, }, + marker: { + fontSize: 13, + color: '#1C1C1E', + fontWeight: '600', + marginTop: 4, + }, }); From 2f6f95fe76177f3a41cac35efc05550229b1cde2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 02:45:03 +0000 Subject: [PATCH 10/10] fix(android): map STANDARD encryption to ZipCrypto ZIP_STANDARD ZIP_STANDARD_VARIANT_STRONG is write-only in zip4j and fails password zip create with "encryption method is not supported", breaking Android E2E password selective extract which uses STANDARD encryption. Co-authored-by: Perry --- .../src/main/java/com/rnziparchive/RNZipArchiveModule.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index d5b8b0ad..4d16ca78 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -574,7 +574,9 @@ private void zipWithPassword(final List filesOrDirectory, final String d parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_128); } } else if ("STANDARD".equals(encryptionMethod)) { - parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD_VARIANT_STRONG); + // ZipCrypto (ZIP_STANDARD). ZIP_STANDARD_VARIANT_STRONG is write-only in zip4j + // and fails create/extract with "encryption method is not supported". + parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD); Log.d(TAG, "Standard Encryption"); } else { parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD);