diff --git a/android/build.gradle b/android/build.gradle index 7a689d1..12f123a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -57,4 +57,5 @@ android { dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + implementation 'net.lingala.zip4j:zip4j:2.11.5' } diff --git a/android/src/main/kotlin/com/kineapps/flutterarchive/FlutterArchivePlugin.kt b/android/src/main/kotlin/com/kineapps/flutterarchive/FlutterArchivePlugin.kt index 9700892..4d2d176 100644 --- a/android/src/main/kotlin/com/kineapps/flutterarchive/FlutterArchivePlugin.kt +++ b/android/src/main/kotlin/com/kineapps/flutterarchive/FlutterArchivePlugin.kt @@ -30,6 +30,9 @@ import java.util.zip.ZipEntry import java.util.zip.ZipEntry.DEFLATED import java.util.zip.ZipFile import java.util.zip.ZipOutputStream +import net.lingala.zip4j.ZipFile as Zip4jFile +import net.lingala.zip4j.model.ZipParameters +import net.lingala.zip4j.model.enums.EncryptionMethod enum class ZipFileOperation { INCLUDE_ITEM, SKIP_ITEM, CANCEL } @@ -101,6 +104,7 @@ class FlutterArchivePlugin : FlutterPlugin, MethodCallHandler { call.argument("includeBaseDirectory") == true val reportProgress = call.argument("reportProgress") val jobId = call.argument("jobId") + val password = call.argument("password") withContext(Dispatchers.IO) { zip( @@ -109,7 +113,8 @@ class FlutterArchivePlugin : FlutterPlugin, MethodCallHandler { recurseSubDirs = recurseSubDirs, includeBaseDirectory = includeBaseDirectory, reportProgress = reportProgress == true, - jobId = jobId!! + jobId = jobId!!, + password = password ) } result.success(true) @@ -127,9 +132,10 @@ class FlutterArchivePlugin : FlutterPlugin, MethodCallHandler { val zipFile = call.argument("zipFile") val includeBaseDirectory = call.argument("includeBaseDirectory") == true + val password = call.argument("password") withContext(Dispatchers.IO) { - zipFiles(sourceDir!!, files!!, zipFile!!, includeBaseDirectory) + zipFiles(sourceDir!!, files!!, zipFile!!, includeBaseDirectory, password) } result.success(true) } catch (e: Exception) { @@ -178,26 +184,29 @@ class FlutterArchivePlugin : FlutterPlugin, MethodCallHandler { recurseSubDirs: Boolean, includeBaseDirectory: Boolean, reportProgress: Boolean, - jobId: Int + jobId: Int, + password: String? ) { Log.i( "zip", - "sourceDirPath: $sourceDirPath, zipFilePath: $zipFilePath, recurseSubDirs: $recurseSubDirs, includeBaseDirectory: $includeBaseDirectory" + "sourceDirPath: $sourceDirPath, zipFilePath: $zipFilePath, recurseSubDirs: $recurseSubDirs, includeBaseDirectory: $includeBaseDirectory, password: ${if (password != null) "***" else "null"}" ) val rootDirectory = if (includeBaseDirectory) File(sourceDirPath).parentFile else File(sourceDirPath) - val totalFileCount = if (reportProgress) getFilesCount(rootDirectory, recurseSubDirs) else 0 - - withContext(Dispatchers.IO) { - ZipOutputStream( - BufferedOutputStream( - FileOutputStream(zipFilePath) - ) - ).use { zipOutputStream -> - addFilesInDirectoryToZip( - zipOutputStream = zipOutputStream, + if (password != null) { + // Use Zip4j for password-protected zip + val totalFileCount = if (reportProgress) getFilesCount(rootDirectory, recurseSubDirs) else 0 + withContext(Dispatchers.IO) { + val zip4jFile = Zip4jFile(zipFilePath, password.toCharArray()) + val zipParameters = ZipParameters() + zipParameters.isEncryptFiles = true + zipParameters.encryptionMethod = EncryptionMethod.ZIP_STANDARD + + addFilesInDirectoryToZip4j( + zip4jFile = zip4jFile, + zipParameters = zipParameters, rootDirectory = rootDirectory, directoryPath = sourceDirPath, recurseSubDirs = recurseSubDirs, @@ -207,6 +216,28 @@ class FlutterArchivePlugin : FlutterPlugin, MethodCallHandler { totalHandledFilesCount = 0 ) } + } else { + // Use existing ZipOutputStream implementation for backward compatibility + val totalFileCount = if (reportProgress) getFilesCount(rootDirectory, recurseSubDirs) else 0 + + withContext(Dispatchers.IO) { + ZipOutputStream( + BufferedOutputStream( + FileOutputStream(zipFilePath) + ) + ).use { zipOutputStream -> + addFilesInDirectoryToZip( + zipOutputStream = zipOutputStream, + rootDirectory = rootDirectory, + directoryPath = sourceDirPath, + recurseSubDirs = recurseSubDirs, + reportProgress = reportProgress, + jobId = jobId, + totalFilesCount = totalFileCount, + totalHandledFilesCount = 0 + ) + } + } } } @@ -317,39 +348,157 @@ class FlutterArchivePlugin : FlutterPlugin, MethodCallHandler { return handledFilesCount } + /** + * Add all files in [rootDirectory] to [zip4jFile] using Zip4j with password protection. + * + * @return Updated total number of handled files + */ + private suspend fun addFilesInDirectoryToZip4j( + zip4jFile: Zip4jFile, + zipParameters: ZipParameters, + rootDirectory: File, + directoryPath: String, + recurseSubDirs: Boolean, + reportProgress: Boolean, + jobId: Int, + totalFilesCount: Int, + totalHandledFilesCount: Int + ): Int { + val directory = File(directoryPath) + + val files = directory.listFiles() ?: arrayOf() + var handledFilesCount = totalHandledFilesCount + for (f in files) { + val path = directoryPath + File.separator + f.name + val relativePath = File(path).relativeTo(rootDirectory).path + + if (f.isDirectory) { + // include subdirectories only if requested + if (!recurseSubDirs) { + continue + } + Log.i("zip", "Adding directory: $relativePath") + + if (reportProgress) { + // report progress + val progress: Double = + handledFilesCount.toDouble() / totalFilesCount.toDouble() * 100.0 + + Log.d(LOG_TAG, "Waiting reportProgress...") + val entry = ZipEntry(relativePath + File.separator) + entry.time = f.lastModified() + val zipFileOperation = reportProgress(jobId, entry, progress) + Log.d(LOG_TAG, "...reportProgress: $zipFileOperation") + + if (zipFileOperation == ZipFileOperation.SKIP_ITEM) { + continue + } else if (zipFileOperation == ZipFileOperation.CANCEL) { + throw CancellationException("Operation cancelled") + } + } + + // zip files and subdirectories in this directory + handledFilesCount = addFilesInDirectoryToZip4j( + zip4jFile = zip4jFile, + zipParameters = zipParameters, + rootDirectory = rootDirectory, + directoryPath = path, + recurseSubDirs = true, + reportProgress = reportProgress, + jobId = jobId, + totalFilesCount = totalFilesCount, + totalHandledFilesCount = handledFilesCount + ) + } else { + Log.i("zip", "Adding file: $relativePath") + ++handledFilesCount + withContext(Dispatchers.IO) { + if (reportProgress) { + // report progress + val progress: Double = + handledFilesCount.toDouble() / totalFilesCount.toDouble() * 100.0 + + Log.d(LOG_TAG, "Waiting reportProgress...") + val entry = ZipEntry(relativePath) + entry.time = f.lastModified() + entry.size = f.length() + val zipFileOperation = reportProgress(jobId, entry, progress) + Log.d(LOG_TAG, "...reportProgress: $zipFileOperation") + + when (zipFileOperation) { + ZipFileOperation.INCLUDE_ITEM -> { + zipParameters.fileNameInZip = relativePath + zip4jFile.addFile(f, zipParameters) + } + ZipFileOperation.CANCEL -> { + throw CancellationException("Operation cancelled") + } + else -> { + // skip this entry + } + } + } else { + zipParameters.fileNameInZip = relativePath + zip4jFile.addFile(f, zipParameters) + } + } + } + } + return handledFilesCount + } + @Throws(IOException::class) private fun zipFiles( sourceDirPath: String, relativeFilePaths: List, zipFilePath: String, - includeBaseDirectory: Boolean + includeBaseDirectory: Boolean, + password: String? ) { Log.i( "zip", "sourceDirPath: $sourceDirPath, " + "zipFilePath: $zipFilePath, " + - "includeBaseDirectory: $includeBaseDirectory" + "includeBaseDirectory: $includeBaseDirectory, " + + "password: ${if (password != null) "***" else "null"}" ) Log.i("zip", "Files: ${relativeFilePaths.joinToString(",")}") val rootDirectory = if (includeBaseDirectory) File(sourceDirPath).parentFile else File(sourceDirPath) - ZipOutputStream( - BufferedOutputStream( - FileOutputStream(zipFilePath) - ) - ).use { zipOutputStream -> + if (password != null) { + // Use Zip4j for password-protected zip + val zip4jFile = Zip4jFile(zipFilePath, password.toCharArray()) + val zipParameters = ZipParameters() + zipParameters.isEncryptFiles = true + zipParameters.encryptionMethod = EncryptionMethod.ZIP_STANDARD + for (relativeFilePath in relativeFilePaths) { val file = rootDirectory.resolve(relativeFilePath) val cleanedRelativeFilePath = file.relativeTo(rootDirectory).path Log.i("zip", "Adding file: $cleanedRelativeFilePath") - FileInputStream(file).use { fileInputStream -> - val entry = ZipEntry(cleanedRelativeFilePath) - entry.time = file.lastModified() - entry.size = file.length() - zipOutputStream.putNextEntry(entry) - fileInputStream.copyTo(zipOutputStream) + zipParameters.fileNameInZip = cleanedRelativeFilePath + zip4jFile.addFile(file, zipParameters) + } + } else { + // Use existing ZipOutputStream implementation for backward compatibility + ZipOutputStream( + BufferedOutputStream( + FileOutputStream(zipFilePath) + ) + ).use { zipOutputStream -> + for (relativeFilePath in relativeFilePaths) { + val file = rootDirectory.resolve(relativeFilePath) + val cleanedRelativeFilePath = file.relativeTo(rootDirectory).path + Log.i("zip", "Adding file: $cleanedRelativeFilePath") + FileInputStream(file).use { fileInputStream -> + val entry = ZipEntry(cleanedRelativeFilePath) + entry.time = file.lastModified() + entry.size = file.length() + zipOutputStream.putNextEntry(entry) + fileInputStream.copyTo(zipOutputStream) + } } } } diff --git a/ios/Classes/SwiftFlutterArchivePlugin.swift b/ios/Classes/SwiftFlutterArchivePlugin.swift index 0084688..107f916 100644 --- a/ios/Classes/SwiftFlutterArchivePlugin.swift +++ b/ios/Classes/SwiftFlutterArchivePlugin.swift @@ -11,6 +11,7 @@ import Flutter /// https://github.com/weichsel/ZIPFoundation import ZIPFoundation +import SSZipArchive enum ZipFileOperation: String { case includeItem @@ -63,6 +64,7 @@ public class SwiftFlutterArchivePlugin: NSObject, FlutterPlugin { let recurseSubDirs = args["recurseSubDirs"] as? Bool == true let reportProgress = args["reportProgress"] as? Bool == true let jobId = args["jobId"] as? Int + let password = args["password"] as? String log("sourceDir: " + sourceDir) log("zipFile: " + zipFile) @@ -70,19 +72,21 @@ public class SwiftFlutterArchivePlugin: NSObject, FlutterPlugin { log("recurseSubDirs: " + recurseSubDirs.description) log("reportProgress: " + reportProgress.description) log("jobId: " + (jobId?.description ?? "")) + log("password: " + (password != nil ? "***" : "null")) DispatchQueue.global(qos: .userInitiated).async { let fileManager = FileManager() let sourceURL = URL(fileURLWithPath: sourceDir) let destinationURL = URL(fileURLWithPath: zipFile) do { - if reportProgress || !recurseSubDirs { + if reportProgress || !recurseSubDirs || password != nil { try self.zipDirectory(at: sourceURL, to: destinationURL, recurseSubDirs: recurseSubDirs, includeBaseDirectory: includeBaseDirectory, reportProgress: reportProgress, - jobId: jobId) + jobId: jobId, + password: password) } else { try fileManager.zipItem(at: sourceURL, to: destinationURL, @@ -129,10 +133,12 @@ public class SwiftFlutterArchivePlugin: NSObject, FlutterPlugin { return } let includeBaseDirectory = args["includeBaseDirectory"] as? Bool == true + let password = args["password"] as? String log("files: " + files.joined(separator: ",")) log("zipFile: " + zipFile) log("includeBaseDirectory: " + includeBaseDirectory.description) + log("password: " + (password != nil ? "***" : "null")) DispatchQueue.global(qos: .userInitiated).async { var sourceURL = URL(fileURLWithPath: sourceDir) @@ -141,12 +147,29 @@ public class SwiftFlutterArchivePlugin: NSObject, FlutterPlugin { } let destinationURL = URL(fileURLWithPath: zipFile) do { - // create zip archive - let archive = try Archive(url: destinationURL, accessMode: .create) + // Use SSZipArchive for password-protected zips + if let password = password { + // Build full file paths + let filePaths = files.map { (relativePath: String) -> String in + let fileURL = sourceURL.appendingPathComponent(relativePath) + return fileURL.path + } + + // Call SSZipArchive directly + let success = SSZipArchive.createZipFile(atPath: destinationURL.path, + withFilesAtPaths: filePaths, + withPassword: password) + if !success { + throw NSError(domain: "ZIP_ERROR", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create password-protected zip file"]) + } + } else { + // Use ZIPFoundation for non-password-protected zips + let archive = try Archive(url: destinationURL, accessMode: .create) - for item in files { - self.log("Adding: " + item) - try archive.addEntry(with: item, relativeTo: sourceURL, compressionMethod: .deflate) + for item in files { + self.log("Adding: " + item) + try archive.addEntry(with: item, relativeTo: sourceURL, compressionMethod: .deflate) + } } DispatchQueue.main.async { @@ -240,8 +263,29 @@ public class SwiftFlutterArchivePlugin: NSObject, FlutterPlugin { recurseSubDirs: Bool, includeBaseDirectory: Bool, reportProgress: Bool, - jobId: Int?) throws + jobId: Int?, + password: String?) throws { + // Use SSZipArchive for password-protected zips + if let password = password { + let sourcePath = sourceURL.path + let zipPath = zipFileURL.path + + // Call SSZipArchive directly + let success = SSZipArchive.createZipFile(atPath: zipPath, + withContentsOfDirectory: sourcePath, + keepParentDirectory: includeBaseDirectory, + compressionLevel: -1, + password: password, + aes: true) + + if !success { + throw NSError(domain: "ZIP_ERROR", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create password-protected zip file"]) + } + return + } + + // Use ZIPFoundation for non-password-protected zips var files = [URL]() if let enumerator = FileManager.default.enumerator( at: sourceURL, diff --git a/ios/flutter_archive.podspec b/ios/flutter_archive.podspec index 40ba075..5bc69a1 100644 --- a/ios/flutter_archive.podspec +++ b/ios/flutter_archive.podspec @@ -17,11 +17,18 @@ A new flutter plugin project. s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' s.dependency 'ZIPFoundation', '0.9.19' + s.dependency 'SSZipArchive', '~> 2.4' s.platform = :ios, '12.0' s.ios.deployment_target = '12.0' # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' + } + + # Ensure Objective-C files are compiled + s.requires_arc = true s.swift_version = '5.0' end diff --git a/lib/flutter_archive.dart b/lib/flutter_archive.dart index 2f57451..53e9978 100644 --- a/lib/flutter_archive.dart +++ b/lib/flutter_archive.dart @@ -55,6 +55,9 @@ class ZipFile { /// By default zip all subdirectories recursively. Set [recurseSubDirs] /// to false to disable recursive zipping. /// + /// Optional [password] parameter can be provided to create a password-protected + /// zip file. If null, the zip file will be created without password protection. + /// /// Optional callback function [onZipping] is called before zipping a file /// or a directory. [onZipping] must return one of the following values: /// [ZipFileOperation.includeItem] - include this file/directory in zip @@ -65,6 +68,7 @@ class ZipFile { required File zipFile, bool includeBaseDirectory = false, bool recurseSubDirs = true, + String? password, OnZipping? onZipping}) async { final reportProgress = onZipping != null; if (reportProgress) { @@ -86,6 +90,7 @@ class ZipFile { 'includeBaseDirectory': includeBaseDirectory, 'reportProgress': reportProgress, 'jobId': jobId, + 'password': password, }); } finally { _onZippingHandlerByJobId.remove(jobId); @@ -99,11 +104,15 @@ class ZipFile { /// Set [includeBaseDirectory] to true to include the directory name from /// [sourceDir] at the root of the archive. Set [includeBaseDirectory] to /// false to include only the contents of the [sourceDir]. + /// + /// Optional [password] parameter can be provided to create a password-protected + /// zip file. If null, the zip file will be created without password protection. static Future createFromFiles({ required Directory sourceDir, required List files, required File zipFile, bool includeBaseDirectory = false, + String? password, }) async { var sourceDirPath = includeBaseDirectory ? sourceDir.parent.path : sourceDir.path; @@ -126,7 +135,8 @@ class ZipFile { 'sourceDir': sourceDir.path, 'files': relativeFilePaths, 'zipFile': zipFile.path, - 'includeBaseDirectory': includeBaseDirectory + 'includeBaseDirectory': includeBaseDirectory, + 'password': password, }); } diff --git a/macos/flutter_archive.podspec b/macos/flutter_archive.podspec index 51e16ed..868d671 100644 --- a/macos/flutter_archive.podspec +++ b/macos/flutter_archive.podspec @@ -14,10 +14,15 @@ A new flutter plugin project. s.author = { 'Your Company' => 'email@example.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' s.dependency 'FlutterMacOS' s.dependency 'ZIPFoundation', '0.9.19' + s.dependency 'SSZipArchive', '~> 2.4' s.platform = :osx, '10.11' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES' + } + s.requires_arc = true s.swift_version = '5.0' end