diff --git a/app/build.gradle b/app/build.gradle index d27b0f936..ea7698f4c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,7 +78,7 @@ android { } dependencies { - implementation 'com.github.SimpleMobileTools:Simple-Commons:d1629c7f1a' + implementation 'com.github.esensar:Simple-Commons:ef5602aee2' implementation 'com.github.tibbi:AndroidPdfViewer:e6a533125b' implementation 'com.github.Stericson:RootTools:df729dcb13' implementation 'com.github.Stericson:RootShell:1.6' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6929cc5a2..e60497ab6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ + + + + + + handleSAFDialog(destination) { if (it) { - ensureBackgroundThread { - decompressTo(destination) + Intent(this, CompressionService::class.java).apply { + action = CompressionService.ACTION_DECOMPRESS + putExtra(CompressionService.EXTRA_URI, uri!!) + putExtra(CompressionService.EXTRA_PASSWORD, password) + putExtra(CompressionService.EXTRA_DESTINATION, destination) + startService(this) } } } } } - private fun decompressTo(destination: String) { - try { - val inputStream = contentResolver.openInputStream(uri!!) - val zipInputStream = ZipInputStream(BufferedInputStream(inputStream!!)) - if (password != null) { - zipInputStream.setPassword(password?.toCharArray()) - } - val buffer = ByteArray(1024) - - zipInputStream.use { - while (true) { - val entry = zipInputStream.nextEntry ?: break - val filename = title.toString().substringBeforeLast(".") - val parent = "$destination/$filename" - val newPath = "$parent/${entry.fileName.trimEnd('/')}" - - if (!getDoesFilePathExist(parent)) { - if (!createDirectorySync(parent)) { - continue - } - } - - if (entry.isDirectory) { - continue - } - - val isVulnerableForZipPathTraversal = !File(newPath).canonicalPath.startsWith(parent) - if (isVulnerableForZipPathTraversal) { - continue - } - - val fos = getFileOutputStreamSync(newPath, newPath.getMimeType()) - var count: Int - while (true) { - count = zipInputStream.read(buffer) - if (count == -1) { - break - } - - fos!!.write(buffer, 0, count) - } - fos!!.close() - } - - toast(R.string.decompression_successful) - finish() - } - } catch (e: Exception) { - showErrorToast(e) - } - } - private fun getFolderItems(parent: String): ArrayList { return allFiles.filter { val fileParent = if (it.path.contains("/")) { @@ -185,47 +141,72 @@ class DecompressActivity : SimpleActivity() { @SuppressLint("NewApi") private fun fillAllListItems(uri: Uri) { - val inputStream = try { - contentResolver.openInputStream(uri) - } catch (e: Exception) { - showErrorToast(e) - return - } + binding.progressBar.beVisible() + ensureBackgroundThread { + val inputStream = try { + contentResolver.openInputStream(uri) + } catch (e: Exception) { + runOnUiThread { + showErrorToast(e) + binding.progressBar.beGone() + } + return@ensureBackgroundThread + } - val zipInputStream = ZipInputStream(BufferedInputStream(inputStream)) - if (password != null) { - zipInputStream.setPassword(password?.toCharArray()) - } - var zipEntry: LocalFileHeader? - while (true) { - try { - zipEntry = zipInputStream.nextEntry - } catch (passwordException: ZipException) { - if (passwordException.type == Type.WRONG_PASSWORD) { - if (password != null) { - toast(getString(R.string.invalid_password)) - passwordDialog?.clearPassword() + val zipInputStream = ZipInputStream(BufferedInputStream(inputStream)) + if (password != null) { + zipInputStream.setPassword(password?.toCharArray()) + } + var zipEntry: LocalFileHeader? + while (true) { + try { + zipEntry = zipInputStream.nextEntry + } catch (passwordException: ZipException) { + if (passwordException.type == Type.WRONG_PASSWORD) { + if (password != null) { + runOnUiThread { + toast(getString(R.string.invalid_password)) + binding.progressBar.beGone() + if (passwordDialog == null) { + askForPassword() + } else { + passwordDialog?.clearPassword() + } + } + } else { + runOnUiThread { + binding.progressBar.beGone() + askForPassword() + } + } + return@ensureBackgroundThread } else { - askForPassword() + break } - return - } else { + } catch (ignored: Exception) { break } - } catch (ignored: Exception) { - break - } - if (zipEntry == null) { - break - } + if (zipEntry == null) { + break + } - val lastModified = if (isOreoPlus()) zipEntry.lastModifiedTime else 0 - val filename = zipEntry.fileName.removeSuffix("/") - val listItem = ListItem(filename, filename.getFilenameFromPath(), zipEntry.isDirectory, 0, 0L, lastModified, false, false) - allFiles.add(listItem) + val lastModified = if (isOreoPlus()) zipEntry.lastModifiedTime else 0 + val filename = zipEntry.fileName.removeSuffix("/") + val listItem = ListItem(filename, filename.getFilenameFromPath(), zipEntry.isDirectory, 0, 0L, lastModified, false, false) + runOnUiThread { + allFiles.add(listItem) + passwordDialog?.dismiss(notify = false) + passwordDialog = null + updateCurrentPath(currentPath) + } + } + runOnUiThread { + binding.progressBar.beGone() + passwordDialog?.dismiss(notify = false) + passwordDialog = null + } } - passwordDialog?.dismiss(notify = false) } private fun askForPassword() { diff --git a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/DecompressItemsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/DecompressItemsAdapter.kt index 53ac45cc5..fab89ced8 100644 --- a/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/DecompressItemsAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/adapters/DecompressItemsAdapter.kt @@ -6,6 +6,7 @@ import android.util.TypedValue import android.view.Menu import android.view.View import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions @@ -21,6 +22,7 @@ import com.simplemobiletools.filemanager.pro.activities.SimpleActivity import com.simplemobiletools.filemanager.pro.databinding.ItemDecompressionListFileDirBinding import com.simplemobiletools.filemanager.pro.extensions.config import com.simplemobiletools.filemanager.pro.models.ListItem +import java.util.ArrayList class DecompressItemsAdapter(activity: SimpleActivity, var listItems: MutableList, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit) : MyRecyclerViewAdapter(activity, recyclerView, itemClick) { @@ -135,4 +137,23 @@ class DecompressItemsAdapter(activity: SimpleActivity, var listItems: MutableLis fileDrawable = resources.getDrawable(R.drawable.ic_file_generic) fileDrawables = getFilePlaceholderDrawables(activity) } + + fun updateItems(listItems: ArrayList) { + val diffResult = DiffUtil.calculateDiff(DiffCallback(this.listItems, listItems)) + this.listItems.clear() + this.listItems.addAll(listItems) + diffResult.dispatchUpdatesTo(this) + } + + class DiffCallback(private val oldList: List, private val newList: List) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition].path == newList[newItemPosition].path + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldList[oldItemPosition] == newList[newItemPosition] + + } } 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..d1f33ef48 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 @@ -43,15 +43,12 @@ import com.simplemobiletools.filemanager.pro.extensions.* import com.simplemobiletools.filemanager.pro.helpers.* import com.simplemobiletools.filemanager.pro.interfaces.ItemOperationsListener import com.simplemobiletools.filemanager.pro.models.ListItem +import com.simplemobiletools.filemanager.pro.services.CompressionService 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.* @@ -486,19 +483,15 @@ class ItemsAdapter( return@handleSAFDialog } - activity.toast(R.string.compressing) val paths = getSelectedFileDirItems().map { it.path } - ensureBackgroundThread { - if (compressPaths(paths, destination, password)) { - activity.runOnUiThread { - activity.toast(R.string.compression_successful) - listener?.refreshFragment() - finishActMode() - } - } else { - activity.toast(R.string.compressing_failed) - } + Intent(activity, CompressionService::class.java).apply { + action = CompressionService.ACTION_COMPRESS + putStringArrayListExtra(CompressionService.EXTRA_PATHS, ArrayList(paths)) + putExtra(CompressionService.EXTRA_PASSWORD, password) + putExtra(CompressionService.EXTRA_DESTINATION, destination) + activity.startService(this) } + finishActMode() } } } @@ -638,89 +631,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/services/CompressionService.kt b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/services/CompressionService.kt new file mode 100644 index 000000000..1d064444d --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/filemanager/pro/services/CompressionService.kt @@ -0,0 +1,347 @@ +package com.simplemobiletools.filemanager.pro.services + +import android.annotation.SuppressLint +import android.app.* +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toFile +import com.simplemobiletools.commons.compose.extensions.getActivity +import com.simplemobiletools.commons.extensions.* +import com.simplemobiletools.commons.helpers.CONFLICT_OVERWRITE +import com.simplemobiletools.commons.helpers.CONFLICT_SKIP +import com.simplemobiletools.commons.helpers.ensureBackgroundThread +import com.simplemobiletools.commons.helpers.isOreoPlus +import com.simplemobiletools.commons.models.FileDirItem +import com.simplemobiletools.filemanager.pro.R +import com.simplemobiletools.filemanager.pro.activities.SplashActivity +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.Collections +import java.util.LinkedList +import java.util.zip.ZipFile + +class CompressionService : Service() { + companion object { + private var NOTIFICATION_ID = 10000 + + private const val ACTION_ID = "com.simplemobiletools.filemanager.pro.action" + const val ACTION_COMPRESS = "$ACTION_ID.ACTION_COMPRESS" + const val ACTION_DECOMPRESS = "$ACTION_ID.ACTION_DECOMPRESS" + const val ACTION_CANCEL = "$ACTION_ID.ACTION_CANCEL" + + const val EXTRA_URI = "uri" + const val EXTRA_PATHS = "paths" + const val EXTRA_PASSWORD = "password" + const val EXTRA_DESTINATION = "destination" + private const val EXTRA_JOB_ID = "job_id" + + private val cancellations = Collections.synchronizedSet(mutableSetOf()) + private val running = Collections.synchronizedSet(mutableSetOf()) + } + + private var notificationId = NOTIFICATION_ID++ + + override fun onBind(p0: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + + when (intent.action) { + ACTION_COMPRESS -> { + intent.apply { + val paths = getStringArrayListExtra(EXTRA_PATHS)!! + val password = getStringExtra(EXTRA_PASSWORD) + val destination = getStringExtra(EXTRA_DESTINATION)!! + val jobId = notificationId++ + startServiceJob(jobId, destination.getFilenameFromPath(), getString(R.string.compressing)) + ensureBackgroundThread { + toast(R.string.compressing) + if (compressPaths(jobId, paths, destination, password)) { + toast(R.string.compression_successful) + } else { + toast(R.string.compressing_failed) + } + finishServiceJob(jobId) + } + } + } + ACTION_DECOMPRESS -> { + intent.apply { + val uri = getParcelableExtra(EXTRA_URI)!! + val password = getStringExtra(EXTRA_PASSWORD) + val destination = getStringExtra(EXTRA_DESTINATION)!! + val realPath = getRealPathFromURI(uri) + val title = realPath?.getFilenameFromPath() ?: Uri.decode(uri.toString().getFilenameFromPath()) + val jobId = notificationId++ + startServiceJob(jobId, title, getString(R.string.decompressing)) + ensureBackgroundThread { + decompressTo(jobId, title, uri, password, destination) + finishServiceJob(jobId) + } + } + } + ACTION_CANCEL -> { + val jobId = intent.getIntExtra(EXTRA_JOB_ID, -1) + if (jobId != -1) { + cancellations.add(jobId) + notificationManager.cancel(jobId) + } + } + } + + return START_NOT_STICKY + } + + private fun decompressTo(jobId: Int, fileName: String, uri: Uri, password: String?, destination: String) { + try { + val inputStream = contentResolver.openInputStream(uri) + val zipInputStream = ZipInputStream(BufferedInputStream(inputStream!!)) + if (password != null) { + zipInputStream.setPassword(password.toCharArray()) + } + val buffer = ByteArray(1024) + var progress = 0 + var progressMax = 0 + + try { + progressMax = ZipFile(uri.toFile()).size() + } catch (e: Exception) { + e.printStackTrace() + // ignored + } + + zipInputStream.use { + while (true) { + if (cancellations.contains(jobId)) { + cancellations.remove(jobId) + return@use + } + + notificationManager.notify(jobId, showNotification(jobId, fileName, getString(R.string.decompressing), progress, progressMax)) + progress++ + + val realPath = getRealPathFromURI(uri) + val title = realPath?.getFilenameFromPath() ?: Uri.decode(uri.toString().getFilenameFromPath()) + val entry = zipInputStream.nextEntry ?: break + val filename = title.toString().substringBeforeLast(".") + val parent = "$destination/$filename" + val newPath = "$parent/${entry.fileName.trimEnd('/')}" + + if (!getDoesFilePathExist(parent)) { + if (!createDirectorySync(parent)) { + continue + } + } + + if (entry.isDirectory) { + continue + } + + val isVulnerableForZipPathTraversal = !File(newPath).canonicalPath.startsWith(parent) + if (isVulnerableForZipPathTraversal) { + continue + } + + val fos = getFileOutputStreamSync(newPath, newPath.getMimeType()) + var count: Int + while (true) { + if (cancellations.contains(jobId)) { + cancellations.remove(jobId) + fos?.close() + return@use + } + count = zipInputStream.read(buffer) + if (count == -1) { + break + } + + fos!!.write(buffer, 0, count) + } + fos!!.close() + } + + toast(R.string.decompression_successful) + } + } catch (e: Exception) { + showErrorToast(e) + } + } + + @SuppressLint("NewApi") + private fun compressPaths(jobId: Int, sourcePaths: List, targetPath: String, password: String? = null): Boolean { + val queue = LinkedList() + val fos = 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 + } + } + + var progress = 0 + var progressMax = sourcePaths.count() + + try { + sourcePaths.forEach { currentPath -> + if (cancellations.contains(jobId)) { + cancellations.remove(jobId) + res.close() + return@forEach + } + + notificationManager.notify(jobId, showNotification(jobId, targetPath.getFilenameFromPath(), getString(R.string.compressing), progress, progressMax)) + progress++ + + var name: String + var mainFilePath = currentPath + val base = "${mainFilePath.getParentPath()}/" + res = zout + queue.push(mainFilePath) + if (getIsPathDirectory(mainFilePath)) { + name = "${mainFilePath.getFilenameFromPath()}/" + zout.putNextEntry( + ZipParameters().also { + it.fileNameInZip = name + } + ) + } + + while (!queue.isEmpty()) { + if (cancellations.contains(jobId)) { + cancellations.remove(jobId) + res.close() + return@forEach + } + + mainFilePath = queue.pop() + if (getIsPathDirectory(mainFilePath)) { + if (isRestrictedSAFOnlyRoot(mainFilePath)) { + getAndroidSAFFileItems(mainFilePath, true) { files -> + for (file in files) { + name = file.path.relativizeWith(base) + if (getIsPathDirectory(file.path)) { + queue.push(file.path) + name = "${name.trimEnd('/')}/" + zout.putNextEntry(zipEntry(name)) + } else { + zout.putNextEntry(zipEntry(name)) + getFileInputStreamSync(file.path)!!.copyTo(zout) + zout.closeEntry() + } + } + } + } else { + val mainFile = File(mainFilePath) + for (file in mainFile.listFiles()) { + name = file.path.relativizeWith(base) + if (getIsPathDirectory(file.absolutePath)) { + queue.push(file.absolutePath) + name = "${name.trimEnd('/')}/" + zout.putNextEntry(zipEntry(name)) + } else { + zout.putNextEntry(zipEntry(name)) + getFileInputStreamSync(file.path)!!.copyTo(zout) + zout.closeEntry() + } + } + } + + } else { + name = if (base == currentPath) currentPath.getFilenameFromPath() else mainFilePath.relativizeWith(base) + zout.putNextEntry(zipEntry(name)) + getFileInputStreamSync(mainFilePath)!!.copyTo(zout) + zout.closeEntry() + } + } + } + } catch (exception: Exception) { + showErrorToast(exception) + return false + } finally { + res.close() + } + return true + } + + private fun showNotification(jobId: Int, fileName: String, title: String, progress: Int, progressMax: Int): Notification { + val channelId = "simple_file_manager_compression" + val label = getString(R.string.app_name) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (isOreoPlus()) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + NotificationChannel(channelId, label, importance).apply { + setSound(null, null) + notificationManager.createNotificationChannel(this) + } + } + + val priority = Notification.PRIORITY_DEFAULT + val icon = R.drawable.ic_decompress_vector + val visibility = NotificationCompat.VISIBILITY_PUBLIC + + val builder = NotificationCompat.Builder(this, channelId) + .setContentTitle(title) + .setContentText(fileName) + .setSmallIcon(icon) + .setContentIntent(getOpenAppIntent()) + .setPriority(priority) + .setVisibility(visibility) + .addAction(com.simplemobiletools.commons.R.drawable.ic_cross_vector, getString(R.string.cancel), getCancelIntent(jobId)) + .setSound(null) + .setOngoing(true) + .setAutoCancel(true) + + if (progressMax > 0) { + builder.setProgress(progressMax, progress, false) + } else { + builder.setProgress(progress, progress, true) + } + + return builder.build() + } + + private fun getOpenAppIntent(): PendingIntent { + val intent = getLaunchIntent() ?: Intent(this, SplashActivity::class.java) + return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + private fun getCancelIntent(jobId: Int): PendingIntent { + val intent = Intent(this, CompressionService::class.java) + intent.action = ACTION_CANCEL + intent.putExtra(EXTRA_JOB_ID, jobId) + return PendingIntent.getService(this, jobId, intent, PendingIntent.FLAG_IMMUTABLE) + } + + private fun startServiceJob(jobId: Int, title: String, mainTitle: String) { + running.add(jobId) + startForeground(jobId, showNotification(jobId, title, mainTitle, 0, 0)) + } + + private fun finishServiceJob(jobId: Int) { + ContextCompat.getMainExecutor(this).execute { + notificationManager.cancel(jobId) + running.remove(jobId) + if (running.isEmpty()) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + } + } +} + diff --git a/app/src/main/res/layout/activity_decompress.xml b/app/src/main/res/layout/activity_decompress.xml index c9c8a3331..7b1720797 100644 --- a/app/src/main/res/layout/activity_decompress.xml +++ b/app/src/main/res/layout/activity_decompress.xml @@ -1,6 +1,7 @@ @@ -22,6 +23,18 @@ android:fillViewport="true" android:scrollbars="none"> + +