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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 12 additions & 21 deletions .maestro/flows/_assets-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,20 @@ appId: ${APP_ID}
---
- runFlow:
when:
notVisible: "Assets (Android)"
notVisible: "Bundled Assets"
commands:
- tapOn: "Playground"
- scrollUntilVisible:
element:
text: "Assets (Android)"
text: "Bundled Assets"
direction: DOWN
- tapOn: "Assets (Android)"
- runFlow:
when:
visible: "Not Supported"
commands:
- assertVisible: "Not Supported"
- runFlow:
when:
visible: "Android Assets Demo"
commands:
- assertVisible: "Android Assets Demo"
- tapOn: "Unzip Assets"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible: "Extracted To"
- extendedWaitUntil:
visible: "Files:"
timeout: 10000
- assertVisible: "Files:"
- tapOn: "Bundled Assets"
- assertVisible: "Bundled Assets Demo"
- tapOn: "Unzip Assets"
- waitForAnimationToEnd:
timeout: 10000
- assertVisible: "Extracted To"
- extendedWaitUntil:
visible: "Files:"
timeout: 10000
- assertVisible: "Files:"
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

### Changed
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Or with an explicit charset:
unzip(sourcePath, targetPath, 'UTF-8', ['readme.md', 'docs'])
```

> 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
const sourcePath = `${DocumentDirectoryPath}/myFile.zip`
Expand Down Expand Up @@ -162,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)
Expand All @@ -176,9 +176,12 @@ listContents(sourcePath)

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

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)
Expand Down Expand Up @@ -265,21 +268,22 @@ useEffect(() => {
| `zip` (files array) | ✅ | ✅ | — |
| `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip |
| `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES |
| `unzip` | ✅ | ✅ | Optional `entries` for selective extract; charset ignored on iOS |
| `unzip` | ✅ | ✅ | Optional `entries`; non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS |
| `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract |
| `listContents` | ✅ | ✅ | Charset ignored on iOS |
| `unzipAssets` | | ✅ | Android only |
| `listContents` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS |
| `unzipAssets` | | ✅ | Android `assets/` (+ `content://`); iOS main bundle |
| `cancel` | ✅ | ✅ | Best-effort mid-operation abort |
| `isPasswordProtected` | ✅ | ✅ | — |
| `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS |
| `getUncompressedSize` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS |
| Progress Events | ✅ | ✅ | File path empty on iOS for zip |

### Cross-Platform Notes

- **Compression levels:** Android supports 0–9 for all operations. iOS supports 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

Expand Down
2 changes: 1 addition & 1 deletion RNZipArchive.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Pod::Spec.new do |s|
end
s.dependency 'SSZipArchive', '~>2.5.5'
s.pod_target_xcconfig = {
'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip"'
'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive" "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"'
}

s.source_files = 'ios/*.{h,m,mm}'
Expand Down
122 changes: 101 additions & 21 deletions ios/RNZipArchive.mm
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
#import "RNZipArchive.h"
#if __has_include(<SSZipArchive/minizip/mz_compat.h>)
#import <SSZipArchive/minizip/mz_compat.h>
#else
#elif __has_include("mz_compat.h")
#import "mz_compat.h"
#else
#import "unzip.h"
#endif
#import <zlib.h>
#import <fcntl.h>
Expand Down Expand Up @@ -127,13 +129,35 @@ - (void)isPasswordProtected:(NSString *)file
}];
}

- (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
entries:(NSArray *)entries
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
(void)charset;
if ([self rejectIfUnsupportedCharset:charset reject:reject]) {
return;
}
[self beginOperation];
[self runAsync:^{
if (entries != nil && entries.count > 0) {
Expand Down Expand Up @@ -174,7 +198,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];
[self runAsync:^{
zipFile zip = unzOpen(source.fileSystemRepresentation);
Expand Down Expand Up @@ -663,10 +689,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<NSArray<NSString *> *> *)expandedZipEntries:(NSArray<NSString *> *)paths {
NSFileManager *fileManager = [[NSFileManager alloc] init];
Expand All @@ -677,7 +704,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];
Expand All @@ -687,9 +714,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;
Expand Down Expand Up @@ -744,7 +775,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);
Expand Down Expand Up @@ -874,27 +914,67 @@ - (void)getUncompressedSize:(NSString *)path
charset:(NSString *)charset
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
(void)charset;
if ([self rejectIfUnsupportedCharset:charset reject:reject]) {
return;
}
[self beginOperation];
[self runAsync:^{
NSError *error = nil;
NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error];
NSError *error = nil;
NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error];

if (error == nil) {
resolve(wantedFileSize);
} else {
resolve(@-1);
}
if (error == nil) {
resolve(wantedFileSize);
} else {
reject(kZipErrCorruptArchive, error.localizedDescription ?: @"Failed to get uncompressed size", error);
}
}];
}

- (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 beginOperation];
[self runAsync:^{
[self unzipFile:assetPath destinationPath:target password:nil resolve:resolve reject:reject];
}];
}

- (void)addListener:(NSString *)eventName {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
23 changes: 5 additions & 18 deletions playground-expo/app/assets.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Expand Down Expand Up @@ -44,26 +43,14 @@ export default function AssetsScreen() {
}
};

if (Platform.OS !== 'android') {
return (
<View style={styles.container}>
<ResultCard title="Not Supported" variant="error">
<Text style={styles.desc}>
unzipAssets is only available on Android. iOS apps should use the main bundle or other asset mechanisms.
</Text>
</ResultCard>
<CodePreview code={SOURCE_CODE} />
</View>
);
}

return (
<ScrollView style={styles.container} contentContainerStyle={{ paddingBottom: 32 }}>
<Text style={styles.section}>Android Assets Demo</Text>
<Text style={styles.section}>Bundled Assets Demo</Text>
<Text style={styles.desc}>
Unzip a pre-bundled asset file (<Text style={styles.mono}>sample.zip</Text>) from the Android assets folder.
Make sure <Text style={styles.mono}>sample.zip</Text> exists in{' '}
<Text style={styles.mono}>android/app/src/main/assets/</Text>.
Unzip a pre-bundled <Text style={styles.mono}>sample.zip</Text>
{Platform.OS === 'android'
? ' from android/app/src/main/assets/.'
: ' from the iOS app bundle (Copy Bundle Resources).'}
</Text>

<TouchableOpacity style={styles.actionBtn} onPress={handleUnzipAssets} disabled={loading}>
Expand Down
2 changes: 1 addition & 1 deletion playground-expo/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Binary file added playground-expo/assets/sample.zip
Binary file not shown.
Loading