diff --git a/app/build.gradle b/app/build.gradle index 97184da..9b7be21 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,7 +19,7 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' debuggable = false applicationIdSuffix '.release' @@ -74,8 +74,11 @@ dependencies { implementation 'androidx.navigation:navigation-ui-ktx:2.7.0' //apache commons compress - implementation 'org.apache.commons:commons-compress:1.24.0' - implementation 'org.tukaani:xz:1.9' + implementation 'org.apache.commons:commons-compress:1.27.1' + implementation 'org.tukaani:xz:1.10' + + //zstd-jni + implementation "com.github.luben:zstd-jni:1.5.6-3" //zip4j implementation 'net.lingala.zip4j:zip4j:2.11.5' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 25d23ca..a219b4a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -112,6 +112,11 @@ android:foregroundServiceType="dataSync" android:exported="false" /> + + diff --git a/app/src/main/java/com/wirelessalien/zipxtract/CreateZipFragment.kt b/app/src/main/java/com/wirelessalien/zipxtract/CreateZipFragment.kt index d0acd62..8494a9b 100644 --- a/app/src/main/java/com/wirelessalien/zipxtract/CreateZipFragment.kt +++ b/app/src/main/java/com/wirelessalien/zipxtract/CreateZipFragment.kt @@ -1 +1 @@ -/* * Copyright (C) 2023 WirelessAlien * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.wirelessalien.zipxtract import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.DocumentsContract import android.provider.OpenableColumns import android.system.ErrnoException import android.system.OsConstants import android.text.InputType import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.CheckBox import android.widget.EditText import android.widget.ImageButton import android.widget.Spinner import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionInflater import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.wirelessalien.zipxtract.databinding.FragmentCreateZipBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.lingala.zip4j.ZipFile import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.model.ZipParameters import net.lingala.zip4j.model.enums.AesKeyStrength import net.lingala.zip4j.model.enums.CompressionLevel import net.lingala.zip4j.model.enums.CompressionMethod import net.lingala.zip4j.model.enums.EncryptionMethod import net.lingala.zip4j.progress.ProgressMonitor import net.sf.sevenzipjbinding.ICryptoGetTextPassword import net.sf.sevenzipjbinding.IOutCreateCallback import net.sf.sevenzipjbinding.IOutFeatureSetEncryptHeader import net.sf.sevenzipjbinding.IOutItem7z import net.sf.sevenzipjbinding.ISequentialInStream import net.sf.sevenzipjbinding.SevenZip import net.sf.sevenzipjbinding.SevenZipException import net.sf.sevenzipjbinding.impl.OutItemFactory import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream import net.sf.sevenzipjbinding.impl.RandomAccessFileOutStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile class CreateZipFragment : Fragment(), FileAdapter.OnDeleteClickListener, FileAdapter.OnFileClickListener { private lateinit var binding: FragmentCreateZipBinding private var outputDirectory: DocumentFile? = null private var pickedDirectory: DocumentFile? = null private lateinit var sharedPreferences: SharedPreferences private lateinit var prefs: SharedPreferences private val tempFiles = mutableListOf() private var selectedFileUri: Uri? = null private val cachedFiles = mutableListOf() private var cachedDirectoryName: String? = null private lateinit var recyclerView: RecyclerView private lateinit var adapter: FileAdapter private lateinit var fileList: MutableList private var cacheFile: File? = null private val pickFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { if (result.data != null) { val clipData = result.data!!.clipData if (clipData != null) { tempFiles.clear() binding.circularProgressBar.visibility = View.VISIBLE CoroutineScope(Dispatchers.IO).launch { for (i in 0 until clipData.itemCount) { val filesUri = clipData.getItemAt(i).uri val cursor = requireActivity().contentResolver.query(filesUri, null, null, null, null) if (cursor != null && cursor.moveToFirst()) { val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val displayName = cursor.getString(displayNameIndex) val tempFile = File(requireContext().cacheDir, displayName) // Copy the content from the selected file URI to a temporary file requireActivity().contentResolver.openInputStream(filesUri)?.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } tempFiles.add(tempFile) // show picked files name val selectedFilesText = getString(R.string.selected_files_text, tempFiles.size) withContext(Dispatchers.Main) { binding.fileNameTextView.text = selectedFilesText binding.fileNameTextView.isSelected = true } cursor.close() } } // Hide the progress bar on the main thread withContext(Dispatchers.Main) { binding.circularProgressBar.visibility = View.GONE val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) } } } } } } private val pickFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { selectedFileUri = result.data?.data if (selectedFileUri != null) { showToast(getString(R.string.file_picked_success)) binding.createZipMBtn.isEnabled = true binding.circularProgressBar.visibility = View.VISIBLE // Display the file name from the intent val fileName = getZipFileName(selectedFileUri!!) val selectedFileText = getString(R.string.selected_file_text, fileName) binding.fileNameTextView.text = selectedFileText binding.fileNameTextView.isSelected = true // Copy the file to the cache directory in background CoroutineScope(Dispatchers.IO).launch { cacheFile = File(requireContext().cacheDir, fileName.toString()) cacheFile!!.outputStream().use { cache -> requireContext().contentResolver.openInputStream(selectedFileUri!!)?.use { it.copyTo(cache) } } withContext(Dispatchers.Main) { binding.circularProgressBar.visibility = View.GONE } } } else { showToast(getString(R.string.file_picked_fail)) } } } private val directoryFilesPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let { requireActivity().contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) pickedDirectory = DocumentFile.fromTreeUri(requireContext(), uri) copyFilesToCache(uri) val directoryName = pickedDirectory?.name binding.fileNameTextView.text = directoryName binding.fileNameTextView.isSelected = true } } private val directoryPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let { requireActivity().contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) outputDirectory = DocumentFile.fromTreeUri(requireContext(), uri) val fullPath = outputDirectory?.uri?.path val displayedPath = fullPath?.replace("/tree/primary", "") if (displayedPath != null) { val directoryText = getString(R.string.directory_path, displayedPath) binding.directoryTextView.text = directoryText } // Save the output directory URI in SharedPreferences val editor = sharedPreferences.edit() editor.putString("outputDirectoryUriZip", uri.toString()) editor.apply() } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { binding = FragmentCreateZipBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val inflater = TransitionInflater.from(requireContext()) exitTransition = inflater.inflateTransition(R.transition.slide_right) sharedPreferences = requireActivity().getSharedPreferences("prefs", AppCompatActivity.MODE_PRIVATE) prefs = requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) fileList = getFilesInCacheDirectory(requireContext().cacheDir) binding.progressBar.visibility = View.GONE binding.circularProgressBar.visibility = View.GONE binding.progressBarNI.visibility = View.GONE binding.pickFileButton.setOnClickListener { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" pickFileLauncher.launch(intent) val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } } binding.pickFilesButton.setOnClickListener { openFile() val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } } binding.changeDirectoryButton.setOnClickListener { chooseOutputDirectory() } binding.pickFolderButton.setOnClickListener { changeDirectoryFilesPicker() val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } } binding.settingsInfo.setOnClickListener { //show alert dialog with info MaterialAlertDialogBuilder(requireContext()) .setMessage(getString(R.string.settings_info_text)) .setPositiveButton(getString(R.string.ok)) { dialog, _ -> dialog.dismiss() } .show() } binding.clearCacheBtnDP.setOnClickListener { val editor = sharedPreferences.edit() editor.putString("outputDirectoryUriZip", null) editor.apply() // clear output directory outputDirectory = null binding.directoryTextView.text = getString(R.string.no_directory_selected) binding.directoryTextView.isSelected = false showToast(getString(R.string.output_directory_cleared)) } binding.clearCacheBtnPF.setOnClickListener { //delete the complete cache directory folder, files and subdirectories val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) selectedFileUri = null binding.fileNameTextView.text = getString(R.string.no_file_selected) binding.fileNameTextView.isSelected = false showToast(getString(R.string.selected_file_cleared)) } binding.createZipMBtn.setOnClickListener { when { cacheFile != null -> { showZipOptionsDialog() } tempFiles.isNotEmpty() -> { showZipOptionsDialog() } cachedFiles.isNotEmpty() -> { showZipOptionsDialog() } else -> { showToast(getString(R.string.file_picked_fail)) } } } binding.create7zBtn.setOnClickListener { when { cacheFile != null -> { create7zSingleFile() } tempFiles.isNotEmpty() -> { create7zFile() } cachedFiles.isNotEmpty() -> { create7zFile() } else -> { showToast(getString(R.string.file_picked_fail)) } } } if (requireActivity().intent?.action == Intent.ACTION_VIEW) { val uri = requireActivity().intent.data if (uri != null) { selectedFileUri = uri binding.createZipMBtn.isEnabled = true // Display the file name from the intent val fileName = getZipFileName(selectedFileUri) val selectedFileText = getString(R.string.selected_file_text, fileName) binding.fileNameTextView.text = selectedFileText binding.fileNameTextView.isSelected = true } else { showToast(getString(R.string.file_picked_fail)) } } val intent = requireActivity().intent if (intent.action == Intent.ACTION_SEND) { val fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) selectedFileUri = fileUri if (fileUri != null) { cacheFile = File(requireActivity().cacheDir, getZipFileName(fileUri).toString()) cacheFile!!.outputStream().use { outputStream -> requireActivity().contentResolver.openInputStream(fileUri)?.use { inputStream -> inputStream.copyTo(outputStream) } } } } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { val fileUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) fileUris?.forEach { fileUri -> val file = File(requireActivity().cacheDir, getZipFileName(fileUri).toString()) file.outputStream().use { outputStream -> requireActivity().contentResolver.openInputStream(fileUri)?.use { inputStream -> inputStream.copyTo(outputStream) } } tempFiles.add(file) } } val savedDirectoryUri = sharedPreferences.getString("outputDirectoryUriZip", null) if (savedDirectoryUri != null) { outputDirectory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(savedDirectoryUri)) val fullPath = outputDirectory?.uri?.path val displayedPath = fullPath?.replace("/tree/primary:", "") if (displayedPath != null) { val directoryText = getString(R.string.directory_path, displayedPath) binding.directoryTextView.text = directoryText } } else { //do nothing } recyclerView = binding.recyclerViewFiles recyclerView.layoutManager = LinearLayoutManager(requireContext()) // Replace getFilesInCacheDirectory with a function to get the initial list of files val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter = FileAdapter(fileList, this, this) recyclerView.adapter = adapter } private fun getFilesInCacheDirectory(directory: File): MutableList { val filesList = mutableListOf() // Recursive function to traverse the directory fun traverseDirectory(file: File) { if (file.isDirectory) { file.listFiles()?.forEach { traverseDirectory(it) } } else { filesList.add(file) } } traverseDirectory(directory) return filesList } override fun onResume() { super.onResume() val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) } override fun onFileClick(file: File) { //open activity val intent = Intent(requireContext(), PickedFilesActivity::class.java) startActivity(intent) } @SuppressLint("NotifyDataSetChanged") override fun onDeleteClick(file: File) { val cacheFile = File(requireContext().cacheDir, file.name) cacheFile.delete() // use relative path to delete the file from cache directory val relativePath = file.path.replace("${requireContext().cacheDir}/", "") val fileToDelete = File(requireContext().cacheDir, relativePath) fileToDelete.delete() val parentFolder = fileToDelete.parentFile if (parentFolder != null) { if (parentFolder.listFiles()?.isEmpty() == true) { parentFolder.delete() } } adapter.fileList.remove(file) adapter.notifyDataSetChanged() } private fun changeDirectoryFilesPicker() { directoryFilesPicker.launch(null) } private fun openFile() { val intent = Intent(Intent.ACTION_GET_CONTENT) intent.type = "*/*" intent.addCategory(Intent.CATEGORY_OPENABLE) intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) pickFilesLauncher.launch(intent) } private fun chooseOutputDirectory() { directoryPicker.launch(null) } private fun copyFilesToCache(directoryUri: Uri) { binding.circularProgressBar.visibility = View.VISIBLE val contentResolver = requireContext().contentResolver val directory = DocumentFile.fromTreeUri(requireContext(), directoryUri) cachedDirectoryName = directory?.name if (directory != null && cachedDirectoryName != null) { val cachedDirectory = File(requireContext().cacheDir, cachedDirectoryName!!) cachedDirectory.mkdirs() val allFiles = mutableListOf() getAllFilesInDirectory(directory, allFiles) lifecycleScope.launch(Dispatchers.IO) { for (file in allFiles) { val relativePath = getRelativePath(directory, file) val outputFile = File(cachedDirectory, relativePath) outputFile.parentFile?.mkdirs() val inputStream = contentResolver.openInputStream(file.uri) val outputStream = FileOutputStream(outputFile) inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } } cachedFiles.add(outputFile) } // Switch to the Main dispatcher to update the UI withContext(Dispatchers.Main) { val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) binding.circularProgressBar.visibility = View.GONE } } } } private fun getRelativePath(baseDirectory: DocumentFile, file: DocumentFile): String { // Get the relative path of the file with respect to the base directory val basePath = baseDirectory.uri.path ?: "" val filePath = file.uri.path ?: "" return if (filePath.startsWith(basePath)) { filePath.substring(basePath.length) } else { // Handle the case where the file is not within the base directory file.name ?: "" } } private val cachedDirectory: File? get() { return if (cachedDirectoryName != null) { File(requireContext().cacheDir, cachedDirectoryName!!) } else { null } } private fun getAllFilesInDirectory(directory: DocumentFile?, fileList: MutableList) { if (directory != null && directory.isDirectory) { val files = directory.listFiles() for (file in files) { if (file.isFile) { fileList.add(file) } else if (file.isDirectory) { // Recursively get files in child directories getAllFilesInDirectory(file, fileList) } } } } private fun getRelativePathForFile(baseDirectory: File, file: File): String { val basePath = baseDirectory.path val filePath = file.path return if (filePath.startsWith(basePath)) { filePath.substring(basePath.length) } else { file.name } } //Not so happy with this function //Sometimes it fails to create the 7z file //I will try to improve it in the future //But for now it works private fun create7zSingleFile() { showPasswordInputDialog7z { password, _, level, solid, thread -> CoroutineScope(Dispatchers.IO).launch { try { showProgressBar(true) showProgressBarText(true) val tempZipFile = File(requireContext().cacheDir, "temp_archive.7z") val outputFileName = getZipFileName(selectedFileUri) withContext(Dispatchers.IO) { RandomAccessFile(tempZipFile, "rw").use { raf -> val outArchive = SevenZip.openOutArchive7z() outArchive.setLevel(level) outArchive.setSolid(solid) outArchive.setThreadCount(thread) outArchive.setHeaderEncryption(true) val fileToArchive = cacheFile!! outArchive.createArchive(RandomAccessFileOutStream(raf), 1, object : IOutCreateCallback, ICryptoGetTextPassword, IOutFeatureSetEncryptHeader { override fun cryptoGetTextPassword(): String? { return password } override fun setOperationResult(operationResultOk: Boolean) { CoroutineScope(Dispatchers.Main).launch { showToast(getString(R.string.please_wait_end_of_job)) } } override fun setTotal(total: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.max = total.toInt() binding.progressText.text = formatFileSize(total) } } override fun setCompleted(complete: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.progress = complete.toInt() binding.progressText.text = formatFileSize(complete) } } override fun getItemInformation(index: Int, outItemFactory: OutItemFactory): IOutItem7z { val item = outItemFactory.createOutItem() if (fileToArchive.isDirectory) { // Directory item.propertyIsDir = true } else { // File item.dataSize = fileToArchive.length() } item.propertyPath = fileToArchive.name return item } override fun getStream(i: Int): RandomAccessFileInStream { return RandomAccessFileInStream(RandomAccessFile(fileToArchive, "r")) } override fun setHeaderEncryption(enabled: Boolean) { outArchive.setHeaderEncryption(enabled) } }) outArchive.close() withContext(Dispatchers.Main) { showProgressBar(true) showProgressBarText(true) } } } if (outputDirectory != null && tempZipFile.exists()) { val outputUri = DocumentsContract.buildDocumentUriUsingTree( outputDirectory!!.uri, DocumentsContract.getTreeDocumentId(outputDirectory!!.uri)) val outputZipUri = DocumentsContract.createDocument( requireActivity().contentResolver, outputUri, "application/x-7z-compressed", outputFileName.toString()) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w").use { outputStream -> FileInputStream(tempZipFile).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer).also { bytesRead = it } != -1) { outputStream!!.write(buffer, 0, bytesRead) } } } val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } showToast(getString(R.string.sevenz_creation_success)) showProgressBar(false) showProgressBarText(false) showExtractionCompletedSnackbar(outputDirectory) } } catch (e: SevenZipException) { e.printStackTraceExtended() showToast(getString(R.string.sevenz_creation_failed)) showProgressBar(false) showProgressBarText(false) } catch (e: IOException) { if (isNoStorageSpaceException(e)) { showProgressBar(false) showProgressBarText(false) showToast("No storage available") } else { showToast("${getString(R.string.extraction_failed)} ${e.message}") } } catch (e: OutOfMemoryError) { e.printStackTrace() showToast("Out of memory") showProgressBar(false) showProgressBarText(false) } } } } private fun create7zFile() { showPasswordInputDialog7z { password, archiveName, level, solid, thread -> CoroutineScope(Dispatchers.IO).launch { try { showProgressBar(true) showProgressBarText(true) val filesToArchive = tempFiles.ifEmpty { cachedFiles } val tempZipFile = File(requireContext().cacheDir, "temp_archive.7z") val outputFileName = "$archiveName.7z" withContext(Dispatchers.IO) { RandomAccessFile(tempZipFile, "rw").use { raf -> val outArchive = SevenZip.openOutArchive7z() outArchive.setLevel(level) outArchive.setSolid(solid) outArchive.setThreadCount(thread) outArchive.setHeaderEncryption(true) outArchive.createArchive(RandomAccessFileOutStream(raf), filesToArchive.size, object : IOutCreateCallback, ICryptoGetTextPassword, IOutFeatureSetEncryptHeader { override fun cryptoGetTextPassword(): String? { return password } override fun setOperationResult(operationResultOk: Boolean) { CoroutineScope(Dispatchers.Main).launch { showToast(getString(R.string.please_wait_end_of_job)) } } override fun setTotal(total: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.max = total.toInt() binding.progressText.text = formatFileSize(total) } } override fun setCompleted(complete: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.progress = complete.toInt() binding.progressText.text = formatFileSize(complete) } } override fun getItemInformation(index: Int, outItemFactory: OutItemFactory): IOutItem7z { val item = outItemFactory.createOutItem() val file = filesToArchive[index] if (file.isDirectory) { // Directory item.propertyIsDir = true } else { // File item.dataSize = file.length() } // Set the file structure inside the archive if (cachedFiles.contains(file)) { val relativePath = getRelativePathForFile(cachedDirectory!!, file) item.propertyPath = relativePath + (if (file.isDirectory) File.separator else "") } else { // Use the relative path for tempFiles val relativePath = getRelativePathForFile(cachedDirectory!!, file) item.propertyPath = relativePath } return item } override fun getStream(i: Int): ISequentialInStream { return RandomAccessFileInStream(RandomAccessFile(filesToArchive[i], "r")) } override fun setHeaderEncryption(enabled: Boolean) { outArchive.setHeaderEncryption(enabled) } }) outArchive.close() withContext(Dispatchers.Main) { showProgressBar(true) showProgressBarText(true) } } } if (outputDirectory != null && tempZipFile.exists()) { val outputUri = DocumentsContract.buildDocumentUriUsingTree( outputDirectory!!.uri, DocumentsContract.getTreeDocumentId(outputDirectory!!.uri)) val outputZipUri = DocumentsContract.createDocument( requireActivity().contentResolver, outputUri, "application/x-7z-compressed", outputFileName) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w").use { outputStream -> FileInputStream(tempZipFile).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer).also { bytesRead = it } != -1) { outputStream!!.write(buffer, 0, bytesRead) } } } val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } showToast(getString(R.string.sevenz_creation_success)) showProgressBar(false) showProgressBarText(false) showExtractionCompletedSnackbar(outputDirectory) } } catch (e: SevenZipException) { e.printStackTraceExtended() showToast(getString(R.string.sevenz_creation_failed)) showProgressBar(false) showProgressBarText(false) } catch (e: IOException) { if (isNoStorageSpaceException(e)) { showProgressBar(false) showProgressBarText(false) showToast("No storage available") } else { showToast("${getString(R.string.extraction_failed)} ${e.message}") } } catch (e: OutOfMemoryError) { e.printStackTrace() showToast("Out of memory") showProgressBar(false) showProgressBarText(false) } } } } private fun isNoStorageSpaceException(e: IOException): Boolean { return e.message?.contains("ENOSPC") == true || e.cause is ErrnoException && (e.cause as ErrnoException).errno == OsConstants.ENOSPC } private fun showZipOptionsDialog() { showCompressionSettingsDialog { _, _, _, _, _, _, _, _ -> } } private fun createZipFile( archiveName: String, password: String?, compressionMethod: CompressionMethod, compressionLevel: CompressionLevel, isEncrypted: Boolean, encryptionMethod: EncryptionMethod?, aesStrength: AesKeyStrength? ){ CoroutineScope(Dispatchers.IO).launch { try { showProgressBar(true) showProgressBarText(true) var outputFileName = archiveName.ifEmpty { getZipFileName(selectedFileUri) ?: "outputZip" } if (outputFileName.isEmpty()) { // Use default name if empty outputFileName = getString(R.string.output_file_name) } else if (!outputFileName.endsWith(".zip", ignoreCase = true)) { // Automatically add .zip extension outputFileName += ".zip" } val zipParameters = ZipParameters() zipParameters.compressionMethod = compressionMethod zipParameters.compressionLevel = compressionLevel zipParameters.encryptionMethod = if (isEncrypted) encryptionMethod else null zipParameters.isEncryptFiles = isEncrypted zipParameters.aesKeyStrength = if (isEncrypted) aesStrength else null val tempZipFile = File(requireContext().cacheDir, "temp_archive.zip") val zipFile = ZipFile(tempZipFile) if (isEncrypted) { zipFile.setPassword(password?.toCharArray()) } val cachedDirectory = cachedDirectory zipFile.isRunInThread = true val progressMonitor = zipFile.progressMonitor try { when { cachedDirectory != null -> { zipFile.addFolder(cachedDirectory, zipParameters) } tempFiles.isNotEmpty() -> { zipFile.addFiles(tempFiles, zipParameters) } cacheFile != null -> { zipFile.addFile(cacheFile, zipParameters) } else -> { showToast(getString(R.string.please_select_files)) } } while (!progressMonitor.state.equals(ProgressMonitor.State.READY)) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) } } if (progressMonitor.result == ProgressMonitor.Result.SUCCESS) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) showToast(getString(R.string.please_wait_end_of_job)) } } else { showToast(getString(R.string.zip_creation_failed)) } } catch (e: ZipException) { showToast("${getString(R.string.zip_creation_failed)} ${e.message}") showProgressBar(false) showProgressBarText(false) } finally { when { cachedFiles.isNotEmpty() -> { for (cachedFile in cachedFiles) { cachedFile.delete() } cachedFiles.clear() } tempFiles.isNotEmpty() -> { for (tempFile in tempFiles) { tempFile.delete() } tempFiles.clear() } cacheFile != null -> { cacheFile!!.delete() } } showProgressBar(true) showProgressBarText(true) } if (outputDirectory != null && tempZipFile.exists()) { val outputUri = DocumentsContract.buildDocumentUriUsingTree( outputDirectory!!.uri, DocumentsContract.getTreeDocumentId(outputDirectory!!.uri) ) val outputZipUri = DocumentsContract.createDocument( requireActivity().contentResolver, outputUri, "application/zip", outputFileName ) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w") .use { outputStream -> FileInputStream(tempZipFile).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer) .also { bytesRead = it } != -1 ) { outputStream!!.write(buffer, 0, bytesRead) } } } val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } showExtractionCompletedSnackbar(outputDirectory) showToast(getString(R.string.zip_creation_success)) } else { showToast(getString(R.string.select_output_directory)) } if (tempZipFile.exists()) tempZipFile.delete() } catch (e: Exception) { e.printStackTrace() showToast(getString(R.string.zip_creation_failed)) } } } private fun createSplitZipFile( archiveName: String, password: String?, compressionMethod: CompressionMethod, compressionLevel: CompressionLevel, isEncrypted: Boolean, encryptionMethod: EncryptionMethod?, aesStrength: AesKeyStrength?, splitZipSize: Long ) { CoroutineScope(Dispatchers.IO).launch { val baseFileName = archiveName.ifEmpty { getZipFileName(selectedFileUri) ?: "splitArchive" }.removeSuffix(".zip") try { showProgressBar(true) showProgressBarText(true) val zipParameters = ZipParameters() zipParameters.compressionMethod = compressionMethod zipParameters.compressionLevel = compressionLevel zipParameters.encryptionMethod = if (isEncrypted) encryptionMethod else null zipParameters.isEncryptFiles = isEncrypted zipParameters.aesKeyStrength = if (isEncrypted) aesStrength else null val tempZipFile = File(requireContext().cacheDir, "$baseFileName.zip") val zipFile = ZipFile(tempZipFile) if (isEncrypted) { zipFile.setPassword(password?.toCharArray()) } val cachedDirectory = cachedDirectory zipFile.isRunInThread = true val progressMonitor = zipFile.progressMonitor try { when { cachedDirectory != null -> { zipFile.createSplitZipFileFromFolder(cachedDirectory, zipParameters, true, splitZipSize) } tempFiles.isNotEmpty() -> { zipFile.createSplitZipFile(tempFiles, zipParameters, true, splitZipSize) } cacheFile != null -> { cacheFile?.let { singleFile -> val fileList = listOf(singleFile) zipFile.createSplitZipFile(fileList, zipParameters, true, splitZipSize) } } else -> { showToast(getString(R.string.please_select_files)) } } while (!progressMonitor.state.equals(ProgressMonitor.State.READY)) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) } } if (progressMonitor.result == ProgressMonitor.Result.SUCCESS) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) showToast(getString(R.string.please_wait_end_of_job)) } } else { showToast(getString(R.string.zip_creation_failed)) } val splitFiles = File(tempZipFile.parent!!).listFiles { _, name -> name.startsWith("$baseFileName.z") } val lastPartIndex = splitFiles?.mapNotNull { it.name.substringAfterLast(".z").toIntOrNull() }?.maxOrNull() ?: 0 val nextIndex = if (lastPartIndex < 9) { lastPartIndex + 1 // Increment by 1 for parts less than 9 } else { lastPartIndex + 1 // Increment by 1 for parts 9 or greater } val lastPartIndexString = if (nextIndex < 10) { String.format("%02d", nextIndex) } else { nextIndex.toString() } val lastPart = File(tempZipFile.parent, "$baseFileName.z$lastPartIndexString") tempZipFile.renameTo(lastPart) } catch (e: ZipException) { showToast("${getString(R.string.zip_creation_failed)} ${e.message}") showProgressBar(false) showProgressBarText(false) } finally { when { cachedFiles.isNotEmpty() -> { for (cachedFile in cachedFiles) { cachedFile.delete() } cachedFiles.clear() } tempFiles.isNotEmpty() -> { for (tempFile in tempFiles) { tempFile.delete() } tempFiles.clear() } cacheFile != null -> { cacheFile!!.delete() } } showProgressBar(true) showProgressBarText(true) } val cacheFiles = requireContext().cacheDir.listFiles() cacheFiles?.forEach { file -> if (file.extension.matches(Regex("z\\d{2}"))) { val outputUri = DocumentsContract.buildDocumentUriUsingTree(outputDirectory!!.uri, DocumentsContract.getTreeDocumentId( outputDirectory!!.uri)) val outputZipUri = DocumentsContract.createDocument(requireActivity().contentResolver, outputUri, "application/octet-stream", file.name) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w").use { outputStream -> FileInputStream(file).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer).also { bytesRead = it } != -1) { outputStream!!.write(buffer, 0, bytesRead) } } } } } showExtractionCompletedSnackbar(outputDirectory) showToast(getString(R.string.zip_creation_success)) } catch (e: Exception) { e.printStackTrace() showToast(getString(R.string.zip_creation_failed)) } } } private suspend fun showProgressBar(show: Boolean) { withContext(Dispatchers.Main) { binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE } } private suspend fun showProgressBarText(show: Boolean) { withContext(Dispatchers.Main) { binding.progressText.visibility = if (show) View.VISIBLE else View.GONE } } private suspend fun showExtractionCompletedSnackbar(outputDirectory: DocumentFile?) { withContext(Dispatchers.Main) { binding.progressBar.visibility = View.GONE // Show a snackbar with a button to open the ZIP file val snackbar = Snackbar.make(binding.root, getString(R.string.zip_creation_success), Snackbar.LENGTH_LONG) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { snackbar.setAction(getString(R.string.open_folder)) { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(outputDirectory?.uri, DocumentsContract.Document.MIME_TYPE_DIR) intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION startActivity(intent) } } snackbar.show() } } private fun getZipFileName(selectedFileUri: Uri?): String? { if (selectedFileUri != null) { val cursor = requireActivity().contentResolver.query(selectedFileUri, null, null, null, null) cursor?.use { if (it.moveToFirst()) { val displayNameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (displayNameIndex != -1) { return it.getString(displayNameIndex) } } } } return null } private fun showCompressionSettingsDialog( onCompressionSettingsEntered: ( String, String?, CompressionMethod, CompressionLevel, Boolean, EncryptionMethod?, AesKeyStrength?, Long?) -> Unit) { val layoutInflater = LayoutInflater.from(requireContext()) val customView = layoutInflater.inflate(R.layout.zip_settings_dialog, null) val compressionMethodSpinner = customView.findViewById(R.id.compression_method_input) val compressionLevelSpinner = customView.findViewById(R.id.compression_level_input) val encryptionMethodSpinner = customView.findViewById(R.id.encryption_method_input) val encryptionStrengthSpinner = customView.findViewById(R.id.encryption_strength_input) val passwordInput = customView.findViewById(R.id.passwordEditText) val showPasswordButton = customView.findViewById(R.id.showPasswordButton) val zipNameEditText = customView.findViewById(R.id.zipNameEditText) val splitZipCheckbox = customView.findViewById(R.id.splitZipCheckbox) val splitSizeInput = customView.findViewById(R.id.splitSizeEditText) val splitSizeUnitSpinner = customView.findViewById(R.id.splitSizeUnitSpinner) val splitSizeUnits = arrayOf("KB", "MB", "GB") val splitSizeUnitAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, splitSizeUnits) splitSizeUnitAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) splitSizeUnitSpinner.adapter = splitSizeUnitAdapter splitZipCheckbox.setOnCheckedChangeListener { _, isChecked -> splitSizeInput.isEnabled = isChecked if (!isChecked) { splitSizeInput.text.clear() } } val compressionMethods = CompressionMethod.values().filter { it != CompressionMethod.AES_INTERNAL_ONLY }.map { it.name }.toTypedArray() val compressionMethodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, compressionMethods) compressionMethodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) compressionMethodSpinner.adapter = compressionMethodAdapter val compressionLevels = CompressionLevel.values().map { it.name }.toTypedArray() val compressionLevelAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, compressionLevels) compressionLevelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) compressionLevelSpinner.adapter = compressionLevelAdapter val encryptionMethods = EncryptionMethod.values().map { it.name }.toTypedArray() val encryptionMethodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, encryptionMethods) encryptionMethodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) encryptionMethodSpinner.adapter = encryptionMethodAdapter val encryptionStrengths = AesKeyStrength.values().filter { it != AesKeyStrength.KEY_STRENGTH_192 }.map { it.name }.toTypedArray() val encryptionStrengthAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, encryptionStrengths) encryptionStrengthAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) encryptionStrengthSpinner.adapter = encryptionStrengthAdapter val filesToArchive = tempFiles.ifEmpty { cachedFiles } var isPasswordVisible = false val builder = MaterialAlertDialogBuilder(requireContext(), R.style.MaterialDialog) builder.setView(customView) builder.setPositiveButton(getString(R.string.ok)) { _, _ -> val defaultName = if (filesToArchive.isNotEmpty()) { filesToArchive[0].nameWithoutExtension } else { "" } val archiveName = zipNameEditText.text.toString().ifBlank { defaultName } val password = passwordInput.text.toString() val isEncryptionEnabled = password.isNotEmpty() val selectedCompressionMethod = CompressionMethod.valueOf(compressionMethods[compressionMethodSpinner.selectedItemPosition]) val selectedCompressionLevel = CompressionLevel.valueOf(compressionLevels[compressionLevelSpinner.selectedItemPosition]) val selectedEncryptionMethod = if (encryptionMethodSpinner.selectedItemPosition != 0) { EncryptionMethod.valueOf(encryptionMethods[encryptionMethodSpinner.selectedItemPosition]) } else { null } val selectedEncryptionStrength = if (selectedEncryptionMethod != null && selectedEncryptionMethod != EncryptionMethod.NONE) { AesKeyStrength.valueOf(encryptionStrengths[encryptionStrengthSpinner.selectedItemPosition]) } else { null } val splitSizeText = splitSizeInput.text.toString() val splitSizeUnit = splitSizeUnits[splitSizeUnitSpinner.selectedItemPosition] val splitZipSize = if (splitZipCheckbox.isChecked) { convertToBytes(splitSizeText.toLongOrNull() ?: 64, splitSizeUnit) } else { null } onCompressionSettingsEntered.invoke( archiveName, password.takeIf { isEncryptionEnabled }, selectedCompressionMethod, selectedCompressionLevel, isEncryptionEnabled, selectedEncryptionMethod, selectedEncryptionStrength, splitZipSize ) // Call createSplitZipFile function only if splitZipCheckbox is checked if (splitZipCheckbox.isChecked) { createSplitZipFile( archiveName, password, selectedCompressionMethod, selectedCompressionLevel, isEncryptionEnabled, selectedEncryptionMethod, selectedEncryptionStrength, splitZipSize!! ) } else { createZipFile( archiveName, password, selectedCompressionMethod, selectedCompressionLevel, isEncryptionEnabled, selectedEncryptionMethod, selectedEncryptionStrength ) } } builder.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> dialog.dismiss() } showPasswordButton.setOnClickListener { isPasswordVisible = !isPasswordVisible if (isPasswordVisible) { // Show password passwordInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD showPasswordButton.setImageResource(R.drawable.ic_visibility_on) } else { // Hide password passwordInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD showPasswordButton.setImageResource(R.drawable.ic_visibility_off) } } encryptionMethodSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { val selectedEncryptionMethod = EncryptionMethod.valueOf(encryptionMethods[position]) passwordInput.isEnabled = selectedEncryptionMethod != EncryptionMethod.NONE encryptionStrengthSpinner.isEnabled = selectedEncryptionMethod == EncryptionMethod.AES } override fun onNothingSelected(parent: AdapterView<*>?) { // Do nothing } } builder.show() } private fun convertToBytes(size: Long?, unit: String): Long? { return size?.times(when (unit) { "KB" -> 1024L "MB" -> 1024L * 1024 "GB" -> 1024L * 1024 * 1024 else -> 1024L }) } private fun showPasswordInputDialog7z(onPasswordEntered: (String?, String, Int, Boolean, Int) -> Unit) { val layoutInflater = LayoutInflater.from(requireContext()) val customView = layoutInflater.inflate(R.layout.seven_z_option_dialog, null) val passwordEditText = customView.findViewById(R.id.passwordEditText) val compressionSpinner = customView.findViewById(R.id.compressionSpinner) val solidCheckBox = customView.findViewById(R.id.solidCheckBox) val threadCountEditText = customView.findViewById(R.id.threadCountEditText) val showPasswordButton = customView.findViewById(R.id.showPasswordButton) val archiveNameEditText = customView.findViewById(R.id.archiveNameEditText) val filesToArchive = tempFiles.ifEmpty { cachedFiles } var isPasswordVisible = false showPasswordButton.setOnClickListener { isPasswordVisible = !isPasswordVisible if (isPasswordVisible) { // Show password passwordEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD showPasswordButton.setImageResource(R.drawable.ic_visibility_on) } else { // Hide password passwordEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD showPasswordButton.setImageResource(R.drawable.ic_visibility_off) } } MaterialAlertDialogBuilder(requireContext(), R.style.MaterialDialog) .setView(customView) .setPositiveButton(getString(R.string.ok)) { _, _ -> val defaultName = if (filesToArchive.isNotEmpty()) { filesToArchive[0].nameWithoutExtension } else { "archive" } val archiveName = archiveNameEditText.text.toString().ifBlank { defaultName } val password = passwordEditText.text.toString() val compressionLevel = when (compressionSpinner.selectedItemPosition) { 0 -> 0 1 -> 1 2 -> 3 3 -> 5 4 -> 7 5 -> 9 else -> -1 } val solid = solidCheckBox.isChecked val threadCount = threadCountEditText.text.toString().toIntOrNull() ?: -1 onPasswordEntered.invoke( password.ifBlank { null }, archiveName, compressionLevel, solid, threadCount ) } .setNegativeButton(getString(R.string.no_password)) { _, _ -> val defaultName = if (filesToArchive.isNotEmpty()) { filesToArchive[0].nameWithoutExtension } else { "archive" } val archiveName = archiveNameEditText.text.toString().ifBlank { defaultName } val compressionLevel = when (compressionSpinner.selectedItemPosition) { 0 -> 0 1 -> 1 2 -> 3 3 -> 5 4 -> 7 5 -> 9 else -> -1 } val solid = solidCheckBox.isChecked val threadCount = threadCountEditText.text.toString().toIntOrNull() ?: -1 onPasswordEntered.invoke( null, archiveName, compressionLevel, solid, threadCount ) } .show() } private fun showToast(message: String) { CoroutineScope(Dispatchers.Main).launch { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } fun formatFileSize(bytes: Long): String { val kilobyte = 1024 val megabyte = kilobyte * 1024 val gigabyte = megabyte * 1024 return when { bytes < kilobyte -> "$bytes B" bytes < megabyte -> String.format("%.2f KB", bytes.toFloat() / kilobyte) bytes < gigabyte -> String.format("%.2f MB", bytes.toFloat() / megabyte) else -> String.format("%.2f GB", bytes.toFloat() / gigabyte) } } companion object { private const val PREFS_NAME = "ZipPrefs" } } \ No newline at end of file +/* * Copyright (C) 2023 WirelessAlien * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package com.wirelessalien.zipxtract import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.DocumentsContract import android.provider.OpenableColumns import android.system.ErrnoException import android.system.OsConstants import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.CheckBox import android.widget.EditText import android.widget.Spinner import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionInflater import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.wirelessalien.zipxtract.databinding.FragmentCreateZipBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.lingala.zip4j.ZipFile import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.model.ZipParameters import net.lingala.zip4j.model.enums.AesKeyStrength import net.lingala.zip4j.model.enums.CompressionLevel import net.lingala.zip4j.model.enums.CompressionMethod import net.lingala.zip4j.model.enums.EncryptionMethod import net.lingala.zip4j.progress.ProgressMonitor import net.sf.sevenzipjbinding.ICryptoGetTextPassword import net.sf.sevenzipjbinding.IOutCreateCallback import net.sf.sevenzipjbinding.IOutFeatureSetEncryptHeader import net.sf.sevenzipjbinding.IOutItem7z import net.sf.sevenzipjbinding.ISequentialInStream import net.sf.sevenzipjbinding.SevenZip import net.sf.sevenzipjbinding.SevenZipException import net.sf.sevenzipjbinding.impl.OutItemFactory import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream import net.sf.sevenzipjbinding.impl.RandomAccessFileOutStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile class CreateZipFragment : Fragment(), FileAdapter.OnDeleteClickListener, FileAdapter.OnFileClickListener { private lateinit var binding: FragmentCreateZipBinding private var outputDirectory: DocumentFile? = null private var pickedDirectory: DocumentFile? = null private lateinit var sharedPreferences: SharedPreferences private lateinit var prefs: SharedPreferences private val tempFiles = mutableListOf() private var selectedFileUri: Uri? = null private val cachedFiles = mutableListOf() private var cachedDirectoryName: String? = null private lateinit var recyclerView: RecyclerView private lateinit var adapter: FileAdapter private lateinit var fileList: MutableList private var cacheFile: File? = null private val pickFilesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { if (result.data != null) { val clipData = result.data!!.clipData if (clipData != null) { tempFiles.clear() binding.circularProgressBar.visibility = View.VISIBLE CoroutineScope(Dispatchers.IO).launch { for (i in 0 until clipData.itemCount) { val filesUri = clipData.getItemAt(i).uri val cursor = requireActivity().contentResolver.query(filesUri, null, null, null, null) if (cursor != null && cursor.moveToFirst()) { val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val displayName = cursor.getString(displayNameIndex) val tempFile = File(requireContext().cacheDir, displayName) // Copy the content from the selected file URI to a temporary file requireActivity().contentResolver.openInputStream(filesUri)?.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } tempFiles.add(tempFile) // show picked files name val selectedFilesText = getString(R.string.selected_files_text, tempFiles.size) withContext(Dispatchers.Main) { binding.fileNameTextView.text = selectedFilesText binding.fileNameTextView.isSelected = true } cursor.close() } } // Hide the progress bar on the main thread withContext(Dispatchers.Main) { binding.circularProgressBar.visibility = View.GONE val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) } } } } } } private val pickFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { selectedFileUri = result.data?.data if (selectedFileUri != null) { showToast(getString(R.string.file_picked_success)) binding.createZipMBtn.isEnabled = true binding.circularProgressBar.visibility = View.VISIBLE // Display the file name from the intent val fileName = getZipFileName(selectedFileUri!!) val selectedFileText = getString(R.string.selected_file_text, fileName) binding.fileNameTextView.text = selectedFileText binding.fileNameTextView.isSelected = true // Copy the file to the cache directory in background CoroutineScope(Dispatchers.IO).launch { cacheFile = File(requireContext().cacheDir, fileName.toString()) cacheFile!!.outputStream().use { cache -> requireContext().contentResolver.openInputStream(selectedFileUri!!)?.use { it.copyTo(cache) } } withContext(Dispatchers.Main) { binding.circularProgressBar.visibility = View.GONE } } } else { showToast(getString(R.string.file_picked_fail)) } } } private val directoryFilesPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let { requireActivity().contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) pickedDirectory = DocumentFile.fromTreeUri(requireContext(), uri) copyFilesToCache(uri) val directoryName = pickedDirectory?.name binding.fileNameTextView.text = directoryName binding.fileNameTextView.isSelected = true } } private val directoryPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let { requireActivity().contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) outputDirectory = DocumentFile.fromTreeUri(requireContext(), uri) val fullPath = outputDirectory?.uri?.path val displayedPath = fullPath?.replace("/tree/primary", "") if (displayedPath != null) { val directoryText = getString(R.string.directory_path, displayedPath) binding.directoryTextView.text = directoryText } // Save the output directory URI in SharedPreferences val editor = sharedPreferences.edit() editor.putString("outputDirectoryUriZip", uri.toString()) editor.apply() } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { binding = FragmentCreateZipBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val inflater = TransitionInflater.from(requireContext()) exitTransition = inflater.inflateTransition(R.transition.slide_right) sharedPreferences = requireActivity().getSharedPreferences("prefs", AppCompatActivity.MODE_PRIVATE) prefs = requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) fileList = getFilesInCacheDirectory(requireContext().cacheDir) binding.progressBar.visibility = View.GONE binding.circularProgressBar.visibility = View.GONE binding.progressBarNI.visibility = View.GONE binding.pickFileButton.setOnClickListener { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" pickFileLauncher.launch(intent) val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } } binding.pickFilesButton.setOnClickListener { openFile() val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } } binding.changeDirectoryButton.setOnClickListener { chooseOutputDirectory() } binding.pickFolderButton.setOnClickListener { changeDirectoryFilesPicker() val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } } binding.settingsInfo.setOnClickListener { //show alert dialog with info MaterialAlertDialogBuilder(requireContext()) .setMessage(getString(R.string.settings_info_text)) .setPositiveButton(getString(R.string.ok)) { dialog, _ -> dialog.dismiss() } .show() } binding.clearCacheBtnDP.setOnClickListener { val editor = sharedPreferences.edit() editor.putString("outputDirectoryUriZip", null) editor.apply() // clear output directory outputDirectory = null binding.directoryTextView.text = getString(R.string.no_directory_selected) binding.directoryTextView.isSelected = false showToast(getString(R.string.output_directory_cleared)) } binding.clearCacheBtnPF.setOnClickListener { //delete the complete cache directory folder, files and subdirectories val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) selectedFileUri = null binding.fileNameTextView.text = getString(R.string.no_file_selected) binding.fileNameTextView.isSelected = false showToast(getString(R.string.selected_file_cleared)) } binding.createZipMBtn.setOnClickListener { when { cacheFile != null -> { showZipOptionsDialog() } tempFiles.isNotEmpty() -> { showZipOptionsDialog() } cachedFiles.isNotEmpty() -> { showZipOptionsDialog() } else -> { showToast(getString(R.string.file_picked_fail)) } } } binding.create7zBtn.setOnClickListener { when { cacheFile != null -> { create7zSingleFile() } tempFiles.isNotEmpty() -> { create7zFile() } cachedFiles.isNotEmpty() -> { create7zFile() } else -> { showToast(getString(R.string.file_picked_fail)) } } } if (requireActivity().intent?.action == Intent.ACTION_VIEW) { val uri = requireActivity().intent.data if (uri != null) { selectedFileUri = uri binding.createZipMBtn.isEnabled = true // Display the file name from the intent val fileName = getZipFileName(selectedFileUri) val selectedFileText = getString(R.string.selected_file_text, fileName) binding.fileNameTextView.text = selectedFileText binding.fileNameTextView.isSelected = true } else { showToast(getString(R.string.file_picked_fail)) } } val intent = requireActivity().intent if (intent.action == Intent.ACTION_SEND) { val fileUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) selectedFileUri = fileUri if (fileUri != null) { cacheFile = File(requireActivity().cacheDir, getZipFileName(fileUri).toString()) cacheFile!!.outputStream().use { outputStream -> requireActivity().contentResolver.openInputStream(fileUri)?.use { inputStream -> inputStream.copyTo(outputStream) } } } } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { val fileUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) fileUris?.forEach { fileUri -> val file = File(requireActivity().cacheDir, getZipFileName(fileUri).toString()) file.outputStream().use { outputStream -> requireActivity().contentResolver.openInputStream(fileUri)?.use { inputStream -> inputStream.copyTo(outputStream) } } tempFiles.add(file) } } val savedDirectoryUri = sharedPreferences.getString("outputDirectoryUriZip", null) if (savedDirectoryUri != null) { outputDirectory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(savedDirectoryUri)) val fullPath = outputDirectory?.uri?.path val displayedPath = fullPath?.replace("/tree/primary:", "") if (displayedPath != null) { val directoryText = getString(R.string.directory_path, displayedPath) binding.directoryTextView.text = directoryText } } else { //do nothing } recyclerView = binding.recyclerViewFiles recyclerView.layoutManager = LinearLayoutManager(requireContext()) // Replace getFilesInCacheDirectory with a function to get the initial list of files val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter = FileAdapter(fileList, this, this) recyclerView.adapter = adapter } private fun getFilesInCacheDirectory(directory: File): MutableList { val filesList = mutableListOf() // Recursive function to traverse the directory fun traverseDirectory(file: File) { if (file.isDirectory) { file.listFiles()?.forEach { traverseDirectory(it) } } else { filesList.add(file) } } traverseDirectory(directory) return filesList } override fun onResume() { super.onResume() val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) } override fun onFileClick(file: File) { //open activity val intent = Intent(requireContext(), PickedFilesActivity::class.java) startActivity(intent) } @SuppressLint("NotifyDataSetChanged") override fun onDeleteClick(file: File) { val cacheFile = File(requireContext().cacheDir, file.name) cacheFile.delete() // use relative path to delete the file from cache directory val relativePath = file.path.replace("${requireContext().cacheDir}/", "") val fileToDelete = File(requireContext().cacheDir, relativePath) fileToDelete.delete() val parentFolder = fileToDelete.parentFile if (parentFolder != null) { if (parentFolder.listFiles()?.isEmpty() == true) { parentFolder.delete() } } adapter.fileList.remove(file) adapter.notifyDataSetChanged() } private fun changeDirectoryFilesPicker() { directoryFilesPicker.launch(null) } private fun openFile() { val intent = Intent(Intent.ACTION_GET_CONTENT) intent.type = "*/*" intent.addCategory(Intent.CATEGORY_OPENABLE) intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) pickFilesLauncher.launch(intent) } private fun chooseOutputDirectory() { directoryPicker.launch(null) } private fun copyFilesToCache(directoryUri: Uri) { binding.circularProgressBar.visibility = View.VISIBLE val contentResolver = requireContext().contentResolver val directory = DocumentFile.fromTreeUri(requireContext(), directoryUri) cachedDirectoryName = directory?.name if (directory != null && cachedDirectoryName != null) { val cachedDirectory = File(requireContext().cacheDir, cachedDirectoryName!!) cachedDirectory.mkdirs() val allFiles = mutableListOf() getAllFilesInDirectory(directory, allFiles) lifecycleScope.launch(Dispatchers.IO) { for (file in allFiles) { val relativePath = getRelativePath(directory, file) val outputFile = File(cachedDirectory, relativePath) outputFile.parentFile?.mkdirs() val inputStream = contentResolver.openInputStream(file.uri) val outputStream = FileOutputStream(outputFile) inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } } cachedFiles.add(outputFile) } // Switch to the Main dispatcher to update the UI withContext(Dispatchers.Main) { val fileList = getFilesInCacheDirectory(requireContext().cacheDir) adapter.updateFileList(fileList) binding.circularProgressBar.visibility = View.GONE } } } } private fun getRelativePath(baseDirectory: DocumentFile, file: DocumentFile): String { // Get the relative path of the file with respect to the base directory val basePath = baseDirectory.uri.path ?: "" val filePath = file.uri.path ?: "" return if (filePath.startsWith(basePath)) { filePath.substring(basePath.length) } else { // Handle the case where the file is not within the base directory file.name ?: "" } } private val cachedDirectory: File? get() { return if (cachedDirectoryName != null) { File(requireContext().cacheDir, cachedDirectoryName!!) } else { null } } private fun getAllFilesInDirectory(directory: DocumentFile?, fileList: MutableList) { if (directory != null && directory.isDirectory) { val files = directory.listFiles() for (file in files) { if (file.isFile) { fileList.add(file) } else if (file.isDirectory) { // Recursively get files in child directories getAllFilesInDirectory(file, fileList) } } } } private fun getRelativePathForFile(baseDirectory: File, file: File): String { val basePath = baseDirectory.path val filePath = file.path return if (filePath.startsWith(basePath)) { filePath.substring(basePath.length) } else { file.name } } //Not so happy with this function //Sometimes it fails to create the 7z file //I will try to improve it in the future //But for now it works private fun create7zSingleFile() { showPasswordInputDialog7z { password, _, level, solid, thread -> CoroutineScope(Dispatchers.IO).launch { try { showProgressBar(true) showProgressBarText(true) val tempZipFile = File(requireContext().cacheDir, "temp_archive.7z") val outputFileName = getZipFileName(selectedFileUri) withContext(Dispatchers.IO) { RandomAccessFile(tempZipFile, "rw").use { raf -> val outArchive = SevenZip.openOutArchive7z() outArchive.setLevel(level) outArchive.setSolid(solid) outArchive.setThreadCount(thread) outArchive.setHeaderEncryption(true) val fileToArchive = cacheFile!! outArchive.createArchive(RandomAccessFileOutStream(raf), 1, object : IOutCreateCallback, ICryptoGetTextPassword, IOutFeatureSetEncryptHeader { override fun cryptoGetTextPassword(): String? { return password } override fun setOperationResult(operationResultOk: Boolean) { CoroutineScope(Dispatchers.Main).launch { showToast(getString(R.string.please_wait_end_of_job)) } } override fun setTotal(total: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.max = total.toInt() binding.progressText.text = formatFileSize(total) } } override fun setCompleted(complete: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.progress = complete.toInt() binding.progressText.text = formatFileSize(complete) } } override fun getItemInformation(index: Int, outItemFactory: OutItemFactory): IOutItem7z { val item = outItemFactory.createOutItem() if (fileToArchive.isDirectory) { // Directory item.propertyIsDir = true } else { // File item.dataSize = fileToArchive.length() } item.propertyPath = fileToArchive.name return item } override fun getStream(i: Int): RandomAccessFileInStream { return RandomAccessFileInStream(RandomAccessFile(fileToArchive, "r")) } override fun setHeaderEncryption(enabled: Boolean) { outArchive.setHeaderEncryption(enabled) } }) outArchive.close() withContext(Dispatchers.Main) { showProgressBar(true) showProgressBarText(true) } } } if (outputDirectory != null && tempZipFile.exists()) { val outputUri = DocumentsContract.buildDocumentUriUsingTree( outputDirectory!!.uri, DocumentsContract.getTreeDocumentId(outputDirectory!!.uri)) val outputZipUri = DocumentsContract.createDocument( requireActivity().contentResolver, outputUri, "application/x-7z-compressed", outputFileName.toString()) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w").use { outputStream -> FileInputStream(tempZipFile).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer).also { bytesRead = it } != -1) { outputStream!!.write(buffer, 0, bytesRead) } } } val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } showToast(getString(R.string.sevenz_creation_success)) showProgressBar(false) showProgressBarText(false) showExtractionCompletedSnackbar(outputDirectory) } } catch (e: SevenZipException) { e.printStackTraceExtended() showToast(getString(R.string.sevenz_creation_failed)) showProgressBar(false) showProgressBarText(false) } catch (e: IOException) { if (isNoStorageSpaceException(e)) { showProgressBar(false) showProgressBarText(false) showToast("No storage available") } else { showToast("${getString(R.string.extraction_failed)} ${e.message}") } } catch (e: OutOfMemoryError) { e.printStackTrace() showToast("Out of memory") showProgressBar(false) showProgressBarText(false) } } } } private fun create7zFile() { showPasswordInputDialog7z { password, archiveName, level, solid, thread -> CoroutineScope(Dispatchers.IO).launch { try { showProgressBar(true) showProgressBarText(true) val filesToArchive = tempFiles.ifEmpty { cachedFiles } val tempZipFile = File(requireContext().cacheDir, "temp_archive.7z") val outputFileName = "$archiveName.7z" withContext(Dispatchers.IO) { RandomAccessFile(tempZipFile, "rw").use { raf -> val outArchive = SevenZip.openOutArchive7z() outArchive.setLevel(level) outArchive.setSolid(solid) outArchive.setThreadCount(thread) outArchive.setHeaderEncryption(true) outArchive.createArchive(RandomAccessFileOutStream(raf), filesToArchive.size, object : IOutCreateCallback, ICryptoGetTextPassword, IOutFeatureSetEncryptHeader { override fun cryptoGetTextPassword(): String? { return password } override fun setOperationResult(operationResultOk: Boolean) { CoroutineScope(Dispatchers.Main).launch { showToast(getString(R.string.please_wait_end_of_job)) } } override fun setTotal(total: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.max = total.toInt() binding.progressText.text = formatFileSize(total) } } override fun setCompleted(complete: Long) { CoroutineScope(Dispatchers.Main).launch { binding.progressBar.progress = complete.toInt() binding.progressText.text = formatFileSize(complete) } } override fun getItemInformation(index: Int, outItemFactory: OutItemFactory): IOutItem7z { val item = outItemFactory.createOutItem() val file = filesToArchive[index] if (file.isDirectory) { // Directory item.propertyIsDir = true } else { // File item.dataSize = file.length() } // Set the file structure inside the archive if (cachedFiles.contains(file)) { val relativePath = getRelativePathForFile(cachedDirectory!!, file) item.propertyPath = relativePath + (if (file.isDirectory) File.separator else "") } else { // Use the relative path for tempFiles val relativePath = getRelativePathForFile(cachedDirectory!!, file) item.propertyPath = relativePath } return item } override fun getStream(i: Int): ISequentialInStream { return RandomAccessFileInStream(RandomAccessFile(filesToArchive[i], "r")) } override fun setHeaderEncryption(enabled: Boolean) { outArchive.setHeaderEncryption(enabled) } }) outArchive.close() withContext(Dispatchers.Main) { showProgressBar(true) showProgressBarText(true) } } } if (outputDirectory != null && tempZipFile.exists()) { val outputUri = DocumentsContract.buildDocumentUriUsingTree( outputDirectory!!.uri, DocumentsContract.getTreeDocumentId(outputDirectory!!.uri)) val outputZipUri = DocumentsContract.createDocument( requireActivity().contentResolver, outputUri, "application/x-7z-compressed", outputFileName) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w").use { outputStream -> FileInputStream(tempZipFile).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer).also { bytesRead = it } != -1) { outputStream!!.write(buffer, 0, bytesRead) } } } val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } showToast(getString(R.string.sevenz_creation_success)) showProgressBar(false) showProgressBarText(false) showExtractionCompletedSnackbar(outputDirectory) } } catch (e: SevenZipException) { e.printStackTraceExtended() showToast(getString(R.string.sevenz_creation_failed)) showProgressBar(false) showProgressBarText(false) } catch (e: IOException) { if (isNoStorageSpaceException(e)) { showProgressBar(false) showProgressBarText(false) showToast("No storage available") } else { showToast("${getString(R.string.extraction_failed)} ${e.message}") } } catch (e: OutOfMemoryError) { e.printStackTrace() showToast("Out of memory") showProgressBar(false) showProgressBarText(false) } } } } private fun isNoStorageSpaceException(e: IOException): Boolean { return e.message?.contains("ENOSPC") == true || e.cause is ErrnoException && (e.cause as ErrnoException).errno == OsConstants.ENOSPC } private fun showZipOptionsDialog() { showCompressionSettingsDialog { _, _, _, _, _, _, _, _ -> } } private fun createZipFile( archiveName: String, password: String?, compressionMethod: CompressionMethod, compressionLevel: CompressionLevel, isEncrypted: Boolean, encryptionMethod: EncryptionMethod?, aesStrength: AesKeyStrength? ){ CoroutineScope(Dispatchers.IO).launch { try { showProgressBar(true) showProgressBarText(true) var outputFileName = archiveName.ifEmpty { getZipFileName(selectedFileUri) ?: "outputZip" } if (outputFileName.isEmpty()) { // Use default name if empty outputFileName = getString(R.string.output_file_name) } else if (!outputFileName.endsWith(".zip", ignoreCase = true)) { // Automatically add .zip extension outputFileName += ".zip" } val zipParameters = ZipParameters() zipParameters.compressionMethod = compressionMethod zipParameters.compressionLevel = compressionLevel zipParameters.encryptionMethod = if (isEncrypted) encryptionMethod else null zipParameters.isEncryptFiles = isEncrypted zipParameters.aesKeyStrength = if (isEncrypted) aesStrength else null val tempZipFile = File(requireContext().cacheDir, "temp_archive.zip") val zipFile = ZipFile(tempZipFile) if (isEncrypted) { zipFile.setPassword(password?.toCharArray()) } val cachedDirectory = cachedDirectory zipFile.isRunInThread = true val progressMonitor = zipFile.progressMonitor try { when { cachedDirectory != null -> { zipFile.addFolder(cachedDirectory, zipParameters) } tempFiles.isNotEmpty() -> { zipFile.addFiles(tempFiles, zipParameters) } cacheFile != null -> { zipFile.addFile(cacheFile, zipParameters) } else -> { showToast(getString(R.string.please_select_files)) } } while (!progressMonitor.state.equals(ProgressMonitor.State.READY)) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) } } if (progressMonitor.result == ProgressMonitor.Result.SUCCESS) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) showToast(getString(R.string.please_wait_end_of_job)) } } else { showToast(getString(R.string.zip_creation_failed)) } } catch (e: ZipException) { showToast("${getString(R.string.zip_creation_failed)} ${e.message}") showProgressBar(false) showProgressBarText(false) } finally { when { cachedFiles.isNotEmpty() -> { for (cachedFile in cachedFiles) { cachedFile.delete() } cachedFiles.clear() } tempFiles.isNotEmpty() -> { for (tempFile in tempFiles) { tempFile.delete() } tempFiles.clear() } cacheFile != null -> { cacheFile!!.delete() } } showProgressBar(true) showProgressBarText(true) } if (outputDirectory != null && tempZipFile.exists()) { val outputUri = DocumentsContract.buildDocumentUriUsingTree( outputDirectory!!.uri, DocumentsContract.getTreeDocumentId(outputDirectory!!.uri) ) val outputZipUri = DocumentsContract.createDocument( requireActivity().contentResolver, outputUri, "application/zip", outputFileName ) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w") .use { outputStream -> FileInputStream(tempZipFile).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer) .also { bytesRead = it } != -1 ) { outputStream!!.write(buffer, 0, bytesRead) } } } val cacheDir = requireContext().cacheDir if (cacheDir.isDirectory) { val children: Array = cacheDir.list()!! for (i in children.indices) { File(cacheDir, children[i]).deleteRecursively() } } showExtractionCompletedSnackbar(outputDirectory) showToast(getString(R.string.zip_creation_success)) } else { showToast(getString(R.string.select_output_directory)) } if (tempZipFile.exists()) tempZipFile.delete() } catch (e: Exception) { e.printStackTrace() showToast(getString(R.string.zip_creation_failed)) } } } private fun createSplitZipFile( archiveName: String, password: String?, compressionMethod: CompressionMethod, compressionLevel: CompressionLevel, isEncrypted: Boolean, encryptionMethod: EncryptionMethod?, aesStrength: AesKeyStrength?, splitZipSize: Long ) { CoroutineScope(Dispatchers.IO).launch { val baseFileName = archiveName.ifEmpty { getZipFileName(selectedFileUri) ?: "splitArchive" }.removeSuffix(".zip") try { showProgressBar(true) showProgressBarText(true) val zipParameters = ZipParameters() zipParameters.compressionMethod = compressionMethod zipParameters.compressionLevel = compressionLevel zipParameters.encryptionMethod = if (isEncrypted) encryptionMethod else null zipParameters.isEncryptFiles = isEncrypted zipParameters.aesKeyStrength = if (isEncrypted) aesStrength else null val tempZipFile = File(requireContext().cacheDir, "$baseFileName.zip") val zipFile = ZipFile(tempZipFile) if (isEncrypted) { zipFile.setPassword(password?.toCharArray()) } val cachedDirectory = cachedDirectory zipFile.isRunInThread = true val progressMonitor = zipFile.progressMonitor try { when { cachedDirectory != null -> { zipFile.createSplitZipFileFromFolder(cachedDirectory, zipParameters, true, splitZipSize) } tempFiles.isNotEmpty() -> { zipFile.createSplitZipFile(tempFiles, zipParameters, true, splitZipSize) } cacheFile != null -> { cacheFile?.let { singleFile -> val fileList = listOf(singleFile) zipFile.createSplitZipFile(fileList, zipParameters, true, splitZipSize) } } else -> { showToast(getString(R.string.please_select_files)) } } while (!progressMonitor.state.equals(ProgressMonitor.State.READY)) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) } } if (progressMonitor.result == ProgressMonitor.Result.SUCCESS) { val progress = progressMonitor.percentDone withContext(Dispatchers.Main) { binding.progressBar.progress = progress binding.progressText.text = getString(R.string.percent_complete, progress) showToast(getString(R.string.please_wait_end_of_job)) } } else { showToast(getString(R.string.zip_creation_failed)) } val splitFiles = File(tempZipFile.parent!!).listFiles { _, name -> name.startsWith("$baseFileName.z") } val lastPartIndex = splitFiles?.mapNotNull { it.name.substringAfterLast(".z").toIntOrNull() }?.maxOrNull() ?: 0 val nextIndex = if (lastPartIndex < 9) { lastPartIndex + 1 // Increment by 1 for parts less than 9 } else { lastPartIndex + 1 // Increment by 1 for parts 9 or greater } val lastPartIndexString = if (nextIndex < 10) { String.format("%02d", nextIndex) } else { nextIndex.toString() } val lastPart = File(tempZipFile.parent, "$baseFileName.z$lastPartIndexString") tempZipFile.renameTo(lastPart) } catch (e: ZipException) { showToast("${getString(R.string.zip_creation_failed)} ${e.message}") showProgressBar(false) showProgressBarText(false) } finally { when { cachedFiles.isNotEmpty() -> { for (cachedFile in cachedFiles) { cachedFile.delete() } cachedFiles.clear() } tempFiles.isNotEmpty() -> { for (tempFile in tempFiles) { tempFile.delete() } tempFiles.clear() } cacheFile != null -> { cacheFile!!.delete() } } showProgressBar(true) showProgressBarText(true) } val cacheFiles = requireContext().cacheDir.listFiles() cacheFiles?.forEach { file -> if (file.extension.matches(Regex("z\\d{2}"))) { val outputUri = DocumentsContract.buildDocumentUriUsingTree(outputDirectory!!.uri, DocumentsContract.getTreeDocumentId( outputDirectory!!.uri)) val outputZipUri = DocumentsContract.createDocument(requireActivity().contentResolver, outputUri, "application/octet-stream", file.name) requireActivity().contentResolver.openOutputStream(outputZipUri!!, "w").use { outputStream -> FileInputStream(file).use { tempInputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int while (tempInputStream.read(buffer).also { bytesRead = it } != -1) { outputStream!!.write(buffer, 0, bytesRead) } } } } } showExtractionCompletedSnackbar(outputDirectory) showToast(getString(R.string.zip_creation_success)) } catch (e: Exception) { e.printStackTrace() showToast(getString(R.string.zip_creation_failed)) } } } private suspend fun showProgressBar(show: Boolean) { withContext(Dispatchers.Main) { binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE } } private suspend fun showProgressBarText(show: Boolean) { withContext(Dispatchers.Main) { binding.progressText.visibility = if (show) View.VISIBLE else View.GONE } } private suspend fun showExtractionCompletedSnackbar(outputDirectory: DocumentFile?) { withContext(Dispatchers.Main) { binding.progressBar.visibility = View.GONE // Show a snackbar with a button to open the ZIP file val snackbar = Snackbar.make(binding.root, getString(R.string.zip_creation_success), Snackbar.LENGTH_LONG) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { snackbar.setAction(getString(R.string.open_folder)) { val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(outputDirectory?.uri, DocumentsContract.Document.MIME_TYPE_DIR) intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION startActivity(intent) } } snackbar.show() } } private fun getZipFileName(selectedFileUri: Uri?): String? { if (selectedFileUri != null) { val cursor = requireActivity().contentResolver.query(selectedFileUri, null, null, null, null) cursor?.use { if (it.moveToFirst()) { val displayNameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (displayNameIndex != -1) { return it.getString(displayNameIndex) } } } } return null } private fun showCompressionSettingsDialog( onCompressionSettingsEntered: ( String, String?, CompressionMethod, CompressionLevel, Boolean, EncryptionMethod?, AesKeyStrength?, Long?) -> Unit) { val layoutInflater = LayoutInflater.from(requireContext()) val customView = layoutInflater.inflate(R.layout.zip_option_dialog, null) val compressionMethodSpinner = customView.findViewById(R.id.compression_method_input) val compressionLevelSpinner = customView.findViewById(R.id.compression_level_input) val encryptionMethodSpinner = customView.findViewById(R.id.encryption_method_input) val encryptionStrengthSpinner = customView.findViewById(R.id.encryption_strength_input) val passwordInput = customView.findViewById(R.id.passwordEditText) val zipNameEditText = customView.findViewById(R.id.zipNameEditText) val splitZipCheckbox = customView.findViewById(R.id.splitZipCheckbox) val splitSizeInput = customView.findViewById(R.id.splitSizeEditText) val splitSizeUnitSpinner = customView.findViewById(R.id.splitSizeUnitSpinner) val splitSizeUnits = arrayOf("KB", "MB", "GB") val splitSizeUnitAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, splitSizeUnits) splitSizeUnitAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) splitSizeUnitSpinner.adapter = splitSizeUnitAdapter splitZipCheckbox.setOnCheckedChangeListener { _, isChecked -> splitSizeInput.isEnabled = isChecked if (!isChecked) { splitSizeInput.text.clear() } } val compressionMethods = CompressionMethod.values().filter { it != CompressionMethod.AES_INTERNAL_ONLY }.map { it.name }.toTypedArray() val compressionMethodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, compressionMethods) compressionMethodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) compressionMethodSpinner.adapter = compressionMethodAdapter val compressionLevels = CompressionLevel.values().map { it.name }.toTypedArray() val compressionLevelAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, compressionLevels) compressionLevelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) compressionLevelSpinner.adapter = compressionLevelAdapter val encryptionMethods = EncryptionMethod.values().map { it.name }.toTypedArray() val encryptionMethodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, encryptionMethods) encryptionMethodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) encryptionMethodSpinner.adapter = encryptionMethodAdapter val encryptionStrengths = AesKeyStrength.values().filter { it != AesKeyStrength.KEY_STRENGTH_192 }.map { it.name }.toTypedArray() val encryptionStrengthAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, encryptionStrengths) encryptionStrengthAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) encryptionStrengthSpinner.adapter = encryptionStrengthAdapter val filesToArchive = tempFiles.ifEmpty { cachedFiles } var isPasswordVisible = false val builder = MaterialAlertDialogBuilder(requireContext(), R.style.MaterialDialog) builder.setView(customView) builder.setPositiveButton(getString(R.string.ok)) { _, _ -> val defaultName = if (filesToArchive.isNotEmpty()) { filesToArchive[0].nameWithoutExtension } else { "" } val archiveName = zipNameEditText.text.toString().ifBlank { defaultName } val password = passwordInput.text.toString() val isEncryptionEnabled = password.isNotEmpty() val selectedCompressionMethod = CompressionMethod.valueOf(compressionMethods[compressionMethodSpinner.selectedItemPosition]) val selectedCompressionLevel = CompressionLevel.valueOf(compressionLevels[compressionLevelSpinner.selectedItemPosition]) val selectedEncryptionMethod = if (encryptionMethodSpinner.selectedItemPosition != 0) { EncryptionMethod.valueOf(encryptionMethods[encryptionMethodSpinner.selectedItemPosition]) } else { null } val selectedEncryptionStrength = if (selectedEncryptionMethod != null && selectedEncryptionMethod != EncryptionMethod.NONE) { AesKeyStrength.valueOf(encryptionStrengths[encryptionStrengthSpinner.selectedItemPosition]) } else { null } val splitSizeText = splitSizeInput.text.toString() val splitSizeUnit = splitSizeUnits[splitSizeUnitSpinner.selectedItemPosition] val splitZipSize = if (splitZipCheckbox.isChecked) { convertToBytes(splitSizeText.toLongOrNull() ?: 64, splitSizeUnit) } else { null } onCompressionSettingsEntered.invoke( archiveName, password.takeIf { isEncryptionEnabled }, selectedCompressionMethod, selectedCompressionLevel, isEncryptionEnabled, selectedEncryptionMethod, selectedEncryptionStrength, splitZipSize ) // Call createSplitZipFile function only if splitZipCheckbox is checked if (splitZipCheckbox.isChecked) { createSplitZipFile( archiveName, password, selectedCompressionMethod, selectedCompressionLevel, isEncryptionEnabled, selectedEncryptionMethod, selectedEncryptionStrength, splitZipSize!! ) } else { createZipFile( archiveName, password, selectedCompressionMethod, selectedCompressionLevel, isEncryptionEnabled, selectedEncryptionMethod, selectedEncryptionStrength ) } } builder.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> dialog.dismiss() } encryptionMethodSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { val selectedEncryptionMethod = EncryptionMethod.valueOf(encryptionMethods[position]) passwordInput.isEnabled = selectedEncryptionMethod != EncryptionMethod.NONE encryptionStrengthSpinner.isEnabled = selectedEncryptionMethod == EncryptionMethod.AES } override fun onNothingSelected(parent: AdapterView<*>?) { // Do nothing } } builder.show() } private fun convertToBytes(size: Long?, unit: String): Long? { return size?.times(when (unit) { "KB" -> 1024L "MB" -> 1024L * 1024 "GB" -> 1024L * 1024 * 1024 else -> 1024L }) } private fun showPasswordInputDialog7z(onPasswordEntered: (String?, String, Int, Boolean, Int) -> Unit) { val layoutInflater = LayoutInflater.from(requireContext()) val customView = layoutInflater.inflate(R.layout.seven_z_option_dialog, null) val passwordEditText = customView.findViewById(R.id.passwordEditText) val compressionSpinner = customView.findViewById(R.id.compressionSpinner) val solidCheckBox = customView.findViewById(R.id.solidCheckBox) val threadCountEditText = customView.findViewById(R.id.threadCountEditText) val archiveNameEditText = customView.findViewById(R.id.archiveNameEditText) val filesToArchive = tempFiles.ifEmpty { cachedFiles } MaterialAlertDialogBuilder(requireContext(), R.style.MaterialDialog) .setView(customView) .setPositiveButton(getString(R.string.ok)) { _, _ -> val defaultName = if (filesToArchive.isNotEmpty()) { filesToArchive[0].nameWithoutExtension } else { "archive" } val archiveName = archiveNameEditText.text.toString().ifBlank { defaultName } val password = passwordEditText.text.toString() val compressionLevel = when (compressionSpinner.selectedItemPosition) { 0 -> 0 1 -> 1 2 -> 3 3 -> 5 4 -> 7 5 -> 9 else -> -1 } val solid = solidCheckBox.isChecked val threadCount = threadCountEditText.text.toString().toIntOrNull() ?: -1 onPasswordEntered.invoke( password.ifBlank { null }, archiveName, compressionLevel, solid, threadCount ) } .setNegativeButton(getString(R.string.no_password)) { _, _ -> val defaultName = if (filesToArchive.isNotEmpty()) { filesToArchive[0].nameWithoutExtension } else { "archive" } val archiveName = archiveNameEditText.text.toString().ifBlank { defaultName } val compressionLevel = when (compressionSpinner.selectedItemPosition) { 0 -> 0 1 -> 1 2 -> 3 3 -> 5 4 -> 7 5 -> 9 else -> -1 } val solid = solidCheckBox.isChecked val threadCount = threadCountEditText.text.toString().toIntOrNull() ?: -1 onPasswordEntered.invoke( null, archiveName, compressionLevel, solid, threadCount ) } .show() } private fun showToast(message: String) { CoroutineScope(Dispatchers.Main).launch { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } fun formatFileSize(bytes: Long): String { val kilobyte = 1024 val megabyte = kilobyte * 1024 val gigabyte = megabyte * 1024 return when { bytes < kilobyte -> "$bytes B" bytes < megabyte -> String.format("%.2f KB", bytes.toFloat() / kilobyte) bytes < gigabyte -> String.format("%.2f MB", bytes.toFloat() / megabyte) else -> String.format("%.2f GB", bytes.toFloat() / gigabyte) } } companion object { private const val PREFS_NAME = "ZipPrefs" } } \ No newline at end of file diff --git a/app/src/main/java/com/wirelessalien/zipxtract/activity/MainActivity.kt b/app/src/main/java/com/wirelessalien/zipxtract/activity/MainActivity.kt index f9ae996..1e9ca55 100644 --- a/app/src/main/java/com/wirelessalien/zipxtract/activity/MainActivity.kt +++ b/app/src/main/java/com/wirelessalien/zipxtract/activity/MainActivity.kt @@ -16,6 +16,7 @@ import android.os.FileObserver import android.os.Handler import android.os.Looper import android.provider.Settings +import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View @@ -62,6 +63,7 @@ import com.wirelessalien.zipxtract.service.Archive7zService import com.wirelessalien.zipxtract.service.ArchiveSplitZipService import com.wirelessalien.zipxtract.service.ArchiveZipService import com.wirelessalien.zipxtract.service.ExtractArchiveService +import com.wirelessalien.zipxtract.service.ExtractCsArchiveService import com.wirelessalien.zipxtract.service.ExtractMultipart7zService import com.wirelessalien.zipxtract.service.ExtractMultipartZipService import com.wirelessalien.zipxtract.service.ExtractRarService @@ -686,12 +688,19 @@ class MainActivity : AppCompatActivity(), FileAdapter.OnItemClickListener, FileA val btnCompress7z = bottomSheetView.findViewById