From b0f9a5524d851be7467f7533e682d337db860881 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 1 Jan 2026 23:20:58 +0800 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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 { From 07e13df4021e0eb49d84ed652a83fa9e52436aab Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 3 Sep 2026 23:51:55 +0800 Subject: [PATCH 18/35] Enable AESCrypt logging only on debug builds Also remove log statement in setPassword() --- .../com/amaze/filemanager/utils/AESCrypt.kt | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt b/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt index 2731f213ed..c2b5a6006b 100644 --- a/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt +++ b/app/src/main/java/com/amaze/filemanager/utils/AESCrypt.kt @@ -21,9 +21,8 @@ package com.amaze.filemanager.utils import android.util.Log +import com.amaze.filemanager.BuildConfig import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil -import com.amaze.filemanager.utils.AESCrypt.DecryptFailureException -import com.amaze.filemanager.utils.AESCrypt.IncorrectEncryptedDataException import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -86,9 +85,14 @@ class AESCrypt(password: String) { private lateinit var ivSpec2: IvParameterSpec private lateinit var aesKey2: SecretKeySpec - /******************* - * PRIVATE METHODS * + /** + * Shorthand for [Log.v] with checks for debug build, so it only logs in debug build. */ + private fun log(msg: String) { + if (BuildConfig.DEBUG) { + Log.v(TAG, msg) + } + } /** * Generates a pseudo-random byte array. @@ -197,7 +201,6 @@ class AESCrypt(password: String) { */ private fun setPassword(password: String) { this.password = password.toByteArray(UTF_16LE) - Log.v(TAG, "Using password: $password") } /************** @@ -221,15 +224,14 @@ class AESCrypt(password: String) { out: OutputStream, progressHandler: ProgressHandler, ) { - var text: ByteArray? ivSpec1 = IvParameterSpec(generateIv1()) aesKey1 = SecretKeySpec(generateAESKey1(ivSpec1.iv, password), CRYPT_ALG) ivSpec2 = IvParameterSpec(generateIV2()) aesKey2 = SecretKeySpec(generateAESKey2(), CRYPT_ALG) - Log.v(TAG, "IV1: ${ivSpec1.iv.toHex()}") - Log.v(TAG, "AES1: ${aesKey1.encoded.toHex()}") - Log.v(TAG, "IV2: ${ivSpec2.iv.toHex()}") - Log.v(TAG, "AES2: ${aesKey2.encoded.toHex()}") + log("IV1: ${ivSpec1.iv.toHex()}") + log("AES1: ${aesKey1.encoded.toHex()}") + log("IV2: ${ivSpec2.iv.toHex()}") + log("AES2: ${aesKey2.encoded.toHex()}") out.write(AESCRYPT_HEADER.toByteArray(UTF_8)) // Heading. out.write(version) // Version. out.write(0) // Reserved. @@ -238,16 +240,16 @@ class AESCrypt(password: String) { out.write(0) } out.write(ivSpec1.iv) // Initialization Vector. - text = ByteArray(BLOCK_SIZE + KEY_SIZE) + var text = ByteArray(BLOCK_SIZE + KEY_SIZE) cipher.init(Cipher.ENCRYPT_MODE, aesKey1, ivSpec1) cipher.update(ivSpec2.iv, 0, BLOCK_SIZE, text) cipher.doFinal(aesKey2.encoded, 0, KEY_SIZE, text, BLOCK_SIZE) out.write(text) // Crypted IV and key. - Log.v(TAG, "IV2 + AES2 ciphertext: ${text.toHex()}") + log("IV2 + AES2 ciphertext: ${text.toHex()}") hmac.init(SecretKeySpec(aesKey1.encoded, HMAC_ALG)) text = hmac.doFinal(text) out.write(text) // HMAC from previous cyphertext. - Log.v(TAG, "HMAC1: ${text.toHex()}") + log("HMAC1: ${text.toHex()}") cipher.init(Cipher.ENCRYPT_MODE, aesKey2, ivSpec2) hmac.init(SecretKeySpec(aesKey2.encoded, HMAC_ALG)) text = ByteArray(BLOCK_SIZE) @@ -264,10 +266,10 @@ class AESCrypt(password: String) { } last = last and 0x0f out.write(last) // Last block size mod 16. - Log.v(TAG, "Last block size mod 16: $last") + log("Last block size mod 16: $last") text = hmac.doFinal() out.write(text) // HMAC from previous cyphertext. - Log.v(TAG, "HMAC2: ${text.toHex()}") + log("HMAC2: ${text.toHex()}") out.flush() `in`.close() @@ -310,7 +312,7 @@ class AESCrypt(password: String) { if (version < 1 || version > 2) { throw IncorrectEncryptedDataException("Unsupported version number: $version") } - Log.v(TAG, "Version: $version") + log("Version: $version") `in`.read() // Reserved. if (version == 2) { // Extensions. text = ByteArray(2) @@ -329,17 +331,17 @@ class AESCrypt(password: String) { `in`.read(text) // Initialization Vector. ivSpec1 = IvParameterSpec(text) aesKey1 = SecretKeySpec(generateAESKey1(ivSpec1.iv, password), CRYPT_ALG) - Log.v(TAG, "IV1: ${ivSpec1.iv.toHex()}") - Log.v(TAG, "AES1: ${aesKey1.encoded.toHex()}") + log("IV1: ${ivSpec1.iv.toHex()}") + log("AES1: ${aesKey1.encoded.toHex()}") cipher.init(Cipher.DECRYPT_MODE, aesKey1, ivSpec1) var backup = ByteArray(BLOCK_SIZE + KEY_SIZE) `in`.read(backup) // IV and key to decrypt file contents. - Log.v(TAG, "IV2 + AES2 ciphertext: ${backup.toHex()}") + log("IV2 + AES2 ciphertext: ${backup.toHex()}") text = cipher.doFinal(backup) ivSpec2 = IvParameterSpec(text, 0, BLOCK_SIZE) aesKey2 = SecretKeySpec(text, BLOCK_SIZE, KEY_SIZE, CRYPT_ALG) - Log.v(TAG, "IV2: ${ivSpec2.iv.toHex()}") - Log.v(TAG, "AES2: ${aesKey2.encoded.toHex()}") + log("IV2: ${ivSpec2.iv.toHex()}") + log("AES2: ${aesKey2.encoded.toHex()}") hmac.init(SecretKeySpec(aesKey1.encoded, HMAC_ALG)) backup = hmac.doFinal(backup) text = ByteArray(SHA_SIZE) @@ -347,7 +349,7 @@ class AESCrypt(password: String) { if (!backup.contentEquals(text)) { throw DecryptFailureException("Message has been altered or password incorrect") } - Log.v(TAG, "HMAC1: ${text.toHex()}") + log("HMAC1: ${text.toHex()}") total = inSize - total // Payload size. if (total % BLOCK_SIZE != 0L) { throw DecryptFailureException( @@ -357,7 +359,7 @@ class AESCrypt(password: String) { if (total == 0L) { // Hack: empty files won't enter block-processing for-loop below. `in`.read() // Skip last block size mod 16. } - Log.v(TAG, "Payload size: $total") + log("Payload size: $total") cipher.init(Cipher.DECRYPT_MODE, aesKey2, ivSpec2) hmac.init(SecretKeySpec(aesKey2.encoded, HMAC_ALG)) backup = ByteArray(BLOCK_SIZE) @@ -383,7 +385,7 @@ class AESCrypt(password: String) { if (!backup.contentEquals(text)) { throw DecryptFailureException("Message has been altered or password incorrect") } - Log.v(TAG, "HMAC2: ${text.toHex()}") + log("HMAC2: ${text.toHex()}") out.flush() `in`.close() out.close() From 390eb158bad5963533a0979cf62fe7dffd11970d Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 3 Sep 2026 23:53:11 +0800 Subject: [PATCH 19/35] Clean up deprecation and syntax warnings before refactor --- .../asynchronous/services/ftp/FtpService.kt | 88 ++++++++++--------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt index d946d64d1f..92adae3738 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt @@ -40,6 +40,7 @@ import android.os.PowerManager import android.os.SystemClock import android.provider.DocumentsContract import androidx.core.app.ServiceCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.amaze.filemanager.BuildConfig import com.amaze.filemanager.R @@ -107,7 +108,7 @@ class FtpService : Service(), Runnable { attempts-- try { Thread.sleep(1000) - } catch (ignored: InterruptedException) { + } catch (_: InterruptedException) { } } else { return START_STICKY @@ -148,12 +149,12 @@ class FtpService : Service(), Runnable { val connectionConfigFactory = ConnectionConfigFactory() val shouldUseAndroidFileSystem = preferences.getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) - if (SDK_INT >= KITKAT && shouldUseAndroidFileSystem) { - fileSystem = AndroidFileSystemFactory(applicationContext) + fileSystem = if (SDK_INT >= KITKAT && shouldUseAndroidFileSystem) { + AndroidFileSystemFactory(applicationContext) } else if (preferences.getBoolean(PREFERENCE_ROOTMODE, false)) { - fileSystem = RootFileSystemFactory() + RootFileSystemFactory() } else { - fileSystem = NativeFileSystemFactory() + NativeFileSystemFactory() } commandFactory = CommandFactoryFactory.create(shouldUseAndroidFileSystem) @@ -175,7 +176,9 @@ class FtpService : Service(), Runnable { }.onFailure { log.warn("failed to decrypt password in ftp service", it) AppConfig.toast(applicationContext, R.string.error) - preferences.edit().putString(KEY_PREFERENCE_PASSWORD, "").apply() + preferences.edit { + putString(KEY_PREFERENCE_PASSWORD, "") + } isPasswordProtected = false } } @@ -223,10 +226,14 @@ class FtpService : Service(), Runnable { "ftpserver", ) fac.isImplicitSsl = true - } catch (e: GeneralSecurityException) { - preferences.edit().putBoolean(KEY_PREFERENCE_SECURE, false).apply() - } catch (e: IOException) { - preferences.edit().putBoolean(KEY_PREFERENCE_SECURE, false).apply() + } catch (_: GeneralSecurityException) { + preferences.edit { + putBoolean(KEY_PREFERENCE_SECURE, false) + } + } catch (_: IOException) { + preferences.edit { + putBoolean(KEY_PREFERENCE_SECURE, false) + } } } fac.port = getPort(preferences) @@ -312,38 +319,33 @@ class FtpService : Service(), Runnable { const val TAG_STARTED_BY_TILE = "started_by_tile" // attribute of action_started, used by notification - private lateinit var _enabledCipherSuites: Array - - init { - _enabledCipherSuites = - LinkedList().apply { - if (SDK_INT >= Q) { - add("TLS_AES_128_GCM_SHA256") - add("TLS_AES_256_GCM_SHA384") - add("TLS_CHACHA20_POLY1305_SHA256") - } - if (SDK_INT >= N) { - add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") - add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") - } - if (SDK_INT >= LOLLIPOP) { - add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") - add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") - add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") - add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") - add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") - add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") - add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") - add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") - add("TLS_RSA_WITH_AES_128_GCM_SHA256") - add("TLS_RSA_WITH_AES_256_GCM_SHA384") - } - if (SDK_INT < LOLLIPOP) { - add("TLS_RSA_WITH_AES_128_CBC_SHA") - add("TLS_RSA_WITH_AES_256_CBC_SHA") - } - }.toTypedArray() - } + private var _enabledCipherSuites: Array = LinkedList().apply { + if (SDK_INT >= Q) { + add("TLS_AES_128_GCM_SHA256") + add("TLS_AES_256_GCM_SHA384") + add("TLS_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= N) { + add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") + add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= LOLLIPOP) { + add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") + add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + add("TLS_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_RSA_WITH_AES_256_GCM_SHA384") + } + if (SDK_INT < LOLLIPOP) { + add("TLS_RSA_WITH_AES_128_CBC_SHA") + add("TLS_RSA_WITH_AES_256_CBC_SHA") + } + }.toTypedArray() /** * Return a list of available ciphers for ftpserver. @@ -390,7 +392,7 @@ class FtpService : Service(), Runnable { } private fun getPort(preferences: SharedPreferences): Int { - return preferences.getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT) + return preferences.getInt(PORT_PREFERENCE_KEY, DEFAULT_PORT) } } } From 85aa30e482cc60025f84b699c98fd233fb4365c1 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Fri, 4 Sep 2026 17:32:06 +0900 Subject: [PATCH 20/35] Use installation-specific SSL certificate for FTP server instead of stock prefab ones Prevent abusing FTP server SSL certificate --- app/build.gradle | 2 - .../asynchronous/services/ftp/FtpService.kt | 221 ++++++++++++++---- .../ui/fragments/FtpServerFragment.kt | 35 ++- app/src/main/res/layout/fragment_ftp.xml | 17 +- app/src/main/res/raw/key.bks | Bin 2137 -> 0 bytes .../filesystem/ftp/FtpHybridFileTest.kt | 2 +- .../filesystem/ftp/FtpsHybridFileTest.kt | 27 ++- 7 files changed, 245 insertions(+), 59 deletions(-) delete mode 100644 app/src/main/res/raw/key.bks diff --git a/app/build.gradle b/app/build.gradle index e226e61195..d2e9ad3f05 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,7 +47,6 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' testProguardFile 'tests-proguard.cfg' buildConfigField "String", "CRYPTO_IV", "\"LxbHiJhhUXcj\"" - buildConfigField "String", "FTP_SERVER_KEYSTORE_PASSWORD", "\"vishal007\"" debuggable true //For "debug" banner on icon enableUnitTestCoverage true } @@ -58,7 +57,6 @@ android { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' buildConfigField "String", "CRYPTO_IV", "\"LxbHiJhhUXcj\"" - buildConfigField "String", "FTP_SERVER_KEYSTORE_PASSWORD", "\"vishal007\"" } } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt index 92adae3738..a827523495 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt @@ -20,6 +20,7 @@ package com.amaze.filemanager.asynchronous.services.ftp +import android.annotation.SuppressLint import android.app.AlarmManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_ONE_SHOT @@ -39,10 +40,10 @@ import android.os.IBinder import android.os.PowerManager import android.os.SystemClock import android.provider.DocumentsContract +import androidx.annotation.VisibleForTesting import androidx.core.app.ServiceCompat import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.amaze.filemanager.BuildConfig import com.amaze.filemanager.R import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.asynchronous.services.AbstractProgressiveService.getPendingIntentFlag @@ -62,12 +63,27 @@ import org.apache.ftpserver.ssl.ClientAuth import org.apache.ftpserver.ssl.impl.DefaultSslConfiguration import org.apache.ftpserver.usermanager.impl.BaseUser import org.apache.ftpserver.usermanager.impl.WritePermission +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.BasicConstraints +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import org.greenrobot.eventbus.EventBus import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException +import java.math.BigInteger import java.security.GeneralSecurityException +import java.security.KeyPair +import java.security.KeyPairGenerator import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.CertificateExpiredException +import java.security.cert.X509Certificate +import java.util.Calendar import java.util.LinkedList import javax.net.ssl.KeyManagerFactory import javax.net.ssl.TrustManagerFactory @@ -149,13 +165,14 @@ class FtpService : Service(), Runnable { val connectionConfigFactory = ConnectionConfigFactory() val shouldUseAndroidFileSystem = preferences.getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) - fileSystem = if (SDK_INT >= KITKAT && shouldUseAndroidFileSystem) { - AndroidFileSystemFactory(applicationContext) - } else if (preferences.getBoolean(PREFERENCE_ROOTMODE, false)) { - RootFileSystemFactory() - } else { - NativeFileSystemFactory() - } + fileSystem = + if (SDK_INT >= KITKAT && shouldUseAndroidFileSystem) { + AndroidFileSystemFactory(applicationContext) + } else if (preferences.getBoolean(PREFERENCE_ROOTMODE, false)) { + RootFileSystemFactory() + } else { + NativeFileSystemFactory() + } commandFactory = CommandFactoryFactory.create(shouldUseAndroidFileSystem) @@ -205,13 +222,12 @@ class FtpService : Service(), Runnable { val fac = ListenerFactory() if (preferences.getBoolean(KEY_PREFERENCE_SECURE, DEFAULT_SECURE)) { try { - val keyStore = KeyStore.getInstance("BKS") - val keyStorePassword = BuildConfig.FTP_SERVER_KEYSTORE_PASSWORD.toCharArray() - keyStore.load(resources.openRawResource(R.raw.key), keyStorePassword) + val keyStore = FtpServerSslKeyStoreProvider.getKeyStore() + keyStore.load(null) val keyManagerFactory = KeyManagerFactory .getInstance(KeyManagerFactory.getDefaultAlgorithm()) - keyManagerFactory.init(keyStore, keyStorePassword) + keyManagerFactory.init(keyStore, null) val trustManagerFactory = TrustManagerFactory .getInstance(TrustManagerFactory.getDefaultAlgorithm()) @@ -295,6 +311,132 @@ class FtpService : Service(), Runnable { companion object { private val log: Logger = LoggerFactory.getLogger(FtpService::class.java) + /** + * Provides the FTPS keystore with an overridable factory for testing. + * Defaults to AndroidKeyStore; tests can inject an in-memory BKS/PKCS12 store. + * Generates a self-signed RSA-2048 certificate on first use or if the existing + * entry is missing/corrupt. + */ + @SuppressLint("StaticFieldLeak") + object FtpServerSslKeyStoreProvider { + private const val FTPS_KEY_ALIAS = "ftpserver" + private const val FTPS_KEY_DN = "CN=localhost" + private const val FTPS_CERT_VALIDITY_DAYS = 7300 // ~20 years + + /** + * Factory lambda to create a KeyStore instance. + * Default: AndroidKeyStore with null load. + * Tests can override this to inject an in-memory BKS/PKCS12 store. + */ + @VisibleForTesting + var keyStoreFactory: () -> KeyStore = { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + + /** + * Return a configured KeyStore instance using the current factory, + * ensuring the FTPS certificate exists and is valid. + */ + fun getKeyStore(): KeyStore { + val keyStore = keyStoreFactory() + + // Try to use existing certificate; regenerate if missing/corrupt + try { + if (keyStore.containsAlias(FTPS_KEY_ALIAS)) { + val existingEntry = keyStore.getEntry(FTPS_KEY_ALIAS, null) + if (existingEntry is KeyStore.PrivateKeyEntry) { + val cert = existingEntry.certificate as? X509Certificate + if (cert != null) { + try { + cert.checkValidity() + return keyStore + } catch (e: CertificateExpiredException) { + log.warn("Existing FTPS certificate is expired or not yet valid", e) + } + } + } + } + } catch (e: Exception) { + log.warn("Existing FTPS certificate is corrupt; regenerating", e) + } + + // Generate new certificate + try { + generateFtpsCertificate(keyStore) + } catch (e: Exception) { + throw RuntimeException("Failed to generate FTPS certificate", e) + } + + return keyStore + } + + /** + * Generate a self-signed RSA-2048 certificate and store it in the keystore. + */ + private fun generateFtpsCertificate(keyStore: KeyStore) { + // Generate RSA-2048 key pair + // BouncyCastle provider is loaded at CustomSshJConfig via AppConfig + val keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC") + keyPairGenerator.initialize(2048, SecureRandom()) + val keyPair: KeyPair = keyPairGenerator.generateKeyPair() + + // Build self-signed certificate + val now = Calendar.getInstance().time + val notAfter = + Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, FTPS_CERT_VALIDITY_DAYS) + }.time + + val serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) + val subject = X500Name(FTPS_KEY_DN) + + val builder = + JcaX509v3CertificateBuilder( + subject, + serialNumber, + now, + notAfter, + subject, + keyPair.public, + ) + + // Add extensions + builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) + builder.addExtension( + Extension.keyUsage, + true, + KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment or KeyUsage.dataEncipherment), + ) + + val signer: ContentSigner = + JcaContentSignerBuilder("SHA256WithRSAEncryption") + .setProvider("BC") + .build(keyPair.private) + + val bcCert = builder.build(signer) + val cert: X509Certificate = + JcaX509CertificateConverter() + .setProvider("BC") + .getCertificate(bcCert) + + // Store in keystore + val privateKeyEntry = KeyStore.PrivateKeyEntry(keyPair.private, arrayOf(cert)) + keyStore.setEntry(FTPS_KEY_ALIAS, privateKeyEntry, null) + + log.info("Generated new FTPS self-signed certificate for alias: $FTPS_KEY_ALIAS") + } + + /** + * Reset the factory to the default AndroidKeyStore. + * Useful for test cleanup. + */ + fun reset() { + keyStoreFactory = { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + } + } + const val DEFAULT_PORT = 2211 const val DEFAULT_USERNAME = "" const val DEFAULT_TIMEOUT = 600 // default timeout, in sec @@ -319,33 +461,34 @@ class FtpService : Service(), Runnable { const val TAG_STARTED_BY_TILE = "started_by_tile" // attribute of action_started, used by notification - private var _enabledCipherSuites: Array = LinkedList().apply { - if (SDK_INT >= Q) { - add("TLS_AES_128_GCM_SHA256") - add("TLS_AES_256_GCM_SHA384") - add("TLS_CHACHA20_POLY1305_SHA256") - } - if (SDK_INT >= N) { - add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") - add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") - } - if (SDK_INT >= LOLLIPOP) { - add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") - add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") - add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") - add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") - add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") - add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") - add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") - add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") - add("TLS_RSA_WITH_AES_128_GCM_SHA256") - add("TLS_RSA_WITH_AES_256_GCM_SHA384") - } - if (SDK_INT < LOLLIPOP) { - add("TLS_RSA_WITH_AES_128_CBC_SHA") - add("TLS_RSA_WITH_AES_256_CBC_SHA") - } - }.toTypedArray() + private var _enabledCipherSuites: Array = + LinkedList().apply { + if (SDK_INT >= Q) { + add("TLS_AES_128_GCM_SHA256") + add("TLS_AES_256_GCM_SHA384") + add("TLS_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= N) { + add("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256") + add("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256") + } + if (SDK_INT >= LOLLIPOP) { + add("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384") + add("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") + add("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + add("TLS_RSA_WITH_AES_128_GCM_SHA256") + add("TLS_RSA_WITH_AES_256_GCM_SHA384") + } + if (SDK_INT < LOLLIPOP) { + add("TLS_RSA_WITH_AES_128_CBC_SHA") + add("TLS_RSA_WITH_AES_256_CBC_SHA") + } + }.toTypedArray() /** * Return a list of available ciphers for ftpserver. diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt index 329bef09ae..a0bb8c357a 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt @@ -57,6 +57,7 @@ import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatTextView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT import androidx.fragment.app.Fragment @@ -101,7 +102,6 @@ import java.security.GeneralSecurityException @Suppress("TooManyFunctions") class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private val log: Logger = LoggerFactory.getLogger(FtpServerFragment::class.java) - private val statusText: AppCompatTextView get() = binding.textViewFtpStatus private val url: AppCompatTextView get() = binding.textViewFtpUrl private val username: AppCompatTextView get() = binding.textViewFtpUsername @@ -110,6 +110,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private val sharedPath: AppCompatTextView get() = binding.textViewFtpPath private val ftpBtn: AppCompatButton get() = binding.startStopButton private val ftpPasswordVisibleButton: AppCompatImageButton get() = binding.ftpPasswordVisible + private val ftpSslCertificateViewButton: AppCompatButton get() = binding.buttonViewCert private var accentColor = 0 private var spannedStatusNoConnection: Spanned? = null private var spannedStatusConnected: Spanned? = null @@ -163,6 +164,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ftpBtn.setOnClickListener { ftpBtnOnClick() } + ftpSslCertificateViewButton.setOnClickListener { + displaySslCertInfoDialog() + } return binding.root } @@ -291,6 +295,11 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { item.isChecked = shouldSecure securePreference = shouldSecure promptUserToRestartServer() + ftpSslCertificateViewButton.visibility = if(shouldSecure) { + View.VISIBLE + } else { + View.GONE + } return true } R.id.checkbox_ftp_legacy_filesystem -> { @@ -573,6 +582,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } url.text = "URL: " ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() + ftpSslCertificateViewButton.isEnabled = false } else { accentColor = mainActivity.accent url.text = spannedStatusUrl @@ -589,7 +599,11 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { username.text = "${resources.getString(R.string.username)}: $usernameFromPreferences" password.text = "${resources.getString(R.string.password)}: $passwordBulleted" ftpPasswordVisibleButton.setImageDrawable( - resources.getDrawable(R.drawable.ic_eye_grey600_24dp), + ResourcesCompat.getDrawable( + resources, + R.drawable.ic_eye_grey600_24dp, + mainActivity.theme + ), ) ftpPasswordVisibleButton.visibility = if (passwordDecrypted?.isEmpty() == true) { @@ -602,13 +616,21 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { // password was not visible, let's make it visible password.text = resources.getString(R.string.password) + ": " + passwordDecrypted ftpPasswordVisibleButton.setImageDrawable( - resources.getDrawable(R.drawable.ic_eye_off_grey600_24dp), + ResourcesCompat.getDrawable( + resources, + R.drawable.ic_eye_off_grey600_24dp, + mainActivity.theme + ), ) } else { // password was visible, let's hide it password.text = resources.getString(R.string.password) + ": " + passwordBulleted ftpPasswordVisibleButton.setImageDrawable( - resources.getDrawable(R.drawable.ic_eye_grey600_24dp), + ResourcesCompat.getDrawable( + resources, + R.drawable.ic_eye_grey600_24dp, + mainActivity.theme + ), ) } } @@ -805,6 +827,11 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) } + private fun displaySslCertInfoDialog(): Unit = if (isRunning() && securePreference) { + + } else Unit + + private val defaultPortFromPreferences: Int get() = mainActivity.prefs diff --git a/app/src/main/res/layout/fragment_ftp.xml b/app/src/main/res/layout/fragment_ftp.xml index 57bcbcce38..6465a07b6f 100644 --- a/app/src/main/res/layout/fragment_ftp.xml +++ b/app/src/main/res/layout/fragment_ftp.xml @@ -44,12 +44,25 @@ android:layout_height="wrap_content" android:maxLines="1" android:textSize="@dimen/material_generic_title" + android:padding="@dimen/padding_normal" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text_view_ftp_status"/> + + + app:layout_constraintTop_toBottomOf="@id/text_view_ftp_url" /> + app:layout_constraintTop_toBottomOf="@id/button_view_cert" /> !_;UbJJOW_jP z5@sy7u~tJ_DvT`C9WQUa=RN1Wf57|Wvz+ss=lPru002Ay07w!uBoO=HBhd}~=2M!J z2TIc&?Dqg5K;#7=0pT|Y*sxpJFaQ8u{Z}lh`==iS@LkZ-M;ZeF7=la!^C6Q!-*|Yv zlusxjMff2aDVUU95fDh24?xtW)%V9-w&TVuQ<1o&> zG`o?J7*-S}^;6&9o`gKFSM-JQI*#!RP=GftvV zyPIy9CI?zCk!`UM=u%bwN`D6iz9{F#Nek}gk3=N-L1%d^1aq`0bXeJPjLNmUVqE!7)y(An?T0Gw`B`y-QkSM zJnJlu%#JN`|Hx81qK1dc;pS(w+pEK@B+SMhtqH`&zT#< ztDhFK7%sf$PVGo=OlwXYFR&%ij$1laqviSBm#S68=@$?oXMU`6xugX$UJWr5a~@l_%kq8phw{-uF||vGoOf!umZ(_L>t?+@%z$J!$cNw$99)+z zsM;C1f-4oexu~H)N`nh9{P_$+w*0Cm1%vFQ&h2zu9tiqcc@;Kp!)fg#Dy--8h-Wwq zl>Zzf)Jh{jtc+WlvVWLGRoBQ5B zX*M%=^|7^kh2hhtoQ1%dl+M)P4V+HTF}R*8Cag%t*jCENrJF-fIQE5H&H=Z@ey|E0 z>N4(r%BYS&^|{JZ%t-i(^CSE@uTg8$$Ff}~sxCLRJSJ!!+AJ84%Wsr@e&C-A&Q1)8 z$P4mI8A>aTkfYy!R)jB{^svr3&_DM=Wr$hEQ=3VvY(9Ln8LgpteFpvFind!zPXa}w zKTF1;qtWEu2E`LHb}QqqN;_Lu(~GRC2OlXAfN{~(2dkajM9BQp87-fc*|)>}O)qIN zR)H(JO*v<3CF3iw%YM1^_hFS15xzdEnPh89`=9{rY5Hi+dUzIsQBR;bB_Yxu(2vl#=IgA(;f*bq2Y zs+}3IVxQc2`CwVr6WfihiL-Z}^f~WMO#EnbHYeCg+H^=bYW<`sRp8|cj?uv$Ky)cm ztYzC}x+II;b%9Plk1m+sRI1pFM$6@S#GcV{N<0OfVA%EzUwrp@xr@W~GR2|Bn|BMx zTz2yD`yTucigYc!6OUFB;*6Ik6a}??VV!Q?7}3EBD2a}p!kwS9jkwQ`=pwh+_xKGx zJT8z^o*$bUKir}MMSmYZJp@{{>Rp74E)hO!Uis|>Q_msYjMJX7y4T-z+Qj~X0WiB6E5?xB zVlA#!^no+ggp9s4jmV4VZ_#_JOEw>8UXYiMnsQp*brH8%SBiIKYc+@Jm2h{aD>r>4 zYZa+VaTCKETf>+>S2jX$cf>r4So%DtDW>qkPUzU^Vv05;vKjYgY4k^My|%$-8E0`s zUzka#*vTu~MViDeY){O^CegmWxwKz8+D89!gPVCY?ba+hOp6Td4A~rWYmy*)zEw2K z(o!M@glw9mwD(Pwk3Fzkw0$WrMvX5z+#OyRbepyOW9U=N8DO}1?qSA2e|8j&X_E|Z K`0A$OC;e~rx!tb- diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpHybridFileTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpHybridFileTest.kt index c11239702e..95dfdf7e43 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpHybridFileTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpHybridFileTest.kt @@ -147,7 +147,7 @@ open class FtpHybridFileTest { * Shutdown FTP server. */ @After - fun tearDown() { + open fun tearDown() { if (!ftpServer.isStopped) { ftpServer.stop() } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt index 046a81cacf..ed76e24055 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt @@ -20,9 +20,7 @@ package com.amaze.filemanager.filesystem.ftp -import com.amaze.filemanager.BuildConfig -import com.amaze.filemanager.R -import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.FtpServerSslKeyStoreProvider import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX import com.amaze.filemanager.filesystem.ssh.test.TestUtils import com.amaze.filemanager.utils.X509CertificateUtil @@ -31,6 +29,7 @@ import org.apache.ftpserver.listener.ListenerFactory import org.apache.ftpserver.ssl.ClientAuth import org.apache.ftpserver.ssl.impl.DefaultSslConfiguration import org.json.JSONObject +import org.junit.After import org.junit.Ignore import java.security.KeyStore import java.security.cert.CertificateFactory @@ -41,7 +40,6 @@ import javax.net.ssl.TrustManagerFactory @Ignore open class FtpsHybridFileTest : FtpHybridFileTest() { private lateinit var keyStore: KeyStore - private lateinit var keyStorePassword: CharArray protected lateinit var certInfo: JSONObject override val ftpPrefix: String @@ -54,12 +52,14 @@ open class FtpsHybridFileTest : FtpHybridFileTest() { } override fun setUp() { - keyStore = KeyStore.getInstance("BKS") - keyStorePassword = BuildConfig.FTP_SERVER_KEYSTORE_PASSWORD.toCharArray() - keyStore.load( - AppConfig.getInstance().resources.openRawResource(R.raw.key), - keyStorePassword, - ) + FtpServerSslKeyStoreProvider.keyStoreFactory = { + KeyStore.getInstance("BKS").apply { + load(null, null) + } + } + + keyStore = FtpServerSslKeyStoreProvider.getKeyStore() + keyStore.load(null, null) certInfo = JSONObject( X509CertificateUtil.parse( @@ -73,13 +73,18 @@ open class FtpsHybridFileTest : FtpHybridFileTest() { super.setUp() } + @After + override fun tearDown() { + FtpServerSslKeyStoreProvider.reset() + } + override fun saveConnectionSettings() = TestUtils.saveFtpConnectionSettings(USERNAME, PASSWORD, certInfo, PORT) override fun createDefaultFtpServerListener(): Listener { val keyManagerFactory = KeyManagerFactory .getInstance(KeyManagerFactory.getDefaultAlgorithm()) - keyManagerFactory.init(keyStore, keyStorePassword) + keyManagerFactory.init(keyStore, null) val trustManagerFactory = TrustManagerFactory .getInstance(TrustManagerFactory.getDefaultAlgorithm()) From 2087d3e421f95eba9b2159e1f3091d886b64e5d0 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Fri, 4 Sep 2026 17:32:06 +0900 Subject: [PATCH 21/35] Use installation-specific SSL certificate for FTP server instead of stock prefab ones Prevent abusing FTP server SSL certificate --- ...tpServerSslKeyStoreProviderEspressoTest.kt | 107 +++++++++ .../ftp/FtpServerSslKeyStoreProvider.kt | 213 ++++++++++++++++++ .../asynchronous/services/ftp/FtpService.kt | 144 ------------ .../ui/fragments/FtpServerFragment.kt | 33 ++- .../filesystem/ftp/FtpsHybridFileTest.kt | 2 +- 5 files changed, 342 insertions(+), 157 deletions(-) create mode 100644 app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt create mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt new file mode 100644 index 0000000000..495cda0940 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt @@ -0,0 +1,107 @@ +/* + * 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.asynchronous.services.ftp + +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.M +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.KeyStore +import java.security.cert.X509Certificate + +/** + * Instrumentation test for [FtpServerSslKeyStoreProvider]. + * + * Robolectric does not support AndroidKeyStore well enough for this code path, so this test uses + * a real device/emulator-backed keystore. + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class FtpServerSslKeyStoreProviderEspressoTest { + @Before + fun setUp() { + assumeTrue(SDK_INT >= M) + clearAliasFromAndroidKeyStore() + FtpServerSslKeyStoreProvider.reset() + } + + @After + fun tearDown() { + runCatching { + clearAliasFromAndroidKeyStore() + } + FtpServerSslKeyStoreProvider.reset() + } + + @Test + fun testGeneratesValidCertificateInAndroidKeyStore() { + val keyStore = FtpServerSslKeyStoreProvider.getKeyStore() + + assertEquals("AndroidKeyStore", keyStore.type) + assertTrue(keyStore.containsAlias(FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS)) + + val entry = + keyStore.getEntry( + FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS, + null, + ) as KeyStore.PrivateKeyEntry + val certificate = entry.certificate as X509Certificate + + assertNotNull(certificate) + certificate.checkValidity() + assertEquals("RSA", entry.privateKey.algorithm) + assertTrue(certificate.subjectX500Principal.name.contains("CN=ftpserver")) + assertTrue(certificate.subjectX500Principal.name.contains("OU=Amaze File Manager")) + assertTrue(certificate.subjectX500Principal.name.contains("O=Team Amaze")) + } + + @Test + fun testClearRemovesGeneratedCertificateFromAndroidKeyStore() { + FtpServerSslKeyStoreProvider.getKeyStore() + assertTrue(realAndroidKeyStore().containsAlias(FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS)) + + FtpServerSslKeyStoreProvider.clear() + + assertFalse(realAndroidKeyStore().containsAlias(FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS)) + } + + private fun clearAliasFromAndroidKeyStore() { + val keyStore = realAndroidKeyStore() + if (keyStore.containsAlias(FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS)) { + keyStore.deleteEntry(FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS) + } + } + + private fun realAndroidKeyStore(): KeyStore = + KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } +} + diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt new file mode 100644 index 0000000000..b66d75321f --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt @@ -0,0 +1,213 @@ +package com.amaze.filemanager.asynchronous.services.ftp + +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Build +import android.os.Build.VERSION_CODES.P +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.annotation.VisibleForTesting +import com.amaze.filemanager.application.AppConfig +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.BasicConstraints +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.slf4j.LoggerFactory +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.CertificateExpiredException +import java.security.cert.X509Certificate +import java.security.spec.RSAKeyGenParameterSpec +import java.util.Calendar +import javax.security.auth.x500.X500Principal + +/** + * Provides the FTPS keystore with an overridable factory for testing. + * Defaults to AndroidKeyStore; tests can inject an in-memory BKS/PKCS12 store. + * Generates a self-signed RSA-2048 certificate on first use or if the existing + * entry is missing/corrupt. + */ +@SuppressLint("StaticFieldLeak") +internal object FtpServerSslKeyStoreProvider { + private val log = LoggerFactory.getLogger(FtpServerSslKeyStoreProvider::class.java) + + internal var FTPS_CERT_ALIAS: String = "${AppConfig.getInstance().packageName}.ftpserver" + + private const val FTPS_KEY_ALGORITHM = "RSA" + private const val FTPS_KEY_SIZE = 2048 + private const val FTPS_KEY_DN = "CN=ftpserver, OU=Amaze File Manager, O=Team Amaze" + private const val FTPS_CERT_VALIDITY_DAYS = 7300 // ~20 years + + private val strongBoxSupported + get() = Build.VERSION.SDK_INT >= P && + AppConfig.getInstance().packageManager.hasSystemFeature( + PackageManager.FEATURE_STRONGBOX_KEYSTORE + ) + + /** + * Factory lambda to create a KeyStore instance. + * Default: AndroidKeyStore with null load. + * Tests can override this to inject an in-memory BKS/PKCS12 store. + */ + @VisibleForTesting + var keyStoreFactory: () -> KeyStore = { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + + /** + * Return a configured KeyStore instance using the current factory, + * ensuring the FTPS certificate exists and is valid. + */ + fun getKeyStore(): KeyStore { + val keyStore = keyStoreFactory() + + // Try to use existing certificate; regenerate if missing/corrupt + try { + if (keyStore.containsAlias(FTPS_CERT_ALIAS)) { + val existingEntry = keyStore.getEntry(FTPS_CERT_ALIAS, null) + if (existingEntry is KeyStore.PrivateKeyEntry) { + val cert = existingEntry.certificate as? X509Certificate + if (cert != null) { + try { + cert.checkValidity() + return keyStore + } catch (e: CertificateExpiredException) { + log.warn("Existing FTPS certificate is expired or not yet valid", e) + } + } + } + } + } catch (e: Exception) { + log.warn("Existing FTPS certificate is corrupt; regenerating", e) + } + + // Generate new certificate + try { + generateFtpsCertificate(keyStore) + } catch (e: Exception) { + throw RuntimeException("Failed to generate FTPS certificate", e) + } + + return keyStore + } + + /** + * Reset the factory to the default AndroidKeyStore. + * Useful for test cleanup. + */ + fun reset() { + keyStoreFactory = { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + } + } + + /** + * Clear the FTPS certificate and key from the keystore. + * Useful for test cleanup or resetting state. + */ + fun clear() { + getKeyStore().run { + if (containsAlias(FTPS_CERT_ALIAS)) { + deleteEntry(FTPS_CERT_ALIAS) + log.info("Cleared FTPS certificate from keystore") + } + } + } + + /** + * Generate a self-signed RSA-2048 certificate and store it in the keystore. + */ + private fun generateFtpsCertificate(keyStore: KeyStore) { + val keyPair = generateKeyPair() + + // Build self-signed certificate + val now = Calendar.getInstance().time + val notAfter = + Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, FTPS_CERT_VALIDITY_DAYS) + }.time + + val serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) + val subject = X500Name(FTPS_KEY_DN) + + val builder = + JcaX509v3CertificateBuilder( + subject, + serialNumber, + now, + notAfter, + subject, + keyPair.public, + ) + + // Add extensions + builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) + builder.addExtension( + Extension.keyUsage, + true, + KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment or KeyUsage.dataEncipherment), + ) + + val signer: ContentSigner = + JcaContentSignerBuilder("SHA256WithRSAEncryption") + .build(keyPair.private) + + val bcCert = builder.build(signer) + val cert: X509Certificate = + JcaX509CertificateConverter() + .getCertificate(bcCert) + + // Store in keystore + val privateKeyEntry = KeyStore.PrivateKeyEntry(keyPair.private, arrayOf(cert)) + keyStore.setEntry(FTPS_CERT_ALIAS, privateKeyEntry, null) + + log.info("Generated new FTPS self-signed certificate for alias: $FTPS_CERT_ALIAS") + } + + /** + * Generate an RSA key pair. + */ + private fun generateKeyPair(): KeyPair { + // Generate RSA-2048 key pair + // BouncyCastle provider is loaded at CustomSshJConfig via AppConfig + val keyPairGenerator = KeyPairGenerator.getInstance(FTPS_KEY_ALGORITHM) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val builder = KeyGenParameterSpec.Builder( + FTPS_CERT_ALIAS, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY, + ) + .setAlgorithmParameterSpec( + RSAKeyGenParameterSpec( + FTPS_KEY_SIZE, + RSAKeyGenParameterSpec.F4 + ) + ) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + .setUserAuthenticationRequired(false) + .setCertificateSubject(X500Principal(FTPS_KEY_DN)) + .setCertificateSerialNumber(BigInteger.valueOf(SecureRandom().nextLong())) + .setCertificateNotBefore(Calendar.getInstance().time) + .setCertificateNotAfter( + Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, FTPS_CERT_VALIDITY_DAYS) + }.time, + ) + if (strongBoxSupported) { + builder.setIsStrongBoxBacked(true) + } + keyPairGenerator.initialize(builder.build()) + } else { + keyPairGenerator.initialize(FTPS_KEY_SIZE, SecureRandom()) + } + val keyPair: KeyPair = keyPairGenerator.generateKeyPair() + return keyPair + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt index a827523495..98cb68501a 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt @@ -20,7 +20,6 @@ package com.amaze.filemanager.asynchronous.services.ftp -import android.annotation.SuppressLint import android.app.AlarmManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_ONE_SHOT @@ -40,7 +39,6 @@ import android.os.IBinder import android.os.PowerManager import android.os.SystemClock import android.provider.DocumentsContract -import androidx.annotation.VisibleForTesting import androidx.core.app.ServiceCompat import androidx.core.content.edit import androidx.preference.PreferenceManager @@ -63,27 +61,11 @@ import org.apache.ftpserver.ssl.ClientAuth import org.apache.ftpserver.ssl.impl.DefaultSslConfiguration import org.apache.ftpserver.usermanager.impl.BaseUser import org.apache.ftpserver.usermanager.impl.WritePermission -import org.bouncycastle.asn1.x500.X500Name -import org.bouncycastle.asn1.x509.BasicConstraints -import org.bouncycastle.asn1.x509.Extension -import org.bouncycastle.asn1.x509.KeyUsage -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder -import org.bouncycastle.operator.ContentSigner -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import org.greenrobot.eventbus.EventBus import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException -import java.math.BigInteger import java.security.GeneralSecurityException -import java.security.KeyPair -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.SecureRandom -import java.security.cert.CertificateExpiredException -import java.security.cert.X509Certificate -import java.util.Calendar import java.util.LinkedList import javax.net.ssl.KeyManagerFactory import javax.net.ssl.TrustManagerFactory @@ -311,132 +293,6 @@ class FtpService : Service(), Runnable { companion object { private val log: Logger = LoggerFactory.getLogger(FtpService::class.java) - /** - * Provides the FTPS keystore with an overridable factory for testing. - * Defaults to AndroidKeyStore; tests can inject an in-memory BKS/PKCS12 store. - * Generates a self-signed RSA-2048 certificate on first use or if the existing - * entry is missing/corrupt. - */ - @SuppressLint("StaticFieldLeak") - object FtpServerSslKeyStoreProvider { - private const val FTPS_KEY_ALIAS = "ftpserver" - private const val FTPS_KEY_DN = "CN=localhost" - private const val FTPS_CERT_VALIDITY_DAYS = 7300 // ~20 years - - /** - * Factory lambda to create a KeyStore instance. - * Default: AndroidKeyStore with null load. - * Tests can override this to inject an in-memory BKS/PKCS12 store. - */ - @VisibleForTesting - var keyStoreFactory: () -> KeyStore = { - KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - } - - /** - * Return a configured KeyStore instance using the current factory, - * ensuring the FTPS certificate exists and is valid. - */ - fun getKeyStore(): KeyStore { - val keyStore = keyStoreFactory() - - // Try to use existing certificate; regenerate if missing/corrupt - try { - if (keyStore.containsAlias(FTPS_KEY_ALIAS)) { - val existingEntry = keyStore.getEntry(FTPS_KEY_ALIAS, null) - if (existingEntry is KeyStore.PrivateKeyEntry) { - val cert = existingEntry.certificate as? X509Certificate - if (cert != null) { - try { - cert.checkValidity() - return keyStore - } catch (e: CertificateExpiredException) { - log.warn("Existing FTPS certificate is expired or not yet valid", e) - } - } - } - } - } catch (e: Exception) { - log.warn("Existing FTPS certificate is corrupt; regenerating", e) - } - - // Generate new certificate - try { - generateFtpsCertificate(keyStore) - } catch (e: Exception) { - throw RuntimeException("Failed to generate FTPS certificate", e) - } - - return keyStore - } - - /** - * Generate a self-signed RSA-2048 certificate and store it in the keystore. - */ - private fun generateFtpsCertificate(keyStore: KeyStore) { - // Generate RSA-2048 key pair - // BouncyCastle provider is loaded at CustomSshJConfig via AppConfig - val keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC") - keyPairGenerator.initialize(2048, SecureRandom()) - val keyPair: KeyPair = keyPairGenerator.generateKeyPair() - - // Build self-signed certificate - val now = Calendar.getInstance().time - val notAfter = - Calendar.getInstance().apply { - add(Calendar.DAY_OF_YEAR, FTPS_CERT_VALIDITY_DAYS) - }.time - - val serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) - val subject = X500Name(FTPS_KEY_DN) - - val builder = - JcaX509v3CertificateBuilder( - subject, - serialNumber, - now, - notAfter, - subject, - keyPair.public, - ) - - // Add extensions - builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) - builder.addExtension( - Extension.keyUsage, - true, - KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment or KeyUsage.dataEncipherment), - ) - - val signer: ContentSigner = - JcaContentSignerBuilder("SHA256WithRSAEncryption") - .setProvider("BC") - .build(keyPair.private) - - val bcCert = builder.build(signer) - val cert: X509Certificate = - JcaX509CertificateConverter() - .setProvider("BC") - .getCertificate(bcCert) - - // Store in keystore - val privateKeyEntry = KeyStore.PrivateKeyEntry(keyPair.private, arrayOf(cert)) - keyStore.setEntry(FTPS_KEY_ALIAS, privateKeyEntry, null) - - log.info("Generated new FTPS self-signed certificate for alias: $FTPS_KEY_ALIAS") - } - - /** - * Reset the factory to the default AndroidKeyStore. - * Useful for test cleanup. - */ - fun reset() { - keyStoreFactory = { - KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - } - } - } - const val DEFAULT_PORT = 2211 const val DEFAULT_USERNAME = "" const val DEFAULT_TIMEOUT = 600 // default timeout, in sec diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt index a0bb8c357a..5300af2621 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt @@ -295,11 +295,12 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { item.isChecked = shouldSecure securePreference = shouldSecure promptUserToRestartServer() - ftpSslCertificateViewButton.visibility = if(shouldSecure) { - View.VISIBLE - } else { - View.GONE - } + ftpSslCertificateViewButton.visibility = + if (shouldSecure) { + View.VISIBLE + } else { + View.GONE + } return true } R.id.checkbox_ftp_legacy_filesystem -> { @@ -598,11 +599,18 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) username.text = "${resources.getString(R.string.username)}: $usernameFromPreferences" password.text = "${resources.getString(R.string.password)}: $passwordBulleted" + ftpSslCertificateViewButton.visibility = + if (securePreference) { + View.VISIBLE + } else { + View.GONE + } + ftpSslCertificateViewButton.isEnabled = isRunning() && securePreference ftpPasswordVisibleButton.setImageDrawable( ResourcesCompat.getDrawable( resources, R.drawable.ic_eye_grey600_24dp, - mainActivity.theme + mainActivity.theme, ), ) ftpPasswordVisibleButton.visibility = @@ -619,7 +627,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ResourcesCompat.getDrawable( resources, R.drawable.ic_eye_off_grey600_24dp, - mainActivity.theme + mainActivity.theme, ), ) } else { @@ -629,7 +637,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ResourcesCompat.getDrawable( resources, R.drawable.ic_eye_grey600_24dp, - mainActivity.theme + mainActivity.theme, ), ) } @@ -827,10 +835,11 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) } - private fun displaySslCertInfoDialog(): Unit = if (isRunning() && securePreference) { - - } else Unit - + private fun displaySslCertInfoDialog(): Unit = + if (isRunning() && securePreference) { + } else { + Unit + } private val defaultPortFromPreferences: Int get() = diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt index ed76e24055..bc7116769d 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/FtpsHybridFileTest.kt @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem.ftp -import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.FtpServerSslKeyStoreProvider +import com.amaze.filemanager.asynchronous.services.ftp.FtpServerSslKeyStoreProvider import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.FTPS_URI_PREFIX import com.amaze.filemanager.filesystem.ssh.test.TestUtils import com.amaze.filemanager.utils.X509CertificateUtil From aab0b6982fc825a02f88f4f4952b6993fd0600f5 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Fri, 4 Sep 2026 17:32:06 +0900 Subject: [PATCH 22/35] Use installation-specific SSL certificate for FTP server instead of stock prefab ones Prevent abusing FTP server SSL certificate Also provides dialog to display generated certificate information and copy to clipboard --- ...tpServerSslKeyStoreProviderEspressoTest.kt | 1 - .../ftp/FtpServerSslKeyStoreProvider.kt | 50 ++------ .../asynchronous/services/ftp/FtpService.kt | 3 +- .../ui/fragments/FtpServerFragment.kt | 117 +++++++++++++----- .../filemanager/utils/X509CertificateUtil.kt | 9 ++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 111 insertions(+), 72 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt index 495cda0940..a805d0ce59 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt @@ -104,4 +104,3 @@ class FtpServerSslKeyStoreProviderEspressoTest { load(null) } } - diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt index b66d75321f..285f8f4ea3 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt @@ -4,8 +4,6 @@ import android.annotation.SuppressLint import android.content.pm.PackageManager import android.os.Build import android.os.Build.VERSION_CODES.P -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import androidx.annotation.VisibleForTesting import com.amaze.filemanager.application.AppConfig import org.bouncycastle.asn1.x500.X500Name @@ -24,9 +22,7 @@ import java.security.KeyStore import java.security.SecureRandom import java.security.cert.CertificateExpiredException import java.security.cert.X509Certificate -import java.security.spec.RSAKeyGenParameterSpec import java.util.Calendar -import javax.security.auth.x500.X500Principal /** * Provides the FTPS keystore with an overridable factory for testing. @@ -43,12 +39,13 @@ internal object FtpServerSslKeyStoreProvider { private const val FTPS_KEY_ALGORITHM = "RSA" private const val FTPS_KEY_SIZE = 2048 private const val FTPS_KEY_DN = "CN=ftpserver, OU=Amaze File Manager, O=Team Amaze" - private const val FTPS_CERT_VALIDITY_DAYS = 7300 // ~20 years + private const val FTPS_CERT_VALIDITY_DAYS = 3650 // ~10 years private val strongBoxSupported - get() = Build.VERSION.SDK_INT >= P && + get() = + Build.VERSION.SDK_INT >= P && AppConfig.getInstance().packageManager.hasSystemFeature( - PackageManager.FEATURE_STRONGBOX_KEYSTORE + PackageManager.FEATURE_STRONGBOX_KEYSTORE, ) /** @@ -152,7 +149,11 @@ internal object FtpServerSslKeyStoreProvider { builder.addExtension( Extension.keyUsage, true, - KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment or KeyUsage.dataEncipherment), + KeyUsage( + KeyUsage.digitalSignature + or KeyUsage.keyEncipherment + or KeyUsage.dataEncipherment, + ), ) val signer: ContentSigner = @@ -162,6 +163,7 @@ internal object FtpServerSslKeyStoreProvider { val bcCert = builder.build(signer) val cert: X509Certificate = JcaX509CertificateConverter() + .setProvider("BC") .getCertificate(bcCert) // Store in keystore @@ -178,35 +180,9 @@ internal object FtpServerSslKeyStoreProvider { // Generate RSA-2048 key pair // BouncyCastle provider is loaded at CustomSshJConfig via AppConfig val keyPairGenerator = KeyPairGenerator.getInstance(FTPS_KEY_ALGORITHM) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val builder = KeyGenParameterSpec.Builder( - FTPS_CERT_ALIAS, - KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY, - ) - .setAlgorithmParameterSpec( - RSAKeyGenParameterSpec( - FTPS_KEY_SIZE, - RSAKeyGenParameterSpec.F4 - ) - ) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) - .setUserAuthenticationRequired(false) - .setCertificateSubject(X500Principal(FTPS_KEY_DN)) - .setCertificateSerialNumber(BigInteger.valueOf(SecureRandom().nextLong())) - .setCertificateNotBefore(Calendar.getInstance().time) - .setCertificateNotAfter( - Calendar.getInstance().apply { - add(Calendar.DAY_OF_YEAR, FTPS_CERT_VALIDITY_DAYS) - }.time, - ) - if (strongBoxSupported) { - builder.setIsStrongBoxBacked(true) - } - keyPairGenerator.initialize(builder.build()) - } else { - keyPairGenerator.initialize(FTPS_KEY_SIZE, SecureRandom()) - } + // Using the previously proved and simpler method to generate key. + // KeyGenParameterSpec.Builder method didn't work for ftpserver + keyPairGenerator.initialize(FTPS_KEY_SIZE, SecureRandom()) val keyPair: KeyPair = keyPairGenerator.generateKeyPair() return keyPair } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt index 98cb68501a..2d8d785432 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt @@ -45,6 +45,7 @@ import androidx.preference.PreferenceManager import com.amaze.filemanager.R import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.asynchronous.services.AbstractProgressiveService.getPendingIntentFlag +import com.amaze.filemanager.asynchronous.services.ftp.FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory import com.amaze.filemanager.filesystem.ftpserver.RootFileSystemFactory import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE @@ -221,7 +222,7 @@ class FtpService : Service(), Runnable { ClientAuth.WANT, "TLS", enabledCipherSuites, - "ftpserver", + FTPS_CERT_ALIAS, ) fac.isImplicitSsl = true } catch (_: GeneralSecurityException) { diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt index 5300af2621..453ee903ad 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt @@ -22,6 +22,8 @@ package com.amaze.filemanager.ui.fragments import android.app.Activity.RESULT_OK import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context import android.content.Intent @@ -57,7 +59,9 @@ import androidx.appcompat.widget.AppCompatImageButton import androidx.appcompat.widget.AppCompatTextView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat +import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat +import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat.FROM_HTML_MODE_COMPACT import androidx.fragment.app.Fragment @@ -67,6 +71,8 @@ import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.folderselector.FolderChooserDialog import com.amaze.filemanager.R import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.asynchronous.services.ftp.FtpServerSslKeyStoreProvider +import com.amaze.filemanager.asynchronous.services.ftp.FtpServerSslKeyStoreProvider.FTPS_CERT_ALIAS import com.amaze.filemanager.asynchronous.services.ftp.FtpService import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.KEY_PREFERENCE_PATH import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.KEY_PREFERENCE_ROOT_FILESYSTEM @@ -85,6 +91,12 @@ import com.amaze.filemanager.utils.NetworkUtil.isConnectedToWifi import com.amaze.filemanager.utils.OneCharacterCharSequence import com.amaze.filemanager.utils.PasswordUtil import com.amaze.filemanager.utils.Utils +import com.amaze.filemanager.utils.X509CertificateUtil +import com.amaze.filemanager.utils.X509CertificateUtil.FINGERPRINT +import com.amaze.filemanager.utils.X509CertificateUtil.ISSUER +import com.amaze.filemanager.utils.X509CertificateUtil.SUBJECT +import com.amaze.filemanager.utils.X509CertificateUtil.VALID_FROM +import com.amaze.filemanager.utils.X509CertificateUtil.VALID_TO import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import org.greenrobot.eventbus.EventBus @@ -94,6 +106,8 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException import java.security.GeneralSecurityException +import java.security.KeyStore +import java.security.cert.X509Certificate /** * Created by yashwanthreddyg on 10-06-2016. Edited by Luca D'Amico (Luca91) on 25 Jul 2017 (Fixed @@ -683,9 +697,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private fun resetFTPPath() { mainActivity.prefs - .edit() - .putString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext())) - .apply() + .edit { + putString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext())) + } } /** Updates the status spans */ @@ -835,11 +849,48 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) } - private fun displaySslCertInfoDialog(): Unit = + private fun displaySslCertInfoDialog() { if (isRunning() && securePreference) { - } else { - Unit + val keyEntry = + FtpServerSslKeyStoreProvider.getKeyStore() + .getEntry(FTPS_CERT_ALIAS, null) as KeyStore.PrivateKeyEntry + val certificate = keyEntry.certificateChain.first() as X509Certificate + val certInfo = X509CertificateUtil.parse(certificate) + val content = + mainActivity.getString( + R.string.ftp_server_ssl_cert_info, + certInfo[SUBJECT], + certInfo[ISSUER], + certInfo[VALID_FROM], + certInfo[VALID_TO], + certInfo[FINGERPRINT], + ) + MaterialDialog.Builder(mainActivity) + .title(R.string.ftp_server_ssl_cert_info_title) + .content(content) + .cancelable(false) + .autoDismiss(false) + .positiveText(android.R.string.ok) + .negativeText(android.R.string.copy) + .onPositive { dialog, _ -> dialog.dismiss() } + .onNegative { _, _ -> + val clipboard = mainActivity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = + ClipData.newPlainText( + mainActivity.getString(R.string.ftp_server_ssl_cert_info_title), + content, + ) + clipboard.setPrimaryClip(clip) + Toast.makeText( + mainActivity, + R.string.ftp_server_ssl_cert_info_copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + } + .build() + .show() } + } private val defaultPortFromPreferences: Int get() = @@ -867,7 +918,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { }.onFailure { log.warn("failed to decrypt ftp server password", it) Toast.makeText(requireContext(), R.string.error, Toast.LENGTH_SHORT).show() - mainActivity.prefs.edit().putString(FtpService.KEY_PREFERENCE_PASSWORD, "").apply() + mainActivity.prefs.edit { putString(FtpService.KEY_PREFERENCE_PASSWORD, "") } }.getOrNull() private val defaultPathFromPreferences: String @@ -882,7 +933,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { path.substringAfter("file://") } path.startsWith("content://") -> { - return Uri.parse(path).let { + return path.toUri().let { "/storage${it.path?.substringAfter("/tree")?.replace(':', '/')}" } } @@ -893,7 +944,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } private fun changeFTPServerPort(port: Int) { - mainActivity.prefs.edit().putInt(FtpService.PORT_PREFERENCE_KEY, port).apply() + mainActivity.prefs.edit { putInt(FtpService.PORT_PREFERENCE_KEY, port) } // first update spans which will point to an updated status updateSpans() @@ -907,21 +958,21 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { * file:/// or content:// as prefix */ fun changeFTPServerPath(path: String) { - val preferences = PreferenceManager.getDefaultSharedPreferences(mainActivity).edit() - if (FileUtils.isRunningAboveStorage(path)) { - preferences.putBoolean(KEY_PREFERENCE_ROOT_FILESYSTEM, true) + mainActivity.prefs.edit { + if (FileUtils.isRunningAboveStorage(path)) { + putBoolean(KEY_PREFERENCE_ROOT_FILESYSTEM, true) + } + putString(KEY_PREFERENCE_PATH, path) } - preferences.putString(KEY_PREFERENCE_PATH, path) - preferences.apply() updateStatus() } private fun setFTPUsername(username: String) { mainActivity .prefs - .edit() - .putString(FtpService.KEY_PREFERENCE_USERNAME, username) - .apply() + .edit { + putString(FtpService.KEY_PREFERENCE_USERNAME, username) + } updateStatus() } @@ -930,12 +981,12 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { context?.run { mainActivity .prefs - .edit() - .putString( - FtpService.KEY_PREFERENCE_PASSWORD, - PasswordUtil.encryptPassword(this, password), - ) - .apply() + .edit { + putString( + FtpService.KEY_PREFERENCE_PASSWORD, + PasswordUtil.encryptPassword(this@run, password), + ) + } } } catch (e: GeneralSecurityException) { log.warn("failed to set ftp password", e) @@ -956,7 +1007,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { .prefs .getInt(FtpService.KEY_PREFERENCE_TIMEOUT, FtpService.DEFAULT_TIMEOUT) private set(seconds) { - mainActivity.prefs.edit().putInt(FtpService.KEY_PREFERENCE_TIMEOUT, seconds).apply() + mainActivity.prefs.edit { putInt(FtpService.KEY_PREFERENCE_TIMEOUT, seconds) } } private var securePreference: Boolean @@ -967,9 +1018,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private set(isSecureEnabled) { mainActivity .prefs - .edit() - .putBoolean(FtpService.KEY_PREFERENCE_SECURE, isSecureEnabled) - .apply() + .edit { + putBoolean(FtpService.KEY_PREFERENCE_SECURE, isSecureEnabled) + } } private var readonlyPreference: Boolean @@ -977,9 +1028,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private set(isReadonly) { mainActivity .prefs - .edit() - .putBoolean(FtpService.KEY_PREFERENCE_READONLY, isReadonly) - .apply() + .edit { + putBoolean(FtpService.KEY_PREFERENCE_READONLY, isReadonly) + } } private var legacyFileSystemPreference: Boolean @@ -987,9 +1038,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private set(useSafFileSystem) { mainActivity .prefs - .edit() - .putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, useSafFileSystem) - .apply() + .edit { + putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, useSafFileSystem) + } } private fun promptUserToRestartServer() { diff --git a/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt b/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt index 7e988ff5b4..07aa1d177b 100644 --- a/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt +++ b/app/src/main/java/com/amaze/filemanager/utils/X509CertificateUtil.kt @@ -23,6 +23,7 @@ package com.amaze.filemanager.utils import net.schmizz.sshj.common.ByteArrayUtils import org.json.JSONObject import java.security.MessageDigest +import java.text.DateFormat import java.util.WeakHashMap object X509CertificateUtil { @@ -30,6 +31,10 @@ object X509CertificateUtil { const val ISSUER = "issuer" const val SERIAL = "serial" const val FINGERPRINT = "sha256Fingerprint" + const val VALID_FROM = "validFrom" + const val VALID_TO = "validTo" + + private val dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) private fun colonSeparatedHex(array: ByteArray) = ByteArrayUtils.toHex(array).chunked(2).joinToString(":") @@ -50,6 +55,8 @@ object X509CertificateUtil { MessageDigest.getInstance("sha-256").run { colonSeparatedHex(digest(certificate.encoded)) } + retval[VALID_FROM] = dateTimeFormat.format(certificate.notBefore) + retval[VALID_TO] = dateTimeFormat.format(certificate.notAfter) return retval } @@ -70,6 +77,8 @@ object X509CertificateUtil { MessageDigest.getInstance("sha-256").run { colonSeparatedHex(digest(certificate.encoded)) } + retval[VALID_FROM] = dateTimeFormat.format(certificate.notBefore) + retval[VALID_TO] = dateTimeFormat.format(certificate.notAfter) return retval } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2c87feb08..e6bcdb1595 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -761,6 +761,9 @@ You only need to do this once, until the next time you select a new location for FTP server shared path had been reset to internal storage You are choosing a path which requires root access. Please enable root explorer in settings if your device is rooted. You are choosing a path which requires root access, which enables read/write operations in the whole filesystem on your device, and it is dangerous - careless mistakes could brick your device!\n\nAre you sure? + FTP SSL Certificate Information + Subject: %s\nIssuer: %s\nNot Valid Before: %s\nNot Valid After: %s\n\nSHA-256 Fingerprint: %s + FTP SSL Certificate information copied to clipboard Error opening URI \"%s\" for reading. \"%s\" is an empty archive. \"%s\" is a corrupted archive. From 5982b99989e3b92247a11cfc4fe92fce42bc8ae1 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 10 Sep 2026 00:13:46 +0800 Subject: [PATCH 23/35] Fix Codacy complaints --- ...tpServerSslKeyStoreProviderEspressoTest.kt | 20 +++++++++++++------ .../ftp/FtpServerSslKeyStoreProvider.kt | 11 +++++++--- .../ui/fragments/FtpServerFragment.kt | 1 + 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt index a805d0ce59..f1f2d29570 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProviderEspressoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2025 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2026 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -20,8 +20,6 @@ package com.amaze.filemanager.asynchronous.services.ftp -import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.M import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import org.junit.After @@ -29,7 +27,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue -import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -37,7 +34,7 @@ import java.security.KeyStore import java.security.cert.X509Certificate /** - * Instrumentation test for [FtpServerSslKeyStoreProvider]. + * Tests for [FtpServerSslKeyStoreProvider]. * * Robolectric does not support AndroidKeyStore well enough for this code path, so this test uses * a real device/emulator-backed keystore. @@ -45,13 +42,18 @@ import java.security.cert.X509Certificate @SmallTest @RunWith(AndroidJUnit4::class) class FtpServerSslKeyStoreProviderEspressoTest { + /** + * Setup before tests. + */ @Before fun setUp() { - assumeTrue(SDK_INT >= M) clearAliasFromAndroidKeyStore() FtpServerSslKeyStoreProvider.reset() } + /** + * Cleanup after tests. + */ @After fun tearDown() { runCatching { @@ -60,6 +62,9 @@ class FtpServerSslKeyStoreProviderEspressoTest { FtpServerSslKeyStoreProvider.reset() } + /** + * Test [FtpServerSslKeyStoreProvider.getKeyStore] generates a valid certificate in keystore. + */ @Test fun testGeneratesValidCertificateInAndroidKeyStore() { val keyStore = FtpServerSslKeyStoreProvider.getKeyStore() @@ -82,6 +87,9 @@ class FtpServerSslKeyStoreProviderEspressoTest { assertTrue(certificate.subjectX500Principal.name.contains("O=Team Amaze")) } + /** + * Test [FtpServerSslKeyStoreProvider.clear]. + */ @Test fun testClearRemovesGeneratedCertificateFromAndroidKeyStore() { FtpServerSslKeyStoreProvider.getKeyStore() diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt index 285f8f4ea3..b0601fe983 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt @@ -10,16 +10,19 @@ import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.asn1.x509.BasicConstraints import org.bouncycastle.asn1.x509.Extension import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.CertIOException import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder import org.bouncycastle.operator.ContentSigner import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import org.slf4j.LoggerFactory import java.math.BigInteger +import java.security.GeneralSecurityException import java.security.KeyPair import java.security.KeyPairGenerator import java.security.KeyStore import java.security.SecureRandom +import java.security.cert.CertificateException import java.security.cert.CertificateExpiredException import java.security.cert.X509Certificate import java.util.Calendar @@ -81,15 +84,17 @@ internal object FtpServerSslKeyStoreProvider { } } } - } catch (e: Exception) { + } catch (e: GeneralSecurityException) { log.warn("Existing FTPS certificate is corrupt; regenerating", e) } // Generate new certificate try { generateFtpsCertificate(keyStore) - } catch (e: Exception) { - throw RuntimeException("Failed to generate FTPS certificate", e) + } catch (e: CertIOException) { + throw CertificateException("Failed to generate FTPS certificate", e) + } catch (e: GeneralSecurityException) { + throw CertificateException("Failed to generate FTPS certificate", e) } return keyStore diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt index 453ee903ad..2a8725d62b 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/FtpServerFragment.kt @@ -976,6 +976,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { updateStatus() } + @Suppress("LabeledExpression") private fun setFTPPassword(password: String) { try { context?.run { From 4f8b59932b5efbbb49a0ff211f4d0fa36be0a48d Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 5 Aug 2026 00:34:21 +0800 Subject: [PATCH 24/35] Dependent library upgrades + Jetifier ignore list For easing the effort on migrating to SDK 36. Upgraded: - Gradle: 8.14.5 - Kotlin: 2.2.20 - JDK: 21 - Android Gradle Plugin: 8.13.2 - Spotless: 8.9.0 - AndroidX core: 1.16.0 - AndroidX fragment: 1.8.9 - AndroidX appcompat: 1.7.1 - AndroidX annotation: 1.10.0 - AndroidX constraint layout: 2.2.2 - sshj: 0.40.0 - Bouncy castle: 1.85 - slf4j: 2.0.18 - junrar: 8.1.0, also enables support of RARv5 - xz: 1.12 - zip4j: 2.11.6 - logback-classic: 1.6.1 - jsoup: 1.23.1 - gson: 2.14.0 - rxjava: 2.2.21 - acra-core: 5.13.1 - leak-canary-android: 2.14 - okHttp: 5.1.0 - commons-net: 3.13.0 - kotlin-stdlib-jdk8: 2.2.10 - espresso: 3.7.0 - AndroidX Test: 1.7.0 - AndroidX Test extension: 1.3.0 - kotlin-coroutine-test: 1.11.0 - UI automator: 2.4.0 - mockito: 5.23.0 - mockk: 1.14.11 - systembartint: 1.0.4 - awaitility: 4.3.0 - glide: 5.0.5 - apache-mina: 2.2.9 - apache-sshd: 2.19.0 - apache-ftpserver: 1.2.1 - robolectric: 4.15 --- app/build.gradle | 37 +++++--- .../ui/fragments/TabFragmentTest.kt | 2 +- ...va => UnsupportedRarVersionException.java} | 17 +++- .../adapters/ContributorAdapter.kt | 1 + .../filemanager/adapters/LanguageAdapter.kt | 1 + .../asynctasks/compress/ZipHelperCallable.kt | 3 + .../asynchronous/services/ExtractService.java | 6 +- .../extractcontents/helpers/ZipExtractor.kt | 5 +- .../filesystem/files/EncryptDecryptUtils.kt | 1 + .../amaze/filemanager/models/LanguageModel.kt | 1 + .../ui/dialogs/DragAndDropDialog.kt | 4 +- .../fragments/CompressedExplorerFragment.kt | 9 +- .../filemanager/utils/ContextCompatExt.kt | 20 +++++ .../filemanager/utils/FingerprintHandler.kt | 1 + .../asynctasks/compress/RarHelperCallable.kt | 6 +- .../extractcontents/helpers/RarExtractor.kt | 14 ++- app/src/test/AndroidManifest.xml | 2 - .../write/WriteTextFileCallableTest.java | 2 +- .../services/ExtractServiceTest.kt | 7 +- .../services/ftp/FtpReceiverTest.kt | 6 +- .../ftp/NetCopyClientConnectionPoolTest.kt | 20 +++++ .../filesystem/ssh/AbstractSftpServerTest.kt | 8 +- .../filesystem/ssh/test/TestKeyProvider.java | 3 +- .../activities/DatabaseViewerActivityTest.kt | 20 +++++ .../dialogs/AbstractSftpConnectDialogTests.kt | 20 +++++ .../AbstractSftpConnectDialogUiTests.kt | 20 +++++ ...SftpConnectDialogArgumentPopulationTest.kt | 20 +++++ .../ui/dialogs/SftpConnectDialogFtpTest.kt | 20 +++++ .../fragments/TabFragmentRobolectricTest.kt | 20 +++++ .../amaze/filemanager/ui/icons/IconsTest.java | 6 +- .../NotificationConstantsTest.java | 8 +- .../filemanager/utils/NetworkUtilTest.kt | 20 +++++ .../compress/RarHelperCallableTest.kt | 16 +--- .../MultipartRarExtractorTest.kt | 45 +++++----- build.gradle | 36 ++++---- commons_compress_7z/build.gradle | 6 +- .../src/main/AndroidManifest.xml | 4 +- file_operations/build.gradle | 12 +-- file_operations/src/main/AndroidManifest.xml | 2 +- gradle.properties | 51 +++++++++++ gradle/libs.versions.toml | 82 +++++++++--------- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 41 +++++---- gradlew.bat | 27 +++--- portscanner/build.gradle | 12 +-- .../shadows/ShadowContentResolver.kt | 48 ---------- .../filemanager/shadows/ShadowFileUtils.kt | 3 +- 48 files changed, 475 insertions(+), 244 deletions(-) rename app/src/fdroid/java/com/github/junrar/exception/{UnsupportedRarV5Exception.java => UnsupportedRarVersionException.java} (51%) delete mode 100644 app/src/test/AndroidManifest.xml delete mode 100644 testShared/src/test/java/com/amaze/filemanager/shadows/ShadowContentResolver.kt diff --git a/app/build.gradle b/app/build.gradle index d2e9ad3f05..bd16fcc929 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,10 +8,21 @@ apply plugin: 'com.google.devtools.ksp' android { namespace "com.amaze.filemanager" - compileSdk libs.versions.compileSdk.get().toInteger() + compileSdkVersion libs.versions.compileSdk.get().toInteger() packagingOptions { resources { - excludes += ['proguard-project.txt', 'project.properties', 'META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + excludes += [ + 'proguard-project.txt', + 'project.properties', + 'META-INF/LICENSE.txt', + 'META-INF/LICENSE.md', + 'META-INF/LICENSE', + 'META-INF/NOTICE.txt', + 'META-INF/NOTICE', + 'META-INF/DEPENDENCIES.txt', + 'META-INF/DEPENDENCIES', + 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' + ] } } @@ -44,7 +55,7 @@ android { debug { applicationIdSuffix ".debug" minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard.cfg' testProguardFile 'tests-proguard.cfg' buildConfigField "String", "CRYPTO_IV", "\"LxbHiJhhUXcj\"" debuggable true //For "debug" banner on icon @@ -55,7 +66,7 @@ android { signingConfig signingConfigs.release minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.cfg' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard.cfg' buildConfigField "String", "CRYPTO_IV", "\"LxbHiJhhUXcj\"" } } @@ -92,12 +103,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = '17' + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } testOptions { @@ -131,6 +138,7 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation libs.systembarTint + implementation libs.androidX.activity implementation libs.androidX.vectordrawable.animated implementation libs.androidX.legacySupportV13 implementation libs.androidX.material @@ -150,6 +158,7 @@ dependencies { ksp libs.androidX.annotation //For tests + kspTest libs.auto.service testImplementation libs.junit//tests the app logic testImplementation libs.robolectric//tests android interaction testImplementation libs.robolectric.shadows.httpclient//tests android interaction @@ -162,14 +171,15 @@ dependencies { testImplementation libs.mockito.inline testImplementation libs.mockito.kotlin testImplementation libs.apache.sshd + testImplementation libs.apache.sshd.scp + testImplementation libs.apache.sshd.sftp testImplementation libs.awaitility testImplementation libs.jsoup testImplementation libs.room.migration testImplementation libs.mockk testImplementation libs.kotlin.coroutine.test testImplementation libs.androidX.core.testing - kspTest libs.auto.service - testImplementation 'ch.qos.logback:logback-classic:1.4.14' + testImplementation libs.logback.classic androidTestImplementation libs.junit //tests the app logic androidTestImplementation libs.androidX.test.expresso @@ -260,7 +270,7 @@ dependencies { } kotlin { - jvmToolchain(17) + jvmToolchain(21) } configurations.configureEach { @@ -268,6 +278,7 @@ configurations.configureEach { dependencySubstitution { substitute module("commons-logging:commons-logging-api:1.1") using module("commons-logging:commons-logging:1.1.1") substitute module("com.android.support:support-annotations:27.1.1") using module("com.android.support:support-annotations:27.0.2") + substitute module("org.bouncycastle:bcprov-jdk18on:1.80.2") using module("org.bouncycastle:bcprov-jdk18on:1.85") // These two lines are added to prevent possible class clashes between awaitility (which uses hamcrest 2.1) and junit (which uses hamcrest 1.3). substitute module('org.hamcrest:hamcrest-core:1.3') using module("org.hamcrest:hamcrest:2.1") substitute module('org.hamcrest:hamcrest-library:1.3') using module("org.hamcrest:hamcrest:2.1") 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 d9e675b581..bdb4937464 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,5 +1,5 @@ /* - * Copyright (C) 2014-2025 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2026 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. diff --git a/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarV5Exception.java b/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java similarity index 51% rename from app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarV5Exception.java rename to app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java index 4ed70a93de..789412c242 100644 --- a/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarV5Exception.java +++ b/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2026 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -20,10 +20,19 @@ package com.github.junrar.exception; -public class UnsupportedRarV5Exception extends Exception { - public UnsupportedRarV5Exception(Throwable cause) { +/** + * Thrown when the archive signature names a RAR format this library cannot read: a future format + * version byte ({@code 0x02}..{@code 0x04}) that the library predates (unrar {@code RARFMT_FUTURE}, + * {@code d861246:archive.cpp:122,178-181}: "so we can return a sensible warning in case we'll want + * to change the archive format sometimes in the future"). The ancient RAR 1.4 format (marker {@code + * 52 45 7e 5e}, unrar {@code RARFMT14}) no longer throws this -- {@code Archive} reads its headers + * through a dedicated loop (P1, issue #293). Distinct from {@link BadRarArchiveException} (no valid + * signature at all). + */ +public class UnsupportedRarVersionException extends Exception { + public UnsupportedRarVersionException(Throwable cause) { super(cause); } - public UnsupportedRarV5Exception() {} + public UnsupportedRarVersionException() {} } diff --git a/app/src/main/java/com/amaze/filemanager/adapters/ContributorAdapter.kt b/app/src/main/java/com/amaze/filemanager/adapters/ContributorAdapter.kt index 80fc31bb2b..1c3c0a8b5a 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/ContributorAdapter.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/ContributorAdapter.kt @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + package com.amaze.filemanager.adapters import android.view.LayoutInflater diff --git a/app/src/main/java/com/amaze/filemanager/adapters/LanguageAdapter.kt b/app/src/main/java/com/amaze/filemanager/adapters/LanguageAdapter.kt index 3b1a252d32..ef14572701 100644 --- a/app/src/main/java/com/amaze/filemanager/adapters/LanguageAdapter.kt +++ b/app/src/main/java/com/amaze/filemanager/adapters/LanguageAdapter.kt @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + package com.amaze.filemanager.adapters import android.view.LayoutInflater diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt index e8387d80b2..f3787bdeef 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt @@ -50,6 +50,9 @@ class ZipHelperCallable( try { fileLocation.path?.run { val zipfile = ZipFile(fileLocation.path) + if (!zipfile.isValidZipFile || zipfile.file.length() == 0L) { + throw ZipException("Zero byte or corrupt zip file") + } val wholelist = filterValidEntryList(zipfile) val strings = ArrayList() for (entry in wholelist) { 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 12b64b9953..b48a9d44f9 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 @@ -44,7 +44,7 @@ import com.amaze.filemanager.utils.DatapointParcelable; import com.amaze.filemanager.utils.ObtainableServiceBinder; import com.amaze.filemanager.utils.ProgressHandler; -import com.github.junrar.exception.UnsupportedRarV5Exception; +import com.github.junrar.exception.UnsupportedRarVersionException; import android.app.PendingIntent; import android.content.BroadcastReceiver; @@ -364,8 +364,8 @@ public boolean isCancelled() { paused = true; publishProgress(e); } else if (e.getCause() != null - && UnsupportedRarV5Exception.class.isAssignableFrom(e.getCause().getClass())) { - LOG.error("RAR " + compressedPath + " is unsupported V5 archive", e); + && UnsupportedRarVersionException.class.isAssignableFrom(e.getCause().getClass())) { + LOG.error(compressedPath + " is an unsupported RAR archive", e); AppConfig.toast( getApplicationContext(), extractService.getString(R.string.error_unsupported_v5_rar, compressedPath)); 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 159cf3d772..50e2aa88c7 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 @@ -68,7 +68,7 @@ class ZipExtractor( invalidArchiveEntries.add(fileHeader.fileName) } } - if (entriesToExtract.size > 0) { + if (entriesToExtract.isNotEmpty()) { listener.onStart(totalBytes, entriesToExtract[0].fileName) for (entry in entriesToExtract) { if (!listener.isCancelled) { @@ -77,6 +77,9 @@ class ZipExtractor( } } } else { + if (zipfile.file.length() == 0L) { + throw ZipException("Zero byte or corrupt zip file") + } throw EmptyArchiveNotice() } listener.onFinish() diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt index a389000915..d1fe98186b 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/EncryptDecryptUtils.kt @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + package com.amaze.filemanager.filesystem.files import android.content.Context diff --git a/app/src/main/java/com/amaze/filemanager/models/LanguageModel.kt b/app/src/main/java/com/amaze/filemanager/models/LanguageModel.kt index 1903454460..8c468016fe 100644 --- a/app/src/main/java/com/amaze/filemanager/models/LanguageModel.kt +++ b/app/src/main/java/com/amaze/filemanager/models/LanguageModel.kt @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + package com.amaze.filemanager.models data class LanguageModel(var title: String, var description: String) diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt index f532ca4a7d..8d4103db6b 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/DragAndDropDialog.kt @@ -135,7 +135,7 @@ class DragAndDropDialog : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { safeLet( context, - mainActivity?.appTheme?.getMaterialDialogTheme(), + mainActivity?.appTheme?.materialDialogTheme, mainActivity?.accent, pasteLocation, operationFiles, @@ -146,7 +146,7 @@ class DragAndDropDialog : DialogFragment() { .title(getString(R.string.choose_operation)) .customView(R.layout.dialog_drag_drop, true) .theme(dialogTheme) - .negativeText(getString(R.string.cancel).toUpperCase()) + .negativeText(getString(R.string.cancel).uppercase()) .negativeColor(accent) .cancelable(false) .onNeutral { _: MaterialDialog?, _: DialogAction? -> diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt index dacea24693..d70d9fa02b 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt @@ -79,7 +79,7 @@ import com.amaze.filemanager.ui.views.DividerItemDecoration import com.amaze.filemanager.ui.views.FastScroller import com.amaze.filemanager.utils.BottomBarButtonPath import com.amaze.filemanager.utils.Utils -import com.github.junrar.exception.UnsupportedRarV5Exception +import com.github.junrar.exception.UnsupportedRarVersionException import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener import io.reactivex.Flowable @@ -656,7 +656,10 @@ class CompressedExplorerFragment : Fragment(), BottomBarButtonPath { private fun archiveCorruptOrUnsupportedToast(e: Throwable?) { @StringRes val msg: Int = - if (e?.cause?.javaClass is UnsupportedRarV5Exception) { + if (UnsupportedRarVersionException::class.java.isAssignableFrom( + e?.cause?.javaClass, + ) + ) { R.string.error_unsupported_v5_rar } else { R.string.archive_unsupported_or_corrupt @@ -774,7 +777,7 @@ class CompressedExplorerFragment : Fragment(), BottomBarButtonPath { get() = isRoot(relativeDirectory) private fun isRoot(folder: String?): Boolean { - return folder == null || folder.isEmpty() + return folder.isNullOrEmpty() } /** diff --git a/app/src/main/java/com/amaze/filemanager/utils/ContextCompatExt.kt b/app/src/main/java/com/amaze/filemanager/utils/ContextCompatExt.kt index 50223c6121..83056c3315 100644 --- a/app/src/main/java/com/amaze/filemanager/utils/ContextCompatExt.kt +++ b/app/src/main/java/com/amaze/filemanager/utils/ContextCompatExt.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.utils import android.annotation.SuppressLint diff --git a/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt b/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt index 45f109f483..97efb65ad6 100644 --- a/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt +++ b/app/src/main/java/com/amaze/filemanager/utils/FingerprintHandler.kt @@ -17,6 +17,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + package com.amaze.filemanager.utils import android.Manifest diff --git a/app/src/play/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallable.kt b/app/src/play/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallable.kt index 62b35e473a..6d738cfce1 100644 --- a/app/src/play/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallable.kt +++ b/app/src/play/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallable.kt @@ -25,7 +25,7 @@ import com.amaze.filemanager.filesystem.compressed.CompressedHelper import com.amaze.filemanager.filesystem.compressed.showcontents.helpers.RarDecompressor.Companion.convertName import com.github.junrar.Archive import com.github.junrar.exception.RarException -import com.github.junrar.exception.UnsupportedRarV5Exception +import com.github.junrar.exception.UnsupportedRarVersionException import org.apache.commons.compress.archivers.ArchiveException import java.io.File import java.io.FileNotFoundException @@ -77,8 +77,8 @@ class RarHelperCallable( ) } } - } catch (e: UnsupportedRarV5Exception) { - throw ArchiveException("RAR v5 archives are not supported", e) + } catch (e: UnsupportedRarVersionException) { + throw ArchiveException("RAR archive version not supported", e) } catch (e: FileNotFoundException) { throw ArchiveException("First part of multipart archive not found", e) } catch (e: RarException) { 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 3cb5ebe485..c4ef3d2988 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 @@ -32,10 +32,11 @@ import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor import com.amaze.filemanager.filesystem.compressed.isPasswordProtectedCompat import com.amaze.filemanager.filesystem.files.GenericCopyUtil import com.github.junrar.Archive +import com.github.junrar.exception.BadRarArchiveException import com.github.junrar.exception.CorruptHeaderException import com.github.junrar.exception.MainHeaderNullException import com.github.junrar.exception.RarException -import com.github.junrar.exception.UnsupportedRarV5Exception +import com.github.junrar.exception.UnsupportedRarVersionException import com.github.junrar.rarfile.FileHeader import org.apache.commons.compress.PasswordRequiredException import java.io.BufferedInputStream @@ -58,13 +59,14 @@ class RarExtractor( override fun extractWithFilter(filter: Filter) { try { var totalBytes: Long = 0 + val file = File(filePath) val rarFile = runCatching { ArchivePasswordCache.getInstance()[filePath]?.let { - Archive(File(filePath), it).also { archive -> + Archive(file, it).also { archive -> archive.password = it } - } ?: Archive(File(filePath)) + } ?: Archive(file) }.onFailure { when { // Hack. CorruptHeaderException will throw if archive is really corrupt or @@ -78,7 +80,11 @@ class RarExtractor( throw BadArchiveNotice(it) } - UnsupportedRarV5Exception::class.java.isAssignableFrom(it::class.java) -> { + BadRarArchiveException::class.java.isAssignableFrom(it::class.java) -> { + throw BadArchiveNotice(it) + } + + UnsupportedRarVersionException::class.java.isAssignableFrom(it::class.java) -> { throw it } diff --git a/app/src/test/AndroidManifest.xml b/app/src/test/AndroidManifest.xml deleted file mode 100644 index 568741e54f..0000000000 --- a/app/src/test/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java index 8fcc850e5d..156314e0cd 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java @@ -44,13 +44,13 @@ import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowContentResolver; import com.amaze.filemanager.fileoperations.exceptions.ShellNotRunningException; import com.amaze.filemanager.fileoperations.exceptions.StreamNotFoundException; import com.amaze.filemanager.filesystem.EditableFileAbstraction; import com.amaze.filemanager.filesystem.FileUtil; import com.amaze.filemanager.filesystem.root.ConcatenateFileCommand; -import com.amaze.filemanager.shadows.ShadowContentResolver; import com.amaze.filemanager.shadows.ShadowMultiDex; import android.content.ContentResolver; 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 7bdbbf94dc..94259d4102 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 @@ -465,11 +465,8 @@ class ExtractServiceTest { if (BuildConfig.FLAVOR == "play") { performTest(multiVolumeRarFileV5Part1) ShadowLooper.idleMainLooper() - await() - .atMost(10, TimeUnit.SECONDS) - .until { - ShadowToast.getLatestToast() != null - } + assertNull(ShadowToast.getLatestToast()) + assertNull(ShadowToast.getTextOfLatestToast()) } } diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt index b08a421056..02d132b982 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt @@ -29,7 +29,6 @@ import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.shadows.ShadowMultiDex -import io.mockk.Called import io.mockk.every import io.mockk.mockkObject import io.mockk.slot @@ -109,8 +108,11 @@ class FtpReceiverTest { val intent = Intent(FtpService.ACTION_START_FTPSERVER) receiver.onReceive(spy, intent) + verify(exactly = 0) { + spy.startService(capturedIntent.captured) + } + verify { - spy.startService(capturedIntent.captured)?.wasNot(Called) spy.startForegroundService(capturedIntent.captured) } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolTest.kt index 00823c7b8c..8d96019571 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolTest.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.filesystem.ftp import org.apache.commons.net.ftp.FTPSClient diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt index a7cce7c3f1..708805e08d 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt @@ -37,17 +37,15 @@ import com.amaze.filemanager.utils.PasswordUtil import io.reactivex.android.plugins.RxAndroidPlugins import io.reactivex.plugins.RxJavaPlugins import io.reactivex.schedulers.Schedulers -import org.apache.sshd.common.NamedFactory import org.apache.sshd.common.config.keys.KeyUtils import org.apache.sshd.common.file.FileSystemFactory import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory -import org.apache.sshd.server.Command +import org.apache.sshd.scp.server.ScpCommandFactory import org.apache.sshd.server.SshServer import org.apache.sshd.server.auth.password.PasswordAuthenticator import org.apache.sshd.server.auth.pubkey.AcceptAllPublickeyAuthenticator -import org.apache.sshd.server.scp.ScpCommandFactory import org.apache.sshd.server.session.ServerSession -import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory +import org.apache.sshd.sftp.server.SftpSubsystemFactory import org.junit.After import org.junit.Before import org.junit.BeforeClass @@ -132,7 +130,7 @@ abstract class AbstractSftpServerTest { server.host = HOST server.keyPairProvider = hostKeyProvider server.commandFactory = ScpCommandFactory() - server.subsystemFactories = listOf>(SftpSubsystemFactory()) + server.subsystemFactories = listOf(SftpSubsystemFactory()) server.passwordAuthenticator = PasswordAuthenticator { username: String, password: String, _: ServerSession? -> username == USERNAME && password == PASSWORD diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/test/TestKeyProvider.java b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/test/TestKeyProvider.java index b76067d0fa..20ff158074 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/test/TestKeyProvider.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/test/TestKeyProvider.java @@ -29,6 +29,7 @@ import java.util.Collections; import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.apache.sshd.common.session.SessionContext; import net.schmizz.sshj.common.KeyType; import net.schmizz.sshj.userauth.keyprovider.KeyProvider; @@ -44,7 +45,7 @@ public TestKeyProvider() throws Exception { } @Override - public Iterable loadKeys() { + public Iterable loadKeys(SessionContext sessionContext) { return Collections.singleton(keyPair); } diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt index 55a7909931..24f0d60582 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.activities import android.content.Intent diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogTests.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogTests.kt index c1181a6e61..a5013f3229 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogTests.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogTests.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.dialogs import com.amaze.filemanager.ui.activities.AbstractMainActivityTestBase diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogUiTests.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogUiTests.kt index 1f5d7629aa..20673f4517 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogUiTests.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractSftpConnectDialogUiTests.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.dialogs import android.os.Bundle diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogArgumentPopulationTest.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogArgumentPopulationTest.kt index a2a1e42705..3aab5209e0 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogArgumentPopulationTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogArgumentPopulationTest.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.dialogs import android.os.Bundle diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogFtpTest.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogFtpTest.kt index 7fcd0855d7..0d90e72ac2 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogFtpTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SftpConnectDialogFtpTest.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.dialogs import android.os.Bundle diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/TabFragmentRobolectricTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/TabFragmentRobolectricTest.kt index c78d0c910d..d8c31cb9a6 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/fragments/TabFragmentRobolectricTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/TabFragmentRobolectricTest.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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 androidx.test.core.app.ActivityScenario diff --git a/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java b/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java index 117e3da274..39ace0bde4 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java @@ -48,9 +48,9 @@ public class IconsTest { public void setUp() { // By default Robolectric's MimeTypeMap is empty, we need to populate them ShadowMimeTypeMap mimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()); - mimeTypeMap.addExtensionMimeTypMapping("zip", "application/zip"); - mimeTypeMap.addExtensionMimeTypMapping("rar", "application/x-rar-compressed"); - mimeTypeMap.addExtensionMimeTypMapping("tar", "application/x-tar"); + mimeTypeMap.addExtensionMimeTypeMapping("zip", "application/zip"); + mimeTypeMap.addExtensionMimeTypeMapping("rar", "application/x-rar-compressed"); + mimeTypeMap.addExtensionMimeTypeMapping("tar", "application/x-tar"); } @Test diff --git a/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java b/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java index 78468e5358..f9c5e4b8ea 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java @@ -140,10 +140,10 @@ public void testFtpNotification() { public void testCreateNormalChannel() { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_NORMAL_ID); NotificationConstants.setMetadata(context, builder, TYPE_NORMAL); - List channels = shadowNotificationManager.getNotificationChannels(); + List channels = shadowNotificationManager.getNotificationChannels(); assertNotNull(channels); assertEquals(1, channels.size()); - NotificationChannel channel = (NotificationChannel) channels.get(0); + NotificationChannel channel = channels.get(0); assertEquals(IMPORTANCE_MIN, channel.getImportance()); assertEquals(CHANNEL_NORMAL_ID, channel.getId()); assertEquals(context.getString(R.string.channel_name_normal), channel.getName()); @@ -155,10 +155,10 @@ public void testCreateNormalChannel() { public void testCreateFtpChannel() { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_FTP_ID); NotificationConstants.setMetadata(context, builder, TYPE_FTP); - List channels = shadowNotificationManager.getNotificationChannels(); + List channels = shadowNotificationManager.getNotificationChannels(); assertNotNull(channels); assertEquals(1, channels.size()); - NotificationChannel channel = (NotificationChannel) channels.get(0); + NotificationChannel channel = channels.get(0); assertEquals(IMPORTANCE_HIGH, channel.getImportance()); assertEquals(CHANNEL_FTP_ID, channel.getId()); assertEquals(context.getString(R.string.channel_name_ftp), channel.getName()); diff --git a/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt b/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt index 5f2a071a64..05e452debb 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.utils import android.app.Service diff --git a/app/src/testPlay/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallableTest.kt b/app/src/testPlay/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallableTest.kt index 3f4bc56889..51faea35ad 100644 --- a/app/src/testPlay/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallableTest.kt +++ b/app/src/testPlay/java/com/amaze/filemanager/asynchronous/asynctasks/compress/RarHelperCallableTest.kt @@ -21,11 +21,8 @@ package com.amaze.filemanager.asynchronous.asynctasks.compress import android.os.Environment -import com.github.junrar.exception.UnsupportedRarV5Exception -import org.apache.commons.compress.archivers.ArchiveException import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Test import java.io.File @@ -72,15 +69,10 @@ class RarHelperCallableTest : AbstractCompressedHelperCallableArchiveTest() { "", false, ) - val result = - try { - callable.call() - } catch (exception: Throwable) { - assertEquals(ArchiveException::class.java, exception.javaClass) - assertEquals(UnsupportedRarV5Exception::class.java, exception.cause!!.javaClass) - null - } - assertNull(result) + val result = callable.call() + assertNotNull(result) + assertEquals(1, result.size) + assertEquals("test.bin", result.first().name) } override fun doCreateCallable( diff --git a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt index e2538e9cf8..c811673e18 100644 --- a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt +++ b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt @@ -28,8 +28,8 @@ import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.Extractor.OnUpdate import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.RarExtractor import com.amaze.filemanager.shadows.ShadowMultiDex -import com.github.junrar.exception.UnsupportedRarV5Exception -import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,7 +38,6 @@ import org.robolectric.shadows.ShadowEnvironment import java.io.File import java.io.FileInputStream import java.io.FileOutputStream -import java.io.IOException import java.util.concurrent.CountDownLatch @RunWith(AndroidJUnit4::class) @@ -87,8 +86,8 @@ class MultipartRarExtractorTest { override fun onFinish() { latch.countDown() val verify = File(Environment.getExternalStorageDirectory(), "test.bin") - Assert.assertTrue(verify.exists()) - Assert.assertEquals((1024 * 128).toLong(), verify.length()) + assertTrue(verify.exists()) + assertEquals((1024 * 128).toLong(), verify.length()) } }, ServiceWatcherUtil.UPDATE_POSITION, @@ -101,23 +100,25 @@ class MultipartRarExtractorTest { */ @Test fun testExtractMultiVolumeV5Rar() { - try { - RarExtractor( - ApplicationProvider.getApplicationContext(), - File( - Environment.getExternalStorageDirectory(), - "test-multipart-archive-v5.part1.rar", - ) - .absolutePath, - Environment.getExternalStorageDirectory().absolutePath, - callback, - ServiceWatcherUtil.UPDATE_POSITION, - ).extractEverything() - Assert.fail("No exception was thrown") - } catch (expected: IOException) { - expected.printStackTrace() - Assert.assertEquals(UnsupportedRarV5Exception::class.java, expected.cause!!.javaClass) - } + val latch = CountDownLatch(1) + RarExtractor( + ApplicationProvider.getApplicationContext(), + File( + Environment.getExternalStorageDirectory(), + "test-multipart-archive-v5.part1.rar", + ).absolutePath, + Environment.getExternalStorageDirectory().absolutePath, + object : OnUpdate by callback { + override fun onFinish() { + latch.countDown() + val verify = File(Environment.getExternalStorageDirectory(), "test.bin") + assertTrue(verify.exists()) + assertEquals((1024 * 128).toLong(), verify.length()) + } + }, + ServiceWatcherUtil.UPDATE_POSITION, + ).extractEverything() + latch.await() } private fun copyArchivesToStorage() { diff --git a/build.gradle b/build.gradle index 379e3c33b2..d24894a4b5 100644 --- a/build.gradle +++ b/build.gradle @@ -18,8 +18,8 @@ buildscript { } plugins { - id "com.diffplug.spotless" version "6.25.0" - id "com.google.devtools.ksp" version "1.9.25-1.0.20" apply false + id "com.diffplug.spotless" version "8.9.0" + id "com.google.devtools.ksp" version "2.2.20-2.0.4" apply false } allprojects { @@ -53,6 +53,7 @@ spotless { kotlin { licenseHeaderFile 'amaze.license.spotless.txt' target '*/src/**/*.kt' + targetExclude 'portscanner/src/**/*.kt' ktlint("1.1.1").setEditorConfigPath("$projectDir/.editorconfig") } } @@ -72,23 +73,25 @@ configurations { robo32 robo33 robo34 + robo35 } dependencies { - robo21 "org.robolectric:android-all-instrumented:5.0.2_r3-robolectric-r0-i4" - robo22 "org.robolectric:android-all-instrumented:5.1.1_r9-robolectric-r2-i4" - robo23 "org.robolectric:android-all-instrumented:6.0.1_r3-robolectric-r1-i4" - robo24 "org.robolectric:android-all-instrumented:7.0.0_r1-robolectric-r1-i4" - robo25 "org.robolectric:android-all-instrumented:7.1.0_r7-robolectric-r1-i4" - robo26 "org.robolectric:android-all-instrumented:8.0.0_r4-robolectric-r1-i4" - robo27 "org.robolectric:android-all-instrumented:8.1.0-robolectric-4611349-i4" - robo28 "org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i4" - robo29 "org.robolectric:android-all-instrumented:10-robolectric-5803371-i4" - robo30 "org.robolectric:android-all-instrumented:11-robolectric-6757853-i4" - robo31 "org.robolectric:android-all-instrumented:12-robolectric-7732740-i4" - robo32 "org.robolectric:android-all-instrumented:12.1-robolectric-8229987-i4" - robo33 "org.robolectric:android-all-instrumented:13-robolectric-9030017-i4" - robo34 "org.robolectric:android-all-instrumented:14-robolectric-10818077-i4" + robo21 "org.robolectric:android-all-instrumented:5.0.2_r3-robolectric-r0-i7" + robo22 "org.robolectric:android-all-instrumented:5.1.1_r9-robolectric-r2-i7" + robo23 "org.robolectric:android-all-instrumented:6.0.1_r3-robolectric-r1-i7" + robo24 "org.robolectric:android-all-instrumented:7.0.0_r1-robolectric-r1-i7" + robo25 "org.robolectric:android-all-instrumented:7.1.0_r7-robolectric-r1-i7" + robo26 "org.robolectric:android-all-instrumented:8.0.0_r4-robolectric-r1-i7" + robo27 "org.robolectric:android-all-instrumented:8.1.0-robolectric-4611349-i7" + robo28 "org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7" + robo29 "org.robolectric:android-all-instrumented:10-robolectric-5803371-i7" + robo30 "org.robolectric:android-all-instrumented:11-robolectric-6757853-i7" + robo31 "org.robolectric:android-all-instrumented:12-robolectric-7732740-i7" + robo32 "org.robolectric:android-all-instrumented:12.1-robolectric-8229987-i7" + robo33 "org.robolectric:android-all-instrumented:13-robolectric-9030017-i7" + robo34 "org.robolectric:android-all-instrumented:14-robolectric-10818077-i7" + robo35 "org.robolectric:android-all-instrumented:15-robolectric-12650502-i7" } def robolectricDependencies = "${rootProject.layout.buildDirectory.get().asFile}/robolectric" @@ -108,6 +111,7 @@ tasks.register('fetchRobolectricDependencies', Copy) { from configurations.robo32 from configurations.robo33 from configurations.robo34 + from configurations.robo35 into robolectricDependencies } diff --git a/commons_compress_7z/build.gradle b/commons_compress_7z/build.gradle index 74d66012b3..5f597d1d1d 100644 --- a/commons_compress_7z/build.gradle +++ b/commons_compress_7z/build.gradle @@ -3,7 +3,7 @@ apply plugin: 'kotlin-android' android { namespace "com.amaze.filemanager.filesystem.compressed.sevenz" - compileSdk libs.versions.compileSdk.get().toInteger() + compileSdkVersion libs.versions.compileSdk.get().toInteger() defaultConfig { minSdkVersion libs.versions.minSdk.get().toInteger() @@ -19,8 +19,8 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } } diff --git a/commons_compress_7z/src/main/AndroidManifest.xml b/commons_compress_7z/src/main/AndroidManifest.xml index 29ba17970f..8072ee00db 100644 --- a/commons_compress_7z/src/main/AndroidManifest.xml +++ b/commons_compress_7z/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - + + diff --git a/file_operations/build.gradle b/file_operations/build.gradle index 5400d64458..3c8f78aebc 100644 --- a/file_operations/build.gradle +++ b/file_operations/build.gradle @@ -5,7 +5,7 @@ apply plugin: 'org.mozilla.rust-android-gradle.rust-android' android { namespace "com.amaze.filemanager.fileoperations" - compileSdk libs.versions.compileSdk.get().toInteger() + compileSdkVersion libs.versions.compileSdk.get().toInteger() ndkVersion libs.versions.ndk.get() defaultConfig { @@ -31,8 +31,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } testOptions { @@ -41,10 +41,6 @@ android { returnDefaultValues = true } } - - kotlinOptions { - jvmTarget = '17' - } } // Rust configuration @@ -61,7 +57,7 @@ cargo { } kotlin { - jvmToolchain(17) + jvmToolchain(21) } // Ensure Rust libraries are built before Android tasks diff --git a/file_operations/src/main/AndroidManifest.xml b/file_operations/src/main/AndroidManifest.xml index f71066fedd..568741e54f 100644 --- a/file_operations/src/main/AndroidManifest.xml +++ b/file_operations/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index a7ce00a43f..f3a8496d45 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,6 +11,57 @@ # The setting is particularly useful for tweaking memory settings. android.enableJetifier=true android.useAndroidX=true +android.jetifier.ignorelist=org.bouncycastle:bcpkix-jdk18on, \ + org.bouncycastle:bcprov-jdk18on, \ + org.bouncycastle:bcutil-jdk18on, \ + org.robolectric:annotations, \ + org.robolectric:junit, \ + org.robolectric:nativeruntime, \ + org.robolectric:pluginapi, \ + org.robolectric:plugins-maven-dependency-resolver, \ + org.robolectric:resource, \ + org.robolectric:robolectric, \ + org.robolectric:sandbox, \ + org.robolectric:shadowapi, \ + org.robolectric:shadows-framework, \ + org.robolectric:shadows-httpclient, \ + org.robolectric:utils, \ + org.robolectric:utils-reflector, \ + org.slf4j:slf4j-api, \ + net.lingala.zip4j:zip4j, \ + org.tukaani:xz, \ + org.jsoup:jsoup, \ + org.hamcrest:hamcrest, \ + org.hamcrest:hamcrest-core, \ + org.hamcrest:hamcrest-library, \ + org.awaitility:awaitility, \ + org.mockito:mockito-core, \ + org.mockito:mockito-inline, \ + io.mockk:mockk, \ + eu.agno3.jcifs:jcifs-ng, \ + commons-codec:commons-codec, \ + commons-net:commons-net, \ + commons-io:commons-io, \ + commons-logging:commons-logging, \ + com.google.guava:guava, \ + com.google.guava:listenablefuture, \ + com.github.npgall:concurrent-trees, \ + com.hierynomus:sshj, \ + com.ibm.icu:icu4j, \ + com.google.code.gson:gson, \ + com.google.guava:guava, \ + com.google.guava:listenablefuture, \ + ch.qos.logback:logback-core, \ + ch.qos.logback:logback-classic, \ + com.almworks.sqlite4java:sqlite4java, \ + org.apache.ftpserver:ftpserver, \ + com.github.topjohnwu.libsu:core, \ + com.github.topjohnwu.libsu:io, \ + org.apache.mina:mina-core, \ + org.apache.sshd:sshd-core, \ + org.apache.sshd:sshd-scp, \ + org.apache.sshd:sshd-sftp, \ + junit:junit org.gradle.jvmargs=-Xmx4608M -XX:+UseParallelGC \ --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8968d58db7..16ee06a521 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,74 +1,75 @@ [versions] -compileSdk = "34" +compileSdk = "35" minSdk = "21" targetSdk = "35" -kotlin = "1.9.25" +kotlin = "2.2.20" ndk = "28.2.13676358" #r28c jacocoAndroid = "0.2.1" -gradle = "8.5.2" +gradle = "8.13.2" easylauncher = "6.1.0" legacySupportV13 = "1.0.0" -systembartint = "1.0.3" -robolectric = "4.9" -glide = "4.16.0" -sshj = "0.39.0" +systembartint = "1.0.4" +robolectric = "4.15" +glide = "5.0.5" +sshj = "0.40.0" jcifs = "2.1.10" eventbus = "3.3.1" fabSpeedDial = "3.3.0" room = "2.5.2" -bouncyCastle = "1.77" -awaitility = "3.1.6" -androidXCore = "1.7.0" +bouncyCastle = "1.85" +awaitility = "4.3.0" +androidXActivity = "1.7.2" +androidXCore = "1.16.0" androidMaterial = "1.5.0" # Upgrade to 1.5 requires targetSdk 31 -androidXFragment = "1.5.6" -androidXAppCompat = "1.6.1" -androidXAnnotation = "1.7.1" +androidXFragment = "1.8.9" +androidXAppCompat = "1.7.1" +androidXAnnotation = "1.10.0" androidXPref = "1.2.1" androidXPalette = "1.0.0" androidXCardView = "1.0.0" -androidXConstraintLayout = "1.1.3" +androidXConstraintLayout = "2.2.2" androidXBiometric = "1.1.0" -androidXTest = "1.6.1" -androidXTestRunner = "1.6.2" -androidXTestExt = "1.2.1" +androidXTest = "1.7.0" +androidXTestRunner = "1.7.0" +androidXTestExt = "1.3.0" androidXArchCoreTest = "2.2.0" androidXMultidex = "2.0.1" autoService = "1.1.1" -kotlinxCoroutineTest = "1.7.3" -kotlinStdlibJdk8 = "1.9.20" +kotlinxCoroutineTest = "1.11.0" +kotlinStdlibJdk8 = "2.2.10" uiAutomator = "2.3.0" junit = "4.13.2" -slf4j = "2.0.13" -apacheSshd = "1.7.0" -apacheMina = "2.0.16" -mockito = "4.11.0" -mockitoInline = "4.11.0" -mockitoKotlin = "4.1.0" +slf4j = "2.0.18" +apacheSshd = "2.19.0" +apacheMina = "2.2.9" +mockito = "5.23.0" +mockitoInline = "5.2.0" +mockitoKotlin = "6.3.0" androidBilling = "8.0.0" cloudrailSiAndroid = "2.22.4" -junrar = "7.4.0" +junrar = "8.1.0" vectordrawableAnimated = "1.2.0" -xz = "1.9" -zip4j = "2.6.4" -espresso = "3.6.1" +xz = "1.12" +zip4j = "2.11.6" +espresso = "3.7.0" materialDialogs = "0.9.6.0" jacoco = "0.8.11" commonsCompress = "1.22" libsu = "3.2.1" -mockk = "1.13.4" +mockk = "1.14.11" logbackAndroid = "3.0.0" -logbackClassic = "1.4.14" -commonsNet = "3.8.0" -ftpserver = "1.1.1" -jsoup = "1.16.1" -gson = "2.11.0" +logbackClassic = "1.6.1" +commonsNet = "3.13.0" +ftpserver = "1.2.1" +jsoup = "1.23.1" +gson = "2.14.0" rxAndroid = "2.1.1" -rxJava = "2.2.9" -okHttp = "4.12.0" -acraCore = "5.12.0" -leakcanaryAndroid = "2.7" +rxJava = "2.2.21" +okHttp = "5.1.0" +acraCore = "5.13.1" +leakcanaryAndroid = "2.14" mpAndroidChart = "v3.0.2" concurrentTrees = "2.6.1" aboutLibraries = "6.1.1" @@ -81,6 +82,7 @@ jacoco-android-plugin = { module = "com.mxalbert.gradle:jacoco-android", version kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } rust-android-gradle = { module = "org.mozilla.rust-android-gradle.rust-android:org.mozilla.rust-android-gradle.rust-android.gradle.plugin", version.ref = "rustAndroidGradle" } +androidX-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidXActivity" } androidX-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidXAppCompat" } androidX-constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidXConstraintLayout" } androidX-core = { module = "androidx.core:core-ktx", version.ref = "androidXCore" } @@ -111,6 +113,8 @@ bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bo apache-mina-core = { module = "org.apache.mina:mina-core", version.ref = "apacheMina" } apache-sshd = { module = "org.apache.sshd:sshd-core", version.ref = "apacheSshd" } +apache-sshd-scp = { module = "org.apache.sshd:sshd-scp", version.ref = "apacheSshd" } +apache-sshd-sftp = { module = "org.apache.sshd:sshd-sftp", version.ref = "apacheSshd" } apache-ftpserver-ftplet-api = { module = "org.apache.ftpserver:ftplet-api", version.ref = "ftpserver" } apache-ftpserver-core = { module = "org.apache.ftpserver:ftpserver-core", version.ref = "ftpserver" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch literal 43764 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 /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -133,22 +133,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,16 +200,20 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index f127cfd49d..db3a6ac207 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -26,6 +28,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -42,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,22 +59,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/portscanner/build.gradle b/portscanner/build.gradle index ee039f9b63..cc0718f0b6 100644 --- a/portscanner/build.gradle +++ b/portscanner/build.gradle @@ -5,11 +5,11 @@ apply plugin: 'kotlin-parcelize' android { namespace 'com.stealthcopter.networktools' - compileSdk libs.versions.compileSdk.get().toInteger() + compileSdkVersion libs.versions.compileSdk.get().toInteger() defaultConfig { - minSdk libs.versions.minSdk.get().toInteger() - targetSdk libs.versions.targetSdk.get().toInteger() + minSdkVersion libs.versions.minSdk.get().toInteger() + targetSdkVersion libs.versions.targetSdk.get().toInteger() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -22,13 +22,13 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } } kotlin { - jvmToolchain(17) + jvmToolchain(21) } dependencies { diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowContentResolver.kt b/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowContentResolver.kt deleted file mode 100644 index d037413740..0000000000 --- a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowContentResolver.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (C) 2014-2022 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.shadows - -import android.content.ContentResolver -import android.net.Uri -import org.robolectric.annotation.Implementation -import org.robolectric.annotation.Implements -import org.robolectric.shadows.ShadowContentResolver -import java.io.OutputStream - -/** - * [ShadowContentResolver] implementation to include - * [ContentResolver.openOutputStream] with Uri and open mode arguments. - * - * @see ContentResolver.openOutputStream(uri, String) - */ -@Implements(ContentResolver::class) -class ShadowContentResolver : ShadowContentResolver() { - /** - * Implements [ContentResolver.openOutputStream] with open mode parameter. - * - * Simply delegate to one without open mode. - */ - @Implementation - fun openOutputStream( - uri: Uri, - mode: String, - ): OutputStream? = super.openOutputStream(uri) -} diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt b/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt index 31e7e499e9..783c481f6a 100644 --- a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt +++ b/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt @@ -1,7 +1,6 @@ /* * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , - * Emmanuel Messulam, Raymond Lai , - * Vishnu Sanal and Contributors. + * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. * From c546b8e7ca8d30823f3c3a2c84b5581668eee3c5 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Mon, 10 Aug 2026 18:02:30 +0900 Subject: [PATCH 25/35] Enable JVM tooling discovery Also add bytebuddy to jetifier ignore list --- gradle.properties | 2 ++ gradle/gradle-daemon-jvm.properties | 12 ++++++++++++ settings.gradle | 3 +++ 3 files changed, 17 insertions(+) create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/gradle.properties b/gradle.properties index f3a8496d45..3179809355 100644 --- a/gradle.properties +++ b/gradle.properties @@ -61,6 +61,8 @@ android.jetifier.ignorelist=org.bouncycastle:bcpkix-jdk18on, \ org.apache.sshd:sshd-core, \ org.apache.sshd:sshd-scp, \ org.apache.sshd:sshd-sftp, \ + net.bytebuddy:byte-buddy, \ + net.bytebuddy:byte-buddy-agent, \ junit:junit org.gradle.jvmargs=-Xmx4608M -XX:+UseParallelGC \ diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..5c2ece29a4 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b43b10bd60d8689faca2651e270e52e7/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/5307a953ce47831ecf5a715128766856/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b43b10bd60d8689faca2651e270e52e7/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/5307a953ce47831ecf5a715128766856/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/03ffe42aae843e3c5ed7cf8ae3ae9a73/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/6ec5bdd39b6256778a046b2b37f8de26/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b43b10bd60d8689faca2651e270e52e7/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/5307a953ce47831ecf5a715128766856/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/6fb5075b2dcb0e97607e0bbd93459403/redirect +toolchainVendor=AMAZON +toolchainVersion=21 diff --git a/settings.gradle b/settings.gradle index 70edc43f9d..0023200c52 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,6 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} include ':file_operations' include ':portscanner' include ':app', ':commons_compress_7z' From d6d4f56f952032ecc9ed0addd26bd774f7811673 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Mon, 10 Aug 2026 18:04:40 +0900 Subject: [PATCH 26/35] Increase Robolectric tests minimum SDK to 23 For preparation to Robolectric 4.16 --- .../asynctasks/movecopy/PreparePasteTask.kt | 79 +++++++++++-------- .../application/AppConfigTest.java | 4 +- .../asynctasks/AbstractDeleteTaskTestBase.kt | 4 +- .../asynctasks/DbViewerTaskTest.java | 4 +- .../AbstractCompressedHelperCallableTest.kt | 4 +- .../CompressedHelperForBadArchiveTest.kt | 4 +- .../SearchResultListSorterTest.kt | 2 +- .../ssh/PemToKeyPairObservableEd25519Test.kt | 4 +- .../ssh/PemToKeyPairObservableRsaTest.kt | 4 +- .../ssh/SshAuthenticationTaskTest.kt | 4 +- .../read/ReadTextFileCallableTest.kt | 4 +- .../write/WriteTextFileCallableTest.java | 4 +- .../services/DecryptServiceTest.kt | 3 +- .../services/EncryptServiceTest.kt | 12 ++- .../services/ExtractServiceTest.kt | 2 +- ...ServiceAndroidFileSystemIntegrationTest.kt | 4 +- .../asynchronous/services/ZipServiceTest.kt | 4 +- .../services/ftp/FtpReceiverTest.kt | 4 +- .../database/ExplorerDatabaseMigrationTest.kt | 4 +- .../UtilitiesDatabaseMigrationTest.kt | 4 +- .../filemanager/database/UtilsHandlerTest.kt | 4 +- .../EncryptedStringTypeConverterTest.kt | 4 +- ...bstractFilenameHelperIncrementNameTests.kt | 4 +- .../filesystem/AbstractOperationsTestBase.kt | 4 +- .../EditableFileAbstractionTest.java | 4 +- .../filemanager/filesystem/HybridFileTest.kt | 4 +- .../filesystem/OperationsTest.java | 4 +- .../filesystem/RootHelperTest.java | 4 +- .../filesystem/compressed/B0rkenZipTest.java | 4 +- .../compressed/CompressedHelperTest.java | 4 +- .../extractcontents/AbstractExtractorTest.kt | 4 +- .../filesystem/files/FileListSorterTest.kt | 2 +- .../filesystem/files/FileUtilsTest.kt | 4 +- .../filesystem/files/sort/DirSortByTest.kt | 2 +- .../filesystem/files/sort/SortByTest.kt | 2 +- .../filesystem/files/sort/SortTypeTest.kt | 2 +- .../ftp/NetCopyClientConnectionPoolFtpTest.kt | 4 +- .../commands/AbstractFtpserverCommandTest.kt | 4 +- .../filesystem/root/ListFilesCommandTest.kt | 4 +- .../filesystem/root/ListFilesCommandTest2.kt | 4 +- .../filesystem/smb/CifsContextsTest.java | 4 +- .../filesystem/smb/SmbHybridFileTest.kt | 4 +- .../filesystem/ssh/AbstractSftpServerTest.kt | 4 +- .../ssh/NetCopyClientConnectionPoolSshTest.kt | 4 +- .../filesystem/ssh/SshHybridFileTest.kt | 4 +- .../AbstractMainActivityTestBase.kt | 4 +- .../activities/DatabaseViewerActivityTest.kt | 4 +- .../ui/activities/PermissionsActivityTest.kt | 4 +- .../ui/activities/TextEditorActivityTest.java | 4 +- .../ui/dialogs/AbstractEncryptDialogTests.kt | 14 +++- .../ui/dialogs/ColorPickerDialogTest.kt | 4 +- .../dialogs/DecryptFingerprintDialogTest.kt | 5 +- .../ui/fragments/CloudSheetFragmentTest.java | 4 +- .../UiPrefsFragmentTest.kt | 2 +- .../amaze/filemanager/ui/icons/IconsTest.java | 8 +- .../NotificationConstantsTest.java | 12 +-- .../filemanager/ui/theme/AppThemeTest.kt | 4 +- .../views/WarnableTextInputValidatorTest.java | 4 +- .../amaze/filemanager/utils/AESCryptTest.kt | 4 +- .../filemanager/utils/AnimUtilsTest.java | 4 +- .../amaze/filemanager/utils/CryptUtilTest.kt | 4 +- .../utils/MinMaxInputFilterTest.kt | 4 +- .../filemanager/utils/NetworkUtilTest.kt | 4 +- .../amaze/filemanager/utils/SmbUtilTest.kt | 4 +- .../amaze/filemanager/utils/TinyDBTest.java | 4 +- .../amaze/filemanager/utils/UtilsTest.java | 4 +- .../utils/X509CertificateUtilTest.kt | 4 +- ...tractSubnetDiscoverDevicesStrategyTests.kt | 4 +- .../MultipartRarExtractorTest.kt | 2 +- build.gradle | 31 ++++---- .../test/ShadowPasswordUtilTest.java | 4 +- 71 files changed, 208 insertions(+), 192 deletions(-) mode change 100644 => 100755 app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt old mode 100644 new mode 100755 index 7ee9cb13cb..7c37a24b8a --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt @@ -21,7 +21,6 @@ package com.amaze.filemanager.asynchronous.asynctasks.movecopy import android.app.ProgressDialog -import android.content.Context import android.content.Intent import android.view.LayoutInflater import android.widget.Toast @@ -133,44 +132,54 @@ class PreparePasteTask(strongRefMain: MainActivity) { return } - val totalBytes = FileUtils.getTotalBytes(filesToCopy, context.get()) - destination = HybridFile(openMode, targetPath) - destination.generateMode(context.get()) + coroutineScope.launch { + val contextRef = context.get() ?: return@launch + val totalBytes = withContext(Dispatchers.IO) { FileUtils.getTotalBytes(filesToCopy, contextRef) } + destination = + withContext(Dispatchers.IO) { + HybridFile(openMode, targetPath).apply { generateMode(contextRef) } + } - if (filesToCopy.isNotEmpty() && - isMove && - filesToCopy[0].getParent(context.get()) == targetPath - ) { - Toast.makeText(context.get(), R.string.same_dir_move_error, Toast.LENGTH_SHORT).show() - MediaConnectionUtils.scanFile(context.get() as Context, filesToCopy.toTypedArray()) - return - } + if (filesToCopy.isNotEmpty() && + isMove && + filesToCopy[0].getParent(contextRef) == targetPath + ) { + withContext(Dispatchers.Main) { + Toast.makeText(contextRef, R.string.same_dir_move_error, Toast.LENGTH_SHORT).show() + } + MediaConnectionUtils.scanFile(contextRef, filesToCopy.toTypedArray()) + return@launch + } - val isMoveSupported = - isMove && - destination.mode == openMode && - MoveFiles.getOperationSupportedFileSystem().contains(openMode) + val isMoveSupported = + isMove && + destination.mode == openMode && + MoveFiles.getOperationSupportedFileSystem().contains(openMode) - if (destination.usableSpace < totalBytes && - !isMoveSupported - ) { - Toast.makeText(context.get(), R.string.in_safe, Toast.LENGTH_SHORT).show() - return - } - @Suppress("DEPRECATION") - progressDialog = - ProgressDialog.show( - context.get(), - "", - context.get()?.getString(R.string.checking_conflicts), + if (destination.usableSpace < totalBytes && !isMoveSupported) { + withContext(Dispatchers.Main) { + Toast.makeText(contextRef, R.string.in_safe, Toast.LENGTH_SHORT).show() + } + return@launch + } + + withContext(Dispatchers.Main) { + @Suppress("DEPRECATION") + progressDialog = + ProgressDialog.show( + contextRef, + "", + contextRef.getString(R.string.checking_conflicts), + ) + } + checkConflicts( + isRootMode, + filesToCopy, + destination, + conflictingFiles, + conflictingDirActionMap, ) - checkConflicts( - isRootMode, - filesToCopy, - destination, - conflictingFiles, - conflictingDirActionMap, - ) + } } private fun checkConflicts( diff --git a/app/src/test/java/com/amaze/filemanager/application/AppConfigTest.java b/app/src/test/java/com/amaze/filemanager/application/AppConfigTest.java index de57e3ea1b..cd623875ca 100644 --- a/app/src/test/java/com/amaze/filemanager/application/AppConfigTest.java +++ b/app/src/test/java/com/amaze/filemanager/application/AppConfigTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.application; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static android.os.Looper.getMainLooper; import static org.awaitility.Awaitility.await; @@ -51,7 +51,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class AppConfigTest { @After diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/AbstractDeleteTaskTestBase.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/AbstractDeleteTaskTestBase.kt index c77388920d..4dcdf74ffc 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/AbstractDeleteTaskTestBase.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/AbstractDeleteTaskTestBase.kt @@ -23,7 +23,7 @@ package com.amaze.filemanager.asynchronous.asynctasks import android.Manifest import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Looper import android.os.storage.StorageManager @@ -70,7 +70,7 @@ import org.robolectric.shadows.ShadowToast ShadowFileUtils::class, ShadowPasswordUtil::class, ], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) abstract class AbstractDeleteTaskTestBase { private var ctx: Context? = null diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTaskTest.java b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTaskTest.java index 343e16b743..eb80992910 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTaskTest.java +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/DbViewerTaskTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.asynchronous.asynctasks; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static android.os.Looper.getMainLooper; import static android.view.View.VISIBLE; @@ -58,7 +58,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class DbViewerTaskTest { private WebView webView; diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedHelperCallableTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedHelperCallableTest.kt index ca6c8959f5..ef9b835b92 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedHelperCallableTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/AbstractCompressedHelperCallableTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.compress import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -37,7 +37,7 @@ import java.io.FileOutputStream import java.util.TimeZone @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) abstract class AbstractCompressedHelperCallableTest { private lateinit var systemTz: TimeZone diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperForBadArchiveTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperForBadArchiveTest.kt index 335f975c18..41db09d4eb 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperForBadArchiveTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/CompressedHelperForBadArchiveTest.kt @@ -20,7 +20,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.compress -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.core.app.ApplicationProvider @@ -42,7 +42,7 @@ import java.io.FileOutputStream * Test behaviour of CompressedHelpers in handling corrupt archives. */ @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P]) class CompressedHelperForBadArchiveTest { /** * Test handling of corrupt archive with random junk. diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorterTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorterTest.kt index 097eeb34fd..ef65130190 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorterTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/searchfilesystem/SearchResultListSorterTest.kt @@ -41,7 +41,7 @@ import java.util.regex.Pattern @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.P, Build.VERSION_CODES.R], + sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.P, Build.VERSION_CODES.R], ) @Suppress("StringLiteralDuplication", "ComplexMethod", "LongMethod", "LargeClass") class SearchResultListSorterTest { diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableEd25519Test.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableEd25519Test.kt index 1cfc834af1..75586a506f 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableEd25519Test.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableEd25519Test.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.ssh import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.shadows.ShadowFileUtils @@ -45,7 +45,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowTabHandler::class, ShadowFileUtils::class], - sdk = [LOLLIPOP, P, VERSION_CODES.R], + sdk = [M, P, VERSION_CODES.R], ) class PemToKeyPairObservableEd25519Test { companion object { diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableRsaTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableRsaTest.kt index 3590c52573..62d8e693cf 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableRsaTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/PemToKeyPairObservableRsaTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.ssh import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.N import android.os.Build.VERSION_CODES.P import androidx.appcompat.widget.AppCompatEditText @@ -65,7 +65,7 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowTabHandler::class, ShadowFileUtils::class], - sdk = [LOLLIPOP, P, VERSION_CODES.R], + sdk = [M, P, VERSION_CODES.R], ) class PemToKeyPairObservableRsaTest { companion object { diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskTest.kt index a176c0fdd6..a9f8a5e33d 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/ssh/SshAuthenticationTaskTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.ssh import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -66,7 +66,7 @@ import java.util.concurrent.CountDownLatch @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) @Suppress("StringLiteralDuplication") class SshAuthenticationTaskTest { diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt index f120e7f542..ed1c35d76d 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt @@ -23,7 +23,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.texteditor.read import android.content.Context import android.net.Uri import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -46,7 +46,7 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class ReadTextFileCallableTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java index 156314e0cd..3c9ecf41c5 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/write/WriteTextFileCallableTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.texteditor.write; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -65,7 +65,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class, ShadowContentResolver.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class WriteTextFileCallableTest { private static final String contents = "This is modified data"; diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/DecryptServiceTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/DecryptServiceTest.kt index eca0bf4fd4..302af8d7af 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/DecryptServiceTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/DecryptServiceTest.kt @@ -25,7 +25,6 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.LOLLIPOP import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment @@ -64,7 +63,7 @@ import java.util.concurrent.TimeUnit import kotlin.random.Random @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) @Suppress("StringLiteralDuplication") class DecryptServiceTest { private lateinit var source: ByteArray diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/EncryptServiceTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/EncryptServiceTest.kt index 7443d72e6d..5c2dc3bac1 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/EncryptServiceTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/EncryptServiceTest.kt @@ -25,8 +25,8 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.LOLLIPOP import android.os.Build.VERSION_CODES.M +import android.os.Build.VERSION_CODES.O import android.os.Build.VERSION_CODES.P import android.os.Environment import android.util.Log @@ -68,7 +68,7 @@ import java.util.concurrent.TimeUnit import kotlin.random.Random @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) class EncryptServiceTest { private lateinit var service: EncryptService private lateinit var notificationManager: ShadowNotificationManager @@ -128,7 +128,9 @@ class EncryptServiceTest { assertTrue(notificationManager.activeNotifications.isNotEmpty()) notificationManager.activeNotifications.first().let { assertEquals(NotificationConstants.ENCRYPT_ID, it.id) - assertEquals(NotificationConstants.CHANNEL_NORMAL_ID, it.notification.channelId) + if (SDK_INT >= O) { + assertEquals(NotificationConstants.CHANNEL_NORMAL_ID, it.notification.channelId) + } } await().atMost(10, TimeUnit.SECONDS).until { targetFile.length() > 0 && notificationManager.activeNotifications.isEmpty() @@ -171,7 +173,9 @@ class EncryptServiceTest { assertTrue(notificationManager.activeNotifications.isNotEmpty()) notificationManager.activeNotifications.first().let { assertEquals(NotificationConstants.ENCRYPT_ID, it.id) - assertEquals(NotificationConstants.CHANNEL_NORMAL_ID, it.notification.channelId) + if (SDK_INT >= O) { + assertEquals(NotificationConstants.CHANNEL_NORMAL_ID, it.notification.channelId) + } } await().atMost(10, TimeUnit.SECONDS).until { targetFile.length() > 0 && notificationManager.activeNotifications.isEmpty() 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 94259d4102..0f843504bd 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 @@ -114,7 +114,7 @@ class ExtractServiceTest { @Rule @JvmField @RequiresApi(VERSION_CODES.R) - val allFilesPermissionRule = + val allFilesPermissionRule: GrantPermissionRule = GrantPermissionRule .grant(Manifest.permission.MANAGE_EXTERNAL_STORAGE) diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceAndroidFileSystemIntegrationTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceAndroidFileSystemIntegrationTest.kt index 3dbfc48c0d..ec613dff73 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceAndroidFileSystemIntegrationTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceAndroidFileSystemIntegrationTest.kt @@ -26,7 +26,7 @@ import android.net.NetworkInfo import android.net.Uri import android.net.wifi.WifiInfo import android.net.wifi.WifiManager -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Environment import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider @@ -64,7 +64,7 @@ import kotlin.random.Random @Ignore("Pending fix for testing against newer Androids") @RunWith(AndroidJUnit4::class) -@Config(sdk = [LOLLIPOP], shadows = [ShadowMultiDex::class]) +@Config(sdk = [M], shadows = [ShadowMultiDex::class]) @LooperMode(LooperMode.Mode.PAUSED) @Suppress("StringLiteralDuplication") class FtpServiceAndroidFileSystemIntegrationTest { diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ZipServiceTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ZipServiceTest.kt index 9faa8029cf..768f2e220a 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ZipServiceTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ZipServiceTest.kt @@ -23,7 +23,7 @@ package com.amaze.filemanager.asynchronous.services import android.content.Context import android.content.Intent import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Looper.getMainLooper import androidx.test.core.app.ApplicationProvider @@ -59,7 +59,7 @@ import kotlin.random.Random @RunWith(RobolectricTestRunner::class) @LooperMode(LooperMode.Mode.PAUSED) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) class ZipServiceTest { val dt = DateTimeFormatter.ofPattern("yyyyMMddkkmm") val dates = diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt index 02d132b982..7b0a5dbb7d 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.asynchronous.services.ftp import android.content.Intent import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.N import android.os.Build.VERSION_CODES.O import android.os.Build.VERSION_CODES.P @@ -43,7 +43,7 @@ import org.junit.runner.RunWith import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) @Suppress("StringLiteralDuplication") class FtpReceiverTest { private lateinit var receiver: FtpReceiver diff --git a/app/src/test/java/com/amaze/filemanager/database/ExplorerDatabaseMigrationTest.kt b/app/src/test/java/com/amaze/filemanager/database/ExplorerDatabaseMigrationTest.kt index 69c1e48289..df15a90475 100644 --- a/app/src/test/java/com/amaze/filemanager/database/ExplorerDatabaseMigrationTest.kt +++ b/app/src/test/java/com/amaze/filemanager/database/ExplorerDatabaseMigrationTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.database import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.room.Room import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory @@ -44,7 +44,7 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) @Suppress("StringLiteralDuplication", "ComplexMethod", "LongMethod") class ExplorerDatabaseMigrationTest { diff --git a/app/src/test/java/com/amaze/filemanager/database/UtilitiesDatabaseMigrationTest.kt b/app/src/test/java/com/amaze/filemanager/database/UtilitiesDatabaseMigrationTest.kt index f3167fcaf0..9bc04a4f09 100644 --- a/app/src/test/java/com/amaze/filemanager/database/UtilitiesDatabaseMigrationTest.kt +++ b/app/src/test/java/com/amaze/filemanager/database/UtilitiesDatabaseMigrationTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.database import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.util.Base64 import androidx.room.Room @@ -52,7 +52,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class UtilitiesDatabaseMigrationTest { companion object { diff --git a/app/src/test/java/com/amaze/filemanager/database/UtilsHandlerTest.kt b/app/src/test/java/com/amaze/filemanager/database/UtilsHandlerTest.kt index 7b8a5e433c..64311aeb44 100644 --- a/app/src/test/java/com/amaze/filemanager/database/UtilsHandlerTest.kt +++ b/app/src/test/java/com/amaze/filemanager/database/UtilsHandlerTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.database import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import android.os.Environment.DIRECTORY_DCIM @@ -53,7 +53,7 @@ import java.io.File @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class UtilsHandlerTest { companion object { diff --git a/app/src/test/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverterTest.kt b/app/src/test/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverterTest.kt index f5adf4c7bd..4e1aefedff 100644 --- a/app/src/test/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverterTest.kt +++ b/app/src/test/java/com/amaze/filemanager/database/typeconverters/EncryptedStringTypeConverterTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.database.typeconverters import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.database.models.StringWrapper @@ -38,7 +38,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class EncryptedStringTypeConverterTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/AbstractFilenameHelperIncrementNameTests.kt b/app/src/test/java/com/amaze/filemanager/filesystem/AbstractFilenameHelperIncrementNameTests.kt index f306e23d7a..e10cfbc453 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/AbstractFilenameHelperIncrementNameTests.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/AbstractFilenameHelperIncrementNameTests.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.filesystem import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.application.AppConfig @@ -42,7 +42,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) @Suppress("StringLiteralDuplication") abstract class AbstractFilenameHelperIncrementNameTests { diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/AbstractOperationsTestBase.kt b/app/src/test/java/com/amaze/filemanager/filesystem/AbstractOperationsTestBase.kt index 1aafbc6a79..75af7b7746 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/AbstractOperationsTestBase.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/AbstractOperationsTestBase.kt @@ -23,7 +23,7 @@ package com.amaze.filemanager.filesystem import android.Manifest import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Looper import android.os.storage.StorageManager @@ -68,7 +68,7 @@ import org.robolectric.shadows.ShadowSQLiteConnection ShadowFileUtils::class, ShadowPasswordUtil::class, ], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) abstract class AbstractOperationsTestBase { private var ctx: Context? = null diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/EditableFileAbstractionTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/EditableFileAbstractionTest.java index 32897ee00e..25964472f7 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/EditableFileAbstractionTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/EditableFileAbstractionTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.CONTENT; import static com.amaze.filemanager.filesystem.EditableFileAbstraction.Scheme.FILE; @@ -49,7 +49,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class EditableFileAbstractionTest { @Test(expected = IllegalArgumentException.class) diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/HybridFileTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/HybridFileTest.kt index 7d1f6267b0..05f9eb8f9e 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/HybridFileTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/HybridFileTest.kt @@ -23,7 +23,7 @@ package com.amaze.filemanager.filesystem import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.core.app.ApplicationProvider @@ -40,7 +40,7 @@ import java.net.URLDecoder import kotlin.random.Random @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) @Suppress("StringLiteralDuplication") class HybridFileTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/OperationsTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/OperationsTest.java index 329e3107e3..dac964e466 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/OperationsTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/OperationsTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -45,7 +45,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class OperationsTest { private File storageRoot = Environment.getExternalStorageDirectory(); diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/RootHelperTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/RootHelperTest.java index f3004396db..7bd64e67ad 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/RootHelperTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/RootHelperTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.fail; @@ -51,7 +51,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) @Ignore("FIXME: should not ignore - please implement a shadow") public class RootHelperTest { diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/B0rkenZipTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/B0rkenZipTest.java index 1047900c01..1ed0c4730d 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/B0rkenZipTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/B0rkenZipTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem.compressed; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static kotlin.io.ConstantsKt.DEFAULT_BUFFER_SIZE; import static org.junit.Assert.assertEquals; @@ -56,7 +56,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class B0rkenZipTest { private File zipfile1 = new File(Environment.getExternalStorageDirectory(), "zip-slip.zip"); 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 0573930e05..e52dc1ce7c 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 @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem.compressed; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -66,7 +66,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class CompressedHelperTest { private Context context; diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/AbstractExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/AbstractExtractorTest.kt index 8e2e8e1c45..2187aa2e80 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/AbstractExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/AbstractExtractorTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.core.app.ApplicationProvider @@ -50,7 +50,7 @@ import java.nio.file.Paths import java.util.TimeZone @RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(shadows = [ShadowMultiDex::class], sdk = [M, P, Build.VERSION_CODES.R]) abstract class AbstractExtractorTest { protected abstract fun extractorClass(): Class diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/FileListSorterTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/FileListSorterTest.kt index e2890ed677..67544eb8bd 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/FileListSorterTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/FileListSorterTest.kt @@ -43,7 +43,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.P, Build.VERSION_CODES.R], + sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.P, Build.VERSION_CODES.R], ) @Suppress("StringLiteralDuplication", "ComplexMethod", "LongMethod", "LargeClass") class FileListSorterTest { diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt index a75c54e152..311913f1ac 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/FileUtilsTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.filesystem.files import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.filesystem.files.FileUtils.getPathsInPath @@ -38,7 +38,7 @@ import java.util.TimeZone @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) -@Config(sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(sdk = [M, P, Build.VERSION_CODES.R]) @Suppress("TooManyFunctions", "StringLiteralDuplication") class FileUtilsTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/DirSortByTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/DirSortByTest.kt index fb00c32fdb..1146a3dd99 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/DirSortByTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/DirSortByTest.kt @@ -32,7 +32,7 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.P, Build.VERSION_CODES.R], + sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.P, Build.VERSION_CODES.R], ) class DirSortByTest { /** Tests if [DirSortBy.getDirSortBy] returns the correct [DirSortBy] corresponding to the given index */ diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortByTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortByTest.kt index a851d96afb..84700d0153 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortByTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortByTest.kt @@ -32,7 +32,7 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.P, Build.VERSION_CODES.R], + sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.P, Build.VERSION_CODES.R], ) class SortByTest { /** Tests if [SortBy.getSortBy] returns the correct [SortBy] corresponding to the given index */ diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortTypeTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortTypeTest.kt index ee9bae2172..29d978bb32 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortTypeTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/sort/SortTypeTest.kt @@ -31,7 +31,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.P, Build.VERSION_CODES.R], + sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.P, Build.VERSION_CODES.R], ) class SortTypeTest { /** Tests if the Int returned from [SortType.toDirectorySortInt] is as expected */ diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolFtpTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolFtpTest.kt index 35d51316ac..eb8bfb9426 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolFtpTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ftp/NetCopyClientConnectionPoolFtpTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.filesystem.ftp import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.application.AppConfig @@ -64,7 +64,7 @@ import kotlin.text.Charsets.UTF_8 @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class NetCopyClientConnectionPoolFtpTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt index 6aed30ecb4..5b9ed5533a 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.filesystem.ftpserver.commands import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.shadows.ShadowMultiDex @@ -38,7 +38,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) abstract class AbstractFtpserverCommandTest { protected lateinit var logger: LogMessageFilter diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest.kt index b00c868784..d2216be56a 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.filesystem.root import android.content.SharedPreferences import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider @@ -54,7 +54,7 @@ import java.io.InputStreamReader @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowNativeOperations::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class ListFilesCommandTest { private val sharedPreferences: SharedPreferences = diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest2.kt b/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest2.kt index 6b828df226..daa421e4f2 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest2.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/root/ListFilesCommandTest2.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.filesystem.root import android.content.SharedPreferences import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider @@ -54,7 +54,7 @@ import java.io.InputStreamReader @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowNativeOperations::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class ListFilesCommandTest2 { private val sharedPreferences: SharedPreferences = diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/smb/CifsContextsTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/smb/CifsContextsTest.java index bb83e42eb4..3759026e9f 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/smb/CifsContextsTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/smb/CifsContextsTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.filesystem.smb; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -44,7 +44,7 @@ import jcifs.ResolverType; import jcifs.context.BaseContext; -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) @RunWith(AndroidJUnit4.class) public class CifsContextsTest { diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/smb/SmbHybridFileTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/smb/SmbHybridFileTest.kt index e6995e4ef4..47a95b6a8c 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/smb/SmbHybridFileTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/smb/SmbHybridFileTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.filesystem.smb import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -45,7 +45,7 @@ import org.robolectric.shadows.ShadowSQLiteConnection @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowSmbUtil::class, ShadowMultiDex::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) @LooperMode(LooperMode.Mode.PAUSED) class SmbHybridFileTest { diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt index 708805e08d..8f631c1dec 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/AbstractSftpServerTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.filesystem.ssh import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -63,7 +63,7 @@ import kotlin.text.Charsets.UTF_8 @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, VERSION_CODES.R], + sdk = [M, P, VERSION_CODES.R], ) abstract class AbstractSftpServerTest { protected var encryptedPassword: String? = diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/NetCopyClientConnectionPoolSshTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/NetCopyClientConnectionPoolSshTest.kt index 27b8e5c406..6fd76ec607 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/NetCopyClientConnectionPoolSshTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/NetCopyClientConnectionPoolSshTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.filesystem.ssh import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.application.AppConfig @@ -75,7 +75,7 @@ import kotlin.text.Charsets.UTF_8 @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class NetCopyClientConnectionPoolSshTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/SshHybridFileTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/SshHybridFileTest.kt index 5c10d396a8..e5ceef2d31 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/SshHybridFileTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/SshHybridFileTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.filesystem.ssh import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -46,7 +46,7 @@ import org.robolectric.annotation.LooperMode @LooperMode(LooperMode.Mode.PAUSED) @Config( shadows = [ShadowMultiDex::class, ShadowPasswordUtil::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class SshHybridFileTest { private var ctx: Context? = null diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/AbstractMainActivityTestBase.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/AbstractMainActivityTestBase.kt index 307828c771..d6291f3a72 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/AbstractMainActivityTestBase.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/AbstractMainActivityTestBase.kt @@ -24,7 +24,7 @@ import android.Manifest import android.content.Context import android.os.Build import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.storage.StorageManager import androidx.annotation.NonNull @@ -54,7 +54,7 @@ import org.robolectric.shadows.ShadowStorageManager */ @RunWith(AndroidJUnit4::class) @Config( - sdk = [LOLLIPOP, P, VERSION_CODES.R], + sdk = [M, P, VERSION_CODES.R], shadows = [ ShadowMultiDex::class, ShadowStorageManager::class, diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt index 24f0d60582..564535e7c0 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/DatabaseViewerActivityTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.ui.activities import android.content.Intent import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.shadows.ShadowMultiDex @@ -43,7 +43,7 @@ import java.util.concurrent.TimeUnit */ @RunWith(AndroidJUnit4::class) @Config( - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], shadows = [ShadowMultiDex::class, ShadowStorageManager::class], ) class DatabaseViewerActivityTest { diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/PermissionsActivityTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/PermissionsActivityTest.kt index c4f178d511..7a25bfe0e1 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/PermissionsActivityTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/PermissionsActivityTest.kt @@ -24,7 +24,7 @@ import android.app.AppOpsManager import android.content.Context import android.net.Uri import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Build.VERSION_CODES.R import android.os.storage.StorageManager @@ -64,7 +64,7 @@ import org.robolectric.shadows.ShadowStorageManager */ @RunWith(AndroidJUnit4::class) @Config( - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], shadows = [ShadowMultiDex::class, ShadowStorageManager::class], ) class PermissionsActivityTest { diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/TextEditorActivityTest.java b/app/src/test/java/com/amaze/filemanager/ui/activities/TextEditorActivityTest.java index c65a67166c..54d53de765 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/activities/TextEditorActivityTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/TextEditorActivityTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.ui.activities; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -58,7 +58,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class TextEditorActivityTest { private final String fileContents = "fsdfsdfs"; diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractEncryptDialogTests.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractEncryptDialogTests.kt index 7c4b684f95..2e262d934f 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractEncryptDialogTests.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/AbstractEncryptDialogTests.kt @@ -23,14 +23,16 @@ package com.amaze.filemanager.ui.dialogs import android.Manifest import android.os.Build import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.N import android.os.Build.VERSION_CODES.P import androidx.annotation.RequiresApi import androidx.lifecycle.Lifecycle +import androidx.preference.PreferenceManager import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule +import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.shadows.ShadowFileUtils import com.amaze.filemanager.shadows.ShadowMultiDex import com.amaze.filemanager.test.ShadowTabHandler @@ -48,7 +50,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowTabHandler::class, ShadowFileUtils::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) abstract class AbstractEncryptDialogTests { protected lateinit var scenario: ActivityScenario @@ -56,7 +58,7 @@ abstract class AbstractEncryptDialogTests { @Rule @JvmField @RequiresApi(Build.VERSION_CODES.R) - var allFilesPermissionRule = + var allFilesPermissionRule: GrantPermissionRule = GrantPermissionRule .grant(Manifest.permission.MANAGE_EXTERNAL_STORAGE) @@ -71,10 +73,14 @@ abstract class AbstractEncryptDialogTests { } /** - * Post test cleanup. + * Post test cleanup. Clear all preferences. */ @After open fun tearDown() { scenario.close() + PreferenceManager.getDefaultSharedPreferences(AppConfig.getInstance()) + .edit() + .clear() + .apply() } } diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialogTest.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialogTest.kt index da53a03bee..ede4e0d9ad 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialogTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/ColorPickerDialogTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.ui.dialogs import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.application.AppConfig @@ -42,7 +42,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class, ShadowTabHandler::class, ShadowFileUtils::class], - sdk = [LOLLIPOP, P, VERSION_CODES.R], + sdk = [M, P, VERSION_CODES.R], ) class ColorPickerDialogTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialogTest.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialogTest.kt index b5af6f68a4..2d5cf9d4de 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialogTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/DecryptFingerprintDialogTest.kt @@ -49,6 +49,7 @@ import org.junit.Before import org.junit.Test import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowDialog import org.robolectric.shadows.ShadowFingerprintManager import java.io.File @@ -96,7 +97,7 @@ class DecryptFingerprintDialogTest : AbstractEncryptDialogTests() { fun testDecryptFingerprintDialogSuccess() { performTest( testContent = { - shadowOf( + Shadow.extract( AppConfig.getInstance().getSystemService(FingerprintManager::class.java), ).run { setDefaultFingerprints(1) @@ -121,7 +122,7 @@ class DecryptFingerprintDialogTest : AbstractEncryptDialogTests() { fun testDecryptFingerprintDialogFailed() { performTest( testContent = { - shadowOf( + Shadow.extract( AppConfig.getInstance().getSystemService(FingerprintManager::class.java), ).run { setDefaultFingerprints(1) diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/CloudSheetFragmentTest.java b/app/src/test/java/com/amaze/filemanager/ui/fragments/CloudSheetFragmentTest.java index 28ca6edcb3..e7554d2b00 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/fragments/CloudSheetFragmentTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/CloudSheetFragmentTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.ui.fragments; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -39,7 +39,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class CloudSheetFragmentTest { @Test diff --git a/app/src/test/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragmentTest.kt b/app/src/test/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragmentTest.kt index 277aaccb33..a997e922df 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragmentTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/fragments/preferencefragments/UiPrefsFragmentTest.kt @@ -52,7 +52,7 @@ import kotlin.random.Random */ @Config( sdk = [ - Build.VERSION_CODES.LOLLIPOP, + Build.VERSION_CODES.M, Build.VERSION_CODES.N, Build.VERSION_CODES.P, Build.VERSION_CODES.R, diff --git a/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java b/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java index 39ace0bde4..17e6e939b0 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/icons/IconsTest.java @@ -20,15 +20,15 @@ package com.amaze.filemanager.ui.icons; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertEquals; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.Shadows; import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowMimeTypeMap; import com.amaze.filemanager.shadows.ShadowMultiDex; @@ -41,13 +41,13 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class IconsTest { @Before public void setUp() { // By default Robolectric's MimeTypeMap is empty, we need to populate them - ShadowMimeTypeMap mimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()); + ShadowMimeTypeMap mimeTypeMap = Shadow.extract(MimeTypeMap.getSingleton()); mimeTypeMap.addExtensionMimeTypeMapping("zip", "application/zip"); mimeTypeMap.addExtensionMimeTypeMapping("rar", "application/x-rar-compressed"); mimeTypeMap.addExtensionMimeTypeMapping("tar", "application/x-tar"); diff --git a/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java b/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java index f9c5e4b8ea..758431e5c2 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/notifications/NotificationConstantsTest.java @@ -22,7 +22,7 @@ import static android.app.NotificationManager.IMPORTANCE_HIGH; import static android.app.NotificationManager.IMPORTANCE_MIN; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static com.amaze.filemanager.ui.notifications.NotificationConstants.CHANNEL_FTP_ID; import static com.amaze.filemanager.ui.notifications.NotificationConstants.CHANNEL_NORMAL_ID; @@ -54,7 +54,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class NotificationConstantsTest { private Context context; @@ -89,7 +89,7 @@ public void testSetMetadataIllegalType() { } @Test - @Config(sdk = {LOLLIPOP}) // max sdk is N + @Config(sdk = {M}) // max sdk is N public void testNormalNotification() { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_NORMAL_ID) @@ -101,7 +101,7 @@ public void testNormalNotification() { NotificationConstants.setMetadata(context, builder, TYPE_NORMAL); Notification result = builder.build(); - if (Build.VERSION.SDK_INT >= LOLLIPOP) { + if (Build.VERSION.SDK_INT >= M) { assertEquals(Notification.CATEGORY_SERVICE, result.category); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { @@ -112,7 +112,7 @@ public void testNormalNotification() { } @Test - @Config(sdk = {LOLLIPOP}) // max sdk is N + @Config(sdk = {M}) // max sdk is N public void testFtpNotification() { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_FTP_ID) @@ -124,7 +124,7 @@ public void testFtpNotification() { .setOnlyAlertOnce(true); NotificationConstants.setMetadata(context, builder, TYPE_FTP); Notification result = builder.build(); - if (Build.VERSION.SDK_INT >= LOLLIPOP) { + if (Build.VERSION.SDK_INT >= M) { assertEquals(Notification.CATEGORY_SERVICE, result.category); assertEquals(Notification.VISIBILITY_PUBLIC, result.visibility); } diff --git a/app/src/test/java/com/amaze/filemanager/ui/theme/AppThemeTest.kt b/app/src/test/java/com/amaze/filemanager/ui/theme/AppThemeTest.kt index 7d614b8431..d421c5d5d0 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/theme/AppThemeTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/theme/AppThemeTest.kt @@ -22,7 +22,7 @@ package com.amaze.filemanager.ui.theme import android.content.Context import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.PowerManager import androidx.preference.PreferenceManager @@ -41,7 +41,7 @@ import java.util.Calendar @RunWith(AndroidJUnit4::class) @Config( - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], shadows = [ShadowMultiDex::class], ) class AppThemeTest { diff --git a/app/src/test/java/com/amaze/filemanager/ui/views/WarnableTextInputValidatorTest.java b/app/src/test/java/com/amaze/filemanager/ui/views/WarnableTextInputValidatorTest.java index 37070cd432..f7096ace25 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/views/WarnableTextInputValidatorTest.java +++ b/app/src/test/java/com/amaze/filemanager/ui/views/WarnableTextInputValidatorTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.ui.views; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -44,7 +44,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class WarnableTextInputValidatorTest { private Context context; diff --git a/app/src/test/java/com/amaze/filemanager/utils/AESCryptTest.kt b/app/src/test/java/com/amaze/filemanager/utils/AESCryptTest.kt index 7559fa97d4..c7c9bad0e9 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/AESCryptTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/AESCryptTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.utils import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertArrayEquals @@ -36,7 +36,7 @@ import kotlin.random.Random * Unit test for [AESCrypt] */ @RunWith(AndroidJUnit4::class) -@Config(sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(sdk = [M, P, Build.VERSION_CODES.R]) class AESCryptTest { /** * Simple sanity test on [AESCrypt]. diff --git a/app/src/test/java/com/amaze/filemanager/utils/AnimUtilsTest.java b/app/src/test/java/com/amaze/filemanager/utils/AnimUtilsTest.java index 92b65fdc29..24df7b4724 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/AnimUtilsTest.java +++ b/app/src/test/java/com/amaze/filemanager/utils/AnimUtilsTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.utils; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -45,7 +45,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class AnimUtilsTest { @Test diff --git a/app/src/test/java/com/amaze/filemanager/utils/CryptUtilTest.kt b/app/src/test/java/com/amaze/filemanager/utils/CryptUtilTest.kt index d7e116593f..6b51727d64 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/CryptUtilTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/CryptUtilTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.utils import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -48,7 +48,7 @@ import kotlin.random.Random @RunWith(AndroidJUnit4::class) @Config( - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class CryptUtilTest { /** diff --git a/app/src/test/java/com/amaze/filemanager/utils/MinMaxInputFilterTest.kt b/app/src/test/java/com/amaze/filemanager/utils/MinMaxInputFilterTest.kt index 0e882b12c8..7f3d2df94b 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/MinMaxInputFilterTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/MinMaxInputFilterTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.utils import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import android.text.SpannedString import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -32,7 +32,7 @@ import org.junit.runner.RunWith import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) -@Config(sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) +@Config(sdk = [M, P, Build.VERSION_CODES.R]) class MinMaxInputFilterTest { /** * Test MinMaxInputFilter functioning diff --git a/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt b/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt index 05e452debb..d13a4ffb72 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/NetworkUtilTest.kt @@ -30,7 +30,7 @@ import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every @@ -53,7 +53,7 @@ import java.util.Collections * Tests for [NetworkUtil]. */ @RunWith(AndroidJUnit4::class) -@Config(sdk = [LOLLIPOP, P, VERSION_CODES.R]) +@Config(sdk = [M, P, VERSION_CODES.R]) @Suppress("StringLiteralDuplication") class NetworkUtilTest { private lateinit var context: Context diff --git a/app/src/test/java/com/amaze/filemanager/utils/SmbUtilTest.kt b/app/src/test/java/com/amaze/filemanager/utils/SmbUtilTest.kt index ccc65a0110..c5f498799b 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/SmbUtilTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/SmbUtilTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.utils import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -46,7 +46,7 @@ import org.robolectric.annotation.Config @Suppress("StringLiteralDuplication") @RunWith(AndroidJUnit4::class) @Config( - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], shadows = [ShadowPasswordUtil::class, ShadowSmbUtil::class], ) class SmbUtilTest { diff --git a/app/src/test/java/com/amaze/filemanager/utils/TinyDBTest.java b/app/src/test/java/com/amaze/filemanager/utils/TinyDBTest.java index 12e9f3cd13..aeff06a154 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/TinyDBTest.java +++ b/app/src/test/java/com/amaze/filemanager/utils/TinyDBTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.utils; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNull; @@ -38,7 +38,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class TinyDBTest { private SharedPreferences prefs; diff --git a/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java b/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java index fa90467fca..d25e9e8a49 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java +++ b/app/src/test/java/com/amaze/filemanager/utils/UtilsTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.utils; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.N; import static android.os.Build.VERSION_CODES.P; import static com.amaze.filemanager.utils.Utils.formatTimer; @@ -56,7 +56,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; @RunWith(AndroidJUnit4.class) -@Config(sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) +@Config(sdk = {M, P, Build.VERSION_CODES.R}) public class UtilsTest { @Test diff --git a/app/src/test/java/com/amaze/filemanager/utils/X509CertificateUtilTest.kt b/app/src/test/java/com/amaze/filemanager/utils/X509CertificateUtilTest.kt index fd74085121..ebfc7a2700 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/X509CertificateUtilTest.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/X509CertificateUtilTest.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.utils import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.shadows.ShadowMultiDex @@ -41,7 +41,7 @@ import java.security.cert.X509Certificate @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], + sdk = [M, P, Build.VERSION_CODES.R], ) class X509CertificateUtilTest { private lateinit var cert: X509Certificate diff --git a/app/src/test/java/com/amaze/filemanager/utils/smb/AbstractSubnetDiscoverDevicesStrategyTests.kt b/app/src/test/java/com/amaze/filemanager/utils/smb/AbstractSubnetDiscoverDevicesStrategyTests.kt index d937bc0c06..c8e8e9783b 100644 --- a/app/src/test/java/com/amaze/filemanager/utils/smb/AbstractSubnetDiscoverDevicesStrategyTests.kt +++ b/app/src/test/java/com/amaze/filemanager/utils/smb/AbstractSubnetDiscoverDevicesStrategyTests.kt @@ -21,7 +21,7 @@ package com.amaze.filemanager.utils.smb import android.os.Build.VERSION_CODES -import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.P import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.utils.NetworkUtil @@ -38,7 +38,7 @@ import java.net.InetAddress * Base class for [SmbDeviceScannerObservable.DiscoverDeviceStrategy] tests. */ @RunWith(AndroidJUnit4::class) -@Config(sdk = [LOLLIPOP, P, VERSION_CODES.R]) +@Config(sdk = [M, P, VERSION_CODES.R]) abstract class AbstractSubnetDiscoverDevicesStrategyTests { /** * Post test cleanup. diff --git a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt index c811673e18..0ab5ac472c 100644 --- a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt +++ b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/MultipartRarExtractorTest.kt @@ -43,7 +43,7 @@ import java.util.concurrent.CountDownLatch @RunWith(AndroidJUnit4::class) @Config( shadows = [ShadowMultiDex::class], - sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.P], + sdk = [Build.VERSION_CODES.M, Build.VERSION_CODES.P], ) class MultipartRarExtractorTest { private val callback = diff --git a/build.gradle b/build.gradle index d24894a4b5..a9f3eab387 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,7 @@ configurations { robo33 robo34 robo35 + robo36 } dependencies { @@ -91,28 +92,24 @@ dependencies { robo32 "org.robolectric:android-all-instrumented:12.1-robolectric-8229987-i7" robo33 "org.robolectric:android-all-instrumented:13-robolectric-9030017-i7" robo34 "org.robolectric:android-all-instrumented:14-robolectric-10818077-i7" - robo35 "org.robolectric:android-all-instrumented:15-robolectric-12650502-i7" + robo35 "org.robolectric:android-all-instrumented:15-robolectric-13954326-i7" + robo36 "org.robolectric:android-all-instrumented:16-robolectric-13921718-i7" } def robolectricDependencies = "${rootProject.layout.buildDirectory.get().asFile}/robolectric" +def robolectricApis = (21..36) + +def fetchRobolectricApiTasks = robolectricApis.collect { apiLevel -> + def configurationName = "robo${apiLevel}" + def taskName = "fetchRobolectric${apiLevel}" + tasks.register(taskName, Copy) { + from configurations.named(configurationName) + into robolectricDependencies + } +} tasks.register('fetchRobolectricDependencies', Copy) { - from configurations.robo21 - from configurations.robo22 - from configurations.robo23 - from configurations.robo24 - from configurations.robo25 - from configurations.robo26 - from configurations.robo27 - from configurations.robo28 - from configurations.robo29 - from configurations.robo30 - from configurations.robo31 - from configurations.robo32 - from configurations.robo33 - from configurations.robo34 - from configurations.robo35 - into robolectricDependencies + dependsOn fetchRobolectricApiTasks } subprojects { diff --git a/testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java b/testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java index c1f8919b50..b0dd8d2061 100644 --- a/testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java +++ b/testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java @@ -20,7 +20,7 @@ package com.amaze.filemanager.test; -import static android.os.Build.VERSION_CODES.LOLLIPOP; +import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.P; import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; @@ -56,7 +56,7 @@ @RunWith(AndroidJUnit4.class) @Config( shadows = {ShadowMultiDex.class, ShadowPasswordUtil.class}, - sdk = {LOLLIPOP, P, Build.VERSION_CODES.R}) + sdk = {M, P, Build.VERSION_CODES.R}) public class ShadowPasswordUtilTest { @Before From 272d3fcbd25d8d8a0e69c77cdd495b441a563376 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Mon, 10 Aug 2026 23:51:35 +0800 Subject: [PATCH 27/35] Compile with SDK 36 Also upgraded libraries: - Robolectric: 4.16.1 - Glide: 5.0.7 - Bouncy Castle 1.85.2 - Google Play Android Billing: 9.1.0 --- gradle/libs.versions.toml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 16ee06a521..e36c9650f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -compileSdk = "35" -minSdk = "21" -targetSdk = "35" +compileSdk = "36" +minSdk = "23" +targetSdk = "36" kotlin = "2.2.20" ndk = "28.2.13676358" #r28c @@ -11,14 +11,15 @@ gradle = "8.13.2" easylauncher = "6.1.0" legacySupportV13 = "1.0.0" systembartint = "1.0.4" -robolectric = "4.15" -glide = "5.0.5" +robolectric = "4.16.1" +glide = "5.0.7" sshj = "0.40.0" jcifs = "2.1.10" eventbus = "3.3.1" fabSpeedDial = "3.3.0" room = "2.5.2" -bouncyCastle = "1.85" +bouncyCastle = "1.85.2" +bouncyCastlePkix = "1.85" awaitility = "4.3.0" androidXActivity = "1.7.2" androidXCore = "1.16.0" @@ -47,7 +48,7 @@ apacheMina = "2.2.9" mockito = "5.23.0" mockitoInline = "5.2.0" mockitoKotlin = "6.3.0" -androidBilling = "8.0.0" +androidBilling = "9.1.0" cloudrailSiAndroid = "2.22.4" junrar = "8.1.0" vectordrawableAnimated = "1.2.0" @@ -108,7 +109,7 @@ androidX-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", auto-service = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } -bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncyCastle" } +bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncyCastlePkix" } bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } apache-mina-core = { module = "org.apache.mina:mina-core", version.ref = "apacheMina" } From 119a58c132bfe45618eb0cfbdda6daea69f19cb4 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Tue, 11 Aug 2026 00:10:07 +0800 Subject: [PATCH 28/35] Moved testShared a common module for test classes Per recommendation for proper source management --- app/build.gradle | 7 +- file_operations/build.gradle | 5 +- settings.gradle | 2 +- testShared/build.gradle | 81 +++++++++++++++++++ .../filemanager/shadows/ShadowFileUtils.kt | 0 .../filemanager/shadows/ShadowMultiDex.java | 0 .../filemanager/shadows/ShadowSmbUtil.kt | 0 .../shadows/jcifs/smb/ShadowSmbFile.java | 0 .../filemanager/test/DummyFileGenerator.java | 0 .../test/ShadowNativeOperations.java | 0 .../filemanager/test/ShadowPasswordUtil.kt | 0 .../test/ShadowPasswordUtilTest.java | 0 .../filemanager/test/ShadowTabHandler.kt | 0 .../com/amaze/filemanager/test/TestUtils.kt | 0 14 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 testShared/build.gradle rename testShared/src/{test => main}/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/shadows/ShadowMultiDex.java (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/shadows/jcifs/smb/ShadowSmbFile.java (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/test/DummyFileGenerator.java (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/test/ShadowNativeOperations.java (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/test/ShadowPasswordUtil.kt (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/test/ShadowTabHandler.kt (100%) rename testShared/src/{test => main}/java/com/amaze/filemanager/test/TestUtils.kt (100%) diff --git a/app/build.gradle b/app/build.gradle index bd16fcc929..ebe36ad437 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,10 +98,6 @@ android { includeInBundle = false } - sourceSets { - test.java.srcDirs += '../testShared/src/test/java' - } - compileOptions { sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 @@ -158,7 +154,7 @@ dependencies { ksp libs.androidX.annotation //For tests - kspTest libs.auto.service + testImplementation project(':testShared') testImplementation libs.junit//tests the app logic testImplementation libs.robolectric//tests android interaction testImplementation libs.robolectric.shadows.httpclient//tests android interaction @@ -180,6 +176,7 @@ dependencies { testImplementation libs.kotlin.coroutine.test testImplementation libs.androidX.core.testing testImplementation libs.logback.classic + kspTest libs.auto.service androidTestImplementation libs.junit //tests the app logic androidTestImplementation libs.androidX.test.expresso diff --git a/file_operations/build.gradle b/file_operations/build.gradle index 3c8f78aebc..3f5cf32335 100644 --- a/file_operations/build.gradle +++ b/file_operations/build.gradle @@ -26,10 +26,6 @@ android { } } - sourceSets { - test.java.srcDirs += '../testShared/src/test/java' - } - compileOptions { sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 @@ -106,6 +102,7 @@ dependencies { //TODO some libs are not needed //For tests + testImplementation project(':testShared') testImplementation libs.junit //tests the app logic testImplementation libs.robolectric //tests android interaction testImplementation libs.robolectric.shadows.httpclient //tests android interaction diff --git a/settings.gradle b/settings.gradle index 0023200c52..cb1e87c6e5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,4 @@ plugins { } include ':file_operations' include ':portscanner' -include ':app', ':commons_compress_7z' +include ':app', ':commons_compress_7z', ':testShared' diff --git a/testShared/build.gradle b/testShared/build.gradle new file mode 100644 index 0000000000..12d423a853 --- /dev/null +++ b/testShared/build.gradle @@ -0,0 +1,81 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "com.amaze.filemanager.test" + compileSdkVersion libs.versions.compileSdk.get().toInteger() + + defaultConfig { + minSdkVersion libs.versions.minSdk.get().toInteger() + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + flavorDimensions = ['static'] + + productFlavors { + fdroid { + dimension 'static' + } + + play { + dimension 'static' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + } + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // App module dependency for accessing app classes + implementation project(':app') + + // File operations module for NativeOperations + implementation project(':file_operations') + + // Core testing dependencies + implementation libs.junit + implementation libs.robolectric + implementation libs.robolectric.shadows.httpclient + implementation libs.androidX.test.core + implementation libs.androidX.test.runner + implementation libs.androidX.test.rules + implementation libs.androidX.test.ext.junit + implementation libs.mockito.core + implementation libs.androidX.multidex + implementation libs.awaitility + + // Kotlin standard library + implementation libs.kotlin.stdlib.jdk8 + + // MockK for Kotlin mocking + implementation libs.mockk + + // RxJava for Completable and reactive types + implementation libs.rxjava + implementation libs.rxandroid + + // Room for database classes + implementation libs.room.runtime + + // SMB for shadow implementations + implementation libs.jcifs.ng + + // Android dependencies + implementation libs.androidX.appcompat + implementation libs.androidX.fragment +} + diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt b/testShared/src/main/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt rename to testShared/src/main/java/com/amaze/filemanager/shadows/ShadowFileUtils.kt diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowMultiDex.java b/testShared/src/main/java/com/amaze/filemanager/shadows/ShadowMultiDex.java similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/shadows/ShadowMultiDex.java rename to testShared/src/main/java/com/amaze/filemanager/shadows/ShadowMultiDex.java diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt b/testShared/src/main/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt rename to testShared/src/main/java/com/amaze/filemanager/shadows/ShadowSmbUtil.kt diff --git a/testShared/src/test/java/com/amaze/filemanager/shadows/jcifs/smb/ShadowSmbFile.java b/testShared/src/main/java/com/amaze/filemanager/shadows/jcifs/smb/ShadowSmbFile.java similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/shadows/jcifs/smb/ShadowSmbFile.java rename to testShared/src/main/java/com/amaze/filemanager/shadows/jcifs/smb/ShadowSmbFile.java diff --git a/testShared/src/test/java/com/amaze/filemanager/test/DummyFileGenerator.java b/testShared/src/main/java/com/amaze/filemanager/test/DummyFileGenerator.java similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/test/DummyFileGenerator.java rename to testShared/src/main/java/com/amaze/filemanager/test/DummyFileGenerator.java diff --git a/testShared/src/test/java/com/amaze/filemanager/test/ShadowNativeOperations.java b/testShared/src/main/java/com/amaze/filemanager/test/ShadowNativeOperations.java similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/test/ShadowNativeOperations.java rename to testShared/src/main/java/com/amaze/filemanager/test/ShadowNativeOperations.java diff --git a/testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtil.kt b/testShared/src/main/java/com/amaze/filemanager/test/ShadowPasswordUtil.kt similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtil.kt rename to testShared/src/main/java/com/amaze/filemanager/test/ShadowPasswordUtil.kt diff --git a/testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java b/testShared/src/main/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java rename to testShared/src/main/java/com/amaze/filemanager/test/ShadowPasswordUtilTest.java diff --git a/testShared/src/test/java/com/amaze/filemanager/test/ShadowTabHandler.kt b/testShared/src/main/java/com/amaze/filemanager/test/ShadowTabHandler.kt similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/test/ShadowTabHandler.kt rename to testShared/src/main/java/com/amaze/filemanager/test/ShadowTabHandler.kt diff --git a/testShared/src/test/java/com/amaze/filemanager/test/TestUtils.kt b/testShared/src/main/java/com/amaze/filemanager/test/TestUtils.kt similarity index 100% rename from testShared/src/test/java/com/amaze/filemanager/test/TestUtils.kt rename to testShared/src/main/java/com/amaze/filemanager/test/TestUtils.kt From e325d0c2ce4ad4eed6470c345d71814753e591d5 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 5 Sep 2026 11:18:14 +0800 Subject: [PATCH 29/35] Changes per PR feedback - ZipHelperCallable throws ArchiveException right away upon processing zero byte zips - i18n string name update: error_unsupported_v5_rar -> error_unsupported_rar_version - bump slf4j and junrar dependencies --- .../junrar/exception/UnsupportedRarVersionException.java | 8 ++++++++ .../asynchronous/asynctasks/compress/ZipHelperCallable.kt | 4 ++-- .../asynchronous/asynctasks/movecopy/PreparePasteTask.kt | 2 +- .../filemanager/asynchronous/services/ExtractService.java | 2 +- .../ui/fragments/CompressedExplorerFragment.kt | 2 +- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-he/strings.xml | 2 +- app/src/main/res/values-hu-rHU/strings.xml | 2 +- app/src/main/res/values-id/strings.xml | 2 +- app/src/main/res/values-it-rIT/strings.xml | 2 +- app/src/main/res/values-my/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-pt-rPT/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 3 ++- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHK/strings.xml | 2 +- app/src/main/res/values-zh-rHN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../extractcontents/PasswordProtectedRarTest.kt | 8 +++++++- gradle/libs.versions.toml | 6 +++--- 30 files changed, 47 insertions(+), 31 deletions(-) diff --git a/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java b/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java index 789412c242..61d9516c3a 100644 --- a/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java +++ b/app/src/fdroid/java/com/github/junrar/exception/UnsupportedRarVersionException.java @@ -20,6 +20,14 @@ package com.github.junrar.exception; +/* + * This file is extracted from junrar library (https://github.com/junrar/junrar). + * The file exist to comply with F-Droid's requirement that no proprietary archive formats can be + * supported. + * + * - TranceLove + */ + /** * Thrown when the archive signature names a RAR format this library cannot read: a future format * version byte ({@code 0x02}..{@code 0x04}) that the library predates (unrar {@code RARFMT_FUTURE}, diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt index f3787bdeef..5cc5265887 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt @@ -51,7 +51,7 @@ class ZipHelperCallable( fileLocation.path?.run { val zipfile = ZipFile(fileLocation.path) if (!zipfile.isValidZipFile || zipfile.file.length() == 0L) { - throw ZipException("Zero byte or corrupt zip file") + throw ArchiveException("Zero byte or corrupt zip file") } val wholelist = filterValidEntryList(zipfile) val strings = ArrayList() @@ -110,7 +110,7 @@ class ZipHelperCallable( strings.add(y) } } else if (y.startsWith("$relativeDirectory/") && - y.length > relativeDirectory.length + 1 + (y.length > (relativeDirectory.length + 1)) ) { val path1 = y.substring(relativeDirectory.length + 1, y.length) val index = relativeDirectory.length + 1 + path1.indexOf("/") diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt index 7c37a24b8a..765d8123e3 100755 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/movecopy/PreparePasteTask.kt @@ -29,7 +29,6 @@ import com.afollestad.materialdialogs.DialogAction import com.afollestad.materialdialogs.MaterialDialog import com.amaze.filemanager.R import com.amaze.filemanager.asynchronous.asynctasks.fromTask -import com.amaze.filemanager.asynchronous.asynctasks.movecopy.PreparePasteTask.CopyNode import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.asynchronous.services.CopyService import com.amaze.filemanager.databinding.CopyDialogBinding @@ -63,6 +62,7 @@ import java.util.LinkedList * is a directory, the conflicts are resolved by inserting a node in [CopyNode] tree and then doing * BFS on this tree. */ +@Suppress("LabeledExpression") class PreparePasteTask(strongRefMain: MainActivity) { private lateinit var targetPath: String private var isMove = false 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 b48a9d44f9..30e3732616 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 @@ -368,7 +368,7 @@ public boolean isCancelled() { LOG.error(compressedPath + " is an unsupported RAR archive", e); AppConfig.toast( getApplicationContext(), - extractService.getString(R.string.error_unsupported_v5_rar, compressedPath)); + extractService.getString(R.string.error_unsupported_rar_version, compressedPath)); return false; } else { LOG.error("Error while extracting file " + compressedPath, e); diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt index d70d9fa02b..a1fb892876 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/CompressedExplorerFragment.kt @@ -660,7 +660,7 @@ class CompressedExplorerFragment : Fragment(), BottomBarButtonPath { e?.cause?.javaClass, ) ) { - R.string.error_unsupported_v5_rar + R.string.error_unsupported_rar_version } else { R.string.archive_unsupported_or_corrupt } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b9bd40287e..2517883f3d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -635,7 +635,7 @@ امكانية توصل للقراءة فقط استخدام كشف التركة للدليل الرئيسي اذا تم اتاحته ، استخدم الطريقة المتوارثة لعرض الملفات - ملف تخزين RAR \" %s \" لا يتم دعم أرشيف RAR v5. + ملف تخزين RAR \" %s \" لا يتم دعم أرشيف RAR v5. سيكون من الضروري اعادة بدء وحدة خدمة FTP لكي تصبح التغييرات فعالة. تم رفض التصريح كلمة سرية أرشيف غير صحيحة. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 0c68b905e5..62a42990d8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -650,7 +650,7 @@ Přístup pouze pro čtení Použít starší výpis souborů pro root Pokud je povoleno, používá k výpisu souborů starší metodu - RAR archív \"%s\" je nepodporovaný RAR v5 archív. + RAR archív \"%s\" je nepodporovaný RAR v5 archív. Budete muset FTP server restartovat pro projevení změn. Oprávněění odmítnuto Nesprávné heslo archívu. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9647b78e83..3daae890e3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -659,7 +659,7 @@ andernfalls wird nach Treffern gesucht. Nur-Lesen Zugriff Alte Auflistung für Root verwenden Wenn aktiviert, wird die Legacy-Methode für die Auflistung von Dateien verwendet - RAR-Archiv \"%s\" ist ein nicht unterstütztes RAR v5-Archiv. + RAR-Archiv \"%s\" ist ein nicht unterstütztes RAR v5-Archiv. Sie müssen den FTP-Server neu starten, damit die Änderungen angewendet werden. Berechtigung verweigert Falsches Archiv-Passwort. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 008ba75afa..760e3c55f2 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -654,7 +654,7 @@ Πρόσβαση μόνο για ανάγνωση Χρήση παλαιού καταλόγου για root Εάν είναι ενεργή, χρησιμοποιεί την παλαιά μέθοδο για τη λίστα αρχείων - Το αρχείο RAR \"%s\" είναι μη υποστηριζόμενο αρχείο RAR v5. + Το αρχείο RAR \"%s\" είναι μη υποστηριζόμενο αρχείο RAR v5. Θα χρειαστεί να επανεκκινήσετε το διακομιστή FTP για να τεθούν σε ισχύ οι αλλαγές. Απορριφθείσα άδεια Λάθος κωδικός πρόσβασης αρχείου. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 88dd8466a3..121f46c7df 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -634,5 +634,6 @@ Solo necesita hacer esto una vez, hasta la siguiente vez que seleccione una nuev Archivos accedidos recientemente usando Amaze Archivos creados o modificados recientemente No se encontró un navegador disponible + El archivo RAR \"%s\" es un tipo de archivo RAR no compatible. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 715e61a756..893d8308e8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -653,7 +653,7 @@ Accès en lecture seule Utiliser l\'ancien affichage pour root Si cette option est activée, l\'ancienne méthode est utilisée pour lister les fichiers. - L\'archive RAR \"%s\" est une archive RAR v5 non prise en charge. + L\'archive RAR \"%s\" est une archive RAR v5 non prise en charge. Vous devrez redémarrer le serveur FTP pour appliquer les changements. Permission refusée Mot de passe erroné diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 61f74a4ec7..ddcead65d0 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -708,7 +708,7 @@ גישה לקריאה בלבד להשתמש בתצוגה מיושנת למשתמש על (root) אם פעיל, ייעשה שימוש בשיטה מיושנת להצגת קבצים - ארכיון ה־RAR ‏„%s” הוא ארכיון RAR v5 שאינו נתמך. + ארכיון ה־RAR ‏„%s” הוא ארכיון RAR v5 שאינו נתמך. יהיה עליך להפעיל את שרת ה־FTP מחדש כדי שהשינויים ייכנסו לתוקף. ההרשאה נדחתה סיסמת הארכיון שגויה. diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 3be818d478..75c8c0ce00 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -658,7 +658,7 @@ Csak olvasási hozzáférés Örökölt lista használata a gyökérhez Ha engedélyezve van, az örökölt módszer lesz használva a fájlok listázására - A(z) \"%s\" RAR egy nem támogatott RAR V5 archívum. + A(z) \"%s\" RAR egy nem támogatott RAR V5 archívum. A módosítások érvénybe léptetéséhez újra kell indítania az FTP-kiszolgálót. Hozzáférés megtagadva Rossz archívum jelszó. diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 5fbcaf17e0..5df8a1b72b 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -652,7 +652,7 @@ Akses baca-saja Gunakan tampilan lama untuk root Jika diaktifkan, gunakan metode lama untuk menampilkan berkas - Arsip RAR \"%s\" adalah arsip RAR v5 yang tidak didukung. + Arsip RAR \"%s\" adalah arsip RAR yang tidak didukung. Anda perlu memulai ulang server FTP agar perubahan terdampak. Perizinan ditolak Sandi arsip salah. diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 73a9bc7089..26113ec98d 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -661,7 +661,7 @@ altrimenti verrà controllata solo l\'occorrenza. Accesso di sola lettura Usa elenco legacy per root Se attivo, usa il metodo legacy per l\'elenco dei file - L\'archivio RAR \"%s\" non è un archivio RAR v5 supportato. + L\'archivio RAR \"%s\" non è un archivio RAR v5 supportato. Devi riavviare il server FTP affinché le modifiche abbiano effetto. Permesso negato Password archivio sbagliata. diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml index 1fce768cd6..05fd3ce2ec 100644 --- a/app/src/main/res/values-my/strings.xml +++ b/app/src/main/res/values-my/strings.xml @@ -653,7 +653,7 @@ Read-only access Use legacy listing for root If enabled, uses legacy method to list files - RAR archive \"%s\" is unsupported RAR v5 archive. + RAR archive \"%s\" is unsupported RAR archive. You will need to restart the FTP server for changes to take effect. Permission denied Wrong archive password. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6fcc304b60..366da96be6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -644,7 +644,7 @@ Dostęp tylko do odczytu Użyj starszej listy dla root Jeśli ta opcja jest włączona, używa starszej metody do wyświetlania listy plików - Archiwum RAR „%s” jest nieobsługiwanym archiwum RAR v5. + Archiwum RAR „%s” jest nieobsługiwanym archiwum RAR v5. Będziesz musiał ponownie uruchomić serwer FTP, aby zmiany zaczęły obowiązywać. Odmowa zezwolenia Nieprawidłowe hasło do archiwum. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index f77620244d..9459bab7b1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -653,7 +653,7 @@ Apenas leitura Usar listagem legada para root Se habilitado, usa método legado para listar arquivos - Arquivo RAR \"%s\" é um arquivo RAR v5 não suportado. + Arquivo RAR \"%s\" é um arquivo RAR v5 não suportado. Você precisa reiniciar o servidor FTP para aplicar as mudanças. Permissão negada Senha incorreta do arquivo. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index d3712ca1bd..f01787c8c1 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -653,7 +653,7 @@ Se inativa, será procurada qualquer correspondência. Apenas leitura Usar listagem legada para root Se habilitado, usa método legado para listar arquivos - Arquivo RAR \"%s\" é um arquivo RAR v5 não suportado. + Arquivo RAR \"%s\" é um arquivo RAR v5 não suportado. Você precisa reiniciar o servidor FTP para aplicar as mudanças. Permissão negada Senha incorreta do arquivo. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9d52f3504a..0b02a58f16 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -641,7 +641,7 @@ Доступ только для чтения Использовать устаревший вид список для root Если включить, будет использоваться устаревший метод для вывода списка файлов - Архив RAR \"%s\" не поддерживается архивом RAR v5. + Архив RAR \"%s\" не поддерживается архивом RAR v5. Чтобы изменения вступили в силу, вам нужно будет перезапустить FTP-сервер. В доступе отказано Неверный пароль архива. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b06d6219ce..441ce1692e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -657,7 +657,7 @@ Ana bilgisayar parmak izini güncellemeye devam edebilir veya SSH bağlantı aya Salt okunur erişim Root için eski listeyi kullan Etkinleştirilirse, dosyaları listelemek için eski yöntemi kullanır - \"%s\" RAR arşivi desteklenmeyen RAR v5 arşividir. + \"%s\" RAR arşivi desteklenmeyen RAR v5 arşividir. Değişikliklerin etkili olması için FTP sunucusunu yeniden başlatmanız gerekecek. İzin reddedildi Yanlış arşiv şifresi. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 762cec223c..c748e7398a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -606,5 +606,6 @@ Дозвіл відмовлено Не вірний пароль архіву. Вимкнено - + Архів RAR \"%s\" є архіву RAR, який не підтримується + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 90d3984a9c..52568d1f06 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -637,7 +637,7 @@ Truy cập chỉ đọc Sử dụng danh sách cũ cho root Nếu được bật, sẽ sử dụng phương pháp cũ để liệt kê các tệp - Lưu trữ RAR \"%s\" là lưu trữ RAR v5 không được hỗ trợ. + Lưu trữ RAR \"%s\" là lưu trữ RAR v5 không được hỗ trợ. Bạn sẽ cần khởi động lại máy chủ FTP để các thay đổi có hiệu lực. Quyền bị từ chối Mật khẩu lưu trữ sai. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 37065f0f7b..0d8f7513f3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -666,7 +666,7 @@ 只读访问权限 对根目录使用传统的文件列表方式 启用后,将使用传统方法列出文件 - 尚不支持RAR 压缩文件\"%s\",因为它是最新的第5版RAR格式。 + 尚不支持RAR 压缩文件\"%s\",因为它是最新的第5版RAR格式。 需要您重启文件传输协议服务器,使得新设定生效 拒绝许可 解压密码有误 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 49a150a5ea..ba1dcf480c 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -651,7 +651,7 @@ 唯讀模式 喺 Root模式使用舊有方式列出文件 開啟呢個設定嘅話,Amaze會用番原有嘅方法列出文件 - RAR 壓縮檔 \"%s\" 係未能支援嘅 RAR v5壓縮檔。 + RAR 壓縮檔 \"%s\" 係未能支援嘅 RAR壓縮檔。 要使設定生效,請重新啟動 FTP伺服器 拒絕存取 壓縮檔嘅密碼錯咗。 diff --git a/app/src/main/res/values-zh-rHN/strings.xml b/app/src/main/res/values-zh-rHN/strings.xml index 6702cd0a41..8c8cd75084 100644 --- a/app/src/main/res/values-zh-rHN/strings.xml +++ b/app/src/main/res/values-zh-rHN/strings.xml @@ -651,7 +651,7 @@ 唯讀模式 喺 Root模式使用舊有方式列出文件 開啟呢個設定嘅話,Amaze會用番原有嘅方法列出文件 - RAR 压缩文件\"%s\"是尚未支持的RAR v5 文件。 + RAR 压缩文件\"%s\"是尚未支持的 RAR文件。 要使設定生效,請重新啟動 FTP伺服器 权限不足 解压密码有误 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b26a193a7c..144d702bff 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -651,7 +651,7 @@ 唯讀存取 在 Root模式使用舊有方式列出文件 開啟此設定的話,Amaze會使用原有的方法列出文件 - RAR 壓縮檔 \"%s\" 是未能支援的 RAR v5壓縮檔。 + RAR 壓縮檔 \"%s\" 是未能支援的 RAR壓縮檔。 請重新啟動 FTP伺服器以套用新設定。 拒絕存取 錯誤的壓縮檔密碼。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6bcdb1595..056dd7afd1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -738,7 +738,7 @@ Read-only access Use legacy listing for root If enabled, uses legacy method to list files - RAR archive \"%s\" is unsupported RAR v5 archive. + RAR archive \"%s\" is an unsupported RAR archive. You will need to restart the FTP server for changes to take effect. Permission denied Wrong archive password. diff --git a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/PasswordProtectedRarTest.kt b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/PasswordProtectedRarTest.kt index bc43af985a..f4251c53db 100644 --- a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/PasswordProtectedRarTest.kt +++ b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/PasswordProtectedRarTest.kt @@ -21,6 +21,7 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.RarExtractor +import com.github.junrar.exception.BadRarArchiveException import org.apache.commons.compress.PasswordRequiredException import org.junit.Ignore import org.junit.Test @@ -29,7 +30,12 @@ import java.io.IOException open class PasswordProtectedRarTest : AbstractExtractorPasswordProtectedArchivesTest() { override fun extractorClass(): Class = RarExtractor::class.java - override fun expectedRootExceptionClass(): Array> = arrayOf(IOException::class.java, PasswordRequiredException::class.java) + override fun expectedRootExceptionClass(): Array> = + arrayOf( + IOException::class.java, + PasswordRequiredException::class.java, + BadRarArchiveException::class.java, + ) override val archiveType: String = "rar" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e36c9650f3..015ac5b1f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ kotlinxCoroutineTest = "1.11.0" kotlinStdlibJdk8 = "2.2.10" uiAutomator = "2.3.0" junit = "4.13.2" -slf4j = "2.0.18" +slf4j = "2.0.19" apacheSshd = "2.19.0" apacheMina = "2.2.9" mockito = "5.23.0" @@ -50,7 +50,7 @@ mockitoInline = "5.2.0" mockitoKotlin = "6.3.0" androidBilling = "9.1.0" cloudrailSiAndroid = "2.22.4" -junrar = "8.1.0" +junrar = "8.1.1" vectordrawableAnimated = "1.2.0" xz = "1.12" zip4j = "2.11.6" @@ -64,7 +64,7 @@ logbackAndroid = "3.0.0" logbackClassic = "1.6.1" commonsNet = "3.13.0" ftpserver = "1.2.1" -jsoup = "1.23.1" +jsoup = "1.23.2" gson = "2.14.0" rxAndroid = "2.1.1" rxJava = "2.2.21" From 774b8dcc9ee5b4dd03cf654a89aba3462a38ff72 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Mon, 7 Sep 2026 00:08:32 +0800 Subject: [PATCH 30/35] Changes per PR feedback - Refactorings to ZipHelperCallable for better exception logic --- .../asynctasks/compress/ZipHelperCallable.kt | 143 +++++++++--------- 1 file changed, 73 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt index 5cc5265887..ed44b616bc 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/ZipHelperCallable.kt @@ -22,6 +22,7 @@ package com.amaze.filemanager.asynchronous.asynctasks.compress import android.content.Context import android.net.Uri +import androidx.core.net.toUri import com.amaze.filemanager.R import com.amaze.filemanager.adapters.data.CompressedObjectParcelable import com.amaze.filemanager.application.AppConfig @@ -41,98 +42,100 @@ class ZipHelperCallable( ) : CompressedHelperCallable(goback) { private val context: WeakReference = WeakReference(c) - private val fileLocation: Uri = Uri.parse(realFileDirectory) + private val fileLocation: Uri = realFileDirectory.toUri() private val relativeDirectory: String? = dir @Throws(ArchiveException::class) @Suppress("ComplexMethod", "LongMethod") - public override fun addElements(elements: ArrayList) = + public override fun addElements(elements: ArrayList) { + val path = + fileLocation.path + ?: throw ArchiveException("Invalid zip file location: $fileLocation") try { - fileLocation.path?.run { - val zipfile = ZipFile(fileLocation.path) - if (!zipfile.isValidZipFile || zipfile.file.length() == 0L) { - throw ArchiveException("Zero byte or corrupt zip file") - } - val wholelist = filterValidEntryList(zipfile) - val strings = ArrayList() - for (entry in wholelist) { - val file = File(entry.path) - val y = - entry.path.let { - if (it.startsWith("/")) { - it.substring(1, it.length) - } else { - it - } + val zipfile = ZipFile(path) + if (!zipfile.isValidZipFile || zipfile.file.length() == 0L) { + throw ZipException("Zero byte or corrupt zip file") + } + val wholelist = filterValidEntryList(zipfile) + val strings = ArrayList() + for (entry in wholelist) { + val file = File(entry.path) + val y = + entry.path.let { + if (it.startsWith("/")) { + it.substring(1, it.length) + } else { + it } - if (relativeDirectory == null || relativeDirectory.trim { it <= ' ' }.isEmpty()) { - var path: String - var zipObj: CompressedObjectParcelable - if (file.parent == null || file.parent!!.isEmpty() || file.parent == "/") { - path = y - zipObj = + } + if (relativeDirectory == null || relativeDirectory.trim { it <= ' ' }.isEmpty()) { + var path: String + var zipObj: CompressedObjectParcelable + if (file.parent == null || file.parent!!.isEmpty() || file.parent == "/") { + path = y + zipObj = + CompressedObjectParcelable( + y, + entry.date, + entry.size, + entry.directory, + ) + } else { + path = y.substring(0, y.indexOf("/") + 1) + zipObj = + CompressedObjectParcelable( + path, + entry.date, + entry.size, + true, + ) + } + if (!strings.contains(path)) { + elements.add(zipObj) + strings.add(path) + } + } else { + if (file.parent != null && + ( + file.parent == relativeDirectory || + file.parent == "/$relativeDirectory" + ) + ) { + if (!strings.contains(y)) { + elements.add( CompressedObjectParcelable( y, entry.date, entry.size, entry.directory, - ) - } else { - path = y.substring(0, y.indexOf("/") + 1) - zipObj = + ), + ) + strings.add(y) + } + } else if (y.startsWith("$relativeDirectory/") && + (y.length > (relativeDirectory.length + 1)) + ) { + val path1 = y.substring(relativeDirectory.length + 1, y.length) + val index = relativeDirectory.length + 1 + path1.indexOf("/") + val path = y.substring(0, index + 1) + if (!strings.contains(path)) { + elements.add( CompressedObjectParcelable( - path, + y.substring(0, index + 1), entry.date, entry.size, true, - ) - } - if (!strings.contains(path)) { - elements.add(zipObj) - strings.add(path) - } - } else { - if (file.parent != null && - ( - file.parent == relativeDirectory || - file.parent == "/$relativeDirectory" + ), ) - ) { - if (!strings.contains(y)) { - elements.add( - CompressedObjectParcelable( - y, - entry.date, - entry.size, - entry.directory, - ), - ) - strings.add(y) - } - } else if (y.startsWith("$relativeDirectory/") && - (y.length > (relativeDirectory.length + 1)) - ) { - val path1 = y.substring(relativeDirectory.length + 1, y.length) - val index = relativeDirectory.length + 1 + path1.indexOf("/") - val path = y.substring(0, index + 1) - if (!strings.contains(path)) { - elements.add( - CompressedObjectParcelable( - y.substring(0, index + 1), - entry.date, - entry.size, - true, - ), - ) - strings.add(path) - } + strings.add(path) } } } - } ?: throw ArchiveException(null) + } } catch (e: ZipException) { throw ArchiveException("Zip file is corrupt", e) } + } private fun filterValidEntryList(zipFile: ZipFile): List { val retval = ArrayList() From 55b2734bf15adb53aa9b89b32e23f94c44157cf1 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Tue, 15 Sep 2026 14:30:09 +0900 Subject: [PATCH 31/35] Changes per PR feedback Remove string resource error_unsupported_v5_rar with error_unsupported_rar --- .../ftp/FtpServerSslKeyStoreProvider.kt | 20 +++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-el/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-he/strings.xml | 1 - app/src/main/res/values-hu-rHU/strings.xml | 1 - app/src/main/res/values-id/strings.xml | 1 - app/src/main/res/values-it-rIT/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt-rPT/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rHN/strings.xml | 1 - 20 files changed, 23 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt index b0601fe983..dcb79e1f75 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerSslKeyStoreProvider.kt @@ -1,3 +1,23 @@ +/* + * Copyright (C) 2014-2026 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.services.ftp import android.annotation.SuppressLint diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2517883f3d..2043bbcd82 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -635,7 +635,6 @@ امكانية توصل للقراءة فقط استخدام كشف التركة للدليل الرئيسي اذا تم اتاحته ، استخدم الطريقة المتوارثة لعرض الملفات - ملف تخزين RAR \" %s \" لا يتم دعم أرشيف RAR v5. سيكون من الضروري اعادة بدء وحدة خدمة FTP لكي تصبح التغييرات فعالة. تم رفض التصريح كلمة سرية أرشيف غير صحيحة. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 62a42990d8..3017555ab8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -650,7 +650,6 @@ Přístup pouze pro čtení Použít starší výpis souborů pro root Pokud je povoleno, používá k výpisu souborů starší metodu - RAR archív \"%s\" je nepodporovaný RAR v5 archív. Budete muset FTP server restartovat pro projevení změn. Oprávněění odmítnuto Nesprávné heslo archívu. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3daae890e3..e7179dd5a5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -659,7 +659,6 @@ andernfalls wird nach Treffern gesucht. Nur-Lesen Zugriff Alte Auflistung für Root verwenden Wenn aktiviert, wird die Legacy-Methode für die Auflistung von Dateien verwendet - RAR-Archiv \"%s\" ist ein nicht unterstütztes RAR v5-Archiv. Sie müssen den FTP-Server neu starten, damit die Änderungen angewendet werden. Berechtigung verweigert Falsches Archiv-Passwort. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 760e3c55f2..5297fa3082 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -654,7 +654,6 @@ Πρόσβαση μόνο για ανάγνωση Χρήση παλαιού καταλόγου για root Εάν είναι ενεργή, χρησιμοποιεί την παλαιά μέθοδο για τη λίστα αρχείων - Το αρχείο RAR \"%s\" είναι μη υποστηριζόμενο αρχείο RAR v5. Θα χρειαστεί να επανεκκινήσετε το διακομιστή FTP για να τεθούν σε ισχύ οι αλλαγές. Απορριφθείσα άδεια Λάθος κωδικός πρόσβασης αρχείου. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 121f46c7df..29422d69ab 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -634,6 +634,6 @@ Solo necesita hacer esto una vez, hasta la siguiente vez que seleccione una nuev Archivos accedidos recientemente usando Amaze Archivos creados o modificados recientemente No se encontró un navegador disponible - El archivo RAR \"%s\" es un tipo de archivo RAR no compatible. + El archivo RAR \"%s\" es un archivo RAR no compatible. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 893d8308e8..f6d72f064c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -653,7 +653,6 @@ Accès en lecture seule Utiliser l\'ancien affichage pour root Si cette option est activée, l\'ancienne méthode est utilisée pour lister les fichiers. - L\'archive RAR \"%s\" est une archive RAR v5 non prise en charge. Vous devrez redémarrer le serveur FTP pour appliquer les changements. Permission refusée Mot de passe erroné diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index ddcead65d0..6e939d2073 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -708,7 +708,6 @@ גישה לקריאה בלבד להשתמש בתצוגה מיושנת למשתמש על (root) אם פעיל, ייעשה שימוש בשיטה מיושנת להצגת קבצים - ארכיון ה־RAR ‏„%s” הוא ארכיון RAR v5 שאינו נתמך. יהיה עליך להפעיל את שרת ה־FTP מחדש כדי שהשינויים ייכנסו לתוקף. ההרשאה נדחתה סיסמת הארכיון שגויה. diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 75c8c0ce00..1389f8b49b 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -658,7 +658,6 @@ Csak olvasási hozzáférés Örökölt lista használata a gyökérhez Ha engedélyezve van, az örökölt módszer lesz használva a fájlok listázására - A(z) \"%s\" RAR egy nem támogatott RAR V5 archívum. A módosítások érvénybe léptetéséhez újra kell indítania az FTP-kiszolgálót. Hozzáférés megtagadva Rossz archívum jelszó. diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 5df8a1b72b..74ebbefe58 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -652,7 +652,6 @@ Akses baca-saja Gunakan tampilan lama untuk root Jika diaktifkan, gunakan metode lama untuk menampilkan berkas - Arsip RAR \"%s\" adalah arsip RAR yang tidak didukung. Anda perlu memulai ulang server FTP agar perubahan terdampak. Perizinan ditolak Sandi arsip salah. diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 26113ec98d..d889a645cb 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -661,7 +661,6 @@ altrimenti verrà controllata solo l\'occorrenza. Accesso di sola lettura Usa elenco legacy per root Se attivo, usa il metodo legacy per l\'elenco dei file - L\'archivio RAR \"%s\" non è un archivio RAR v5 supportato. Devi riavviare il server FTP affinché le modifiche abbiano effetto. Permesso negato Password archivio sbagliata. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 366da96be6..31719397e7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -644,7 +644,6 @@ Dostęp tylko do odczytu Użyj starszej listy dla root Jeśli ta opcja jest włączona, używa starszej metody do wyświetlania listy plików - Archiwum RAR „%s” jest nieobsługiwanym archiwum RAR v5. Będziesz musiał ponownie uruchomić serwer FTP, aby zmiany zaczęły obowiązywać. Odmowa zezwolenia Nieprawidłowe hasło do archiwum. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9459bab7b1..f3cdd3594b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -653,7 +653,6 @@ Apenas leitura Usar listagem legada para root Se habilitado, usa método legado para listar arquivos - Arquivo RAR \"%s\" é um arquivo RAR v5 não suportado. Você precisa reiniciar o servidor FTP para aplicar as mudanças. Permissão negada Senha incorreta do arquivo. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index f01787c8c1..e715b91a91 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -653,7 +653,6 @@ Se inativa, será procurada qualquer correspondência. Apenas leitura Usar listagem legada para root Se habilitado, usa método legado para listar arquivos - Arquivo RAR \"%s\" é um arquivo RAR v5 não suportado. Você precisa reiniciar o servidor FTP para aplicar as mudanças. Permissão negada Senha incorreta do arquivo. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0b02a58f16..87f850e272 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -641,7 +641,6 @@ Доступ только для чтения Использовать устаревший вид список для root Если включить, будет использоваться устаревший метод для вывода списка файлов - Архив RAR \"%s\" не поддерживается архивом RAR v5. Чтобы изменения вступили в силу, вам нужно будет перезапустить FTP-сервер. В доступе отказано Неверный пароль архива. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 441ce1692e..5fd225aeec 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -657,7 +657,6 @@ Ana bilgisayar parmak izini güncellemeye devam edebilir veya SSH bağlantı aya Salt okunur erişim Root için eski listeyi kullan Etkinleştirilirse, dosyaları listelemek için eski yöntemi kullanır - \"%s\" RAR arşivi desteklenmeyen RAR v5 arşividir. Değişikliklerin etkili olması için FTP sunucusunu yeniden başlatmanız gerekecek. İzin reddedildi Yanlış arşiv şifresi. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c748e7398a..0f4a20e9a5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -603,9 +603,9 @@ Очистити кеш Очищає вибрані програми відкриття файлів за замовчуванням Використовуйте застарілий список для root + Архів RAR \"%s\" є архівом RAR, який не підтримується. Дозвіл відмовлено Не вірний пароль архіву. Вимкнено - Архів RAR \"%s\" є архіву RAR, який не підтримується diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 52568d1f06..0b208714c2 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -637,7 +637,6 @@ Truy cập chỉ đọc Sử dụng danh sách cũ cho root Nếu được bật, sẽ sử dụng phương pháp cũ để liệt kê các tệp - Lưu trữ RAR \"%s\" là lưu trữ RAR v5 không được hỗ trợ. Bạn sẽ cần khởi động lại máy chủ FTP để các thay đổi có hiệu lực. Quyền bị từ chối Mật khẩu lưu trữ sai. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0d8f7513f3..112c5b4723 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -666,7 +666,7 @@ 只读访问权限 对根目录使用传统的文件列表方式 启用后,将使用传统方法列出文件 - 尚不支持RAR 压缩文件\"%s\",因为它是最新的第5版RAR格式。 + 尚不支持RAR 压缩文件\"%s\",因为它是最新的 RAR格式。 需要您重启文件传输协议服务器,使得新设定生效 拒绝许可 解压密码有误 diff --git a/app/src/main/res/values-zh-rHN/strings.xml b/app/src/main/res/values-zh-rHN/strings.xml index 8c8cd75084..61db2071f6 100644 --- a/app/src/main/res/values-zh-rHN/strings.xml +++ b/app/src/main/res/values-zh-rHN/strings.xml @@ -651,7 +651,6 @@ 唯讀模式 喺 Root模式使用舊有方式列出文件 開啟呢個設定嘅話,Amaze會用番原有嘅方法列出文件 - RAR 压缩文件\"%s\"是尚未支持的 RAR文件。 要使設定生效,請重新啟動 FTP伺服器 权限不足 解压密码有误 From 7b7086934b9ed5f9967f74da6ace528ddb134ba7 Mon Sep 17 00:00:00 2001 From: TranceLove Date: Thu, 24 Sep 2026 18:41:41 +0900 Subject: [PATCH 32/35] Build improvements - Pin Rust version to 1.98.1 at Github Actions - Add rust-toolchain.toml to pin Rust version on local builds - Github Action use JDK 21 - Bump NDK version to r30 LTS - Bump Github Action versions and align arguments with reference from release/4.0 branch --- .github/workflows/android-build.yml | 52 ++++++++++--------- .../android-debug-artifact-ondemand.yml | 31 ++++++----- .../android-debug-artifact-release.yml | 29 +++++------ .github/workflows/android-feature.yml | 48 ++++++++--------- .github/workflows/android-main.yml | 27 +++++----- .github/workflows/fastlane-pr.yml | 2 +- app/build.gradle | 6 +-- commons_compress_7z/build.gradle | 4 +- file_operations/build.gradle | 13 +++-- file_operations/rust-toolchain.toml | 4 ++ gradle/libs.versions.toml | 3 +- gradle/setupRustAndroidLocal.gradle | 46 ++++++++++++++++ portscanner/build.gradle | 6 +-- testShared/build.gradle | 6 +-- 14 files changed, 165 insertions(+), 112 deletions(-) create mode 100644 file_operations/rust-toolchain.toml create mode 100644 gradle/setupRustAndroidLocal.gradle diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index d59a924175..e16cb27486 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -15,24 +15,21 @@ jobs: name: Check spotless runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Set up NDK - uses: nttld/setup-ndk@afb4c9964b521afb97c864b7d40b11e6911bd410 + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - ndk-version: r28c + ndk-version: r30 link-to-sdk: true - local-cache: true - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -60,27 +57,32 @@ jobs: echo "❌ .cargo/config.toml missing" exit 1 fi + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 - name: Check formatting using spotless - uses: gradle/actions/setup-gradle@v3 - with: - arguments: spotlessCheck --stacktrace + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + run: ./gradlew spotlessCheck --stacktrace build: name: Build debug runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + java-version: 21 + - name: Set up NDK + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - toolchain: stable + ndk-version: r30 + link-to-sdk: true + - name: Install Rust + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -108,7 +110,7 @@ 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 --stacktrace \ No newline at end of file + run: ./gradlew assembleDebug --stacktrace \ No newline at end of file diff --git a/.github/workflows/android-debug-artifact-ondemand.yml b/.github/workflows/android-debug-artifact-ondemand.yml index 4dd72ba16b..9abc05a58a 100644 --- a/.github/workflows/android-debug-artifact-ondemand.yml +++ b/.github/workflows/android-debug-artifact-ondemand.yml @@ -18,7 +18,7 @@ jobs: reactions: '+1' - name: Github API Request id: request - uses: octokit/request-action@v2.0.2 + uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae with: route: ${{ github.event.issue.pull_request.url }} env: @@ -30,28 +30,27 @@ jobs: echo "::set-output name=repo_clone_url::${{ fromJson(steps.request.outputs.data).head.repo.clone_url }}" echo "::set-output name=repo_ssh_url::${{ fromJson(steps.request.outputs.data).head.repo.ssh_url }}" - name: Checkout PR Branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} repository: ${{fromJson(steps.request.outputs.data).head.repo.full_name}} ref: ${{fromJson(steps.request.outputs.data).head.ref}} - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Set up NDK - uses: nttld/setup-ndk@afb4c9964b521afb97c864b7d40b11e6911bd410 + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - ndk-version: r28c + ndk-version: r30 link-to-sdk: true - local-cache: true - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.98.1 with: toolchain: stable - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -79,19 +78,19 @@ 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 --stacktrace + run: ./gradlew assembleDebug --stacktrace - name: Upload fdroid artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 id: artifact-upload-fdroid with: name: Amaze-Fdroid-debug path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk if-no-files-found: error - name: Upload play artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 id: artifact-upload-play with: name: Amaze-Play-debug @@ -105,7 +104,7 @@ jobs: echo "PR_Number: $PR_NUMBER" echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_OUTPUT" - name: Notify the user with a comment once the APK is uploaded - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 with: issue-number: ${{ steps.find-pr-id.outputs.PR_NUMBER }} body: | diff --git a/.github/workflows/android-debug-artifact-release.yml b/.github/workflows/android-debug-artifact-release.yml index e837df793f..18f570e60d 100644 --- a/.github/workflows/android-debug-artifact-release.yml +++ b/.github/workflows/android-debug-artifact-release.yml @@ -8,24 +8,21 @@ jobs: apk: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Set up NDK - uses: nttld/setup-ndk@afb4c9964b521afb97c864b7d40b11e6911bd410 + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - ndk-version: r28c + ndk-version: r30 link-to-sdk: true - local-cache: true - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -53,17 +50,17 @@ 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 --stacktrace - name: Upload fdroid artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Amaze-Fdroid-debug path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk - name: Upload play artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Amaze-Play-debug path: app/build/outputs/apk/play/debug/app-play-debug.apk diff --git a/.github/workflows/android-feature.yml b/.github/workflows/android-feature.yml index 23aca0ec1b..e58870d567 100755 --- a/.github/workflows/android-feature.yml +++ b/.github/workflows/android-feature.yml @@ -16,42 +16,44 @@ jobs: name: Check spotless runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Set up NDK id: setup-ndk - uses: nttld/setup-ndk@afb4c9964b521afb97c864b7d40b11e6911bd410 + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - ndk-version: r28c + ndk-version: r30 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 --stacktrace env: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + run: ./gradlew spotlessCheck build: name: Build debug and run Jacoco tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + java-version: 21 + - name: Set up NDK + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - toolchain: stable + ndk-version: r30 + link-to-sdk: true + - name: Install Rust + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -79,11 +81,9 @@ 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 --stacktrace + run: ./gradlew assembleDebug --stacktrace - name: Run test cases - uses: gradle/actions/setup-gradle@v3 - with: - arguments: jacocoTestPlayDebugUnitTestReport --stacktrace --info \ No newline at end of file + run: ./gradlew jacocoTestPlayDebugUnitTestReport --stacktrace --info \ No newline at end of file diff --git a/.github/workflows/android-main.yml b/.github/workflows/android-main.yml index e1dcf8055b..2b9e41c57f 100644 --- a/.github/workflows/android-main.yml +++ b/.github/workflows/android-main.yml @@ -17,20 +17,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Set up NDK - uses: nttld/setup-ndk@afb4c9964b521afb97c864b7d40b11e6911bd410 + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 with: - ndk-version: r28c + ndk-version: r30 link-to-sdk: true - local-cache: true - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Check formatting using spotless + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} run: ./gradlew spotlessCheck build: @@ -40,15 +41,13 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust dependencies uses: actions/cache@v5 with: @@ -108,15 +107,13 @@ jobs: steps: - name: checkout uses: actions/checkout@v6 - - name: Java 17 + - name: Java 21 uses: actions/setup-java@v5 with: distribution: "temurin" - java-version: 17 + java-version: 21 - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust dependencies uses: actions/cache@v5 with: diff --git a/.github/workflows/fastlane-pr.yml b/.github/workflows/fastlane-pr.yml index 6dabbc40b3..02a99cd3e5 100644 --- a/.github/workflows/fastlane-pr.yml +++ b/.github/workflows/fastlane-pr.yml @@ -13,7 +13,7 @@ jobs: # required to run on Linux because this is a docker container action runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2 with: fastlaneDir: ./fastlane/metadata/android # optional diff --git a/app/build.gradle b/app/build.gradle index ebe36ad437..e2b1f7d9a4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,8 +99,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) + targetCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) } testOptions { @@ -267,7 +267,7 @@ dependencies { } kotlin { - jvmToolchain(21) + jvmToolchain(libs.versions.jvmTarget.get().toInteger()) } configurations.configureEach { diff --git a/commons_compress_7z/build.gradle b/commons_compress_7z/build.gradle index 5f597d1d1d..a4df2c7acd 100644 --- a/commons_compress_7z/build.gradle +++ b/commons_compress_7z/build.gradle @@ -19,8 +19,8 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) + targetCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) } } diff --git a/file_operations/build.gradle b/file_operations/build.gradle index 3f5cf32335..78859f395e 100644 --- a/file_operations/build.gradle +++ b/file_operations/build.gradle @@ -2,6 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'com.google.devtools.ksp' apply plugin: 'org.mozilla.rust-android-gradle.rust-android' +apply from: "../gradle/setupRustAndroidLocal.gradle" android { namespace "com.amaze.filemanager.fileoperations" @@ -27,8 +28,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) + targetCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) } testOptions { @@ -53,7 +54,7 @@ cargo { } kotlin { - jvmToolchain(21) + jvmToolchain(libs.versions.jvmTarget.get().toInteger()) } // Ensure Rust libraries are built before Android tasks @@ -128,4 +129,10 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation libs.androidX.appcompat +} + +tasks.matching { + it.name.startsWith('cargoBuild') +}.configureEach { + it.dependsOn 'setupRustAndroidLocal' } \ No newline at end of file diff --git a/file_operations/rust-toolchain.toml b/file_operations/rust-toolchain.toml new file mode 100644 index 0000000000..9ef1e31ca5 --- /dev/null +++ b/file_operations/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.1" +profile = "default" +targets = ["aarch64-linux-android", "armv7-linux-androideabi", "i686-linux-android", "x86_64-linux-android"] \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 015ac5b1f5..22f1b82beb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] +jvmTarget = "21" compileSdk = "36" minSdk = "23" targetSdk = "36" kotlin = "2.2.20" -ndk = "28.2.13676358" #r28c +ndk = "30.0.16248370" #r30 jacocoAndroid = "0.2.1" gradle = "8.13.2" diff --git a/gradle/setupRustAndroidLocal.gradle b/gradle/setupRustAndroidLocal.gradle new file mode 100644 index 0000000000..51ec80f09c --- /dev/null +++ b/gradle/setupRustAndroidLocal.gradle @@ -0,0 +1,46 @@ +tasks.register("setupRustAndroidLocal") { + + onlyIf { System.getenv("GITHUB_ACTIONS") == null } + + doFirst { + println("Setting up Rust for Android build...") + } + + // Detect existence of rustup. + def rustupExists = { + + // First check common installation path + def isWindows = System.getProperty("os.name").toLowerCase().contains("windows") + def home = System.getProperty("user.home") + def rustupPath = isWindows + ? new File(home, ".cargo/bin/rustup.exe") + : new File(home, ".cargo/bin/rustup") + + if (rustupPath.exists() && rustupPath.canExecute()) { + return true + } + + // If not, check on user $PATH/%PATH% + try { + def process = new ProcessBuilder("rustup") + .redirectErrorStream(true) + .start() + process.waitFor() + return process.exitValue() == 0 + } catch (IOException ignored) { + return false + } + }() + + if (!rustupExists) { + throw new GradleException("rustup is not installed or not found in PATH. Please install rustup from https://rustup.rs/ and ensure it is accessible.") + } + + doLast { + exec { + workingDir file(rootProject.project("file_operations").projectDir) + commandLine("env", "bash", "setup_rust_android.sh") + } + println("Rust targets for Android have been set up successfully.") + } +} \ No newline at end of file diff --git a/portscanner/build.gradle b/portscanner/build.gradle index cc0718f0b6..bd38c10ac1 100644 --- a/portscanner/build.gradle +++ b/portscanner/build.gradle @@ -22,13 +22,13 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) + targetCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) } } kotlin { - jvmToolchain(21) + jvmToolchain(libs.versions.jvmTarget.get().toInteger()) } dependencies { diff --git a/testShared/build.gradle b/testShared/build.gradle index 12d423a853..cda0911307 100644 --- a/testShared/build.gradle +++ b/testShared/build.gradle @@ -23,8 +23,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) + targetCompatibility JavaVersion.toVersion(libs.versions.jvmTarget.get().toInteger()) } testOptions { @@ -36,7 +36,7 @@ android { } kotlin { - jvmToolchain(21) + jvmToolchain(libs.versions.jvmTarget.get().toInteger()) } dependencies { From 404f189a767540a987ce5abc34a84d3e1e87b79f Mon Sep 17 00:00:00 2001 From: TranceLove Date: Mon, 28 Sep 2026 14:10:22 +0900 Subject: [PATCH 33/35] Changes per PR feedback Add check for NDK and NDK version in setup_rust_android.sh --- file_operations/setup_rust_android.sh | 48 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/file_operations/setup_rust_android.sh b/file_operations/setup_rust_android.sh index 132c3b9551..56dba62ab0 100755 --- a/file_operations/setup_rust_android.sh +++ b/file_operations/setup_rust_android.sh @@ -10,6 +10,10 @@ set -e echo "🦀 Setting up Rust for Android development..." +# Resolve paths relative to this script so it can be run from any working directory. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERSION_CATALOG="$SCRIPT_DIR/../gradle/libs.versions.toml" + # Function to detect operating system detect_os() { case "$(uname -s)" in @@ -35,14 +39,10 @@ find_android_sdk() { fi } -# Function to find the latest NDK version -find_ndk_version() { - local sdk_path="$1" - local ndk_dir="$sdk_path/ndk" - - if [ -d "$ndk_dir" ]; then - # Find the latest version (highest version number) - ls -1 "$ndk_dir" | sort -V | tail -n 1 +# Function to extract required NDK version from version catalog +extract_ndk_version() { + if [ -f "$VERSION_CATALOG" ]; then + grep -E '^ndk[[:space:]]*=' "$VERSION_CATALOG" | head -n 1 | cut -d '"' -f 2 else echo "" fi @@ -51,13 +51,12 @@ find_ndk_version() { # Function to extract API level from build.gradle and version catalog extract_api_level() { local build_gradle="build.gradle" - local version_catalog="../gradle/libs.versions.toml" - + # First, check if build.gradle uses version catalog if [ -f "$build_gradle" ] && grep -q "libs.versions.minSdk" "$build_gradle"; then # Extract from version catalog - if [ -f "$version_catalog" ]; then - grep "^minSdk" "$version_catalog" | cut -d '"' -f 2 + if [ -f "$VERSION_CATALOG" ]; then + grep "^minSdk" "$VERSION_CATALOG" | cut -d '"' -f 2 else echo "21" # Default fallback fi @@ -88,15 +87,30 @@ fi echo "📁 Found Android SDK: $ANDROID_SDK_PATH" -# Find NDK version -NDK_VERSION=$(find_ndk_version "$ANDROID_SDK_PATH") +# Find required NDK version from version catalog +NDK_VERSION=$(extract_ndk_version) if [ -z "$NDK_VERSION" ]; then - echo "❌ NDK not found in $ANDROID_SDK_PATH/ndk" - echo " Please install NDK through Android Studio SDK Manager" + echo "❌ Could not read required NDK version from $VERSION_CATALOG" + echo " Please ensure the 'ndk' version is defined under [versions]" + exit 1 +fi + +NDK_DIR="$ANDROID_SDK_PATH/ndk" +if [ ! -d "$NDK_DIR" ] || [ -z "$(ls -A "$NDK_DIR" 2>/dev/null)" ]; then + echo "❌ No NDK installation found in $NDK_DIR" + echo " Please install NDK version $NDK_VERSION first via Android Studio SDK Manager" + exit 1 +fi + +if [ ! -d "$NDK_DIR/$NDK_VERSION" ]; then + echo "❌ NDK is installed, but required version $NDK_VERSION is missing." + echo " Found installed versions:" + ls -1 "$NDK_DIR" | sed 's/^/ - /' + echo " Please install NDK version $NDK_VERSION first via Android Studio SDK Manager" exit 1 fi -echo "🔨 Found NDK version: $NDK_VERSION" +echo "🔨 Found required NDK version: $NDK_VERSION" # Extract API level API_LEVEL=$(extract_api_level) From 95aa5bc590cbdfece2b83404e0b8f094c7fd34a8 Mon Sep 17 00:00:00 2001 From: Vishnu Sanal T <50027064+VishnuSanal@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:20:29 +0530 Subject: [PATCH 34/35] fix rustup exit code issue and update CONTRIBUTING.md (#4740) Signed-off-by: VishnuSanal --- CONTRIBUTING.md | 1 + gradle/setupRustAndroidLocal.gradle | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 180c4af723..a265779618 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,7 @@ If we feel your PR is a significant help to us, we'll award you a bounty with an - Please follow [Android/JAVA code style](https://source.android.com/docs/setup/contribute/code-style) for writing any code. Please see [this](https://github.com/TeamAmaze/AmazeFileManager/issues/986) issue too. - Also, follow [Android Material Design guidelines](https://m3.material.io/get-started) in case you make changes to any UI element. - The `file_operations` module contains native code in Rust. Please see [file_operations/src/main/rust/README.md](file_operations/src/main/rust/README.md) for more details on how to work with it. + - You would want to install NDK version `30.0.16248370` from Android Studio to build the project ## PS: please make sure to diff --git a/gradle/setupRustAndroidLocal.gradle b/gradle/setupRustAndroidLocal.gradle index 51ec80f09c..ecffe4542c 100644 --- a/gradle/setupRustAndroidLocal.gradle +++ b/gradle/setupRustAndroidLocal.gradle @@ -22,7 +22,7 @@ tasks.register("setupRustAndroidLocal") { // If not, check on user $PATH/%PATH% try { - def process = new ProcessBuilder("rustup") + def process = new ProcessBuilder("rustup", "--version") .redirectErrorStream(true) .start() process.waitFor() From 6cb301513877d5508720a93ee522c4deee12bc04 Mon Sep 17 00:00:00 2001 From: VishnuSanal Date: Thu, 15 Oct 2026 16:28:13 +0530 Subject: [PATCH 35/35] v3.11.4 -> 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/126.txt | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/126.txt diff --git a/app/build.gradle b/app/build.gradle index e2b1f7d9a4..b448036717 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,8 +30,8 @@ android { applicationId "com.amaze.filemanager" minSdkVersion libs.versions.minSdk.get().toInteger() targetSdkVersion libs.versions.targetSdk.get().toInteger() - versionCode 125 - versionName "3.11.3" + versionCode 126 + versionName "3.11.4" multiDexEnabled true vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/res/values/translators.xml b/app/src/main/res/values/translators.xml index 410070b3bb..7492ed30a7 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.3 + v3.11.4 Arpit Khurana Vishal Nehra Emmanuel Messulam diff --git a/fastlane/metadata/android/en-US/changelogs/126.txt b/fastlane/metadata/android/en-US/changelogs/126.txt new file mode 100644 index 0000000000..52c95a2349 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/126.txt @@ -0,0 +1,3 @@ +* Library upgrades and SDK 36 adaptation +* Improve FTP server security +* Build & CI improvements \ No newline at end of file