diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3e4621cd..0d6d28a21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,8 @@ dependencies { implementation(libs.androidx.documentfile) implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidpdfviewer) + implementation(libs.apache.commons.compress) + implementation(libs.tukaanixz) implementation(libs.roottools) implementation(libs.rootshell) implementation(libs.gestureviews) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f3d41e79b..f284f0287 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,3 +2,4 @@ -dontnote org.apache.http.** -keep class com.simplemobiletools.** { *; } -dontwarn com.simplemobiletools.** +-dontwarn com.github.luben.zstd.ZstdOutputStream diff --git a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/ItemsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/ItemsAdapter.kt index 0c0932e1e..24a2e6ea1 100644 --- a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/ItemsAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/ItemsAdapter.kt @@ -46,12 +46,8 @@ import com.simplemobiletools.filemanager.pro.models.ListItem import com.stericson.RootTools.RootTools import net.lingala.zip4j.exception.ZipException import net.lingala.zip4j.io.inputstream.ZipInputStream -import net.lingala.zip4j.io.outputstream.ZipOutputStream import net.lingala.zip4j.model.LocalFileHeader -import net.lingala.zip4j.model.ZipParameters -import net.lingala.zip4j.model.enums.EncryptionMethod import java.io.BufferedInputStream -import java.io.Closeable import java.io.File import java.util.* @@ -476,7 +472,7 @@ class ItemsAdapter( return } - CompressAsDialog(activity, firstPath) { destination, password -> + CompressAsDialog(activity, firstPath) { destination, compressionFormat, password -> activity.handleAndroidSAFDialog(firstPath) { granted -> if (!granted) { return@handleAndroidSAFDialog @@ -489,7 +485,7 @@ class ItemsAdapter( activity.toast(R.string.compressing) val paths = getSelectedFileDirItems().map { it.path } ensureBackgroundThread { - if (compressPaths(paths, destination, password)) { + if (CompressionHelper.compress(activity, paths, destination, compressionFormat, password)) { activity.runOnUiThread { activity.toast(R.string.compression_successful) listener?.refreshFragment() @@ -638,89 +634,6 @@ class ItemsAdapter( } } - @SuppressLint("NewApi") - private fun compressPaths(sourcePaths: List, targetPath: String, password: String? = null): Boolean { - val queue = LinkedList() - val fos = activity.getFileOutputStreamSync(targetPath, "application/zip") ?: return false - - val zout = password?.let { ZipOutputStream(fos, password.toCharArray()) } ?: ZipOutputStream(fos) - var res: Closeable = fos - - fun zipEntry(name: String) = ZipParameters().also { - it.fileNameInZip = name - if (password != null) { - it.isEncryptFiles = true - it.encryptionMethod = EncryptionMethod.AES - } - } - - try { - sourcePaths.forEach { currentPath -> - var name: String - var mainFilePath = currentPath - val base = "${mainFilePath.getParentPath()}/" - res = zout - queue.push(mainFilePath) - if (activity.getIsPathDirectory(mainFilePath)) { - name = "${mainFilePath.getFilenameFromPath()}/" - zout.putNextEntry( - ZipParameters().also { - it.fileNameInZip = name - } - ) - } - - while (!queue.isEmpty()) { - mainFilePath = queue.pop() - if (activity.getIsPathDirectory(mainFilePath)) { - if (activity.isRestrictedSAFOnlyRoot(mainFilePath)) { - activity.getAndroidSAFFileItems(mainFilePath, true) { files -> - for (file in files) { - name = file.path.relativizeWith(base) - if (activity.getIsPathDirectory(file.path)) { - queue.push(file.path) - name = "${name.trimEnd('/')}/" - zout.putNextEntry(zipEntry(name)) - } else { - zout.putNextEntry(zipEntry(name)) - activity.getFileInputStreamSync(file.path)!!.copyTo(zout) - zout.closeEntry() - } - } - } - } else { - val mainFile = File(mainFilePath) - for (file in mainFile.listFiles()) { - name = file.path.relativizeWith(base) - if (activity.getIsPathDirectory(file.absolutePath)) { - queue.push(file.absolutePath) - name = "${name.trimEnd('/')}/" - zout.putNextEntry(zipEntry(name)) - } else { - zout.putNextEntry(zipEntry(name)) - activity.getFileInputStreamSync(file.path)!!.copyTo(zout) - zout.closeEntry() - } - } - } - - } else { - name = if (base == currentPath) currentPath.getFilenameFromPath() else mainFilePath.relativizeWith(base) - zout.putNextEntry(zipEntry(name)) - activity.getFileInputStreamSync(mainFilePath)!!.copyTo(zout) - zout.closeEntry() - } - } - } - } catch (exception: Exception) { - activity.showErrorToast(exception) - return false - } finally { - res.close() - } - return true - } - private fun askConfirmDelete() { activity.handleDeletePasswordProtection { val itemsCnt = selectedKeys.size diff --git a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/dialogs/CompressAsDialog.kt b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/dialogs/CompressAsDialog.kt index ce2c69726..ccc5101c2 100644 --- a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/dialogs/CompressAsDialog.kt +++ b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/dialogs/CompressAsDialog.kt @@ -1,6 +1,7 @@ package com.simplemobiletools.filemanager.pro.dialogs import android.view.View +import android.widget.ArrayAdapter import androidx.appcompat.app.AlertDialog import com.simplemobiletools.commons.activities.BaseSimpleActivity import com.simplemobiletools.commons.dialogs.FilePickerDialog @@ -8,8 +9,13 @@ import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.filemanager.pro.R import com.simplemobiletools.filemanager.pro.databinding.DialogCompressAsBinding import com.simplemobiletools.filemanager.pro.extensions.config +import com.simplemobiletools.filemanager.pro.helpers.CompressionFormat -class CompressAsDialog(val activity: BaseSimpleActivity, val path: String, val callback: (destination: String, password: String?) -> Unit) { +class CompressAsDialog( + val activity: BaseSimpleActivity, + val path: String, + val callback: (destination: String, compressionFormat: CompressionFormat, password: String?) -> Unit +) { private val binding = DialogCompressAsBinding.inflate(activity.layoutInflater) init { @@ -29,6 +35,24 @@ class CompressAsDialog(val activity: BaseSimpleActivity, val path: String, val c } } + compressionFormatValue.apply { + setOnClickListener { + activity.hideKeyboard(filenameValue) + } + val adapter = ArrayAdapter( + activity, + android.R.layout.simple_dropdown_item_1line, + CompressionFormat.entries.take(CompressionFormat.entries.size - 1).map { it.extension }) + setAdapter(adapter) + setText(adapter.getItem(0), false) + + setOnItemClickListener { _, _, i, _ -> + val compressionFormat = CompressionFormat.entries[i] + filenameHint.hint = String.format(activity.getString(R.string.filename_without_extension), compressionFormat.extension) + passwordProtect.beVisibleIf(compressionFormat.canCreateEncryptedArchive) + enterPasswordHint.beVisibleIf(compressionFormat.canCreateEncryptedArchive && passwordProtect.isChecked) + } + } passwordProtect.setOnCheckedChangeListener { _, _ -> enterPasswordHint.beVisibleIf(passwordProtect.isChecked) } @@ -53,14 +77,14 @@ class CompressAsDialog(val activity: BaseSimpleActivity, val path: String, val c when { name.isEmpty() -> activity.toast(R.string.empty_name) name.isAValidFilename() -> { - val newPath = "$realPath/$name.zip" + val newPath = "$realPath/$name${getSelectedCompressionFormat().extension}" if (activity.getDoesFilePathExist(newPath)) { activity.toast(R.string.name_taken) return@OnClickListener } alertDialog.dismiss() - callback(newPath, password) + callback(newPath, getSelectedCompressionFormat(), password) } else -> activity.toast(R.string.invalid_name) @@ -69,4 +93,6 @@ class CompressAsDialog(val activity: BaseSimpleActivity, val path: String, val c } } } + + private fun getSelectedCompressionFormat() = CompressionFormat.fromExtension(binding.compressionFormatValue.text.toString()) } diff --git a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/helpers/CompressionFormat.kt b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/helpers/CompressionFormat.kt new file mode 100644 index 000000000..a1ab5aab8 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/helpers/CompressionFormat.kt @@ -0,0 +1,35 @@ +package com.simplemobiletools.filemanager.pro.helpers + +import org.apache.commons.compress.compressors.CompressorStreamFactory + +enum class CompressionFormat( + val extension: String, + val mimeType: String, + val compressorStreamFactory: String, + val canReadEncryptedArchive: Boolean, + val canCreateEncryptedArchive: Boolean +) { + ZIP(".zip", "application/zip", "", true, true), + SEVEN_ZIP(".7z", "application/x-7z-compressed", "", true, false), + TAR_GZ(".tar.gz", "application/gzip", CompressorStreamFactory.GZIP, false, false), + TAR_XZ(".tar.xz", "application/x-xz", CompressorStreamFactory.XZ, false, false), + UNKNOWN("", "", "", false, false); + + companion object { + fun fromExtension(extension: String): CompressionFormat { + val normalizedExtension = if (extension.startsWith(".")) { + extension + } else { + ".$extension" + } + + return when (normalizedExtension.lowercase()) { + ZIP.extension -> ZIP + SEVEN_ZIP.extension -> SEVEN_ZIP + TAR_GZ.extension -> TAR_GZ + TAR_XZ.extension -> TAR_XZ + else -> UNKNOWN + } + } + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/helpers/CompressionHelper.kt b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/helpers/CompressionHelper.kt new file mode 100644 index 000000000..1cebb9376 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/helpers/CompressionHelper.kt @@ -0,0 +1,218 @@ +package com.simplemobiletools.filemanager.pro.helpers + +import com.simplemobiletools.commons.activities.BaseSimpleActivity +import com.simplemobiletools.commons.extensions.* +import net.lingala.zip4j.io.outputstream.ZipOutputStream +import net.lingala.zip4j.model.ZipParameters +import net.lingala.zip4j.model.enums.EncryptionMethod +import org.apache.commons.compress.archivers.ArchiveEntry +import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.compressors.CompressorStreamFactory +import org.apache.commons.compress.utils.IOUtils +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.LinkedList + +object CompressionHelper { + fun compress( + activity: BaseSimpleActivity, + sourcePaths: List, + targetPath: String, + compressionFormat: CompressionFormat, + password: String? = null + ): Boolean { + return when (compressionFormat) { + CompressionFormat.ZIP -> compressToZip(activity, sourcePaths, targetPath, password) + CompressionFormat.SEVEN_ZIP -> compressToSevenZip(activity, sourcePaths, targetPath) + CompressionFormat.TAR_GZ, + CompressionFormat.TAR_XZ -> compressToTarVariants(activity, sourcePaths, targetPath, compressionFormat) + + CompressionFormat.UNKNOWN -> false + } + } + + private fun compressToZip( + activity: BaseSimpleActivity, + sourcePaths: List, + targetPath: String, + password: String? = null + ): Boolean { + val queue = LinkedList() + val fos = activity.getFileOutputStreamSync(targetPath, CompressionFormat.ZIP.mimeType) ?: return false + + val zout = password?.let { ZipOutputStream(fos, password.toCharArray()) } ?: ZipOutputStream(fos) + var res: Closeable = fos + + fun zipEntry(name: String) = ZipParameters().also { + it.fileNameInZip = name + if (password != null) { + it.isEncryptFiles = true + it.encryptionMethod = EncryptionMethod.AES + } + } + + try { + sourcePaths.forEach { currentPath -> + var name: String + var mainFilePath = currentPath + val base = "${mainFilePath.getParentPath()}/" + res = zout + queue.push(mainFilePath) + if (activity.getIsPathDirectory(mainFilePath)) { + name = "${mainFilePath.getFilenameFromPath()}/" + zout.putNextEntry( + ZipParameters().also { + it.fileNameInZip = name + } + ) + } + + while (!queue.isEmpty()) { + mainFilePath = queue.pop() + if (activity.getIsPathDirectory(mainFilePath)) { + if (activity.isRestrictedSAFOnlyRoot(mainFilePath)) { + activity.getAndroidSAFFileItems(mainFilePath, true) { files -> + for (file in files) { + name = file.path.relativizeWith(base) + if (activity.getIsPathDirectory(file.path)) { + queue.push(file.path) + name = "${name.trimEnd('/')}/" + zout.putNextEntry(zipEntry(name)) + } else { + zout.putNextEntry(zipEntry(name)) + activity.getFileInputStreamSync(file.path)!!.copyTo(zout) + zout.closeEntry() + } + } + } + } else { + val mainFile = File(mainFilePath) + for (file in mainFile.listFiles()) { + name = file.path.relativizeWith(base) + if (activity.getIsPathDirectory(file.absolutePath)) { + queue.push(file.absolutePath) + name = "${name.trimEnd('/')}/" + zout.putNextEntry(zipEntry(name)) + } else { + zout.putNextEntry(zipEntry(name)) + activity.getFileInputStreamSync(file.path)!!.copyTo(zout) + zout.closeEntry() + } + } + } + + } else { + name = if (base == currentPath) currentPath.getFilenameFromPath() else mainFilePath.relativizeWith(base) + zout.putNextEntry(zipEntry(name)) + activity.getFileInputStreamSync(mainFilePath)!!.copyTo(zout) + zout.closeEntry() + } + } + } + } catch (exception: Exception) { + activity.showErrorToast(exception) + return false + } finally { + res.close() + } + return true + } + + private fun compressToSevenZip( + activity: BaseSimpleActivity, + sourcePaths: List, + targetPath: String, + ): Boolean { + try { + SevenZOutputFile(File(targetPath)).use { sevenZOutput -> + sourcePaths.forEach { sourcePath -> + Files.walk(File(sourcePath).toPath()).forEach { path -> + val file = path.toFile() + val basePath = "${sourcePath.getParentPath()}/" + + if (!activity.getIsPathDirectory(file.absolutePath)) { + FileInputStream(file).use { _ -> + val entryName = if (basePath == sourcePath) { + sourcePath.getFilenameFromPath() + } else { + path.toString().relativizeWith(basePath) + } + + val sevenZArchiveEntry = sevenZOutput.createArchiveEntry(file, entryName) + sevenZOutput.putArchiveEntry(sevenZArchiveEntry) + sevenZOutput.write(Files.readAllBytes(file.toPath())) + sevenZOutput.closeArchiveEntry() + } + } + } + } + + sevenZOutput.finish() + } + } catch (exception: IOException) { + activity.showErrorToast(exception) + return false + } + return true + } + + private fun compressToTarVariants( + activity: BaseSimpleActivity, + sourcePaths: List, + outFilePath: String, + format: CompressionFormat + ): Boolean { + if (!listOf( + CompressionFormat.TAR_GZ, + CompressionFormat.TAR_XZ + ).contains(format) + ) { + return false + } + + val fos = activity.getFileOutputStreamSync(outFilePath, format.mimeType) + try { + fos.use { fileOutputStream -> + CompressorStreamFactory() + .createCompressorOutputStream(format.compressorStreamFactory, fileOutputStream).use { compressedOut -> + TarArchiveOutputStream(compressedOut).use { archive -> + archive.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX) + sourcePaths.forEach { sourcePath -> + val basePath = "${sourcePath.getParentPath()}/" + Files.walk(File(sourcePath).toPath()).forEach { path: Path -> + val file = path.toFile() + + if (!activity.getIsPathDirectory(file.absolutePath)) { + val entryName = if (basePath == sourcePath) { + sourcePath.getFilenameFromPath() + } else { + path.toString().relativizeWith(basePath) + } + + val tarArchiveEntry: ArchiveEntry = TarArchiveEntry(file, entryName) + FileInputStream(file).use { fis -> + archive.putArchiveEntry(tarArchiveEntry) + IOUtils.copy(fis, archive) + archive.closeArchiveEntry() + } + } + } + } + + archive.finish() + } + } + } + } catch (exception: IOException) { + activity.showErrorToast(exception) + return false + } + return true + } +} diff --git a/app/src/main/res/layout/dialog_compress_as.xml b/app/src/main/res/layout/dialog_compress_as.xml index f5229ff18..48e6cad66 100644 --- a/app/src/main/res/layout/dialog_compress_as.xml +++ b/app/src/main/res/layout/dialog_compress_as.xml @@ -24,10 +24,28 @@ + + + + + + Decompression successful Compressing failed Decompressing failed + + Compression format + + Filename (without %s) Manage favorites diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30c4e7b5a..87a20de1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,12 +13,14 @@ androidpdfviewer = "e6a533125b" rootshell = "1.6" roottools = "df729dcb13" zip4j = "2.11.5" +apachecommonscompress = "1.22" +tukaanixz = "1.9" #Gradle gradlePlugins-agp = "8.1.1" #build app-build-compileSDKVersion = "34" app-build-targetSDK = "34" -app-build-minimumSDK = "23" +app-build-minimumSDK = "26" app-build-javaVersion = "VERSION_17" app-build-kotlinJVMTarget = "17" #versioning @@ -38,6 +40,8 @@ gestureviews = { module = "com.alexvasilkov:gesture-views", version.ref = "gestu rootshell = { module = "com.github.Stericson:RootShell", version.ref = "rootshell" } roottools = { module = "com.github.Stericson:RootTools", version.ref = "roottools" } zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" } +apache-commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "apachecommonscompress" } +tukaanixz = { module = "org.tukaani:xz", version.ref = "tukaanixz" } [plugins] android = { id = "com.android.application", version.ref = "gradlePlugins-agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }