From b0f9a5524d851be7467f7533e682d337db860881 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 1 Jan 2026 23:20:58 +0800 Subject: [PATCH 01/17] Proguard rules fix to include everything below org.bouncycastle.jcajce.provider.asymmetric Fixes problems with SSH connections in recent release builds --- app/proguard.cfg | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/proguard.cfg b/app/proguard.cfg index 738663b299..bfe3e59991 100644 --- a/app/proguard.cfg +++ b/app/proguard.cfg @@ -86,17 +86,14 @@ -keep class org.bouncycastle.crypto.prng.* {*;} -keep class org.bouncycastle.crypto.signers.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.util.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.dh.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.ec.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.rsa.* {*;} +-keep class org.bouncycastle.jcajce.provider.asymmetric.** {*;} -keep class org.bouncycastle.jcajce.provider.digest.** {*;} -keep class org.bouncycastle.jcajce.provider.keystore.** {*;} -keep class org.bouncycastle.jcajce.provider.symmetric.** {*;} -keep class org.bouncycastle.jcajce.spec.* {*;} -keep class org.bouncycastle.jce.** {*;} +-keep class org.bouncycastle.openssl.** {*;} -dontwarn org.bouncycastle.jsse.** -dontwarn org.bouncycastle.asn1.ASN1ApplicationSpecific @@ -127,4 +124,7 @@ } -keep class com.amaze.trashbin.** { *; } --dontwarn ch.qos.logback.core.net.* \ No newline at end of file +-dontwarn ch.qos.logback.core.net.* + +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile \ No newline at end of file From 3a67b5235f1364ac6d7d1bfe134cacfd54841d45 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 26 Mar 2026 00:16:55 +0800 Subject: [PATCH 02/17] Fixes for path traversal vulnerabilities --- .../compressed/CompressedHelper.java | 7 ++- .../AbstractCommonsArchiveExtractor.kt | 5 +- .../helpers/SevenZipExtractor.kt | 3 + .../extractcontents/helpers/ZipExtractor.kt | 9 +-- .../extractcontents/helpers/RarExtractor.kt | 15 +++-- .../extractcontents/SevenZipExtractorTest.kt | 56 +++++++++++++++++ .../extractcontents/TarGzExtractorTest.kt | 57 ++++++++++++++++++ .../extractcontents/ZipExtractorTest.kt | 57 ++++++++++++++++++ app/src/test/resources/malicious.7z | Bin 0 -> 273 bytes app/src/test/resources/malicious.tar.gz | Bin 0 -> 223 bytes app/src/test/resources/malicious.zip | Bin 0 -> 419 bytes 11 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 app/src/test/resources/malicious.7z create mode 100644 app/src/test/resources/malicious.tar.gz create mode 100644 app/src/test/resources/malicious.zip diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java index 5d36ad0875..ec08a49d76 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java @@ -228,8 +228,11 @@ public static String getFileName(String compressedName) { } } - public static final boolean isEntryPathValid(String entryPath) { - return !entryPath.startsWith("..\\") && !entryPath.startsWith("../") && !entryPath.equals(".."); + public static boolean isEntryPathValid(String entryPath) { + return !entryPath.startsWith("..\\") + && !entryPath.startsWith("../") + && !entryPath.equals("..") + && !entryPath.contains("/../"); } private static boolean isZip(String type) { diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt index d9e8e63899..0a1207aad8 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt @@ -67,7 +67,7 @@ abstract class AbstractCommonsArchiveExtractor( } } } - if (archiveEntries.size > 0) { + if (archiveEntries.isNotEmpty()) { listener.onStart(totalBytes, archiveEntries[0].name) inputStream.close() inputStream = createFrom(FileInputStream(filePath)) @@ -101,6 +101,9 @@ abstract class AbstractCommonsArchiveExtractor( return } val outputFile = File(outputDir, entry.name) + if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator)) { + throw IOException("Incorrect archive entry path: ${entry.name}") + } if (false == outputFile.parentFile?.exists()) { MakeDirectoryOperation.mkdir(outputFile.parentFile, context) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt index b39c9db054..88d27b729d 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt @@ -111,6 +111,9 @@ class SevenZipExtractor( return } val outputFile = File(outputDir, name) + if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator)) { + throw IOException("Incorrect 7z entry path: $name") + } if (!outputFile.parentFile.exists()) { MakeDirectoryOperation.mkdir(outputFile.parentFile, context) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt index 368bbbf2b3..313ff808a5 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt @@ -21,7 +21,6 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers import android.content.Context -import android.os.Build import com.amaze.filemanager.R import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache @@ -47,8 +46,6 @@ class ZipExtractor( listener: OnUpdate, updatePosition: UpdatePosition, ) : Extractor(context, filePath, outputPath, listener, updatePosition) { - private val isRobolectricTest = Build.HARDWARE == "robolectric" - @Throws(IOException::class) override fun extractWithFilter(filter: Filter) { var totalBytes: Long = 0 @@ -110,9 +107,9 @@ class ZipExtractor( outputDir: String, ) { val outputFile = File(outputDir, fixEntryName(entry.fileName)) - if (!outputFile.canonicalPath.startsWith(outputDir) && - (isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir")) - ) { + val canonicalOutput = outputFile.canonicalPath + val canonicalDir = File(outputDir).canonicalPath + File.separator + if (!canonicalOutput.startsWith(canonicalDir)) { throw IOException("Incorrect ZipEntry path!") } if (entry.isDirectory) { diff --git a/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt b/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt index 672298019f..b50de82d80 100644 --- a/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt +++ b/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt @@ -77,9 +77,11 @@ class RarExtractor( MainHeaderNullException::class.java.isAssignableFrom(it::class.java) -> { throw BadArchiveNotice(it) } + UnsupportedRarV5Exception::class.java.isAssignableFrom(it::class.java) -> { throw it } + else -> { throw PasswordRequiredException(filePath) } @@ -144,9 +146,9 @@ class RarExtractor( CompressedHelper.SEPARATOR, ) val outputFile = File(outputDir, name) - if (!outputFile.canonicalPath.startsWith(outputDir) && - (isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir")) - ) { + val canonicalOutput = outputFile.canonicalPath + val canonicalDir = File(outputDir).canonicalPath + File.separator + if (!canonicalOutput.startsWith(canonicalDir)) { throw IOException("Incorrect RAR FileHeader path!") } if (entry.isDirectory) { @@ -232,7 +234,12 @@ class RarExtractor( "\\\\".toRegex(), CompressedHelper.SEPARATOR, ) - extractEntry(context, archive, header, context.externalCacheDir!!.absolutePath) + extractEntry( + context, + archive, + header, + context.externalCacheDir!!.absolutePath, + ) return "${context.externalCacheDir!!.absolutePath}/$filename" } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt index d730b39644..2b6c7b7703 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt @@ -20,10 +20,66 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.SevenZipExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException open class SevenZipExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "7z" override fun extractorClass(): Class = SevenZipExtractor::class.java + + /** + * Verify that a 7-Zip archive carrying a path-traversal entry + * (../POC_7Z_PROOF.txt) is blocked by the canonical-path guard: + * - extractEverything() must throw IOException + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMalicious7z() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.7z") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + SevenZipExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + // Confirm the guard fired (not a generic bad-archive error) + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + // The malicious file must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_7Z_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt index 0f537554b4..bcf9805724 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt @@ -20,10 +20,67 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarGzExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException open class TarGzExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.gz" override fun extractorClass(): Class = TarGzExtractor::class.java + + /** + * Verify that a tar.gz archive carrying a path-traversal entry + * (../POC_ZIPSLIP_PROOF.txt) is blocked by the canonical-path guard + * in AbstractCommonsArchiveExtractor: + * - extractEverything() must throw IOException + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMaliciousTarGz() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.gz") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarGzExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + // Confirm the guard fired (not a generic bad-archive error) + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + // The malicious file must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt index 418868998e..2e66ee0396 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt @@ -20,10 +20,67 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.ZipExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File class ZipExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "zip" override fun extractorClass(): Class = ZipExtractor::class.java + + /** + * Verify that a ZIP archive carrying a path-traversal entry + * (foo/../../POC_ZIP_PROOF.txt) is handled safely: + * - extraction completes without an exception + * - the offending entry is recorded in invalidArchiveEntries + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMaliciousZip() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.zip") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + ZipExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + // Extraction must succeed — path-traversal entries are quarantined, not thrown + extractor.extractEverything() + + // The traversal entry must be recorded as invalid … + assertTrue( + "Malicious path-traversal entry must be captured in invalidArchiveEntries", + extractor.invalidArchiveEntries.isNotEmpty(), + ) + assertTrue( + "invalidArchiveEntries must contain the POC entry", + extractor.invalidArchiveEntries.any { "POC_ZIP_PROOF" in it }, + ) + // … and must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/resources/malicious.7z b/app/src/test/resources/malicious.7z new file mode 100644 index 0000000000000000000000000000000000000000..5576b92418b2920be2945de38e1003ebe801fba5 GIT binary patch literal 273 zcmXr7+Ou9=hJoepyw$gFGeCeCls2uOZT*13hruJ3L0$Tk+q+vEJhiTUTk~lBhl+jf z_PpyaOuj08b*QN{PiwE{yG2X?6_>T! z{55o7V0gf=fT1~-fuYfueI5U$gZY0Qc27x>se7H2{B7s-Nm{oT?qBn42A}7>f|Hz* zcgwou>Tm9S6Czi>`S&vMrKO2!EKgN7e2lU&&X0>fIBDv;AGcJ0yZ6aC26Hsb@0{N8 zUnrGHqFVRY`;dOa6#3lE&bo1n&rEJ@W?#mqI4{=la{j@ubJ^B@zP1tQ5C#Tuwg5)X VhHeISZbk-1MMXvlo`!`C3;=Qm{=Z?pclAB}O;PJ6IO!@t`;*jeYqjjHwylJs6z40wb-|Y6!{wETR1gbI)y$|7@bSO8@}SQ@HAPfs6+0{orhqdWuR1A_eh z-9Yw*WMmdYY%5PJRwysZECE`nkY8F-oSBlUP?C|VkXlhvl$czSnV+YSl3A3RT#{c@ z333u6lL#|zZvkxug9b(rh2}|Q&DeYd(JH|3-_Z%kg!m8DI&>dGbTBY9Fs49t;P7vN SH!B-RF*6YE0Mb`M90mX)e^%E3 literal 0 HcmV?d00001 From 7d970e953784d6306a6ecb824b358f0ba05af8cb Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 29 Mar 2026 10:11:09 +0800 Subject: [PATCH 03/17] Changes per PR feedback --- .../filesystem/compressed/CompressedHelper.java | 17 +++++++++++++---- .../helpers/AbstractCommonsArchiveExtractor.kt | 10 ++++++---- .../helpers/SevenZipExtractor.kt | 13 +++++++------ .../extractcontents/helpers/ZipExtractor.kt | 5 ++++- .../extractcontents/ZipExtractorTest.kt | 7 ++++++- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java index ec08a49d76..26a4c6ab55 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java @@ -229,10 +229,19 @@ public static String getFileName(String compressedName) { } public static boolean isEntryPathValid(String entryPath) { - return !entryPath.startsWith("..\\") - && !entryPath.startsWith("../") - && !entryPath.equals("..") - && !entryPath.contains("/../"); + if (entryPath == null || entryPath.isEmpty()) { + return false; + } + // Normalize path separators to handle both Unix and Windows-style paths. + String normalized = entryPath.replace('\\', '/'); + // Reject any path that attempts to traverse up the directory tree. + String[] segments = normalized.split("/"); + for (String segment : segments) { + if ("..".equals(segment)) { + return false; + } + } + return true; } private static boolean isZip(String type) { diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt index 0a1207aad8..4db930d239 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt @@ -96,14 +96,16 @@ abstract class AbstractCommonsArchiveExtractor( entry: ArchiveEntry, outputDir: String, ) { + val outputFile = File(outputDir, entry.name) + if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator) && + outputFile.canonicalPath != File(outputDir).canonicalPath + ) { + throw IOException("Incorrect archive entry path: ${entry.name}") + } if (entry.isDirectory) { MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) return } - val outputFile = File(outputDir, entry.name) - if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator)) { - throw IOException("Incorrect archive entry path: ${entry.name}") - } if (false == outputFile.parentFile?.exists()) { MakeDirectoryOperation.mkdir(outputFile.parentFile, context) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt index 88d27b729d..84c656f4b5 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt @@ -105,15 +105,16 @@ class SevenZipExtractor( entry: SevenZArchiveEntry, outputDir: String, ) { - val name = entry.name + val outputFile = File(outputDir, entry.name) + if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator) && + outputFile.canonicalPath != File(outputDir).canonicalPath + ) { + throw IOException("Incorrect archive entry path: ${entry.name}") + } if (entry.isDirectory) { - MakeDirectoryOperation.mkdir(File(outputDir, name), context) + MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) return } - val outputFile = File(outputDir, name) - if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator)) { - throw IOException("Incorrect 7z entry path: $name") - } if (!outputFile.parentFile.exists()) { MakeDirectoryOperation.mkdir(outputFile.parentFile, context) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt index 313ff808a5..6a240fabe9 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt @@ -110,7 +110,10 @@ class ZipExtractor( val canonicalOutput = outputFile.canonicalPath val canonicalDir = File(outputDir).canonicalPath + File.separator if (!canonicalOutput.startsWith(canonicalDir)) { - throw IOException("Incorrect ZipEntry path!") + throw IOException( + "Refusing to extract Zip entry '${entry.fileName}' to '$canonicalOutput' " + + "outside target directory '$canonicalDir'", + ) } if (entry.isDirectory) { // zip entry is a directory, return after creating new directory diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt index 2e66ee0396..89f2ea2237 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt @@ -78,9 +78,14 @@ class ZipExtractorTest : AbstractArchiveExtractorTest() { extractor.invalidArchiveEntries.any { "POC_ZIP_PROOF" in it }, ) // … and must NOT have been written outside the output directory + val escapedFile = File(outputDir, "foo/../../POC_ZIP_PROOF.txt").canonicalFile assertFalse( "Malicious file must not escape the output directory", - File(outputDir.parentFile, "POC_ZIP_PROOF.txt").exists(), + escapedFile.exists(), + ) + assertFalse( + "Escaped file canonical path must not reside under output directory", + escapedFile.canonicalPath.startsWith(outputDir.canonicalPath), ) } } From 3200bfafc47587dcb7143124534cbb81c9e690eb Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 4 Apr 2026 11:58:53 +0800 Subject: [PATCH 04/17] Fix edit SMB connection unable to populate value to dialog Fixes #4543 --- .../ui/dialogs/SmbConnectDialog.java | 6 +- .../ui/dialogs/SmbConnectDialogTest.kt | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java index a1f4dcad60..818058a145 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java @@ -32,7 +32,6 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; -import java.net.URL; import java.security.GeneralSecurityException; import org.slf4j.Logger; @@ -55,6 +54,7 @@ import android.app.Dialog; import android.content.Context; +import android.net.Uri; import android.net.UrlQuerySanitizer; import android.os.Bundle; import android.text.Editable; @@ -258,7 +258,7 @@ public void afterTextChanged(@NonNull Editable s) { conName.setText(name); try { - URL a = new URL(path); + Uri a = Uri.parse(path); String userinfo = a.getUserInfo(); if (userinfo != null) { String inf = decode(userinfo, Charsets.UTF_8.name()); @@ -296,8 +296,6 @@ public void afterTextChanged(@NonNull Editable s) { } } catch (UnsupportedEncodingException | IllegalArgumentException e) { LOG.warn("failed to load smb dialog info for path {}", path, e); - } catch (MalformedURLException e) { - LOG.warn("failed to load smb dialog info", e); } } else if (path != null && path.length() > 0) { diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt index 72305b064a..1bf2f87bd4 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt @@ -36,6 +36,7 @@ import com.amaze.filemanager.utils.smb.SmbUtil import io.mockk.confirmVerified import io.mockk.spyk import io.mockk.verify +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.robolectric.shadows.ShadowDialog @@ -45,6 +46,64 @@ import org.robolectric.shadows.ShadowLooper * Tests [SmbConnectDialog]. */ class SmbConnectDialogTest : AbstractMainActivityTestBase() { + /** + * Test editing an existing connection pre-fills all dialog fields. + * Regression test for https://github.com/TeamAmaze/AmazeFileManager/issues/4543 + */ + @Test + fun testEditConnectionPreFillsAllFields() { + val listener = spyk() + val encryptedPath = + SmbUtil.getSmbEncryptedPath( + AppConfig.getInstance(), + "smb://user:password@192.168.1.100/share", + ) + doTestWithDialog( + listener = listener, + arguments = + Bundle().also { + it.putString(ARG_NAME, "My SMB Connection") + it.putString(ARG_PATH, encryptedPath) + it.putBoolean(ARG_EDIT, true) + }, + withDialog = { dialog, _ -> + dialog.binding.run { + assertEquals("My SMB Connection", this.connectionET.text.toString()) + assertEquals("192.168.1.100", this.ipET.text.toString()) + assertEquals("share", this.shareET.text.toString()) + assertEquals("user", this.usernameET.text.toString()) + assertEquals("password", this.passwordET.text.toString()) + } + }, + ) + } + + /** + * Test editing an anonymous connection checks the anonymous checkbox. + * Regression test for https://github.com/TeamAmaze/AmazeFileManager/issues/4543 + */ + @Test + fun testEditAnonymousConnectionSetsAnonymousCheckbox() { + val listener = spyk() + doTestWithDialog( + listener = listener, + arguments = + Bundle().also { + it.putString(ARG_NAME, "Anonymous SMB") + it.putString(ARG_PATH, "smb://192.168.1.100/share") + it.putBoolean(ARG_EDIT, true) + }, + withDialog = { dialog, _ -> + dialog.binding.run { + assertEquals("Anonymous SMB", this.connectionET.text.toString()) + assertEquals("192.168.1.100", this.ipET.text.toString()) + assertEquals("share", this.shareET.text.toString()) + assertTrue(this.chkSmbAnonymous.isChecked) + } + }, + ) + } + /** * Test call to [SmbConnectionListener.addConnection] is encrypted path. */ From 9040316f1ec147f2785d6b0feba242fe973670c9 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 5 Apr 2026 00:12:01 +0800 Subject: [PATCH 05/17] Add test case for malicious RAR --- app/src/test/resources/malicious.rar | Bin 0 -> 162 bytes .../extractcontents/RarExtractorTest.kt | 52 ++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 app/src/test/resources/malicious.rar diff --git a/app/src/test/resources/malicious.rar b/app/src/test/resources/malicious.rar new file mode 100644 index 0000000000000000000000000000000000000000..a1ac941229acd103d2fe4e5a5afea327aa8b0bc0 GIT binary patch literal 162 zcmWGaEK-zWXE;Bhn1O+p0Rl3PmpquD0p&0-Fo9TecU8rR7%(#k0NH8z`3xHvRz-10 zlqgIHW#9q|fiTnQ=Xw3>SAK_^dLCE-#I?WF(^JD$lu>hucV?R21A75 Z!`0a?N(Dvv`DscDyj(|Ys~H^F834i}DZ2mw literal 0 HcmV?d00001 diff --git a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt index 81b4d7c32a..c9dc1376bf 100644 --- a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt +++ b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt @@ -27,6 +27,8 @@ import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.RarExtractor import com.github.junrar.Archive import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Ignore import org.junit.Test import java.io.File @@ -89,6 +91,56 @@ class RarExtractorTest : AbstractArchiveExtractorTest() { } } + /** + * Verify that a RAR4 archive carrying a path-traversal entry + * (foo//../../POC_RAR_PROOF.txt/POC_RAR_PROOF.txt) is handled safely: + * - extraction completes without an exception + * - the offending entry is recorded in invalidArchiveEntries + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMaliciousRar() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.rar") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + RarExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + // Extraction must succeed — path-traversal entries are quarantined, not thrown + extractor.extractEverything() + + // The traversal entry must be recorded as invalid … + assertTrue( + "Malicious path-traversal entry must be captured in invalidArchiveEntries", + extractor.invalidArchiveEntries.isNotEmpty(), + ) + assertTrue( + "invalidArchiveEntries must contain the POC entry", + extractor.invalidArchiveEntries.any { "POC_RAR_PROOF" in it }, + ) + // … and must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_RAR_PROOF.txt").exists(), + ) + } + @Test @Ignore override fun testExtractBadArchive() = Unit } From 402d26c9df8a2538e47a84f68d702b68bf0d776c Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 5 Apr 2026 01:21:46 +0800 Subject: [PATCH 06/17] Changes per PR feedback Decoupled check path logic to Extractor Improve CompressedHelper.isEntryPathValid() for edge cases --- .../compressed/CompressedHelper.java | 15 +++++-- .../compressed/extractcontents/Extractor.java | 22 ++++++++++ .../AbstractCommonsArchiveExtractor.kt | 6 +-- .../helpers/SevenZipExtractor.kt | 6 +-- .../extractcontents/helpers/ZipExtractor.kt | 9 +--- .../extractcontents/helpers/RarExtractor.kt | 6 +-- .../compressed/CompressedHelperTest.java | 42 +++++++++++++++++++ 7 files changed, 79 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java index 26a4c6ab55..731d8bdc31 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java @@ -234,11 +234,18 @@ public static boolean isEntryPathValid(String entryPath) { } // Normalize path separators to handle both Unix and Windows-style paths. String normalized = entryPath.replace('\\', '/'); - // Reject any path that attempts to traverse up the directory tree. - String[] segments = normalized.split("/"); - for (String segment : segments) { + // Walk the path segments, tracking depth to detect escaping the archive root. + // A path like "dir/sub/.." is valid (it resolves to "dir"), but "../../evil" is not. + int depth = 0; + for (String segment : normalized.split("/")) { if ("..".equals(segment)) { - return false; + depth--; + if (depth < 0) { + // Path would escape the archive root. + return false; + } + } else if (!segment.isEmpty() && !".".equals(segment)) { + depth++; } } return true; diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java index 3e1a5097a4..3e98b0acf1 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java @@ -23,6 +23,7 @@ import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR; import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR_CHAR; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -113,6 +114,27 @@ protected String fixEntryName(String entryName) { } } + /** + * Verifies that {@code outputFile} is contained within {@code outputDir}, guarding against + * zip-slip / path-traversal attacks. + * + * @throws IOException if the resolved canonical path of {@code outputFile} would land outside + * {@code outputDir}. + */ + protected static void checkEntryPath(File outputFile, String outputDir) throws IOException { + String canonicalOutput = outputFile.getCanonicalPath(); + String canonicalDir = new File(outputDir).getCanonicalPath() + File.separator; + if (!canonicalOutput.startsWith(canonicalDir) + && !canonicalOutput.equals(new File(outputDir).getCanonicalPath())) { + throw new IOException( + "Refusing to extract entry '" + + outputFile.getName() + + "' outside target directory '" + + canonicalDir + + "'"); + } + } + public static class EmptyArchiveNotice extends IOException {} public static class BadArchiveNotice extends IOException { diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt index 4db930d239..c030961fac 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt @@ -97,11 +97,7 @@ abstract class AbstractCommonsArchiveExtractor( outputDir: String, ) { val outputFile = File(outputDir, entry.name) - if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator) && - outputFile.canonicalPath != File(outputDir).canonicalPath - ) { - throw IOException("Incorrect archive entry path: ${entry.name}") - } + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) return diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt index 84c656f4b5..83e7a7b042 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt @@ -106,11 +106,7 @@ class SevenZipExtractor( outputDir: String, ) { val outputFile = File(outputDir, entry.name) - if (!outputFile.canonicalPath.startsWith(File(outputDir).canonicalPath + File.separator) && - outputFile.canonicalPath != File(outputDir).canonicalPath - ) { - throw IOException("Incorrect archive entry path: ${entry.name}") - } + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) return diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt index 6a240fabe9..159cf3d772 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt @@ -107,14 +107,7 @@ class ZipExtractor( outputDir: String, ) { val outputFile = File(outputDir, fixEntryName(entry.fileName)) - val canonicalOutput = outputFile.canonicalPath - val canonicalDir = File(outputDir).canonicalPath + File.separator - if (!canonicalOutput.startsWith(canonicalDir)) { - throw IOException( - "Refusing to extract Zip entry '${entry.fileName}' to '$canonicalOutput' " + - "outside target directory '$canonicalDir'", - ) - } + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { // zip entry is a directory, return after creating new directory MakeDirectoryOperation.mkdir(outputFile, context) diff --git a/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt b/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt index b50de82d80..3cb5ebe485 100644 --- a/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt +++ b/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt @@ -146,11 +146,7 @@ class RarExtractor( CompressedHelper.SEPARATOR, ) val outputFile = File(outputDir, name) - val canonicalOutput = outputFile.canonicalPath - val canonicalDir = File(outputDir).canonicalPath + File.separator - if (!canonicalOutput.startsWith(canonicalDir)) { - throw IOException("Incorrect RAR FileHeader path!") - } + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { MakeDirectoryOperation.mkdir(outputFile, context) outputFile.setLastModified(entry.mTime.time) diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java index 8208508b35..0573930e05 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java @@ -279,4 +279,46 @@ public void getFileNameTest() throws Exception { // no path assertEquals("", CompressedHelper.getFileName("")); } + + /** + * isEntryPathValid() tests. + * + *

Validates that paths which resolve within the archive root are accepted, and that paths + * which would escape the archive root are rejected — including cases where {@code ..} components + * appear in the middle of an otherwise valid path. + */ + @Test + public void isEntryPathValidTest() { + // null / empty are always invalid + assertFalse(CompressedHelper.isEntryPathValid(null)); + assertFalse(CompressedHelper.isEntryPathValid("")); + + // simple valid paths + assertTrue(CompressedHelper.isEntryPathValid("test.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/sub/file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/")); + + // ".." that resolves back inside the root is VALID + // e.g. "dir/sub/.." resolves to "dir" — still inside the archive + assertTrue(CompressedHelper.isEntryPathValid("dir/sub/..")); + assertTrue(CompressedHelper.isEntryPathValid("dir/sub/../file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("a/b/c/../../file.txt")); + + // paths that escape the archive root are INVALID + assertFalse(CompressedHelper.isEntryPathValid("../evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("../../evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("foo/../../evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("foo/../../../evil.txt")); + + // Windows-style separators are normalised first + assertFalse(CompressedHelper.isEntryPathValid("..\\evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("foo\\..\\..\\evil.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir\\sub\\file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir\\sub\\..\\file.txt")); + + // "." segments are ignored (current directory) + assertTrue(CompressedHelper.isEntryPathValid("./test.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/./file.txt")); + } } From 5666d63707cfe08e353ba625dc3501c434c731f2 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 20 Jun 2026 12:22:31 +0800 Subject: [PATCH 07/17] Changes per PR feedback Don't throw exception if no Decompressor found, which would crash debug build --- .../filemanager/filesystem/compressed/CompressedHelper.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java index 731d8bdc31..a214e76143 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java @@ -164,10 +164,6 @@ public static Decompressor getCompressorInstance(@NonNull Context context, @NonN // without the compression extension decompressor = new UnknownCompressedFileDecompressor(context); } else { - if (BuildConfig.DEBUG) { - throw new IllegalArgumentException("The compressed file has no way of opening it: " + file); - } - LOG.error("The compressed file has no way of opening it: " + file); decompressor = null; } From fde039a35104b351e128b54d840fad8ce0e92a82 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Mon, 22 Jun 2026 23:01:33 +0800 Subject: [PATCH 08/17] Changes per PR feedback - Fixed 7z extraction errors - Added test cases for extracting malicious 7z and tar.gz for quality gate --- .../compress/SevenZipHelperCallable.kt | 4 +- .../asynchronous/services/ExtractService.java | 3 +- .../SevenZipHelperCallableMaliciousTest.kt | 36 +++++++++++++++++ .../services/ExtractServiceTest.kt | 40 +++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt index 45790663fb..027849707b 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt @@ -58,7 +58,7 @@ class SevenZipHelperCallable( entries.addAll( consolidate( entriesMap.keys.filter { - it.startsWith(relativePath) + CompressedHelper.isEntryPathValid(it) && it.startsWith(relativePath) }, if (relativePath == "") { 0 @@ -101,7 +101,7 @@ class SevenZipHelperCallable( } catch (e: PasswordRequiredException) { // this is so that the caller can use onError to ask the user for the password throw e - } catch (e: IOException) { + } catch (_: IOException) { throw ArchiveException(String.format("7zip archive %s is corrupt", filePath)) } } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java index 409600c6d8..12b64b9953 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java @@ -373,8 +373,7 @@ public boolean isCancelled() { } else { LOG.error("Error while extracting file " + compressedPath, e); AppConfig.toast(getApplicationContext(), extractService.getString(R.string.error)); - paused = true; - publishProgress(e); + return false; } } catch (Throwable unhandledException) { LOG.error("Unhandled exception thrown", unhandledException); diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt new file mode 100644 index 0000000000..1c88c2cb21 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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 + * (at your option) 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.amaze.filemanager.asynchronous.asynctasks.compress + +import android.os.Environment +import org.junit.Assert.assertFalse +import org.junit.Test +import java.io.File + +class SevenZipHelperCallableMaliciousTest : AbstractCompressedHelperCallableTest() { + @Test + fun testRootDoesNotExposeParentFolderEntries() { + val archive = File(Environment.getExternalStorageDirectory(), "malicious.7z") + val result = SevenZipHelperCallable(archive.absolutePath, "", false).call() + + assertFalse(result.any { it.name == ".." || it.name.startsWith("../") }) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt index 10b2b9d8d7..5ff3c49b55 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt @@ -49,6 +49,7 @@ import org.awaitility.Awaitility.await import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before import org.junit.Ignore @@ -95,6 +96,8 @@ class ExtractServiceTest { private val tarXzfile: File private val tarBz2file: File private val sevenZipfile: File + private val maliciousTarGzFile: File + private val maliciousSevenZipFile: File private val passwordProtectedZipfile: File private val passwordProtected7Zipfile: File private val listPasswordProtected7Zipfile: File @@ -126,6 +129,8 @@ class ExtractServiceTest { tarXzfile = File(this, "test-archive.tar.xz") tarBz2file = File(this, "test-archive.tar.bz2") sevenZipfile = File(this, "test-archive.7z") + maliciousTarGzFile = File(this, "malicious.tar.gz") + maliciousSevenZipFile = File(this, "malicious.7z") passwordProtectedZipfile = File(this, "test-archive-encrypted.zip") passwordProtected7Zipfile = File(this, "test-archive-encrypted.7z") listPasswordProtected7Zipfile = File(this, "test-archive-encrypted-list.7z") @@ -163,6 +168,8 @@ class ExtractServiceTest { getResourceAsStream("test-archive.tar.xz").copyTo(FileOutputStream(tarXzfile)) getResourceAsStream("test-archive.tar.bz2").copyTo(FileOutputStream(tarBz2file)) getResourceAsStream("test-archive.7z").copyTo(FileOutputStream(sevenZipfile)) + getResourceAsStream("malicious.tar.gz").copyTo(FileOutputStream(maliciousTarGzFile)) + getResourceAsStream("malicious.7z").copyTo(FileOutputStream(maliciousSevenZipFile)) getResourceAsStream("test-archive-encrypted.zip") .copyTo(FileOutputStream(passwordProtectedZipfile)) getResourceAsStream("test-archive-encrypted.7z") @@ -326,6 +333,39 @@ class ExtractServiceTest { assertNull(ShadowToast.getTextOfLatestToast()) } + /** + * Test malicious 7z extraction exits with a regular error toast. + */ + @Test + fun testExtractMalicious7z() { + performTest(maliciousSevenZipFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.gz extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarGz() { + performTest(maliciousTarGzFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + private fun assertErrorOrInvalidEntriesToast() { + val context = ApplicationProvider.getApplicationContext() + val latestToastText = ShadowToast.getTextOfLatestToast() + assertTrue( + setOf( + context.getString(R.string.error), + context.getString(R.string.multiple_invalid_archive_entries), + ).contains(latestToastText), + ) + } + /** * Test password-protected zip. */ From 8fc7822ab9a339e70dd8cc6b29133b2df156b2f8 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 27 Jun 2026 00:19:51 +0800 Subject: [PATCH 09/17] Additional test cases for quality gates to tar.bz2, tar.xz and tar.lzma archives --- .../SevenZipHelperCallableMaliciousTest.kt | 7 +++ .../services/ExtractServiceTest.kt | 42 ++++++++++++++ .../extractcontents/TarBzip2ExtractorTest.kt | 54 ++++++++++++++++++ .../extractcontents/TarBzip2ExtractorTest2.kt | 3 + .../extractcontents/TarLzmaExtractorTest.kt | 54 ++++++++++++++++++ .../extractcontents/TarXzExtractorTest.kt | 54 ++++++++++++++++++ app/src/test/resources/malicious.tar.bz2 | Bin 0 -> 218 bytes app/src/test/resources/malicious.tar.lzma | Bin 0 -> 217 bytes app/src/test/resources/malicious.tar.xz | Bin 0 -> 264 bytes 9 files changed, 214 insertions(+) create mode 100644 app/src/test/resources/malicious.tar.bz2 create mode 100644 app/src/test/resources/malicious.tar.lzma create mode 100644 app/src/test/resources/malicious.tar.xz diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt index 1c88c2cb21..2f2926762c 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt @@ -25,7 +25,14 @@ import org.junit.Assert.assertFalse import org.junit.Test import java.io.File +/** + * Tests for [SevenZipHelperCallable]. + */ class SevenZipHelperCallableMaliciousTest : AbstractCompressedHelperCallableTest() { + /** + * Test to ensure that the root of the archive does not expose parent folder entries + * (e.g., ".." or "../"). + */ @Test fun testRootDoesNotExposeParentFolderEntries() { val archive = File(Environment.getExternalStorageDirectory(), "malicious.7z") diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt index 5ff3c49b55..7bdbbf94dc 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt @@ -97,6 +97,9 @@ class ExtractServiceTest { private val tarBz2file: File private val sevenZipfile: File private val maliciousTarGzFile: File + private val maliciousTarBz2File: File + private val maliciousTarXzFile: File + private val maliciousTarLzmaFile: File private val maliciousSevenZipFile: File private val passwordProtectedZipfile: File private val passwordProtected7Zipfile: File @@ -130,6 +133,9 @@ class ExtractServiceTest { tarBz2file = File(this, "test-archive.tar.bz2") sevenZipfile = File(this, "test-archive.7z") maliciousTarGzFile = File(this, "malicious.tar.gz") + maliciousTarBz2File = File(this, "malicious.tar.bz2") + maliciousTarXzFile = File(this, "malicious.tar.xz") + maliciousTarLzmaFile = File(this, "malicious.tar.lzma") maliciousSevenZipFile = File(this, "malicious.7z") passwordProtectedZipfile = File(this, "test-archive-encrypted.zip") passwordProtected7Zipfile = File(this, "test-archive-encrypted.7z") @@ -169,6 +175,9 @@ class ExtractServiceTest { getResourceAsStream("test-archive.tar.bz2").copyTo(FileOutputStream(tarBz2file)) getResourceAsStream("test-archive.7z").copyTo(FileOutputStream(sevenZipfile)) getResourceAsStream("malicious.tar.gz").copyTo(FileOutputStream(maliciousTarGzFile)) + getResourceAsStream("malicious.tar.bz2").copyTo(FileOutputStream(maliciousTarBz2File)) + getResourceAsStream("malicious.tar.xz").copyTo(FileOutputStream(maliciousTarXzFile)) + getResourceAsStream("malicious.tar.lzma").copyTo(FileOutputStream(maliciousTarLzmaFile)) getResourceAsStream("malicious.7z").copyTo(FileOutputStream(maliciousSevenZipFile)) getResourceAsStream("test-archive-encrypted.zip") .copyTo(FileOutputStream(passwordProtectedZipfile)) @@ -355,6 +364,39 @@ class ExtractServiceTest { assertErrorOrInvalidEntriesToast() } + /** + * Test malicious tar.bz2 extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarBz2() { + performTest(maliciousTarBz2File) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.xz extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarXz() { + performTest(maliciousTarXzFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.lzma extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarLzma() { + performTest(maliciousTarLzmaFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + private fun assertErrorOrInvalidEntriesToast() { val context = ApplicationProvider.getApplicationContext() val latestToastText = ShadowToast.getTextOfLatestToast() diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt index 53761d1387..6f134d98d4 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt @@ -20,10 +20,64 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarBzip2Extractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +/** + * Tests for [TarBzip2Extractor]. + */ open class TarBzip2ExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.bz2" override fun extractorClass(): Class = TarBzip2Extractor::class.java + + /** + * Test extracting a malicious tar.bz2 archive does not allow path traversal. + */ + @Test + fun testExtractMaliciousTarBzip2() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.bz2") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarBzip2Extractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt index ae8ada8978..9b3eafffb7 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt @@ -20,6 +20,9 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +/** + * Tests for [TarBzip2Extractor], but with .tbz extension instead of .tar.bz2. + */ class TarBzip2ExtractorTest2 : TarBzip2ExtractorTest() { override val archiveType: String = "tbz" } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt index e4d0707408..784a81e81f 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt @@ -20,10 +20,64 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarLzmaExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +/** + * Tests for [TarLzmaExtractor]. + */ class TarLzmaExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.lzma" override fun extractorClass(): Class = TarLzmaExtractor::class.java + + /** + * Test extracting a malicious tar.lzma archive does not allow path traversal. + */ + @Test + fun testExtractMaliciousTarLzma() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.lzma") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarLzmaExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt index 6125b05b7f..359af1c1fd 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt @@ -20,10 +20,64 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarXzExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +/** + * Tests for [TarXzExtractor]. + */ class TarXzExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.xz" override fun extractorClass(): Class = TarXzExtractor::class.java + + /** + * Test extracting a malicious tar.xz archive does not allow path traversal. + */ + @Test + fun testExtractMaliciousTarXz() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.xz") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarXzExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/resources/malicious.tar.bz2 b/app/src/test/resources/malicious.tar.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..eeba086779774ff42974eb5221a861fab560b4ae GIT binary patch literal 218 zcmV<0044uIT4*^jL0KkKS@7x!xc~sIUxCVyKmqrF31$2c?`NLi0ze1|FaWx2B}R$r zo~D2R(?irYplD z#9|P@Od%@U-^-jPKYw9Drh@UUqYL9fhYDOU*zvg*jOLS4kS16}e4|(DmRx#BolB2N zMq`zPPn6fb*Il;6CpaWVM|^rBCOMY{h#}h_ASLs?)@@J}-7y5E^gaVP1s9OJdN>vm UL^(AY1cUgyk}1N3hlfy2$b1b~fdBvi literal 0 HcmV?d00001 diff --git a/app/src/test/resources/malicious.tar.lzma b/app/src/test/resources/malicious.tar.lzma new file mode 100644 index 0000000000000000000000000000000000000000..4b2303f611f4442a71627e150cd3d9176c75f291 GIT binary patch literal 217 zcmV;~04D!k004jh|NsC0|NsC005==TtBA}v)qvHZ-W-jZq)FQ~US}gP0V^$MVWX=t z?8jn~-i-^v4egW4Ifv3fP#17Q?b3qKL;+w593aI&8LC}3o-beVa=I}GVch3S}0o}anXw7UnC6|2WA?$O(8|U?#92potPSq T@rjc+MUqPO5`5hU{4>ue%*Sgw literal 0 HcmV?d00001 diff --git a/app/src/test/resources/malicious.tar.xz b/app/src/test/resources/malicious.tar.xz new file mode 100644 index 0000000000000000000000000000000000000000..200e5eee585fee6884d98cf27f1dc5391eafa487 GIT binary patch literal 264 zcmV+j0r&p>H+ooF000E$*0e?f03iVu0001VFXf})C;tG(T>v*5%BzUXIMsmFq23&g znxskFHC|^UFaawqXJMnOG3>`;lHQFA!42)9ag5XX$Yv8gE5FI4*0(rl4}qt}f5Z;d z`Ex-Gsp6ZhG76~qoS_Psr*V@9S~~TzFC=KxE{j2EqX-3$nqcgB987Dd4jHJu?8hl! zT?RyCn~ng%a)JWxTm5Fsv6*z!n~YxV)9I)zKMOJVong(HiZbsAgIXwCAaT)(<6k5V z7YAk Date: Thu, 23 Jul 2026 11:53:19 +0800 Subject: [PATCH 10/17] Upgrade Google Play Billing library to 8.0.0 Upgrade on Google Play's notice --- .../com/amaze/filemanager/utils/Billing.kt | 41 ++++++++++--------- gradle/libs.versions.toml | 2 +- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/app/src/play/java/com/amaze/filemanager/utils/Billing.kt b/app/src/play/java/com/amaze/filemanager/utils/Billing.kt index 091a88c5c1..8ca3393fc1 100644 --- a/app/src/play/java/com/amaze/filemanager/utils/Billing.kt +++ b/app/src/play/java/com/amaze/filemanager/utils/Billing.kt @@ -44,6 +44,7 @@ import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsParams.Product +import com.android.billingclient.api.QueryProductDetailsResult import org.slf4j.LoggerFactory import java.util.concurrent.Callable @@ -59,13 +60,28 @@ class Billing(private val activity: BasicActivity) : private lateinit var productDetails: List // create new donations client - private lateinit var billingClient: BillingClient + private var billingClient: BillingClient // True if billing service is connected private var isServiceConnected = false private lateinit var donationDialog: MaterialDialog + init { + productList = + listOf( + createProductWith("donations"), + createProductWith("donations_2"), + createProductWith("donations_3"), + createProductWith("donations_4"), + ) + billingClient = + BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(), + ).build() + initiatePurchaseFlow() + } + override fun onPurchasesUpdated( response: BillingResult, purchases: List?, @@ -93,11 +109,11 @@ class Billing(private val activity: BasicActivity) : billingClient.queryProductDetailsAsync( params.build(), - ) { responseCode: BillingResult, queryResult: List -> - if (queryResult.isNotEmpty()) { + ) { responseCode: BillingResult, queryResult: QueryProductDetailsResult -> + if (queryResult.productDetailsList.isNotEmpty()) { // Successfully fetched product details - productDetails = queryResult - popProductsList(responseCode, queryResult) + productDetails = queryResult.productDetailsList + popProductsList(responseCode, queryResult.productDetailsList) } else { AppConfig.toast(activity, R.string.error_fetching_google_play_product_list) @Suppress("ktlint:standard:max-line-length") @@ -198,21 +214,6 @@ class Billing(private val activity: BasicActivity) : } } - init { - productList = - listOf( - createProductWith("donations"), - createProductWith("donations_2"), - createProductWith("donations_3"), - createProductWith("donations_4"), - ) - billingClient = - BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases( - PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(), - ).build() - initiatePurchaseFlow() - } - /** * We executes a connection request to Google Play * diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e475d17e45..8968d58db7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ apacheMina = "2.0.16" mockito = "4.11.0" mockitoInline = "4.11.0" mockitoKotlin = "4.1.0" -androidBilling = "7.1.1" +androidBilling = "8.0.0" cloudrailSiAndroid = "2.22.4" junrar = "7.4.0" vectordrawableAnimated = "1.2.0" From 1d7520eab2ccdecee3c5fda9da440e97c48dd95f Mon Sep 17 00:00:00 2001 From: VishnuSanal Date: Thu, 30 Jul 2026 11:25:59 +0530 Subject: [PATCH 11/17] v3.11.3 -> update version code and changelog Signed-off-by: VishnuSanal --- app/build.gradle | 4 ++-- app/src/main/res/values/translators.xml | 2 +- fastlane/metadata/android/en-US/changelogs/125.txt | 1 + gradle.properties | 3 +++ 4 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/125.txt diff --git a/app/build.gradle b/app/build.gradle index c394440b3f..e226e61195 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,8 +19,8 @@ android { applicationId "com.amaze.filemanager" minSdkVersion libs.versions.minSdk.get().toInteger() targetSdkVersion libs.versions.targetSdk.get().toInteger() - versionCode 124 - versionName "3.11.2" + versionCode 125 + versionName "3.11.3" multiDexEnabled true vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/res/values/translators.xml b/app/src/main/res/values/translators.xml index cc95411197..410070b3bb 100644 --- a/app/src/main/res/values/translators.xml +++ b/app/src/main/res/values/translators.xml @@ -43,7 +43,7 @@ ngoisaosang Naofumi Fukue Kuralarasi for StarsSoft - v3.11.2 + v3.11.3 Arpit Khurana Vishal Nehra Emmanuel Messulam diff --git a/fastlane/metadata/android/en-US/changelogs/125.txt b/fastlane/metadata/android/en-US/changelogs/125.txt new file mode 100644 index 0000000000..ed04ddd0a4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/125.txt @@ -0,0 +1 @@ +* Fixes for path traversal vulnerabilities \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e9994ab628..a7ce00a43f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,3 +28,6 @@ org.gradle.parallel=true android.disableResourceValidation=true # for macs, omit for other operating systems # org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From e7733bc9b0585f1698526d51d8a1a2fd5630c88f Mon Sep 17 00:00:00 2001 From: Vishnu Sanal T <50027064+VishnuSanal@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:21:16 +0530 Subject: [PATCH 12/17] Merge pull request #4696 from VishnuSanal/hotfix/3.11.3 add cargo.lock file to file_operations module --- file_operations/.gitignore | 3 +- file_operations/Cargo.lock | 339 +++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 file_operations/Cargo.lock diff --git a/file_operations/.gitignore b/file_operations/.gitignore index f2869aa55f..a930b72f50 100644 --- a/file_operations/.gitignore +++ b/file_operations/.gitignore @@ -1,11 +1,10 @@ # Rust build artifacts target/ **/*.rs.bk -Cargo.lock # Auto-generated configuration (generated from template) .cargo/config.toml # Android build artifacts .cxx/ -build/ \ No newline at end of file +build/ diff --git a/file_operations/Cargo.lock b/file_operations/Cargo.lock new file mode 100644 index 0000000000..62ac5f7e67 --- /dev/null +++ b/file_operations/Cargo.lock @@ -0,0 +1,339 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_log-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e" + +[[package]] +name = "android_logger" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8619b80c242aa7bd638b5c7ddd952addeecb71f69c75e33f1d47b2804f8f883a" +dependencies = [ + "android_log-sys", + "env_logger", + "log", + "once_cell", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rootoperations" +version = "0.1.0" +dependencies = [ + "android_logger", + "jni", + "libc", + "log", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" From 6ccda3368c703ce89b44d81aac003d2aaeb8569f Mon Sep 17 00:00:00 2001 From: VishnuSanal Date: Sat, 1 Aug 2026 21:27:59 +0530 Subject: [PATCH 13/17] Add back missing bits in Android Main CI workflow Co-authored-by: Raymond Lai Signed-off-by: VishnuSanal --- .github/workflows/android-main.yml | 67 +++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/.github/workflows/android-main.yml b/.github/workflows/android-main.yml index ea0959f7ad..e1dcf8055b 100644 --- a/.github/workflows/android-main.yml +++ b/.github/workflows/android-main.yml @@ -16,9 +16,9 @@ jobs: name: Check spotless runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: 17 @@ -28,20 +28,20 @@ jobs: ndk-version: r28c link-to-sdk: true local-cache: true + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 - name: Check formatting using spotless - uses: gradle/actions/setup-gradle@v3 - with: - arguments: spotlessCheck + run: ./gradlew spotlessCheck build: name: Build debug, Jacoco test and publish to codacy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: 17 @@ -50,7 +50,7 @@ jobs: with: toolchain: stable - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -78,14 +78,12 @@ jobs: echo "❌ .cargo/config.toml missing" exit 1 fi + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 - name: Build with Gradle - uses: gradle/actions/setup-gradle@v3 - with: - arguments: assembledebug + run: ./gradlew assembledebug - name: Run test cases - uses: gradle/actions/setup-gradle@v3 - with: - arguments: jacocoTestPlayDebugUnitTestReport + run: ./gradlew jacocoTestPlayDebugUnitTestReport - name: Publish test cases run: | export CODACY_PROJECT_TOKEN=${{ secrets.CODACY_TOKEN }} @@ -109,21 +107,52 @@ jobs: api-level: [ 21, 28 ] steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Java 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: 17 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + - name: Cache Rust dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + file_operations/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Setup Rust for Android + run: | + cd file_operations + chmod +x setup_rust_android.sh + ./setup_rust_android.sh + - name: Verify Rust setup + run: | + cd file_operations + echo "🔍 Verifying Rust Android targets..." + rustup target list --installed | grep android || echo "No Android targets found" + echo "🔍 Verifying cargo configuration..." + if [ -f .cargo/config.toml ]; then + echo "✅ .cargo/config.toml exists" + else + echo "❌ .cargo/config.toml missing" + exit 1 + fi - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Gradle cache - uses: gradle/actions/setup-gradle@v3 - name: AVD cache - uses: actions/cache@v4 + uses: actions/cache@v5 id: avd-cache with: path: | From feebe138fc466389a4dde12ea91be0736709c488 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 5 Aug 2026 23:52:59 +0800 Subject: [PATCH 14/17] Fix Espresso tests failing on #4692 --- .../test/StoragePermissionHelper.kt | 51 ++--- .../ui/fragments/BackupPrefsFragmentTest.kt | 116 ++++++----- .../ui/fragments/TabFragmentTest.kt | 180 ++++++++++++------ 3 files changed, 222 insertions(+), 125 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt index 62bca738ed..59c455424e 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -21,6 +21,8 @@ package com.amaze.filemanager.test import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -40,34 +42,37 @@ object StoragePermissionHelper { */ @JvmStatic fun grantManageStoragePermission() { - // Ensure that an activity that has the dialog is launched - ActivityScenario.launch(MainActivity::class.java) + // Only need to run on Androids >= R + if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { + // Ensure that an activity that has the dialog is launched + ActivityScenario.launch(MainActivity::class.java) - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) + val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) - if (device.hasObject(By.text(grantPermissionExplanation))) { - // First press Amaze's grant button - onView(withText(R.string.grant)).perform(click()) + if (device.hasObject(By.text(grantPermissionExplanation))) { + // First press Amaze's grant button + onView(withText(R.string.grant)).perform(click()) - // Identifier names are taken here: - // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml - val resources = context.packageManager.getResourcesForApplication("com.android.settings") - val resId = - resources.getIdentifier( - "permit_manage_external_storage", - "string", - "com.android.settings", - ) - val permitManageExternalStorage = resources.getString(resId) + // Identifier names are taken here: + // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml + val resources = context.packageManager.getResourcesForApplication("com.android.settings") + val resId = + resources.getIdentifier( + "permit_manage_external_storage", + "string", + "com.android.settings", + ) + val permitManageExternalStorage = resources.getString(resId) - val grantToggle = - device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) - grantToggle.click() - device.pressBack() + val grantToggle = + device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) + grantToggle.click() + device.pressBack() + } } } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 1e831fec2f..5cff15bb59 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -27,13 +27,13 @@ import android.content.SharedPreferences import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU +import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.preference.PreferenceManager import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule @@ -43,6 +43,7 @@ import com.amaze.filemanager.ui.activities.PreferencesActivity import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import org.awaitility.Awaitility.await import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -52,10 +53,11 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.File +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class BackupPrefsFragmentTest { - var storagePath = "/storage/emulated/0" + var storagePath = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule @@ -93,6 +95,19 @@ class BackupPrefsFragmentTest { import(exportFile) } + /** + * Waits (with a timeout) for the given file to exist, since some writes to storage happen + * asynchronously on a background thread. + */ + private fun waitForFile( + file: File, + timeoutSeconds: Long = 5L, + ) { + await().atMost(timeoutSeconds, TimeUnit.SECONDS).until { + file.exists() + } + } + /** * Test whether the exported file contains the expected preference values */ @@ -103,59 +118,70 @@ class BackupPrefsFragmentTest { val backupPrefsFragment = BackupPrefsFragment() val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) - activityScenario.moveToState(Lifecycle.State.STARTED) + // Espresso requires an activity to be RESUMED to dispatch view actions/clicks. + activityScenario.moveToState(Lifecycle.State.RESUMED) - activityScenario.onActivity { - it.supportFragmentManager.beginTransaction() + lateinit var preferences: SharedPreferences + + activityScenario.onActivity { preferencesActivity -> + preferencesActivity.supportFragmentManager.beginTransaction() .add(backupPrefsFragment, null) .commitNow() backupPrefsFragment.exportPrefs() - } - - val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - assertTrue(tempFile.exists()) - - onView(withId(R.id.home)).perform(ViewActions.click()) - onView(withText(R.string.save)).perform(ViewActions.click()) - - assertTrue(exportFile.exists()) - - activityScenario.onActivity { preferencesActivity -> - val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) - val preferenceMap: Map = preferences.all + val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - val inputString = - exportFile - .inputStream() - .bufferedReader() - .use { - it.readText() - } + assertTrue(tempFile.exists()) - val type = object : TypeToken>() {}.type + preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) + } - val importMap: Map = - GsonBuilder() - .create() - .fromJson( - inputString, - type, - ) - - for ((key, value) in preferenceMap) { - val importedValue = importMap[key] - val mapValue = - if (importedValue != null && importedValue::class.simpleName.equals("Double")) { - (importedValue as Double).toInt() // since Gson parses Integer as Double - } else { - importedValue - } + // Espresso's onView().perform() must run on the instrumentation/test thread, never from + // inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread). + // Espresso internally synchronizes with the UI thread itself; calling it from the UI + // thread can deadlock or throw IllegalStateException. + // exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar + // with a "Save" action; that is the only view action needed here. + onView(withText(R.string.save)).perform(ViewActions.click()) - assertEquals("Difference found at key $key", value, mapValue) - } + // The actual write to storagePath happens asynchronously (RxJava) after the "Save" click + // and after MainActivity finishes, so poll for the file instead of asserting immediately. + waitForFile(exportFile) + + val preferenceMap: Map = preferences.all + + val inputString = + exportFile + .inputStream() + .bufferedReader() + .use { + it.readText() + } + + val type = object : TypeToken>() {}.type + + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) + + for ((key, value) in preferenceMap) { + val importedValue = importMap[key] + val mapValue = + if (importedValue != null && importedValue::class.simpleName.equals("Double")) { + (importedValue as Double).toInt() // since Gson parses Integer as Double + } else { + importedValue + } + + assertEquals("Difference found at key $key", value, mapValue) } + + activityScenario.close() } /** @@ -211,6 +237,8 @@ class BackupPrefsFragmentTest { assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value)) } } + + activityScenario.close() } private fun checkPrefEqual( diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index b26a65854a..b3d5c88c9b 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -1,23 +1,20 @@ package com.amaze.filemanager.ui.fragments -import android.content.pm.ActivityInfo import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.swipeLeft -import androidx.test.espresso.action.ViewActions.swipeRight -import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.ActivityTestRule import androidx.test.rule.GrantPermissionRule +import androidx.viewpager2.widget.ViewPager2 import com.amaze.filemanager.R import com.amaze.filemanager.test.StoragePermissionHelper import com.amaze.filemanager.ui.activities.MainActivity +import org.awaitility.Awaitility.await import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit /** * Tests for [TabFragment] functionality, mainly for @@ -28,9 +25,6 @@ import org.junit.runner.RunWith @Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) class TabFragmentTest { - @get:Rule - val activityRule = ActivityTestRule(MainActivity::class.java) - @Rule @JvmField val storagePermissionRule: GrantPermissionRule = @@ -52,25 +46,25 @@ class TabFragmentTest { } /** - * This test causes a rotation to happen while the MainFragment detaches, to check if it - * fails. This could happen in reality, but should be very rare + * This test saves state while a MainFragment is detached. */ @Test fun testFragmentStateSavingDuringDetachment() { - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment - - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.forEach { detach(it) } - commit() + withScenario { scenario -> + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment + + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } } + + recreateActivity(scenario) } } @@ -80,13 +74,11 @@ class TabFragmentTest { */ @Test fun testFragmentStateSavingDuringConfigChange() { - // First perform the swipe action - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Force a configuration change by rotating the screen - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - Thread.sleep(1000) // Give time for the rotation to complete - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + withScenario { scenario -> + setCurrentItem(scenario, 1) + recreateActivity(scenario) + awaitCurrentItem(scenario, 1) + } } /** @@ -94,16 +86,15 @@ class TabFragmentTest { */ @Test fun testRapidTabSwitchingAndStateSaving() { - // Perform rapid tab switches - repeat(10) { - onView(withId(R.id.pager)).perform(swipeLeft()) - Thread.sleep(100) // Small delay to ensure swipe completes - onView(withId(R.id.pager)).perform(swipeRight()) - Thread.sleep(100) // Small delay to ensure swipe completes - } + withScenario { scenario -> + repeat(10) { + setCurrentItem(scenario, 1) + setCurrentItem(scenario, 0) + } - // Force a save state by rotating - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + recreateActivity(scenario) + awaitCurrentItem(scenario, 0) + } } /** @@ -111,24 +102,97 @@ class TabFragmentTest { */ @Test fun testFragmentDetachmentAndStateSaving() { - // First switch to a different tab - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment - - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.firstOrNull()?.let { detach(it) } - commit() + withScenario { scenario -> + setCurrentItem(scenario, 1) + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment + + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } + } + + recreateActivity(scenario) + } + } + + private fun withScenario(testBody: (ActivityScenario) -> Unit) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + awaitPager(scenario) + testBody(scenario) + } + } + + private fun awaitPager(scenario: ActivityScenario): ViewPager2 { + var pager: ViewPager2? = null + + await().atMost(10, TimeUnit.SECONDS).until { + scenario.onActivity { activity -> + pager = activity.findViewById(R.id.pager) + } + + pager != null + } + + return requireNotNull(pager) + } + + private fun awaitTabFragment(scenario: ActivityScenario): TabFragment { + var tabFragment: TabFragment? = null + + await().atMost(10, TimeUnit.SECONDS).until { + runCatching { + scenario.onActivity { activity -> + tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as? TabFragment + } + } + + tabFragment?.view != null && tabFragment?.fragments?.isNotEmpty() == true + } + + return requireNotNull(tabFragment) + } + + private fun setCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + awaitPager(scenario) + + scenario.onActivity { activity -> + activity.findViewById(R.id.pager).setCurrentItem(index, false) + } + + awaitCurrentItem(scenario, index) + } + + private fun awaitCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + await().atMost(5, TimeUnit.SECONDS).until { + var currentItem = -1 + + runCatching { + scenario.onActivity { activity -> + currentItem = activity.findViewById(R.id.pager).currentItem + } } + + currentItem == index } + } - // Force state save through configuration change - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + private fun recreateActivity(scenario: ActivityScenario) { + scenario.recreate() + awaitPager(scenario) + awaitTabFragment(scenario) } -} +} From b7c1d308f9abd94808c0ca7d62a8f4c1bb862cb0 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 6 Aug 2026 23:45:24 +0800 Subject: [PATCH 15/17] Changes per PR feedback - TabFragmentTest use back actions to swipe instead of programmatically --- .../ui/fragments/BackupPrefsFragmentTest.kt | 2 +- .../ui/fragments/TabFragmentTest.kt | 123 +++++++++++++++--- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 5cff15bb59..08cbbaab58 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -57,7 +57,7 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class BackupPrefsFragmentTest { - var storagePath = Environment.getExternalStorageDirectory().absolutePath + var storagePath: String = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index b3d5c88c9b..520d842b43 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -1,8 +1,34 @@ +/* + * Copyright (C) 2014-2025 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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 + * (at your option) 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.amaze.filemanager.ui.fragments +import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.action.ViewActions.swipeRight +import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import androidx.viewpager2.widget.ViewPager2 @@ -51,6 +77,7 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringDetachment() { withScenario { scenario -> + swipeToItem(scenario, 1) awaitTabFragment(scenario) scenario.onActivity { activity -> @@ -64,7 +91,7 @@ class TabFragmentTest { } } - recreateActivity(scenario) + rotateScreen(scenario) } } @@ -75,8 +102,8 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringConfigChange() { withScenario { scenario -> - setCurrentItem(scenario, 1) - recreateActivity(scenario) + swipeToItem(scenario, 1) + rotateScreen(scenario) awaitCurrentItem(scenario, 1) } } @@ -88,11 +115,11 @@ class TabFragmentTest { fun testRapidTabSwitchingAndStateSaving() { withScenario { scenario -> repeat(10) { - setCurrentItem(scenario, 1) - setCurrentItem(scenario, 0) + swipeToItem(scenario, 1) + swipeToItem(scenario, 0) } - recreateActivity(scenario) + rotateScreen(scenario) awaitCurrentItem(scenario, 0) } } @@ -103,7 +130,7 @@ class TabFragmentTest { @Test fun testFragmentDetachmentAndStateSaving() { withScenario { scenario -> - setCurrentItem(scenario, 1) + swipeToItem(scenario, 1) awaitTabFragment(scenario) scenario.onActivity { activity -> @@ -117,7 +144,7 @@ class TabFragmentTest { } } - recreateActivity(scenario) + rotateScreen(scenario) } } @@ -160,19 +187,87 @@ class TabFragmentTest { return requireNotNull(tabFragment) } - private fun setCurrentItem( + // Swipe to the other tab in the ViewPager2. + // Index 0 is the first tab, index 1 is the second tab. + private fun swipeToItem( scenario: ActivityScenario, index: Int, ) { awaitPager(scenario) - scenario.onActivity { activity -> - activity.findViewById(R.id.pager).setCurrentItem(index, false) + when (index) { + 0 -> onView(withId(R.id.pager)).perform(swipeRight()) + 1 -> onView(withId(R.id.pager)).perform(swipeLeft()) + else -> error("Unsupported pager index: $index") } awaitCurrentItem(scenario, index) } + private fun rotateScreen(scenario: ActivityScenario) { + val initialOrientation = + currentOrientation(scenario).takeIf { + it == Configuration.ORIENTATION_LANDSCAPE || it == Configuration.ORIENTATION_PORTRAIT + } ?: Configuration.ORIENTATION_PORTRAIT + val rotatedRequestedOrientation = + if (initialOrientation == Configuration.ORIENTATION_LANDSCAPE) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + setRequestedOrientation(scenario, rotatedRequestedOrientation) + awaitOrientation(scenario, orientationForRequest(rotatedRequestedOrientation)) + + setRequestedOrientation(scenario, orientationRequestFor(initialOrientation)) + awaitOrientation(scenario, initialOrientation) + + awaitPager(scenario) + awaitTabFragment(scenario) + } + + private fun setRequestedOrientation( + scenario: ActivityScenario, + requestedOrientation: Int, + ) { + scenario.onActivity { activity -> + activity.requestedOrientation = requestedOrientation + } + } + + private fun currentOrientation(scenario: ActivityScenario): Int { + var orientation = Configuration.ORIENTATION_UNDEFINED + + scenario.onActivity { activity -> + orientation = activity.resources.configuration.orientation + } + + return orientation + } + + private fun orientationForRequest(requestedOrientation: Int): Int = + when (requestedOrientation) { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> Configuration.ORIENTATION_LANDSCAPE + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> Configuration.ORIENTATION_PORTRAIT + else -> Configuration.ORIENTATION_UNDEFINED + } + + private fun orientationRequestFor(orientation: Int): Int = + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + + private fun awaitOrientation( + scenario: ActivityScenario, + expectedOrientation: Int, + ) { + await().atMost(10, TimeUnit.SECONDS).until { + currentOrientation(scenario) == expectedOrientation + } + } + private fun awaitCurrentItem( scenario: ActivityScenario, index: Int, @@ -189,10 +284,4 @@ class TabFragmentTest { currentItem == index } } - - private fun recreateActivity(scenario: ActivityScenario) { - scenario.recreate() - awaitPager(scenario) - awaitTabFragment(scenario) - } } From 18b58dadd4c979a28531f2b2daad6ab80cff75c4 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Fri, 7 Aug 2026 16:28:50 +0900 Subject: [PATCH 16/17] Fix failing BackupPrefsFragmentTest --- .../ui/fragments/BackupPrefsFragmentTest.kt | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 08cbbaab58..256e882ee5 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -47,7 +47,6 @@ import org.awaitility.Awaitility.await import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Before import org.junit.Rule import org.junit.Test @@ -104,7 +103,7 @@ class BackupPrefsFragmentTest { timeoutSeconds: Long = 5L, ) { await().atMost(timeoutSeconds, TimeUnit.SECONDS).until { - file.exists() + file.exists() && file.length() > 0L } } @@ -121,20 +120,21 @@ class BackupPrefsFragmentTest { // Espresso requires an activity to be RESUMED to dispatch view actions/clicks. activityScenario.moveToState(Lifecycle.State.RESUMED) - lateinit var preferences: SharedPreferences + lateinit var preferenceSnapshot: Map activityScenario.onActivity { preferencesActivity -> preferencesActivity.supportFragmentManager.beginTransaction() .add(backupPrefsFragment, null) .commitNow() + val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) + preferenceSnapshot = HashMap(preferences.all) + backupPrefsFragment.exportPrefs() val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") assertTrue(tempFile.exists()) - - preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) } // Espresso's onView().perform() must run on the instrumentation/test thread, never from @@ -149,8 +149,6 @@ class BackupPrefsFragmentTest { // and after MainActivity finishes, so poll for the file instead of asserting immediately. waitForFile(exportFile) - val preferenceMap: Map = preferences.all - val inputString = exportFile .inputStream() @@ -169,14 +167,9 @@ class BackupPrefsFragmentTest { type, ) - for ((key, value) in preferenceMap) { + for ((key, value) in preferenceSnapshot) { val importedValue = importMap[key] - val mapValue = - if (importedValue != null && importedValue::class.simpleName.equals("Double")) { - (importedValue as Double).toInt() // since Gson parses Integer as Double - } else { - importedValue - } + val mapValue = normalizeImportedValue(importedValue, value) assertEquals("Difference found at key $key", value, mapValue) } @@ -198,7 +191,15 @@ class BackupPrefsFragmentTest { .add(backupPrefsFragment, null) .commitNow() - javaClass.getResourceAsStream("/$fileName")?.copyTo(exportFile.outputStream()) + val resourceStream = + requireNotNull(javaClass.getResourceAsStream("/$fileName")) { + "Missing test resource /$fileName" + } + resourceStream.use { input -> + exportFile.outputStream().use { output -> + input.copyTo(output) + } + } backupPrefsFragment.onActivityResult( BackupPrefsFragment.IMPORT_BACKUP_FILE, @@ -230,9 +231,8 @@ class BackupPrefsFragmentTest { assertFalse(preferenceMap.containsKey(null)) for ((k, v) in preferenceMap) { - // This cast tells the kotlin type checker that fail() never returns - val key = k ?: (fail() as Nothing) - val value = v ?: (fail() as Nothing) + val key = requireNotNull(k) { "Preference key unexpectedly null" } + val value = requireNotNull(v) { "Preference value unexpectedly null for $key" } assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value)) } @@ -251,15 +251,14 @@ class BackupPrefsFragmentTest { "Boolean" -> return importMap[key] as Boolean == preferences.getBoolean(key, false) "Float" -> - importMap[key] as Float == + (importMap[key] as Number).toFloat() == preferences.getFloat(key, 0f) "Int" -> { - // since Gson parses Integer as Double - val toInt = (importMap[key] as Double).toInt() + val toInt = (importMap[key] as Number).toInt() return toInt == preferences.getInt(key, 0) } - "Long" -> return importMap[key] as Long == + "Long" -> return (importMap[key] as Number).toLong() == preferences.getLong(key, 0L) "String" -> return importMap[key] as String == preferences.getString(key, null) @@ -268,4 +267,20 @@ class BackupPrefsFragmentTest { } return false } + + private fun normalizeImportedValue( + importedValue: Any?, + expectedValue: Any?, + ): Any? { + if (importedValue !is Number || expectedValue !is Number) { + return importedValue + } + return when (expectedValue) { + is Int -> importedValue.toInt() + is Long -> importedValue.toLong() + is Float -> importedValue.toFloat() + is Double -> importedValue.toDouble() + else -> importedValue + } + } } From 10c422268586334edaa164c3000f1fdc42a4dc59 Mon Sep 17 00:00:00 2001 From: EmmanuelMess Date: Sun, 9 Aug 2026 14:26:17 -0300 Subject: [PATCH 17/17] More test fixes --- .../test/StoragePermissionHelper.kt | 52 ++++++++++--------- .../ui/fragments/BackupPrefsFragmentTest.kt | 27 ++++------ .../ui/fragments/TabFragmentTest.kt | 13 +++-- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt index 59c455424e..436f7d5e47 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -43,36 +43,38 @@ object StoragePermissionHelper { @JvmStatic fun grantManageStoragePermission() { // Only need to run on Androids >= R - if (Build.VERSION.SDK_INT >= VERSION_CODES.R) { - // Ensure that an activity that has the dialog is launched - ActivityScenario.launch(MainActivity::class.java) + if (Build.VERSION.SDK_INT < VERSION_CODES.R) { + return + } + + // Ensure that an activity that has the dialog is launched + ActivityScenario.launch(MainActivity::class.java) - val context: Context = InstrumentationRegistry.getInstrumentation().targetContext - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) + val grantPermissionHeader = amazeResources.getString(R.string.grantper) - if (device.hasObject(By.text(grantPermissionExplanation))) { - // First press Amaze's grant button - onView(withText(R.string.grant)).perform(click()) + if (device.hasObject(By.text(grantPermissionHeader))) { + // First press Amaze's grant button + onView(withText(R.string.grant)).perform(click()) - // Identifier names are taken here: - // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml - val resources = context.packageManager.getResourcesForApplication("com.android.settings") - val resId = - resources.getIdentifier( - "permit_manage_external_storage", - "string", - "com.android.settings", - ) - val permitManageExternalStorage = resources.getString(resId) + // Identifier names are taken here: + // https://cs.android.com/android/platform/superproject/+/master:packages/apps/Settings/res/values/strings.xml + val resources = context.packageManager.getResourcesForApplication("com.android.settings") + val resId = + resources.getIdentifier( + "permit_manage_external_storage", + "string", + "com.android.settings", + ) + val permitManageExternalStorage = resources.getString(resId) - val grantToggle = - device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) - grantToggle.click() - device.pressBack() - } + val grantToggle = + device.findObject(UiSelector().textMatches("(?i)$permitManageExternalStorage")) + grantToggle.click() + device.pressBack() } } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 256e882ee5..1a6a749c8b 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -159,6 +159,7 @@ class BackupPrefsFragmentTest { val type = object : TypeToken>() {}.type + // TODO This breaks the exported file's types, all Numbers get converted to Double val importMap: Map = GsonBuilder() .create() @@ -169,9 +170,15 @@ class BackupPrefsFragmentTest { for ((key, value) in preferenceSnapshot) { val importedValue = importMap[key] - val mapValue = normalizeImportedValue(importedValue, value) - assertEquals("Difference found at key $key", value, mapValue) + if (value is Number) { + // HACK GsonBuilder().create().fromJson() breaks Number types + assertEquals("Difference found at key $key", value.toDouble(), importedValue as Double, 0.1) + } else { + assertEquals("Different type at key $key", value?.javaClass, importedValue?.javaClass) + + assertEquals("Difference found at key $key", value, importedValue) + } } activityScenario.close() @@ -267,20 +274,4 @@ class BackupPrefsFragmentTest { } return false } - - private fun normalizeImportedValue( - importedValue: Any?, - expectedValue: Any?, - ): Any? { - if (importedValue !is Number || expectedValue !is Number) { - return importedValue - } - return when (expectedValue) { - is Int -> importedValue.toInt() - is Long -> importedValue.toLong() - is Float -> importedValue.toFloat() - is Double -> importedValue.toDouble() - else -> importedValue - } - } } diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index 520d842b43..d9e675b581 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -77,6 +77,8 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringDetachment() { withScenario { scenario -> + rotateScreen(scenario) + swipeToItem(scenario, 1) awaitTabFragment(scenario) @@ -90,8 +92,6 @@ class TabFragmentTest { commitNow() } } - - rotateScreen(scenario) } } @@ -102,7 +102,10 @@ class TabFragmentTest { @Test fun testFragmentStateSavingDuringConfigChange() { withScenario { scenario -> + // First perform the swipe action swipeToItem(scenario, 1) + // Then force a configuration change by rotating the screen + rotateScreen(scenario) rotateScreen(scenario) awaitCurrentItem(scenario, 1) } @@ -114,11 +117,13 @@ class TabFragmentTest { @Test fun testRapidTabSwitchingAndStateSaving() { withScenario { scenario -> + // Perform rapid tab switches repeat(10) { swipeToItem(scenario, 1) swipeToItem(scenario, 0) } + // Then force a save state by rotating rotateScreen(scenario) awaitCurrentItem(scenario, 0) } @@ -138,12 +143,14 @@ class TabFragmentTest { activity.supportFragmentManager .findFragmentById(R.id.content_frame) as TabFragment + // Detach TabFragment through FragmentManager activity.supportFragmentManager.beginTransaction().apply { tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } commitNow() } } + // Force state save through configuration change rotateScreen(scenario) } } @@ -272,7 +279,7 @@ class TabFragmentTest { scenario: ActivityScenario, index: Int, ) { - await().atMost(5, TimeUnit.SECONDS).until { + await().pollDelay(50, TimeUnit.MILLISECONDS).atMost(100, TimeUnit.MILLISECONDS).until { var currentItem = -1 runCatching {