diff --git a/package-lock.json b/package-lock.json index 051ef47b6..2550e534e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,6 +123,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "JsZip-Java": "file:src/plugins/jszip-java", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", @@ -9154,6 +9155,10 @@ "setimmediate": "^1.0.5" } }, + "node_modules/JsZip-Java": { + "resolved": "src/plugins/jszip-java", + "link": true + }, "node_modules/just-diff": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", @@ -12102,6 +12107,12 @@ "dev": true, "license": "ISC" }, + "src/plugins/jszip-java": { + "name": "JsZip-Java", + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, "src/plugins/pluginContext": { "name": "com.foxdebug.acode.rk.plugin.plugincontext", "version": "1.0.0", diff --git a/package.json b/package.json index 81feeea3a..1990e06b8 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "com.foxdebug.acode.rk.exec.proot": {}, "cordova-plugin-iap": {}, "com.foxdebug.acode.rk.customtabs": {}, - "cordova-plugin-system": {} + "cordova-plugin-system": {}, + "com.foxdebug.acode.rk.zip": {} }, "platforms": [ "android" @@ -98,6 +99,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "JsZip-Java": "file:src/plugins/jszip-java", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index b7de3508d..9d99a4c4a 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -3,7 +3,6 @@ import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; import purchaseListener from "handlers/purchase"; -import JSZip from "jszip"; import helpers from "utils/helpers"; import Url from "utils/Url"; import config from "./config"; @@ -101,7 +100,7 @@ export default async function installPlugin( } if (plugin) { - const zip = new JSZip(); + const zip = new window.JSZip(); await zip.loadAsync(plugin); if (!zip.files["plugin.json"]) { diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index 34ee260cb..599f6c8a8 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -12,7 +12,6 @@ import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; import prompt from "dialogs/prompt"; import select from "dialogs/select"; -import JSZip from "jszip"; import actionStack from "lib/actionStack"; import checkFiles from "lib/checkFiles"; import config from "lib/config"; @@ -302,7 +301,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { try { const zipContent = await fsOperation(zipFile).readFile(); - const zip = await JSZip.loadAsync(zipContent); + const zip = await window.JSZip.loadAsync(zipContent); const targetDir = currentDir.url; const targetFs = fsOperation(targetDir); @@ -381,7 +380,7 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { break; } - const zip = new JSZip(); + const zip = new window.JSZip(); let loadingLoader = loader.create( strings["loading"], "Compressing files", diff --git a/src/plugins/jszip-java/package.json b/src/plugins/jszip-java/package.json new file mode 100644 index 000000000..ab1c6f092 --- /dev/null +++ b/src/plugins/jszip-java/package.json @@ -0,0 +1,17 @@ +{ + "name": "JsZip-Java", + "version": "1.0.0", + "description": "A complete drop-in replacement for jszip with java backend", + "cordova": { + "id": "com.foxdebug.acode.rk.zip", + "platforms": [ + "android" + ] + }, + "keywords": [ + "ecosystem:cordova", + "cordova-android" + ], + "author": "@RohitKushvaha01", + "license": "MIT" +} diff --git a/src/plugins/jszip-java/plugin.xml b/src/plugins/jszip-java/plugin.xml new file mode 100644 index 000000000..cbb6ff3e7 --- /dev/null +++ b/src/plugins/jszip-java/plugin.xml @@ -0,0 +1,32 @@ + + + JsZip-Java + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/jszip-java/src/android/JsZip.java b/src/plugins/jszip-java/src/android/JsZip.java new file mode 100644 index 000000000..6af727344 --- /dev/null +++ b/src/plugins/jszip-java/src/android/JsZip.java @@ -0,0 +1,904 @@ +package com.foxdebug.acode.rk.zip; + +import android.util.Base64; +import android.util.Log; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; +import org.apache.cordova.*; +import org.json.*; + +public class JsZip extends CordovaPlugin { + + private final ConcurrentHashMap instances = + new ConcurrentHashMap<>(); + + private static class FileEntry { + + String name; + boolean isDir; + long date; + String comment; + Integer unixPermissions; + Integer dosPermissions; + File tempFile; + } + + private static class ZipInstance { + + String id; + File baseDir; + ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + ZipInstance(String id, File cacheDir) { + this.id = id; + this.baseDir = new File(cacheDir, "jszip_" + id); + if (!baseDir.exists()) { + baseDir.mkdirs(); + } + } + + void destroy() { + deleteDir(baseDir); + } + } + + private static void deleteDir(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteDir(child); + } + } + } + file.delete(); + } + + private static Method setExternalAttributesMethod = null; + private static Method getExternalAttributesMethod = null; + + static { + try { + setExternalAttributesMethod = ZipEntry.class.getMethod( + "setExternalAttributes", + long.class + ); + } catch (Exception e) { + // Not available + } + try { + getExternalAttributesMethod = ZipEntry.class.getMethod( + "getExternalAttributes" + ); + } catch (Exception e) { + // Not available + } + } + + private static void setUnixPermissions(ZipEntry ze, FileEntry entry) { + if (setExternalAttributesMethod != null) { + try { + long attrs = 0; + if (entry.unixPermissions != null) { + attrs = ((long) entry.unixPermissions) << 16; + } else if (entry.dosPermissions != null) { + attrs = entry.dosPermissions & 0xFF; + } else { + attrs = (entry.isDir ? 0755 : 0644) << 16; + } + if (entry.isDir) { + attrs |= 0x10; + } + setExternalAttributesMethod.invoke(ze, attrs); + } catch (Exception e) { + // Ignore + } + } + } + + private static Integer getUnixPermissions(ZipEntry ze) { + if (getExternalAttributesMethod != null) { + try { + long attrs = (Long) getExternalAttributesMethod.invoke(ze); + if (attrs != 0) { + return (int) ((attrs >> 16) & 0xFFFF); + } + } catch (Exception e) { + // Ignore + } + } + return null; + } + + @Override + protected void pluginInitialize() { + super.pluginInitialize(); + cordova.getThreadPool().execute( + new Runnable() { + @Override + public void run() { + File cacheDir = cordova.getActivity().getCacheDir(); + File[] children = cacheDir.listFiles(); + if (children != null) { + for (File child : children) { + if (child.getName().startsWith("jszip_")) { + deleteDir(child); + } + } + } + } + } + ); + } + + @Override + public void onDestroy() { + cordova.getThreadPool().execute( + new Runnable() { + @Override + public void run() { + for (String id : instances.keySet()) { + destroyInstance(id, null); + } + } + } + ); + super.onDestroy(); + } + + @Override + public boolean execute( + String action, + CordovaArgs args, + final CallbackContext callbackContext + ) throws JSONException { + cordova.getThreadPool().execute( + new Runnable() { + @Override + public void run() { + try { + if ("create".equals(action)) { + String id = args.getString(0); + createInstance(id); + callbackContext.success(); + } else if ("addFile".equals(action)) { + String id = args.getString(0); + String path = args.getString(1); + JSONObject options = args.optJSONObject(3); + if (options == null) { + options = new JSONObject(); + } + addFile(id, path, args, options, callbackContext); + } else if ("removeFile".equals(action)) { + String id = args.getString(0); + String path = args.getString(1); + removeFile(id, path, callbackContext); + } else if ("load".equals(action)) { + String id = args.getString(0); + JSONObject options = args.optJSONObject(2); + if (options == null) { + options = new JSONObject(); + } + loadZip(id, args, options, callbackContext); + } else if ("getFileContent".equals(action)) { + String id = args.getString(0); + String path = args.getString(1); + String type = args.getString(2); + getFileContent(id, path, type, callbackContext); + } else if ("generate".equals(action)) { + String id = args.getString(0); + String prefix = args.getString(1); + JSONObject options = args.optJSONObject(2); + if (options == null) { + options = new JSONObject(); + } + generateZip(id, prefix, options, callbackContext); + } else if ("destroy".equals(action)) { + String id = args.getString(0); + destroyInstance(id, callbackContext); + } else if ("extractToDir".equals(action)) { + String id = args.getString(0); + String prefix = args.getString(1); + String targetDir = args.getString(2); + extractToDir(id, prefix, targetDir, callbackContext); + } else if ("extractZipFileToDir".equals(action)) { + String zipFilePath = args.getString(0); + String targetDir = args.getString(1); + extractZipFileToDir(zipFilePath, targetDir, callbackContext); + } else { + callbackContext.error("Invalid action: " + action); + } + } catch (Exception e) { + callbackContext.error( + "Error in action " + action + ": " + e.getMessage() + ); + } + } + } + ); + return true; + } + + private void createInstance(String id) { + File cacheDir = cordova.getActivity().getCacheDir(); + ZipInstance instance = new ZipInstance(id, cacheDir); + instances.put(id, instance); + } + + private void addFile( + String id, + String name, + CordovaArgs args, + JSONObject options, + CallbackContext callbackContext + ) { + try { + ZipInstance instance = instances.get(id); + if (instance == null) { + callbackContext.error("Instance not found"); + return; + } + + boolean isDir = options.optBoolean("dir", false); + FileEntry entry = instance.entries.get(name); + if (entry == null) { + entry = new FileEntry(); + entry.name = name; + instance.entries.put(name, entry); + } + + entry.isDir = isDir; + entry.comment = options.optString("comment", ""); + entry.date = options.optLong("date", System.currentTimeMillis()); + if (!options.isNull("unixPermissions")) { + entry.unixPermissions = options.optInt("unixPermissions"); + } + if (!options.isNull("dosPermissions")) { + entry.dosPermissions = options.optInt("dosPermissions"); + } + + if (!isDir && !args.isNull(2)) { + if (entry.tempFile == null) { + entry.tempFile = new File( + instance.baseDir, + UUID.randomUUID().toString() + ".tmp" + ); + } + + String dataType = options.optString("dataType", "string"); + byte[] bytes; + if ("arraybuffer".equals(dataType)) { + bytes = args.getArrayBuffer(2); + } else if ("base64".equals(dataType)) { + String data = args.getString(2); + bytes = Base64.decode(data, Base64.DEFAULT); + } else { + String data = args.getString(2); + boolean binary = options.optBoolean("binary", false); + if (binary) { + bytes = data.getBytes("ISO-8859-1"); + } else { + bytes = data.getBytes("UTF-8"); + } + } + + try (FileOutputStream fos = new FileOutputStream(entry.tempFile)) { + fos.write(bytes); + } + } + + callbackContext.success(); + } catch (Exception e) { + callbackContext.error("Failed to add file: " + e.getMessage()); + } + } + + private void removeFile( + String id, + String name, + CallbackContext callbackContext + ) { + try { + ZipInstance instance = instances.get(id); + if (instance == null) { + callbackContext.error("Instance not found"); + return; + } + + String normalizedDir = name.endsWith("/") ? name : name + "/"; + for (String key : instance.entries.keySet()) { + if ( + key.equals(name) || + key.equals(normalizedDir) || + key.startsWith(normalizedDir) + ) { + FileEntry entry = instance.entries.remove(key); + if ( + entry != null && entry.tempFile != null && entry.tempFile.exists() + ) { + entry.tempFile.delete(); + } + } + } + callbackContext.success(); + } catch (Exception e) { + callbackContext.error("Failed to remove file: " + e.getMessage()); + } + } + + private void loadZip( + String id, + CordovaArgs args, + JSONObject options, + CallbackContext callbackContext + ) { + File tempZipFile = null; + try { + ZipInstance instance = instances.get(id); + if (instance == null) { + callbackContext.error("Instance not found"); + return; + } + + // Clean current entries if any + for (FileEntry entry : instance.entries.values()) { + if (entry.tempFile != null && entry.tempFile.exists()) { + entry.tempFile.delete(); + } + } + instance.entries.clear(); + + String dataType = options.optString("dataType", "base64"); + byte[] zipBytes; + if ("arraybuffer".equals(dataType)) { + zipBytes = args.getArrayBuffer(1); + } else if ("base64".equals(dataType)) { + String data = args.getString(1); + zipBytes = Base64.decode(data, Base64.DEFAULT); + } else { + String data = args.getString(1); + zipBytes = data.getBytes("ISO-8859-1"); + } + + //Log.d("JsZip", "loadZip: dataType=" + dataType + ", zipBytesLength=" + zipBytes.length); + //System.out.println("[JsZip-Java Debug Native] loadZip: dataType=" + dataType + ", zipBytesLength=" + zipBytes.length); + + // Write to a temporary ZIP file in cache + tempZipFile = new File( + instance.baseDir, + "temp_load_" + UUID.randomUUID().toString() + ".zip" + ); + try (FileOutputStream fos = new FileOutputStream(tempZipFile)) { + fos.write(zipBytes); + } + + boolean createFolders = options.optBoolean("createFolders", false); + JSONArray metaList = new JSONArray(); + + // Open using ZipFile (robust against streaming Data Descriptors) + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(tempZipFile)) { + java.util.Enumeration entries = zf.entries(); + + while (entries.hasMoreElements()) { + ZipEntry ze = entries.nextElement(); + String name = ze.getName(); + boolean isDir = ze.isDirectory(); + + /*System.out.println( + "[JsZip-Java Debug Native] Found entry: " + + name + + ", isDir: " + + isDir + + ", compressedSize: " + + ze.getCompressedSize() + );*/ + + FileEntry entry = new FileEntry(); + entry.name = name; + entry.isDir = isDir; + entry.date = ze.getTime(); + entry.comment = ze.getComment(); + entry.unixPermissions = getUnixPermissions(ze); + if (entry.unixPermissions == null) { + entry.unixPermissions = isDir ? 0755 : 0644; + } + + if (!isDir) { + entry.tempFile = new File( + instance.baseDir, + UUID.randomUUID().toString() + ".tmp" + ); + try ( + java.io.InputStream is = zf.getInputStream(ze); + FileOutputStream fos = new FileOutputStream(entry.tempFile) + ) { + byte[] buffer = new byte[8192]; + int count; + while ((count = is.read(buffer)) != -1) { + fos.write(buffer, 0, count); + } + } + } + + instance.entries.put(name, entry); + + JSONObject meta = new JSONObject(); + meta.put("name", name); + meta.put("dir", isDir); + meta.put("date", entry.date); + meta.put("comment", entry.comment != null ? entry.comment : ""); + meta.put("unixPermissions", entry.unixPermissions); + meta.put( + "dosPermissions", + entry.dosPermissions != null + ? entry.dosPermissions + : JSONObject.NULL + ); + metaList.put(meta); + } + } + + if (createFolders) { + java.util.HashSet impliedDirs = new java.util.HashSet<>(); + for (String name : instance.entries.keySet()) { + String[] parts = name.split("/"); + String current = ""; + for (int i = 0; i < parts.length - 1; i++) { + current += parts[i] + "/"; + if (!instance.entries.containsKey(current)) { + impliedDirs.add(current); + } + } + } + for (String dirPath : impliedDirs) { + FileEntry folderEntry = new FileEntry(); + folderEntry.name = dirPath; + folderEntry.isDir = true; + folderEntry.date = System.currentTimeMillis(); + folderEntry.unixPermissions = 0755; + instance.entries.put(dirPath, folderEntry); + + JSONObject meta = new JSONObject(); + meta.put("name", dirPath); + meta.put("dir", true); + meta.put("date", folderEntry.date); + meta.put("comment", ""); + meta.put("unixPermissions", 0755); + meta.put("dosPermissions", JSONObject.NULL); + metaList.put(meta); + } + } + + JSONObject resultObj = new JSONObject(); + resultObj.put("files", metaList); + resultObj.put("base64Length", zipBytes.length); // Maintain compatibility/logs + resultObj.put("zipBytesLength", zipBytes.length); + callbackContext.success(resultObj); + } catch (Exception e) { + callbackContext.error("Failed to load ZIP: " + e.getMessage()); + } finally { + if (tempZipFile != null && tempZipFile.exists()) { + tempZipFile.delete(); + } + } + } + + private void streamFile(File file, CallbackContext callbackContext) { + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buffer = new byte[262144]; // 256 KB chunk size + int count; + while ((count = fis.read(buffer)) != -1) { + byte[] chunk = new byte[count]; + System.arraycopy(buffer, 0, chunk, 0, count); + PluginResult chunkResult = new PluginResult( + PluginResult.Status.OK, + chunk + ); + chunkResult.setKeepCallback(true); + callbackContext.sendPluginResult(chunkResult); + } + + // Send final "done" signal + JSONObject doneObj = new JSONObject(); + doneObj.put("done", true); + PluginResult finalResult = new PluginResult( + PluginResult.Status.OK, + doneObj + ); + finalResult.setKeepCallback(false); + callbackContext.sendPluginResult(finalResult); + } catch (Exception e) { + callbackContext.error("Failed to stream file: " + e.getMessage()); + } + } + + private void getFileContent( + String id, + String name, + String type, + CallbackContext callbackContext + ) { + try { + ZipInstance instance = instances.get(id); + if (instance == null) { + callbackContext.error("Instance not found"); + return; + } + + FileEntry entry = instance.entries.get(name); + if (entry == null) { + callbackContext.error("File not found: " + name); + return; + } + + if (entry.isDir) { + callbackContext.error("Cannot get content of a directory"); + return; + } + + if (entry.tempFile == null || !entry.tempFile.exists()) { + callbackContext.error("Temporary file not found for entry: " + name); + return; + } + + streamFile(entry.tempFile, callbackContext); + } catch (Exception e) { + callbackContext.error("Failed to get file content: " + e.getMessage()); + } + } + + private void generateZip( + String id, + String prefix, + JSONObject options, + CallbackContext callbackContext + ) { + File zipFile = null; + try { + ZipInstance instance = instances.get(id); + if (instance == null) { + callbackContext.error("Instance not found"); + return; + } + + String compression = options.optString("compression", "STORE"); + int compressionLevel = options.optInt("compressionLevel", 6); + String comment = options.optString("comment", null); + + zipFile = new File( + instance.baseDir, + "output_" + UUID.randomUUID().toString() + ".zip" + ); + + try ( + FileOutputStream fos = new FileOutputStream(zipFile); + ZipOutputStream zos = new ZipOutputStream(fos) + ) { + if ("DEFLATE".equalsIgnoreCase(compression)) { + zos.setMethod(ZipOutputStream.DEFLATED); + zos.setLevel(compressionLevel); + } else { + zos.setMethod(ZipOutputStream.STORED); + } + + if (comment != null && !comment.isEmpty()) { + zos.setComment(comment); + } + + List sortedKeys = new ArrayList<>(instance.entries.keySet()); + Collections.sort(sortedKeys); + + int totalEntries = 0; + for (String key : sortedKeys) { + if (key.startsWith(prefix)) { + totalEntries++; + } + } + + int currentEntryIndex = 0; + for (String key : sortedKeys) { + if (!key.startsWith(prefix)) { + continue; + } + + FileEntry entry = instance.entries.get(key); + if (entry == null) { + continue; + } + + String zipPath = key.substring(prefix.length()); + if (zipPath.isEmpty()) { + continue; + } + + currentEntryIndex++; + + // Send progress update + JSONObject progress = new JSONObject(); + progress.put("progress", true); + progress.put("percent", (currentEntryIndex * 100) / totalEntries); + progress.put("currentFile", zipPath); + PluginResult progressResult = new PluginResult( + PluginResult.Status.OK, + progress + ); + progressResult.setKeepCallback(true); + callbackContext.sendPluginResult(progressResult); + + ZipEntry ze = new ZipEntry(zipPath); + ze.setTime(entry.date); + if (entry.comment != null && !entry.comment.isEmpty()) { + ze.setComment(entry.comment); + } + setUnixPermissions(ze, entry); + + if (entry.isDir) { + if (!zipPath.endsWith("/")) { + ze = new ZipEntry(zipPath + "/"); + setUnixPermissions(ze, entry); + } + if (!"DEFLATE".equalsIgnoreCase(compression)) { + ze.setSize(0); + ze.setCompressedSize(0); + ze.setCrc(0); + } + zos.putNextEntry(ze); + zos.closeEntry(); + } else { + if (entry.tempFile != null && entry.tempFile.exists()) { + long size = entry.tempFile.length(); + if (!"DEFLATE".equalsIgnoreCase(compression)) { + ze.setSize(size); + ze.setCompressedSize(size); + long crc = calculateCRC32(entry.tempFile); + ze.setCrc(crc); + } + + zos.putNextEntry(ze); + + try (FileInputStream fis = new FileInputStream(entry.tempFile)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = fis.read(buffer)) != -1) { + zos.write(buffer, 0, count); + } + } + zos.closeEntry(); + } + } + } + + zos.finish(); + } + + streamFile(zipFile, callbackContext); + } catch (Exception e) { + callbackContext.error("Failed to generate ZIP: " + e.getMessage()); + } finally { + if (zipFile != null && zipFile.exists()) { + zipFile.delete(); + } + } + } + + private static long calculateCRC32(File file) throws IOException { + CRC32 crc = new CRC32(); + try (FileInputStream fis = new FileInputStream(file)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = fis.read(buffer)) != -1) { + crc.update(buffer, 0, count); + } + } + return crc.getValue(); + } + + private static boolean isSafePath(File destDir, File destFile) + throws IOException { + String destDirCanonical = destDir.getCanonicalPath(); + String destFileCanonical = destFile.getCanonicalPath(); + return ( + destFileCanonical.startsWith(destDirCanonical + File.separator) || + destFileCanonical.equals(destDirCanonical) + ); + } + + private void extractToDir( + String id, + String prefix, + String targetDir, + CallbackContext callbackContext + ) { + try { + ZipInstance instance = instances.get(id); + if (instance == null) { + callbackContext.error("Instance not found"); + return; + } + + File destDir = new File(targetDir); + if (!destDir.exists()) { + destDir.mkdirs(); + } + + List sortedKeys = new ArrayList<>(instance.entries.keySet()); + Collections.sort(sortedKeys); + + int totalEntries = 0; + for (String key : sortedKeys) { + if (key.startsWith(prefix)) { + totalEntries++; + } + } + + int currentEntryIndex = 0; + for (String key : sortedKeys) { + if (!key.startsWith(prefix)) { + continue; + } + + FileEntry entry = instance.entries.get(key); + if (entry == null) { + continue; + } + + String relativePath = key.substring(prefix.length()); + if (relativePath.isEmpty()) { + continue; + } + + currentEntryIndex++; + + // Progress report + JSONObject progress = new JSONObject(); + progress.put("progress", true); + progress.put("percent", (currentEntryIndex * 100) / totalEntries); + progress.put("currentFile", relativePath); + PluginResult progressResult = new PluginResult( + PluginResult.Status.OK, + progress + ); + progressResult.setKeepCallback(true); + callbackContext.sendPluginResult(progressResult); + + File destFile = new File(destDir, relativePath); + if (!isSafePath(destDir, destFile)) { + throw new SecurityException( + "Path traversal attempt detected in entry: " + relativePath + ); + } + + if (entry.isDir) { + destFile.mkdirs(); + } else { + File parent = destFile.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + + if (entry.tempFile != null && entry.tempFile.exists()) { + try ( + FileInputStream fis = new FileInputStream(entry.tempFile); + FileOutputStream fos = new FileOutputStream(destFile) + ) { + byte[] buffer = new byte[8192]; + int count; + while ((count = fis.read(buffer)) != -1) { + fos.write(buffer, 0, count); + } + } + } + } + } + + callbackContext.success(); + } catch (Exception e) { + callbackContext.error("Failed to extract: " + e.getMessage()); + } + } + + private void extractZipFileToDir( + String zipFilePath, + String targetDir, + CallbackContext callbackContext + ) { + try { + File zipFile = new File(zipFilePath); + if (!zipFile.exists()) { + callbackContext.error("Source zip file not found: " + zipFilePath); + return; + } + + File destDir = new File(targetDir); + if (!destDir.exists()) { + destDir.mkdirs(); + } + + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(zipFile)) { + int totalEntries = zf.size(); + int currentEntryIndex = 0; + java.util.Enumeration entries = zf.entries(); + + while (entries.hasMoreElements()) { + ZipEntry ze = entries.nextElement(); + String name = ze.getName(); + boolean isDir = ze.isDirectory(); + + currentEntryIndex++; + + // Progress report + JSONObject progress = new JSONObject(); + progress.put("progress", true); + progress.put("percent", (currentEntryIndex * 100) / totalEntries); + progress.put("currentFile", name); + PluginResult progressResult = new PluginResult( + PluginResult.Status.OK, + progress + ); + progressResult.setKeepCallback(true); + callbackContext.sendPluginResult(progressResult); + + File destFile = new File(destDir, name); + if (!isSafePath(destDir, destFile)) { + throw new SecurityException( + "Path traversal attempt detected in entry: " + name + ); + } + + if (isDir) { + destFile.mkdirs(); + } else { + File parent = destFile.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + + try ( + java.io.InputStream is = zf.getInputStream(ze); + FileOutputStream fos = new FileOutputStream(destFile) + ) { + byte[] buffer = new byte[8192]; + int count; + while ((count = is.read(buffer)) != -1) { + fos.write(buffer, 0, count); + } + } + } + } + } + + callbackContext.success(); + } catch (Exception e) { + callbackContext.error("Failed to extract ZIP: " + e.getMessage()); + } + } + + private void destroyInstance(String id, CallbackContext callbackContext) { + try { + ZipInstance instance = instances.remove(id); + if (instance != null) { + instance.destroy(); + } + if (callbackContext != null) { + callbackContext.success(); + } + } catch (Exception e) { + if (callbackContext != null) { + callbackContext.error("Failed to destroy: " + e.getMessage()); + } + } + } +} diff --git a/src/plugins/jszip-java/www/JsZip.js b/src/plugins/jszip-java/www/JsZip.js new file mode 100644 index 000000000..6c4c091e0 --- /dev/null +++ b/src/plugins/jszip-java/www/JsZip.js @@ -0,0 +1,747 @@ +/* + * JsZip-Java - A complete drop-in replacement for JSZip with a Java backend for Cordova. + */ + +var exec = require('cordova/exec'); + +function genUUID() { + return 'zip_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} + +var registry = typeof FinalizationRegistry !== 'undefined' ? new FinalizationRegistry(function(id) { + exec(null, null, "JsZip", "destroy", [id]); +}) : null; + +function arrayBufferToBase64(bufferOrView) { + var bytes; + if (bufferOrView instanceof ArrayBuffer) { + bytes = new Uint8Array(bufferOrView); + } else if (ArrayBuffer.isView(bufferOrView)) { + bytes = new Uint8Array(bufferOrView.buffer, bufferOrView.byteOffset, bufferOrView.byteLength); + } else { + throw new Error("Invalid buffer type"); + } + var binary = ''; + var len = bytes.byteLength; + var chunk = 8192; + for (var i = 0; i < len; i += chunk) { + var slice = bytes.subarray(i, i + chunk); + binary += String.fromCharCode.apply(null, slice); + } + return window.btoa(binary); +} + +function JSZip() { + if (!(this instanceof JSZip)) { + return new JSZip(); + } + this.id = genUUID(); + this.root = this; + this.prefix = ""; + this.files = {}; + this._pending = []; + + // Notify native of instance creation + exec(null, null, "JsZip", "create", [this.id]); + + if (registry) { + registry.register(this, this.id); + } +} + +JSZip.support = { + arraybuffer: true, + uint8array: true, + blob: true, + nodebuffer: false, + base64: true +}; + +JSZip.external = { + Promise: window.Promise +}; + +JSZip.version = "3.10.1"; + +JSZip.defaults = { + compression: "STORE", + compressionOptions: { + level: null + } +}; + +JSZip.prototype.folder = function(name) { + var self = this; + if (name instanceof RegExp) { + var results = []; + var regex = name; + for (var filename in self.files) { + var file = self.files[filename]; + if (file.dir && filename.indexOf(self.prefix) === 0) { + var relative = filename.slice(self.prefix.length); + if (regex.test(relative)) { + results.push(file); + } + } + } + return results; + } + + if (!name.endsWith('/')) { + name += '/'; + } + var fullPath = self.prefix + name; + + if (!self.root.files[fullPath]) { + var folderObj = new JSZipObject(fullPath, true, self.root); + self.root.files[fullPath] = folderObj; + + var promise = new Promise(function(resolve, reject) { + exec(resolve, reject, "JsZip", "addFile", [self.root.id, fullPath, null, { dir: true }]); + }); + self.root._pending.push(promise); + } + + var child = Object.create(JSZip.prototype); + child.id = self.root.id; + child.root = self.root; + child.prefix = fullPath; + child.files = self.root.files; + child._pending = self.root._pending; + return child; +}; + +JSZip.prototype.file = function(name, data, options) { + if (name instanceof RegExp) { + var results = []; + var regex = name; + for (var filename in this.files) { + var file = this.files[filename]; + if (!file.dir && filename.indexOf(this.prefix) === 0) { + var relative = filename.slice(this.prefix.length); + if (regex.test(relative)) { + results.push(file); + } + } + } + return results; + } + + if (data === undefined || data === null) { + var fullPath = this.prefix + name; + if (options && options.dir) { + return this.folder(name); + } + return this.root.files[fullPath] || null; + } + + var fullPath = this.prefix + name; + options = options || {}; + + var fileObj = new JSZipObject(fullPath, false, this.root, options); + fileObj._data = data; + this.root.files[fullPath] = fileObj; + + if (typeof data.then === 'function') { + var self = this; + var promise = data.then(function(resolvedData) { + fileObj._data = resolvedData; + return self._addFileToNativePromise(fullPath, resolvedData, options); + }); + this.root._pending.push(promise); + } else { + this._addFileToNative(fullPath, data, options); + } + + return this; +}; + +JSZip.prototype._ensureParentFolders = function(fullPath) { + var self = this; + var parts = fullPath.split('/'); + var current = ""; + var promise = Promise.resolve(); + + for (var i = 0; i < parts.length - 1; i++) { + current += parts[i] + "/"; + if (!this.root.files[current]) { + (function(folderPath) { + var folderObj = new JSZipObject(folderPath, true, self.root); + self.root.files[folderPath] = folderObj; + promise = promise.then(function() { + return new Promise(function(resolve, reject) { + exec( + resolve, + reject, + "JsZip", + "addFile", + [self.root.id, folderPath, null, { dir: true }] + ); + }); + }); + })(current); + } + } + return promise; +}; + +JSZip.prototype._addFileToNative = function(fullPath, data, options) { + var promise = this._addFileToNativePromise(fullPath, data, options); + this.root._pending.push(promise); + return promise; +}; + +JSZip.prototype._addFileToNativePromise = function(fullPath, data, options) { + var self = this; + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.onload = function(e) { + resolve(e.target.result); + }; + reader.onerror = function(err) { + reject(err); + }; + reader.readAsArrayBuffer(data); + }).then(function(buffer) { + return self._addFileToNativePromise(fullPath, buffer, options); + }); + } + + if (data && typeof data.async === 'function') { + var fileObj = self.root.files[fullPath]; + return data.async("arraybuffer").then(function(buffer) { + if (fileObj) { + fileObj._data = buffer; + } + return self._addFileToNativePromise(fullPath, buffer, options); + }); + } + + var createFolders = options.createFolders !== false; + var parentPromise = Promise.resolve(); + if (createFolders) { + parentPromise = self._ensureParentFolders(fullPath); + } + + var dataType = "string"; + var payload = data; + if (data instanceof ArrayBuffer) { + dataType = "arraybuffer"; + payload = data; + } else if (ArrayBuffer.isView(data)) { + dataType = "arraybuffer"; + payload = data; + } else if (options.base64) { + dataType = "base64"; + } + + return parentPromise.then(function() { + return new Promise(function(resolve, reject) { + exec( + function() { + var fileObj = self.root.files[fullPath]; + if (fileObj) { + delete fileObj._data; + } + resolve(); + }, + function(err) { + reject(err); + }, + "JsZip", + "addFile", + [ + self.root.id, + fullPath, + payload, + { + dataType: dataType, + dir: options.dir || false, + date: options.date ? (options.date instanceof Date ? options.date.getTime() : options.date) : null, + comment: options.comment || "", + unixPermissions: options.unixPermissions || null, + dosPermissions: options.dosPermissions || null, + binary: options.binary || false + } + ] + ); + }); + }); +}; + +JSZip.prototype.remove = function(name) { + var self = this; + var fullPath = self.prefix + name; + var normalizedPath = fullPath.endsWith('/') ? fullPath : fullPath + '/'; + + for (var filename in self.root.files) { + if (filename === fullPath || filename === normalizedPath || filename.indexOf(normalizedPath) === 0) { + delete self.root.files[filename]; + } + } + + var promise = new Promise(function(resolve, reject) { + exec(resolve, reject, "JsZip", "removeFile", [self.root.id, fullPath]); + }); + self.root._pending.push(promise); + return self; +}; + +JSZip.prototype.forEach = function(callback) { + for (var filename in this.files) { + if (filename.indexOf(this.prefix) === 0 && filename !== this.prefix) { + var relativePath = filename.slice(this.prefix.length); + callback(relativePath, this.files[filename]); + } + } +}; + +JSZip.prototype.filter = function(callback) { + var results = []; + this.forEach(function(relativePath, file) { + if (callback(relativePath, file)) { + results.push(file); + } + }); + return results; +}; + +JSZip.prototype._convertOutput = function(arrayBuffer, type, mimeType) { + type = (type || "").toLowerCase(); + + if (type === "arraybuffer") { + return arrayBuffer; + } + + var bytes = new Uint8Array(arrayBuffer); + var len = bytes.byteLength; + + if (type === "uint8array") { + return bytes; + } + + if (type === "blob") { + return new Blob([arrayBuffer], { type: mimeType }); + } + + if (type === "array") { + var arr = new Array(len); + for (var i = 0; i < len; i++) { + arr[i] = bytes[i]; + } + return arr; + } + + // For string formats, we convert to binary string first + var binaryString = ''; + var chunk = 8192; + for (var i = 0; i < len; i += chunk) { + var slice = bytes.subarray(i, i + chunk); + binaryString += String.fromCharCode.apply(null, slice); + } + + if (type === "string" || type === "binarystring") { + return binaryString; + } + + if (type === "base64") { + return window.btoa(binaryString); + } + + if (type === "text") { + if (typeof TextDecoder !== 'undefined') { + return new TextDecoder().decode(arrayBuffer); + } else { + return decodeURIComponent(escape(binaryString)); + } + } + + throw new Error("Type '" + type + "' is not supported or not implemented."); +}; + +JSZip.prototype.generateAsync = function(options, onUpdate) { + options = options || {}; + var type = options.type || "base64"; + var compression = options.compression || "STORE"; + var compressionOptions = options.compressionOptions || {}; + var comment = options.comment || null; + var mimeType = options.mimeType || "application/zip"; + var self = this; + + return Promise.all(this.root._pending).then(function() { + self.root._pending = []; + + return new Promise(function(resolve, reject) { + var chunks = []; + exec( + function(result) { + if (result && result.progress) { + if (typeof onUpdate === 'function') { + onUpdate({ + percent: result.percent, + currentFile: result.currentFile + }); + } + return; + } + if (result && result.done) { + var totalLength = 0; + for (var i = 0; i < chunks.length; i++) { + totalLength += chunks[i].byteLength; + } + var merged = new Uint8Array(totalLength); + var offset = 0; + for (var i = 0; i < chunks.length; i++) { + merged.set(new Uint8Array(chunks[i]), offset); + offset += chunks[i].byteLength; + } + try { + resolve(self._convertOutput(merged.buffer, type, mimeType)); + } catch (e) { + reject(e); + } + return; + } + if (result instanceof ArrayBuffer) { + chunks.push(result); + } else if (ArrayBuffer.isView(result)) { + chunks.push(result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength)); + } + }, + function(err) { + reject(err); + }, + "JsZip", + "generate", + [ + self.root.id, + self.prefix, + { + compression: compression, + compressionLevel: compressionOptions.level || 6, + comment: comment + } + ] + ); + }); + }); +}; + +JSZip.prototype.loadAsync = function(data, options) { + options = options || {}; + var self = this; + + return Promise.resolve() + .then(function() { + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.onload = function(e) { + resolve({ + payload: e.target.result, + dataType: "arraybuffer" + }); + }; + reader.onerror = function(err) { + reject(err); + }; + reader.readAsArrayBuffer(data); + }); + } else if (data instanceof ArrayBuffer) { + return { + payload: data, + dataType: "arraybuffer" + }; + } else if (ArrayBuffer.isView(data)) { + return { + payload: data, + dataType: "arraybuffer" + }; + } else if (typeof data === 'string') { + if (options.base64) { + return { + payload: data, + dataType: "base64" + }; + } else { + return { + payload: data, + dataType: "string" + }; + } + } else { + throw new Error("Unsupported data type for loadAsync"); + } + }) + .then(function(normalized) { + return new Promise(function(resolve, reject) { + exec( + function(result) { + var filesList = (result && result.files) ? result.files : result; + filesList.forEach(function(meta) { + var fileObj = new JSZipObject( + meta.name, + meta.dir, + self.root, + { + date: meta.date ? new Date(meta.date) : null, + comment: meta.comment || "", + unixPermissions: meta.unixPermissions, + dosPermissions: meta.dosPermissions + } + ); + self.root.files[meta.name] = fileObj; + }); + resolve(self); + }, + function(err) { + reject(err); + }, + "JsZip", + "load", + [ + self.root.id, + normalized.payload, + { + dataType: normalized.dataType, + checkCRC32: options.checkCRC32 || false, + createFolders: options.createFolders || false + } + ] + ); + }); + }); +}; + +JSZip.loadAsync = function(data, options) { + return new JSZip().loadAsync(data, options); +}; + +JSZip.prototype.extractToDir = function(targetDir, onUpdate) { + var self = this; + return Promise.all(this.root._pending).then(function() { + self.root._pending = []; + + return new Promise(function(resolve, reject) { + exec( + function(result) { + if (result && result.progress) { + if (typeof onUpdate === 'function') { + onUpdate({ + percent: result.percent, + currentFile: result.currentFile + }); + } + return; + } + resolve(); + }, + function(err) { + reject(err); + }, + "JsZip", + "extractToDir", + [ + self.root.id, + self.prefix, + targetDir + ] + ); + }); + }); +}; + +JSZip.extractToDir = function(zipFilePath, targetDir, onUpdate) { + return new Promise(function(resolve, reject) { + exec( + function(result) { + if (result && result.progress) { + if (typeof onUpdate === 'function') { + onUpdate({ + percent: result.percent, + currentFile: result.currentFile + }); + } + return; + } + resolve(); + }, + function(err) { + reject(err); + }, + "JsZip", + "extractZipFileToDir", + [ + zipFilePath, + targetDir + ] + ); + }); +}; + +JSZip.prototype.destroy = function() { + exec(null, null, "JsZip", "destroy", [this.root.id]); +}; + +JSZip.prototype.load = function() { + throw new Error("This method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +JSZip.prototype.generate = function() { + throw new Error("This method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +JSZip.prototype.generateNodeStream = function() { + throw new Error("generateNodeStream is not supported by this platform"); +}; + +JSZip.prototype.generateInternalStream = function() { + throw new Error("generateInternalStream is not supported by this platform"); +}; + + +function JSZipObject(name, dir, root, options) { + this.name = name; + this.dir = dir || false; + this.root = root; + + options = options || {}; + this.date = options.date || new Date(); + this.comment = options.comment || ""; + this.unixPermissions = options.unixPermissions || null; + this.dosPermissions = options.dosPermissions || null; + this.options = options; +} + +JSZipObject.prototype.async = function(type, onUpdate) { + var self = this; + + function resolveLocal(data) { + if (typeof Blob !== 'undefined' && data instanceof Blob) { + if (type.toLowerCase() === "blob") { + return Promise.resolve(data); + } + return new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.onload = function(e) { + try { + resolve(self.root._convertOutput(e.target.result, type, data.type)); + } catch (e) { + reject(e); + } + }; + reader.onerror = function(err) { + reject(err); + }; + reader.readAsArrayBuffer(data); + }); + } + + var buffer; + if (data instanceof ArrayBuffer) { + buffer = data; + } else if (ArrayBuffer.isView(data)) { + buffer = data.buffer; + } else if (typeof data === 'string') { + var bytes = new Uint8Array(data.length); + for (var i = 0; i < data.length; i++) { + bytes[i] = data.charCodeAt(i); + } + buffer = bytes.buffer; + } + + try { + return Promise.resolve(self.root._convertOutput(buffer || data, type, "application/octet-stream")); + } catch (e) { + return Promise.reject(e); + } + } + + if (this._data !== undefined && this._data !== null) { + if (typeof this._data.then === 'function') { + return this._data.then(resolveLocal); + } + return resolveLocal(this._data); + } + + var chunks = []; + return new Promise(function(resolve, reject) { + exec( + function(result) { + if (result && result.progress) { + if (typeof onUpdate === 'function') { + onUpdate({ + percent: result.percent, + currentFile: self.name + }); + } + return; + } + if (result && result.done) { + var totalLength = 0; + for (var i = 0; i < chunks.length; i++) { + totalLength += chunks[i].byteLength; + } + var merged = new Uint8Array(totalLength); + var offset = 0; + for (var i = 0; i < chunks.length; i++) { + merged.set(new Uint8Array(chunks[i]), offset); + offset += chunks[i].byteLength; + } + try { + resolve(self.root._convertOutput(merged.buffer, type, "application/octet-stream")); + } catch (e) { + reject(e); + } + return; + } + if (result instanceof ArrayBuffer) { + chunks.push(result); + } else if (ArrayBuffer.isView(result)) { + chunks.push(result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength)); + } + }, + function(err) { + reject(err); + }, + "JsZip", + "getFileContent", + [self.root.id, self.name, type] + ); + }); +}; + +JSZipObject.prototype.nodeStream = function() { + throw new Error("nodeStream is not supported by this platform"); +}; + +JSZipObject.prototype.internalStream = function() { + throw new Error("internalStream is not supported by this platform"); +}; + +JSZipObject.prototype.asText = function() { + throw new Error("this method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +JSZipObject.prototype.asBinary = function() { + throw new Error("this method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +JSZipObject.prototype.asNodeBuffer = function() { + throw new Error("this method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +JSZipObject.prototype.asUint8Array = function() { + throw new Error("this method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +JSZipObject.prototype.asArrayBuffer = function() { + throw new Error("this method has been removed in JSZip 3.0, please check the upgrade guide."); +}; + +if (typeof window !== 'undefined') { + window.JSZip = JSZip; +} + +module.exports = JSZip; diff --git a/src/test/jszip.tests.js b/src/test/jszip.tests.js new file mode 100644 index 000000000..734394ac7 --- /dev/null +++ b/src/test/jszip.tests.js @@ -0,0 +1,330 @@ +import JSZip from "jszip"; +import { TestRunner } from "./tester"; + +export async function runJsZipTests(writeOutput) { + const runner = new TestRunner("JSZip-Java Compatibility Tests"); + + const helperCompareBuffers = (buf1, buf2) => { + const arr1 = new Uint8Array(buf1); + const arr2 = new Uint8Array(buf2); + if (arr1.length !== arr2.length) return false; + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return false; + } + return true; + }; + + // Test 1: Constructor and instance check + runner.test("Instance Creation", (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + test.assert(stdZip instanceof JSZip, "stdZip should be instance of JSZip"); + test.assert( + jvZip instanceof window.JSZip, + "jvZip should be instance of window.JSZip", + ); + test.assertEqual( + typeof jvZip.file, + "function", + "jvZip.file should be a function", + ); + test.assertEqual( + typeof jvZip.folder, + "function", + "jvZip.folder should be a function", + ); + }); + + // Test 2: API Function Presence & Equivalence + runner.test("API Function Presence", (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + // Static methods on JSZip constructor + const stdStaticKeys = Object.keys(JSZip); + for (const key of stdStaticKeys) { + if (typeof JSZip[key] === "function") { + test.assertEqual( + typeof window.JSZip[key], + "function", + `Static function '${key}' must be present on window.JSZip`, + ); + } + } + + // Instance methods / prototype methods + const stdProto = Object.getPrototypeOf(stdZip); + const stdProtoKeys = Object.getOwnPropertyNames(stdProto); + + for (const key of stdProtoKeys) { + if (key === "constructor") continue; + // Filter internal/private methods (methods starting with _) + if (key.startsWith("_")) continue; + if (typeof stdProto[key] === "function") { + test.assertEqual( + typeof Object.getPrototypeOf(jvZip)[key], + "function", + `Instance prototype function '${key}' must be present on window.JSZip`, + ); + } + } + + // JSZipObject prototype methods + const stdFile = stdZip.file("dummy.txt", "content").file("dummy.txt"); + const jvFile = jvZip.file("dummy.txt", "content").file("dummy.txt"); + + const stdFileProto = Object.getPrototypeOf(stdFile); + const stdFileProtoKeys = Object.getOwnPropertyNames(stdFileProto); + + for (const key of stdFileProtoKeys) { + if (key === "constructor") continue; + if (key.startsWith("_")) continue; + if (typeof stdFileProto[key] === "function") { + test.assertEqual( + typeof Object.getPrototypeOf(jvFile)[key], + "function", + `JSZipObject prototype function '${key}' must be present on JSZipObject`, + ); + } + } + }); + + // Test 3: Adding and reading text file content + runner.test("File Add & Async Read (Text)", async (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + const content = "Hello JSZip World!"; + stdZip.file("test.txt", content); + jvZip.file("test.txt", content); + + const stdRes = await stdZip.file("test.txt").async("text"); + const jvRes = await jvZip.file("test.txt").async("text"); + + test.assertEqual( + jvRes, + stdRes, + "Returned text content must match standard JSZip", + ); + test.assertEqual( + jvRes, + content, + "Returned content must match original content", + ); + }); + + // Test 4: Adding and reading binary/arraybuffer file content + runner.test("File Add & Async Read (Binary ArrayBuffer)", async (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + const bytes = new Uint8Array([72, 101, 108, 108, 111, 0, 1, 2, 3]); + const buffer = bytes.buffer; + + stdZip.file("bin.dat", buffer); + jvZip.file("bin.dat", buffer); + + const stdBuf = await stdZip.file("bin.dat").async("arraybuffer"); + const jvBuf = await jvZip.file("bin.dat").async("arraybuffer"); + + test.assert( + helperCompareBuffers(jvBuf, stdBuf), + "Returned ArrayBuffer should match standard JSZip", + ); + + const stdArr = await stdZip.file("bin.dat").async("uint8array"); + const jvArr = await jvZip.file("bin.dat").async("uint8array"); + + test.assertEqual( + jvArr.length, + stdArr.length, + "Uint8Array lengths must match", + ); + test.assertEqual(jvArr[5], 0, "Binary zero must be preserved"); + test.assertEqual(jvArr[8], 3, "Binary values must match"); + }); + + // Test 5: folder prefixing and nested hierarchy + runner.test("Folder Navigation & Structure", async (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + const stdSub = stdZip.folder("nested").folder("deep"); + const jvSub = jvZip.folder("nested").folder("deep"); + + stdSub.file("hello.txt", "inside nested"); + jvSub.file("hello.txt", "inside nested"); + + // Access via root + const stdRes = await stdZip.file("nested/deep/hello.txt").async("text"); + const jvRes = await jvZip.file("nested/deep/hello.txt").async("text"); + + test.assertEqual( + jvRes, + stdRes, + "Nested file content should match via root access", + ); + + // Access via child instance + const stdResChild = await stdSub.file("hello.txt").async("text"); + const jvResChild = await jvSub.file("hello.txt").async("text"); + + test.assertEqual( + jvResChild, + stdResChild, + "Nested file content should match via child access", + ); + }); + + // Test 6: Files directory listing and iteration comparison + runner.test("Directory listing & forEach comparison", (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + const setup = (zip) => { + zip.file("a.txt", "1"); + zip.file("b/c.txt", "2"); + zip.folder("b/d"); + }; + + setup(stdZip); + setup(jvZip); + + const stdList = []; + stdZip.forEach((rel, obj) => { + stdList.push({ path: rel, isDir: obj.dir }); + }); + + const jvList = []; + jvZip.forEach((rel, obj) => { + jvList.push({ path: rel, isDir: obj.dir }); + }); + + test.assertEqual( + jvList.length, + stdList.length, + "forEach count must match standard JSZip", + ); + + stdList.sort((a, b) => a.path.localeCompare(b.path)); + jvList.sort((a, b) => a.path.localeCompare(b.path)); + + for (let i = 0; i < stdList.length; i++) { + test.assertEqual( + jvList[i].path, + stdList[i].path, + `Entry ${i} path must match`, + ); + test.assertEqual( + jvList[i].isDir, + stdList[i].isDir, + `Entry ${i} isDir must match`, + ); + } + }); + + // Test 7: Removing files and directories + runner.test("File & Folder Removal", async (test) => { + const stdZip = new JSZip(); + const jvZip = new window.JSZip(); + + stdZip.file("test1.txt", "data"); + stdZip.file("folder/test2.txt", "data"); + stdZip.remove("test1.txt"); + stdZip.remove("folder"); + + jvZip.file("test1.txt", "data"); + jvZip.file("folder/test2.txt", "data"); + jvZip.remove("test1.txt"); + jvZip.remove("folder"); + + test.assertEqual( + jvZip.file("test1.txt"), + null, + "test1.txt must be null after removal", + ); + test.assertEqual( + jvZip.file("folder/test2.txt"), + null, + "folder/test2.txt must be null after folder removal", + ); + + const stdKeys = Object.keys(stdZip.files); + const jvKeys = Object.keys(jvZip.files); + test.assertEqual( + jvKeys.length, + stdKeys.length, + "Remaining file keys count must match standard JSZip", + ); + }); + + // Test 8: Output Type conversion compatibility (Base64, BinaryString) + runner.test("Type Conversions (base64, binarystring)", async (test) => { + const jvZip = new window.JSZip(); + const content = "Testing type conversions \x00\x01\x02"; + jvZip.file("data.txt", content, { binary: true }); + + const b64 = await jvZip.file("data.txt").async("base64"); + const binStr = await jvZip.file("data.txt").async("binarystring"); + const text = await jvZip.file("data.txt").async("text"); + + test.assertEqual( + b64, + window.btoa(content), + "Base64 conversion should match window.btoa", + ); + test.assertEqual( + binStr, + content, + "BinaryString must match original raw string", + ); + test.assert( + text.startsWith("Testing type conversions"), + "Text must resolve correctly", + ); + }); + + // Test 9: ZIP Compilation and Cross-Load compatibility + runner.test("ZIP Generation & Cross-Loading", async (test) => { + const stdZip = new JSZip(); + stdZip.file("info.json", JSON.stringify({ version: "1.0.0" })); + stdZip.file("images/logo.png", new Uint8Array([1, 2, 3, 4]).buffer); + + // 1. Generate using standard, load in jszip-java + const stdZipBuffer = await stdZip.generateAsync({ type: "arraybuffer" }); + + const jvZip = new window.JSZip(); + await jvZip.loadAsync(stdZipBuffer); + + const jvJson = await jvZip.file("info.json").async("text"); + const jvPng = await jvZip.file("images/logo.png").async("uint8array"); + + test.assertEqual( + JSON.parse(jvJson).version, + "1.0.0", + "Loaded JSON config should match", + ); + test.assertEqual(jvPng.length, 4, "Loaded nested binary length must match"); + test.assertEqual(jvPng[3], 4, "Loaded binary byte must match"); + + // 2. Generate using jszip-java, load in standard + const jvZipBuffer = await jvZip.generateAsync({ + type: "arraybuffer", + compression: "DEFLATE", + }); + + const crossStdZip = new JSZip(); + await crossStdZip.loadAsync(jvZipBuffer); + + const crossJson = await crossStdZip.file("info.json").async("text"); + test.assertEqual( + JSON.parse(crossJson).version, + "1.0.0", + "Cross-loaded standard JSZip must read jszip-java output successfully", + ); + }); + + // Run tests + return await runner.run(writeOutput); +} diff --git a/src/test/test-definitions.js b/src/test/test-definitions.js index f29c7cead..dea571513 100644 --- a/src/test/test-definitions.js +++ b/src/test/test-definitions.js @@ -2,6 +2,7 @@ import { runAceCompatibilityTests } from "./ace.test"; import { runCodeMirrorTests } from "./editor.tests"; import { runExecutorTests } from "./exec.tests"; import { runFsTests } from "./fs.tests"; +import { runJsZipTests } from "./jszip.tests"; import { runSanityTests } from "./sanity.tests"; import { runUrlTests } from "./url.tests"; @@ -14,6 +15,7 @@ export const testDefinitions = [ runExecutorTests, runUrlTests, runFsTests, + runJsZipTests, runCodeMirrorTests, runAceCompatibilityTests, ];