From bac92745a82471f8d26d802880f09aefb10765b5 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 5 Apr 2025 00:19:05 +0800 Subject: [PATCH 01/15] FTP server minor improvements - Replace EventBus with kotlinx.coroutines - FtpServerFragment update code to recommended --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e4f84059f..78cf688d9c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ androidXArchCoreTest = "2.2.0" androidXMultidex = "2.0.1" autoService = "1.1.1" kotlinxCoroutines = "1.7.3" -kotlinStdlibJdk8 = "1.9.20" +kotlinStdlibJdk8 = "1.9.25" uiAutomator = "2.3.0" junit = "4.13.2" slf4j = "2.0.13" From 4182891eb14694a01c2d2ebce1dbdbf266e4552b Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 1 Feb 2026 09:49:20 +0800 Subject: [PATCH 02/15] Migrate ftpserver to separate module This enables future integration of other file services more easier. --- app/build.gradle | 2 + app/play/release/output-metadata.json | 21 + .../GetWebdavHostCertificateTaskCallable.kt | 94 ++++ .../typeconverters/JsonTypeConverter.kt | 47 ++ ...etWebdavHostCertificateTaskCallableTest.kt | 37 ++ app/src/test/resources/ftpusers | 1 + .../filesystem/cloud/CloudStreamServer2.kt | 137 ++++++ ftpserver/.gitignore | 1 + ftpserver/build.gradle | 74 +++ ftpserver/consumer-rules.pro | 0 ftpserver/proguard-rules.pro | 21 + ftpserver/src/main/AndroidManifest.xml | 22 + .../ftpserver/FtpServerProvider.kt | 127 +++++ .../filemanager/ftpserver/commands/AVBL.kt | 137 ++++++ .../filemanager/ftpserver/commands/FEAT.kt | 49 ++ .../filemanager/ftpserver/commands/PWD.kt | 64 +++ .../filesystem/AndroidFileSystemFactory.kt | 37 ++ .../ftpserver/filesystem/AndroidFtpFile.kt | 139 ++++++ .../filesystem/AndroidFtpFileSystemView.kt | 156 ++++++ .../filesystem/RootFileSystemFactory.kt | 32 ++ .../filesystem/RootFileSystemView.kt | 211 +++++++++ .../ftpserver/filesystem/RootFtpFile.kt | 130 +++++ .../ftpserver/service/FtpCipherSuites.kt | 71 +++ .../service/FtpCommandFactoryFactory.kt | 51 ++ .../ftpserver/service/FtpEventBus.kt | 49 ++ .../ftpserver/service/FtpPreferences.kt | 130 +++++ .../ftpserver/service/FtpReceiver.kt | 68 +++ .../ftpserver/service/FtpServerEngine.kt | 228 +++++++++ .../ftpserver/service/FtpServerService.kt | 204 ++++++++ .../ftpserver/ui/BaseFtpServerFragment.kt | 447 ++++++++++++++++++ .../ftpserver/ui/FtpServerNotification.kt | 159 +++++++ .../src/main/res/drawable/ic_clear_all.xml | 9 + .../main/res/drawable/ic_eye_grey600_24dp.xml | 9 + .../src/main/res/drawable/ic_ftp_dark.xml | 9 + .../src/main/res/drawable/ic_ftp_light.xml | 9 + .../src/main/res/layout/fragment_ftp.xml | 153 ++++++ .../src/main/res/menu/ftp_server_menu.xml | 27 ++ ftpserver/src/main/res/values/colors.xml | 4 + ftpserver/src/main/res/values/strings.xml | 62 +++ plan-modularizeFtpServer.prompt.md | 128 +++++ server-core/build.gradle | 43 ++ server-core/consumer-rules.pro | 1 + server-core/proguard-rules.pro | 1 + server-core/src/main/AndroidManifest.xml | 3 + .../amaze/filemanager/server/FileServer.kt | 73 +++ .../amaze/filemanager/server/ServerEvent.kt | 44 ++ .../filemanager/server/ServerNotification.kt | 64 +++ .../filemanager/server/ServerPreferences.kt | 102 ++++ .../filemanager/server/ServerRegistry.kt | 112 +++++ settings.gradle | 2 + 50 files changed, 3801 insertions(+) create mode 100644 app/play/release/output-metadata.json create mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt create mode 100644 app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt create mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt create mode 100644 app/src/test/resources/ftpusers create mode 100644 file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt create mode 100644 ftpserver/.gitignore create mode 100644 ftpserver/build.gradle create mode 100644 ftpserver/consumer-rules.pro create mode 100644 ftpserver/proguard-rules.pro create mode 100644 ftpserver/src/main/AndroidManifest.xml create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt create mode 100644 ftpserver/src/main/res/drawable/ic_clear_all.xml create mode 100644 ftpserver/src/main/res/drawable/ic_eye_grey600_24dp.xml create mode 100644 ftpserver/src/main/res/drawable/ic_ftp_dark.xml create mode 100644 ftpserver/src/main/res/drawable/ic_ftp_light.xml create mode 100644 ftpserver/src/main/res/layout/fragment_ftp.xml create mode 100644 ftpserver/src/main/res/menu/ftp_server_menu.xml create mode 100644 ftpserver/src/main/res/values/colors.xml create mode 100644 ftpserver/src/main/res/values/strings.xml create mode 100644 plan-modularizeFtpServer.prompt.md create mode 100644 server-core/build.gradle create mode 100644 server-core/consumer-rules.pro create mode 100644 server-core/proguard-rules.pro create mode 100644 server-core/src/main/AndroidManifest.xml create mode 100644 server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt create mode 100644 server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt create mode 100644 server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt create mode 100644 server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt create mode 100644 server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt diff --git a/app/build.gradle b/app/build.gradle index 3c380ce487..1542748ca6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -248,6 +248,8 @@ dependencies { implementation project(':commons_compress_7z') implementation project(':file_operations') implementation project(':portscanner') + implementation project(':server-core') + implementation project(':ftpserver') implementation libs.kotlin.stdlib.jdk8 implementation libs.acra.core diff --git a/app/play/release/output-metadata.json b/app/play/release/output-metadata.json new file mode 100644 index 0000000000..33c1e87580 --- /dev/null +++ b/app/play/release/output-metadata.json @@ -0,0 +1,21 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.amaze.filemanager", + "variantName": "playRelease", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 121, + "versionName": "3.10", + "outputFile": "app-play-release.apk" + } + ], + "elementType": "File", + "minSdkVersionForDexing": 26 +} \ No newline at end of file diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt new file mode 100644 index 0000000000..1db8684795 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2014-2024 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.webdav + +import android.annotation.SuppressLint +import com.amaze.filemanager.utils.toHex +import okhttp3.OkHttpClient +import okhttp3.Request +import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator +import org.bouncycastle.util.io.pem.PemWriter +import java.io.StringWriter +import java.security.MessageDigest +import java.security.cert.X509Certificate +import java.util.concurrent.Callable +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +class GetWebdavHostCertificateTaskCallable( + private val url: String, + private val firstContact: Boolean = false, +) : Callable> { + private lateinit var serverCertificate: X509Certificate + + override fun call(): Pair { + val trustManager = createTrustManagerForGettingCertificate() + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(trustManager), null) + + val client = + OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .hostnameVerifier { _, _ -> true }.build() + + client.newCall(Request.Builder().head().url(url).build()).execute() + + return Pair(sha256(serverCertificate), toPemString(serverCertificate)) + } + + internal fun sha256(of: X509Certificate): String { + val md = MessageDigest.getInstance("SHA-256") + val der = of.encoded + md.update(der) + val digest = md.digest() + + return digest.toHex(":") + } + + internal fun toPemString(from: X509Certificate): String { + val stringWriter = StringWriter() + PemWriter(stringWriter).runCatching { + val pemGenerator = JcaMiscPEMGenerator(from) + writeObject(pemGenerator) + } + stringWriter.close() + return stringWriter.toString() + } + + @SuppressLint("CustomX509TrustManager") + private fun createTrustManagerForGettingCertificate(): X509TrustManager { + return object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + + override fun checkClientTrusted( + chain: Array?, + authType: String?, + ) = Unit + + override fun checkServerTrusted( + chain: Array?, + authType: String?, + ) { + serverCertificate = chain!![0] + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt b/app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt new file mode 100644 index 0000000000..b83c8c38bc --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt @@ -0,0 +1,47 @@ +/* + * 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.database.typeconverters + +import androidx.room.TypeConverter +import org.json.JSONObject + +/** + * [TypeConverter] between [JSONObject] as object and String representation. + */ +object JsonTypeConverter { + /** + * Convert from [JSONObject] to string. + */ + @JvmStatic + @TypeConverter + fun fromJsonObject(value: JSONObject): String { + return value.toString() + } + + /** + * Convert from string to [JSONObject]. + */ + @JvmStatic + @TypeConverter + fun fromJsonString(value: String): JSONObject { + return JSONObject(value) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt new file mode 100644 index 0000000000..780dacaf5f --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2024 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.webdav + +import org.junit.Assert.assertNotNull +import org.junit.Ignore +import org.junit.Test + +@Ignore +class GetWebdavHostCertificateTaskCallableTest { + @Test + fun testConnect() { + val callable = GetWebdavHostCertificateTaskCallable("https://httpbin.org", false) + val result = callable.call() + assertNotNull(result) + System.err.println(result.first) + System.err.println(result.second) + } +} diff --git a/app/src/test/resources/ftpusers b/app/src/test/resources/ftpusers new file mode 100644 index 0000000000..4ec37cb749 --- /dev/null +++ b/app/src/test/resources/ftpusers @@ -0,0 +1 @@ +ftpuser:passw0rD diff --git a/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt b/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt new file mode 100644 index 0000000000..a34f8e1283 --- /dev/null +++ b/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt @@ -0,0 +1,137 @@ +/* + * 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.fileoperations.filesystem.cloud + +abstract class CloudStreamServer2(private val port: Int) { +// +// protected val socket: IoAcceptor = NioSocketAcceptor() +// +// companion object { +// @JvmStatic +// private val LOG = LoggerFactory.getLogger(CloudStreamServer2::class.java) +// +// @JvmStatic +// protected val gmtFormat: DateFormat = SimpleDateFormat( +// "E, d MMM yyyy HH:mm:ss 'GMT'", +// Locale.US +// ).also { +// it.timeZone = TimeZone.getTimeZone("GMT") +// } +// +// /** Some HTTP response status codes */ +// const val HTTP_OK = "200 OK" +// const val HTTP_PARTIALCONTENT = "206 Partial Content" +// const val HTTP_RANGE_NOT_SATISFIABLE = "416 Requested Range Not Satisfiable" +// const val HTTP_REDIRECT = "301 Moved Permanently" +// const val HTTP_FORBIDDEN = "403 Forbidden" +// const val HTTP_NOTFOUND = "404 Not Found" +// const val HTTP_BADREQUEST = "400 Bad Request" +// const val HTTP_INTERNALERROR = "500 Internal Server Error" +// const val HTTP_NOTIMPLEMENTED = "501 Not Implemented" +// +// /** Common mime types for dynamic content */ +// const val MIME_PLAINTEXT = "text/plain" +// const val MIME_HTML = "text/html" +// const val MIME_DEFAULT_BINARY = "application/octet-stream" +// const val MIME_XML = "text/xml" +// } +// +// // ================================================== +// // API parts +// // ================================================== +// abstract fun serve( +// uri: String, +// method: String, +// header: Properties, +// params: Properties, +// files: Properties +// ): Response +// +// init { +// socket.filterChain.addLast("logger", LoggingFilter(javaClass.simpleName)) +// socket.filterChain.addLast( +// "message", +// ProtocolCodecFilter(HttpResponseEncoder(), HttpRequestDecoder()) +// ) +// socket.handler = HttpSessionHandler() +// socket.sessionConfig.bothIdleTime = 10 +// socket.sessionConfig.readBufferSize = 8192 +// socket.bind(InetSocketAddress(port)) +// } +// +// fun stop() { +// socket.unbind() +// } +// +// /** +// * Since CloudStreamServer and Streamer both uses the same port, shutdown the Streamer before +// * acquiring the port. +// * +// * @return ServerSocket +// */ +// @Throws(IOException::class) +// private fun tryBind(port: Int): ServerSocket { +// val socket: ServerSocket = try { +// ServerSocket(port) +// } catch (ifPortIsOccupiedByStreamer: BindException) { +// Streamer.getInstance().stop() +// ServerSocket(port) +// } +// return socket +// } +// +// /** HTTP response. Return one of these from serve(). */ +// data class Response( +// private val status: String, +// private val mimeType: String? = null, +// private var data: CloudStreamSource? +// ) { +// /** Default constructor: response = HTTP_OK, data = mime = 'null' */ +// constructor() : this(HTTP_OK, null, null) +// +// /** Adds given line to the header. */ +// fun addHeader(name: String, value: String) { +// header[name] = value +// } +// +// /** Headers for the HTTP response. Use addHeader() to add lines. */ +// var header = Properties() +// } +// +// class HttpRequestDecoder : TextLineDecoder(Charsets.UTF_8, LineDelimiter.AUTO) { +// override fun writeText(session: IoSession?, text: String?, out: ProtocolDecoderOutput?) { +// LOG.debug(text) +// throw NotImplementedError() +// } +// } +// +// class HttpResponseEncoder : ProtocolEncoderAdapter() { +// override fun encode(session: IoSession, message: Any, out: ProtocolEncoderOutput) { +// TODO("Not yet implemented") +// } +// } +// +// class HttpSessionHandler : IoHandlerAdapter() { +// override fun messageReceived(session: IoSession?, message: Any?) { +// super.messageReceived(session, message) +// } +// } +} diff --git a/ftpserver/.gitignore b/ftpserver/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/ftpserver/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ftpserver/build.gradle b/ftpserver/build.gradle new file mode 100644 index 0000000000..de246574fc --- /dev/null +++ b/ftpserver/build.gradle @@ -0,0 +1,74 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "com.amaze.filemanager.ftpserver" + compileSdk libs.versions.compileSdk.get().toInteger() + + defaultConfig { + minSdk libs.versions.minSdk.get().toInteger() + targetSdk libs.versions.targetSdk.get().toInteger() + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true + buildConfig true + } +} + +dependencies { + implementation project(":server-core") + + implementation libs.androidX.core + implementation libs.androidX.appcompat + implementation libs.androidX.fragment + implementation libs.androidX.preference + implementation libs.androidX.cardview + implementation libs.androidX.constraintLayout + implementation libs.androidX.material + implementation 'androidx.documentfile:documentfile:1.0.1' + + implementation 'org.greenrobot:eventbus:3.3.1' + implementation libs.apache.mina.core + implementation libs.apache.ftpserver.ftplet.api + implementation libs.apache.ftpserver.core + + implementation libs.slf4j.api + implementation libs.materialdialogs.core + implementation libs.materialdialogs.commons + + // Kotlin coroutines for Flow + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + // Root filesystem support + implementation libs.libsu.core + implementation libs.libsu.io + + testImplementation libs.junit + testImplementation libs.mockito.core + testImplementation libs.mockk + testImplementation libs.robolectric + + androidTestImplementation libs.androidX.test.ext.junit + androidTestImplementation libs.androidX.test.expresso +} \ No newline at end of file diff --git a/ftpserver/consumer-rules.pro b/ftpserver/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ftpserver/proguard-rules.pro b/ftpserver/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/ftpserver/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ftpserver/src/main/AndroidManifest.xml b/ftpserver/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1bcfb46372 --- /dev/null +++ b/ftpserver/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt new file mode 100644 index 0000000000..e761cfc1d8 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver + +import android.content.Context +import android.content.SharedPreferences +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.amaze.filemanager.ftpserver.service.FtpPreferences +import com.amaze.filemanager.ftpserver.service.FtpServerEngine +import com.amaze.filemanager.server.FileServer +import com.amaze.filemanager.server.ServerNotification +import com.amaze.filemanager.server.ServerPreferences +import com.amaze.filemanager.server.ServerProvider +import com.amaze.filemanager.server.ServerType + +/** + * FTP Server provider implementation for the server registry. + */ +class FtpServerProvider( + private val context: Context, + private val fragmentFactory: () -> Fragment, + private val notificationHandler: ServerNotification +) : ServerProvider { + + override val serverType: ServerType = ServerType.FTP + + override val displayName: String = "FTP Server" + + override fun createFragment(): Fragment = fragmentFactory() + + override fun getPreferences(): ServerPreferences = FtpServerPreferencesImpl() + + override fun getNotification(): ServerNotification = notificationHandler + + override fun isServerRunning(): Boolean = FtpServerEngine.isRunning() + + override fun getServerUrl(): String? { + if (!isServerRunning()) return null + val port = FtpPreferences.getPort(context) + val secure = FtpPreferences.isSecure(context) + val prefix = if (secure) FtpPreferences.INITIALS_HOST_SFTP else FtpPreferences.INITIALS_HOST_FTP + // Note: actual IP address needs to be obtained from NetworkUtil in the app module + return "${prefix}localhost:$port/" + } + + /** + * Implementation of ServerPreferences for FTP + */ + private inner class FtpServerPreferencesImpl : ServerPreferences { + override fun getPreferences(context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + override fun getPort(context: Context): Int = FtpPreferences.getPort(context) + + override fun setPort(context: Context, port: Int) { + getPreferences(context).edit() + .putInt(FtpPreferences.PORT_PREFERENCE_KEY, port) + .apply() + } + + override fun getPath(context: Context): String = FtpPreferences.getPath(context) + + override fun setPath(context: Context, path: String) { + getPreferences(context).edit() + .putString(FtpPreferences.KEY_PREFERENCE_PATH, path) + .apply() + } + + override fun getUsername(context: Context): String? = + FtpPreferences.getUsername(context).takeIf { it.isNotEmpty() } + + override fun setUsername(context: Context, username: String?) { + getPreferences(context).edit() + .putString(FtpPreferences.KEY_PREFERENCE_USERNAME, username ?: "") + .apply() + } + + override fun isAuthenticationEnabled(context: Context): Boolean = + getUsername(context) != null + + override fun isSecureConnection(context: Context): Boolean = + FtpPreferences.isSecure(context) + + override fun setSecureConnection(context: Context, secure: Boolean) { + getPreferences(context).edit() + .putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, secure) + .apply() + } + + override fun isReadOnly(context: Context): Boolean = + FtpPreferences.isReadOnly(context) + + override fun setReadOnly(context: Context, readOnly: Boolean) { + getPreferences(context).edit() + .putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, readOnly) + .apply() + } + + override fun getTimeout(context: Context): Int = FtpPreferences.getTimeout(context) + + override fun setTimeout(context: Context, timeout: Int) { + getPreferences(context).edit() + .putInt(FtpPreferences.KEY_PREFERENCE_TIMEOUT, timeout) + .apply() + } + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt new file mode 100644 index 0000000000..4c78353e41 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import com.amaze.filemanager.ftpserver.filesystem.AndroidFileSystemFactory +import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.ftplet.DefaultFtpReply +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.ftplet.FtpReply.REPLY_213_FILE_STATUS +import org.apache.ftpserver.ftplet.FtpReply.REPLY_502_COMMAND_NOT_IMPLEMENTED +import org.apache.ftpserver.ftplet.FtpReply.REPLY_550_REQUESTED_ACTION_NOT_TAKEN +import org.apache.ftpserver.ftplet.FtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.apache.ftpserver.usermanager.impl.WriteRequest +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Implements FTP extension AVBL command, to answer device remaining space in FTP command. + * + * Only supports RootFileSystemFactory and NativeFileSystemFactory. Otherwise will simply return + * 550 Access Denied. + * + * See [Draft spec](https://www.ietf.org/archive/id/draft-peterson-streamlined-ftp-command-extensions-10.txt) + */ +class AVBL( + private val errorMessageProvider: ErrorMessageProvider +) : AbstractCommand() { + + /** + * Interface for providing localized error messages + */ + interface ErrorMessageProvider { + fun getErrorMessage(subId: String, fileName: String? = null): String + } + + companion object { + private val LOG: Logger = LoggerFactory.getLogger(AVBL::class.java) + } + + override fun execute( + session: FtpIoSession, + context: FtpServerContext, + request: FtpRequest, + ) { + val fileName: String? = request.argument + if (context.fileSystemManager is AndroidFileSystemFactory) { + doWriteReply( + session, + REPLY_502_COMMAND_NOT_IMPLEMENTED, + "AVBL.notimplemented", + ) + } else { + val ftpFile: FtpFile? = + if (true == fileName?.isNotBlank()) { + runCatching { + session.fileSystemView.getFile(fileName) + }.getOrNull() + } else { + session.fileSystemView.homeDirectory + } + if (ftpFile != null) { + if (session.user.authorize( + if (true == fileName?.isNotBlank()) { + WriteRequest(fileName) + } else { + WriteRequest() + }, + ) != null || + !(ftpFile.physicalFile as File).canWrite() + ) { + (ftpFile.physicalFile as File).apply { + if (this.isDirectory) { + runCatching { + freeSpace.let { + session.write( + DefaultFtpReply(REPLY_213_FILE_STATUS, it.toString()), + ) + } + }.onFailure { + LOG.error("Error getting directory free space", it) + replyError(session, "AVBL.accessdenied") + return + } + } else { + replyError(session, "AVBL.isafile") + } + } + } else { + replyError(session, "AVBL.accessdenied") + } + } else { + replyError(session, "AVBL.missing", fileName) + } + } + } + + private fun replyError( + session: FtpIoSession, + subId: String, + fileName: String? = null, + ) = doWriteReply(session, REPLY_550_REQUESTED_ACTION_NOT_TAKEN, subId, fileName) + + private fun doWriteReply( + session: FtpIoSession, + code: Int, + subId: String, + fileName: String? = null, + ) { + session.write( + DefaultFtpReply( + code, + errorMessageProvider.getErrorMessage(subId, fileName), + ), + ) + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt new file mode 100644 index 0000000000..df617e6028 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.ftplet.DefaultFtpReply +import org.apache.ftpserver.ftplet.FtpReply +import org.apache.ftpserver.ftplet.FtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext + +/** + * Custom FEAT command to add AVBL command to the list. + */ +class FEAT( + private val featResponseProvider: () -> String +) : AbstractCommand() { + override fun execute( + session: FtpIoSession, + context: FtpServerContext, + request: FtpRequest, + ) { + session.resetState() + session.write( + DefaultFtpReply( + FtpReply.REPLY_211_SYSTEM_STATUS_REPLY, + featResponseProvider(), + ), + ) + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt new file mode 100644 index 0000000000..e813b2cbe4 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.ftplet.FtpException +import org.apache.ftpserver.ftplet.FtpReply +import org.apache.ftpserver.ftplet.FtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.apache.ftpserver.impl.LocalizedFtpReply +import java.io.IOException + +/** + * Monkey-patch PWD to prevent true path exposed to end user. + */ +class PWD : AbstractCommand() { + @Throws(IOException::class, FtpException::class) + override fun execute( + session: FtpIoSession, + context: FtpServerContext, + request: FtpRequest, + ) { + session.resetState() + val fsView = session.fileSystemView + var currDir = + fsView.workingDirectory.absolutePath + .substringAfter(fsView.homeDirectory.absolutePath) + if (currDir.isEmpty()) { + currDir = "/" + } + if (!currDir.startsWith("/")) { + currDir = "/$currDir" + } + session.write( + LocalizedFtpReply.translate( + session, + request, + context, + FtpReply.REPLY_257_PATHNAME_CREATED, + "PWD", + currDir, + ), + ) + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt new file mode 100644 index 0000000000..370104c2d3 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.filesystem + +import android.content.Context +import android.os.Build.VERSION_CODES.KITKAT +import androidx.annotation.RequiresApi +import org.apache.ftpserver.ftplet.FileSystemFactory +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.User + +@RequiresApi(KITKAT) +class AndroidFileSystemFactory( + private val context: Context, + private val defaultPathProvider: () -> String +) : FileSystemFactory { + override fun createFileSystemView(user: User?): FileSystemView = + AndroidFtpFileSystemView(context, user?.homeDirectory ?: defaultPathProvider()) +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt new file mode 100644 index 0000000000..53445a53cb --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.filesystem + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build.VERSION_CODES.KITKAT +import android.provider.DocumentsContract +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import org.apache.ftpserver.ftplet.FtpFile +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.ref.WeakReference + +@RequiresApi(KITKAT) +@Suppress("TooManyFunctions") +class AndroidFtpFile( + context: Context, + private val parentDocument: DocumentFile, + private val backingDocument: DocumentFile?, + private val path: String, +) : FtpFile { + private val _context: WeakReference = WeakReference(context) + private val context: Context + get() = _context.get()!! + + override fun getAbsolutePath(): String { + return path + } + + override fun getName(): String = backingDocument?.name ?: path.substringAfterLast('/') + + override fun isHidden(): Boolean = name.startsWith(".") && name != "." + + override fun isDirectory(): Boolean = backingDocument?.isDirectory ?: false + + override fun isFile(): Boolean = backingDocument?.isFile ?: false + + override fun doesExist(): Boolean = backingDocument?.exists() ?: false + + override fun isReadable(): Boolean = backingDocument?.canRead() ?: false + + override fun isWritable(): Boolean = backingDocument?.canWrite() ?: true + + override fun isRemovable(): Boolean = backingDocument?.canWrite() ?: true + + override fun getOwnerName(): String = "user" + + override fun getGroupName(): String = "user" + + override fun getLinkCount(): Int = 0 + + override fun getLastModified(): Long = backingDocument?.lastModified() ?: 0L + + override fun setLastModified(time: Long): Boolean { + return if (doesExist()) { + val updateValues = + ContentValues().also { + it.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time) + } + val docUri: Uri = backingDocument!!.uri + val updated: Int = + context.contentResolver.update( + docUri, + updateValues, + null, + null, + ) + return updated == 1 + } else { + false + } + } + + override fun getSize(): Long = backingDocument?.length() ?: 0L + + override fun getPhysicalFile(): Any = backingDocument!! + + override fun mkdir(): Boolean = parentDocument.createDirectory(name) != null + + override fun delete(): Boolean = backingDocument?.delete() ?: false + + override fun move(destination: FtpFile): Boolean = backingDocument?.renameTo(destination.name) ?: false + + override fun listFiles(): MutableList = + if (doesExist()) { + backingDocument!!.listFiles().map { + AndroidFtpFile(context, backingDocument, it, it.name!!) + }.toMutableList() + } else { + mutableListOf() + } + + override fun createOutputStream(offset: Long): OutputStream? = + runCatching { + val uri = + if (doesExist()) { + backingDocument!!.uri + } else { + val newFile = parentDocument.createFile("", name) + newFile?.uri ?: throw IOException("Cannot create file at $path") + } + context.contentResolver.openOutputStream(uri) + }.getOrThrow() + + override fun createInputStream(offset: Long): InputStream? = + runCatching { + if (doesExist()) { + context.contentResolver.openInputStream(backingDocument!!.uri).also { + it?.skip(offset) + } + } else { + throw FileNotFoundException(path) + } + }.getOrThrow() +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt new file mode 100644 index 0000000000..554a1a9526 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.filesystem + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION_CODES.KITKAT +import android.os.Build.VERSION_CODES.M +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.FtpFile +import java.io.File +import java.net.URI + +@RequiresApi(KITKAT) +class AndroidFtpFileSystemView(private var context: Context, root: String) : FileSystemView { + private val rootPath = root + private val rootDocumentFile = createDocumentFileFrom(rootPath) + private var currentPath: String? = "/" + + override fun getHomeDirectory(): FtpFile = AndroidFtpFile(context, rootDocumentFile, resolveDocumentFileFromRoot("/"), "/") + + override fun getWorkingDirectory(): FtpFile { + return AndroidFtpFile( + context, + rootDocumentFile, + resolveDocumentFileFromRoot(currentPath!!), + currentPath!!, + ) + } + + override fun changeWorkingDirectory(dir: String?): Boolean { + return when { + dir.isNullOrBlank() -> false + dir == "/" -> { + currentPath = "/" + true + } + dir.startsWith("..") -> { + if (currentPath.isNullOrEmpty() || currentPath == "/") { + false + } else { + currentPath = normalizePath("$currentPath/$dir") + resolveDocumentFileFromRoot(currentPath) != null + } + } + else -> { + currentPath = + when { + currentPath.isNullOrEmpty() || currentPath == "/" -> dir + !dir.startsWith("/") -> normalizePath("$currentPath/$dir") + else -> normalizePath(dir) + } + resolveDocumentFileFromRoot(currentPath) != null + } + } + } + + override fun getFile(file: String): FtpFile { + val path = + if (currentPath.isNullOrEmpty() || currentPath == "/") { + "/$file" + } else if (file.startsWith('/')) { + file + } else { + "$currentPath/$file" + } + return normalizePath(path).let { normalizedPath -> + AndroidFtpFile( + context, + resolveDocumentFileFromRoot(getParentFrom(normalizedPath))!!, + resolveDocumentFileFromRoot(normalizedPath), + normalizedPath, + ) + } + } + + override fun isRandomAccessible(): Boolean = false + + override fun dispose() { + // context = null!! + } + + private fun normalizePath(path: String): String { + return when { + path == "\\" || path == "/" -> { + "/" + } + path.length <= 1 -> { + path + } + else -> { + Uri.decode( + URI(Uri.encode(path, "/")) + .normalize() + .toString(), + ).replace("//", "/") + } + } + } + + private fun getParentFrom(normalizedPath: String): String { + return if (normalizedPath.length <= 1) { + normalizedPath + } else { + normalizedPath.substringBeforeLast('/') + } + } + + private fun createDocumentFileFrom(path: String): DocumentFile { + return if (Build.VERSION.SDK_INT in KITKAT until M) { + DocumentFile.fromFile(File(path)) + } else { + DocumentFile.fromTreeUri(context, Uri.parse(path))!! + } + } + + private fun resolveDocumentFileFromRoot(path: String?): DocumentFile? { + return if (path.isNullOrBlank() or ("/" == path) or ("./" == path)) { + rootDocumentFile + } else { + val pathElements = path!!.split('/') + if (pathElements.isEmpty()) { + rootDocumentFile + } else { + var retval: DocumentFile? = rootDocumentFile + pathElements.forEach { pathElement -> + if (pathElement.isNotBlank()) { + retval = retval?.findFile(pathElement) + } + } + retval + } + } + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt new file mode 100644 index 0000000000..849e67f02e --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.filesystem + +import org.apache.ftpserver.ftplet.FileSystemFactory +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.User + +class RootFileSystemFactory( + private val fileFactory: RootFileSystemView.SuFileFactory = + RootFileSystemView.DefaultSuFileFactory(), +) : FileSystemFactory { + override fun createFileSystemView(user: User): FileSystemView = RootFileSystemView(user, fileFactory) +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt new file mode 100644 index 0000000000..472710a529 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.filesystem + +import android.util.Log +import com.topjohnwu.superuser.io.SuFile +import org.apache.ftpserver.ftplet.FileSystemView +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.ftplet.User +import java.io.File +import java.net.URI +import java.util.StringTokenizer + +class RootFileSystemView( + private val user: User, + private val fileFactory: SuFileFactory, +) : FileSystemView { + private var currDir: String + private var rootDir: String + + companion object { + private const val TAG = "RootFileSystemView" + } + + init { + requireNotNull(user.homeDirectory) { "User home directory can not be null" } + + var rootDir = user.homeDirectory + rootDir = normalizeSeparateChar(rootDir) + rootDir = appendSlash(rootDir) + + Log.d( + TAG, + "Native filesystem view created for user \"${user.name}\" with root \"${rootDir}\"", + ) + + this.rootDir = rootDir + currDir = "/" + } + + override fun getHomeDirectory(): FtpFile { + return RootFtpFile("/", fileFactory.create(rootDir), user) + } + + override fun getWorkingDirectory(): FtpFile { + return if (currDir == "/") { + RootFtpFile("/", fileFactory.create(rootDir), user) + } else { + val file = fileFactory.create(rootDir, currDir.substring(1)) + RootFtpFile(currDir, file, user) + } + } + + override fun changeWorkingDirectory(dirArg: String): Boolean { + var dir = dirArg + + dir = getPhysicalName(rootDir, currDir, dir) + val dirObj = fileFactory.create(dir) + if (!dirObj.isDirectory) { + return false + } + + dir = dir.substring(rootDir.length - 1) + if (dir[dir.length - 1] != '/') { + dir = "$dir/" + } + + currDir = dir + return true + } + + override fun getFile(file: String): FtpFile { + val physicalName = getPhysicalName(rootDir, currDir, file) + val fileObj = fileFactory.create(physicalName) + + val userFileName = physicalName.substring(rootDir.length - 1) + return RootFtpFile(userFileName, fileObj, user) + } + + override fun isRandomAccessible(): Boolean = false + + override fun dispose() = Unit + + private fun getPhysicalName( + rootDir: String, + currDir: String, + fileName: String, + ): String { + var normalizedRootDir: String = normalizeSeparateChar(rootDir) + normalizedRootDir = appendSlash(normalizedRootDir) + + val normalizedFileName = normalizeSeparateChar(fileName) + var result: String? + + result = + if (normalizedFileName[0] != '/') { + val normalizedCurrDir = normalize(currDir) + normalizedRootDir + normalizedCurrDir.substring(1) + } else { + normalizedRootDir + } + + result = trimTrailingSlash(result) + + val st = StringTokenizer(normalizedFileName, "/") + while (st.hasMoreTokens()) { + val tok = st.nextToken() + + if (tok == ".") { + // ignore + } else if (tok == "..") { + if (result!!.startsWith(normalizedRootDir)) { + val slashIndex = result.lastIndexOf('/') + if (slashIndex != -1) { + result = result.substring(0, slashIndex) + } + } + } else if (tok == "~") { + result = trimTrailingSlash(normalizedRootDir) + continue + } else { + result = "$result/$tok" + } + } + + if (result!!.length + 1 == normalizedRootDir.length) { + result += '/' + } + + if (!result.startsWith(normalizedRootDir)) { + result = normalizedRootDir + } + return result + } + + private fun appendSlash(path: String): String { + return if (!path.endsWith("/")) { + "$path/" + } else { + path + } + } + + private fun prependSlash(path: String): String { + return if (!path.startsWith("/")) { + "/$path" + } else { + path + } + } + + private fun trimTrailingSlash(path: String?): String { + return if (path!![path.length - 1] == '/') { + path.substring(0, path.length - 1) + } else { + path + } + } + + private fun normalizeSeparateChar(pathName: String): String { + return pathName + .replace(File.separatorChar, '/') + .replace('\\', '/') + } + + private fun normalize(pathArg: String?): String { + var path: String? = pathArg + if (path == null || path.trim { it <= ' ' }.isEmpty()) { + path = "/" + } + path = normalizeSeparateChar(path) + path = prependSlash(appendSlash(path)) + return path + } + + interface SuFileFactory { + fun create(pathname: String): SuFile = SuFile(pathname) + + fun create( + parent: String, + child: String, + ): SuFile = SuFile(parent, child) + + fun create( + parent: File, + child: String, + ): SuFile = SuFile(parent, child) + + fun create(uri: URI): SuFile = SuFile(uri) + } + + class DefaultSuFileFactory : SuFileFactory +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt new file mode 100644 index 0000000000..41ca8193ea --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.filesystem + +import com.topjohnwu.superuser.io.SuFile +import com.topjohnwu.superuser.io.SuFileInputStream +import com.topjohnwu.superuser.io.SuFileOutputStream +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.ftplet.User +import org.apache.ftpserver.usermanager.impl.WriteRequest +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.io.OutputStream + +class RootFtpFile( + private val fileName: String, + private val backingFile: SuFile, + private val user: User, +) : FtpFile { + companion object { + @JvmStatic + private val logger: Logger = LoggerFactory.getLogger(RootFtpFile::class.java) + } + + override fun getAbsolutePath(): String = backingFile.absolutePath + + override fun getName(): String = backingFile.name + + override fun isHidden(): Boolean = backingFile.isHidden + + override fun isDirectory(): Boolean = backingFile.isDirectory + + override fun isFile(): Boolean = backingFile.isFile + + override fun doesExist(): Boolean = backingFile.exists() + + override fun isReadable(): Boolean = backingFile.canRead() + + override fun isWritable(): Boolean { + logger.debug("Checking authorization for $absolutePath") + if (user.authorize(WriteRequest(absolutePath)) == null) { + logger.debug("Not authorized") + return false + } + + logger.debug("Checking if file exists") + if (backingFile.exists()) { + logger.debug("Checking can write: " + backingFile.canWrite()) + return backingFile.canWrite() + } + + logger.debug("Authorized") + return true + } + + override fun isRemovable(): Boolean { + if ("/" == fileName) { + return false + } + + val fullName = absolutePath + if (user.authorize(WriteRequest(fullName)) == null) { + return false + } + + val indexOfSlash = fullName.lastIndexOf('/') + val parentFullName: String = + if (indexOfSlash == 0) { + "/" + } else { + fullName.substring(0, indexOfSlash) + } + + return backingFile.absoluteFile.parentFile?.run { + RootFtpFile( + parentFullName, + this, + user, + ).isWritable + } ?: false + } + + override fun getOwnerName(): String = "user" + + override fun getGroupName(): String = "user" + + override fun getLinkCount(): Int = if (backingFile.isDirectory) 3 else 1 + + override fun getLastModified(): Long = backingFile.lastModified() + + override fun setLastModified(time: Long): Boolean = backingFile.setLastModified(time) + + override fun getSize(): Long = backingFile.length() + + override fun getPhysicalFile(): Any = backingFile + + override fun mkdir(): Boolean = backingFile.mkdirs() + + override fun delete(): Boolean = backingFile.delete() + + override fun move(destination: FtpFile): Boolean = backingFile.renameTo(destination.physicalFile as SuFile) + + override fun listFiles(): MutableList = + backingFile.listFiles()?.map { + RootFtpFile(it.name, it, user) + }?.toMutableList() ?: emptyList().toMutableList() + + override fun createOutputStream(offset: Long): OutputStream = SuFileOutputStream.open(backingFile.absolutePath) + + override fun createInputStream(offset: Long): InputStream = SuFileInputStream.open(backingFile.absolutePath) +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt new file mode 100644 index 0000000000..b0e7437892 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.N +import android.os.Build.VERSION_CODES.Q +import java.util.LinkedList + +/** + * Provides SSL/TLS cipher suites configuration for FTP server. + */ +object FtpCipherSuites { + /** + * Return a list of available ciphers for ftpserver. + * + * Added SDK detection since some ciphers are available only on higher versions, and they + * have to be on top of the list to make a more secure SSL + * + * @see [org.apache.ftpserver.ssl.SslConfiguration] + * @see [javax.net.ssl.SSLEngine] + */ + @JvmStatic + val 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() +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt new file mode 100644 index 0000000000..cfaa01aafd --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import com.amaze.filemanager.ftpserver.commands.AVBL +import com.amaze.filemanager.ftpserver.commands.FEAT +import com.amaze.filemanager.ftpserver.commands.PWD +import com.amaze.filemanager.ftpserver.filesystem.AndroidFtpFileSystemView +import org.apache.ftpserver.command.CommandFactory +import org.apache.ftpserver.command.CommandFactoryFactory + +/** + * Custom CommandFactory factory with custom commands. + */ +object FtpCommandFactoryFactory { + /** + * Encapsulate custom CommandFactory construction logic. Append custom AVBL and PWD command, + * as well as feature flag in FEAT command if not using AndroidFtpFileSystemView. + */ + fun create( + useAndroidFileSystem: Boolean, + errorMessageProvider: AVBL.ErrorMessageProvider, + featResponseProvider: () -> String + ): CommandFactory { + val cf = CommandFactoryFactory() + if (!useAndroidFileSystem) { + cf.addCommand("AVBL", AVBL(errorMessageProvider)) + cf.addCommand("FEAT", FEAT(featResponseProvider)) + cf.addCommand("PWD", PWD()) + } + return cf.createCommandFactory() + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt new file mode 100644 index 0000000000..eacea35250 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Event bus for FTP server events using Kotlin's Flow. + */ +object FtpEventBus { + private val _events = MutableSharedFlow(replay = 0) + val events = _events.asSharedFlow() + + /** + * Emit the event signal to the event bus. + */ + suspend fun emit(event: FtpServerEvent) { + _events.emit(event) + } +} + +/** + * Events broadcast when FTP server state changes. + */ +sealed class FtpServerEvent { + data object Started : FtpServerEvent() + data object StartedFromTile : FtpServerEvent() + data object Stopped : FtpServerEvent() + data object FailedToStart : FtpServerEvent() +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt new file mode 100644 index 0000000000..35fc786e2c --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.M +import android.os.Environment +import android.provider.DocumentsContract +import androidx.preference.PreferenceManager + +/** + * Configuration and preference keys for FTP server. + */ +object FtpPreferences { + const val DEFAULT_PORT = 2211 + const val DEFAULT_USERNAME = "" + const val DEFAULT_TIMEOUT = 600 // default timeout, in sec + const val DEFAULT_SECURE = true + + const val PORT_PREFERENCE_KEY = "ftpPort" + const val KEY_PREFERENCE_PATH = "ftp_path" + const val KEY_PREFERENCE_USERNAME = "ftp_username" + const val KEY_PREFERENCE_PASSWORD = "ftp_password_encrypted" + const val KEY_PREFERENCE_TIMEOUT = "ftp_timeout" + const val KEY_PREFERENCE_SECURE = "ftp_secure" + const val KEY_PREFERENCE_READONLY = "ftp_readonly" + const val KEY_PREFERENCE_SAF_FILESYSTEM = "ftp_saf_filesystem" + const val KEY_PREFERENCE_ROOT_FILESYSTEM = "ftp_root_filesystem" + + const val INITIALS_HOST_FTP = "ftp://" + const val INITIALS_HOST_SFTP = "ftps://" + + const val ACTION_START_FTPSERVER = + "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_START_FTPSERVER" + const val ACTION_STOP_FTPSERVER = + "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_STOP_FTPSERVER" + const val TAG_STARTED_BY_TILE = "started_by_tile" + + /** + * Get default preferences for FTP server + */ + fun getPreferences(context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + /** + * Get configured port + */ + fun getPort(context: Context): Int { + return getPreferences(context).getInt(PORT_PREFERENCE_KEY, DEFAULT_PORT) + } + + /** + * Get whether secure connection is enabled + */ + fun isSecure(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_PREFERENCE_SECURE, DEFAULT_SECURE) + } + + /** + * Get configured timeout + */ + fun getTimeout(context: Context): Int { + return getPreferences(context).getInt(KEY_PREFERENCE_TIMEOUT, DEFAULT_TIMEOUT) + } + + /** + * Get configured path + */ + fun getPath(context: Context): String { + return getPreferences(context).getString(KEY_PREFERENCE_PATH, defaultPath(context)) + ?: defaultPath(context) + } + + /** + * Get configured username + */ + fun getUsername(context: Context): String { + return getPreferences(context).getString(KEY_PREFERENCE_USERNAME, DEFAULT_USERNAME) + ?: DEFAULT_USERNAME + } + + /** + * Check if read-only mode is enabled + */ + fun isReadOnly(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_PREFERENCE_READONLY, false) + } + + /** + * Check if SAF filesystem should be used + */ + fun useSafFilesystem(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) + } + + /** + * Derive the FTP server's default share path, depending the user's Android version. + */ + fun defaultPath(context: Context): String { + return if (useSafFilesystem(context) && SDK_INT > M) { + DocumentsContract.buildTreeDocumentUri( + "com.android.externalstorage.documents", + "primary:", + ).toString() + } else { + Environment.getExternalStorageDirectory().absolutePath + } + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt new file mode 100644 index 0000000000..6154cf648a --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat + +/** + * Broadcast receiver for FTP server start/stop commands. + * + * This is an abstract class that should be extended by the app module + * to provide the concrete FtpServerService class. + */ +abstract class FtpReceiver : BroadcastReceiver() { + + private val TAG = FtpReceiver::class.java.simpleName + + /** + * Get the FTP service class to start/stop + */ + abstract fun getFtpServiceClass(): Class<*> + + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "Received: ${intent.action}") + + val serviceIntent = Intent(context, getFtpServiceClass()) + serviceIntent.putExtras(intent) + + runCatching { + when (intent.action) { + FtpPreferences.ACTION_START_FTPSERVER -> { + if (!FtpServerEngine.isRunning()) { + ContextCompat.startForegroundService(context, serviceIntent) + } + Unit + } + FtpPreferences.ACTION_STOP_FTPSERVER -> { + context.stopService(serviceIntent) + Unit + } + else -> Unit + } + }.onFailure { + Log.e(TAG, "Failed to start/stop on intent: ${it.message}") + } + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt new file mode 100644 index 0000000000..874fb92ca2 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.KITKAT +import com.amaze.filemanager.ftpserver.commands.AVBL +import com.amaze.filemanager.ftpserver.filesystem.AndroidFileSystemFactory +import com.amaze.filemanager.ftpserver.filesystem.RootFileSystemFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.apache.ftpserver.ConnectionConfigFactory +import org.apache.ftpserver.FtpServer +import org.apache.ftpserver.FtpServerFactory +import org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory +import org.apache.ftpserver.listener.ListenerFactory +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.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.TrustManagerFactory + +/** + * FTP Server engine that handles the actual server lifecycle. + * This is the core server logic extracted from FtpService. + */ +object FtpServerEngine { + private val log: Logger = LoggerFactory.getLogger(FtpServerEngine::class.java) + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private var server: FtpServer? = null + private var serverThread: Thread? = null + + /** + * Configuration for the FTP server + */ + data class ServerConfig( + val port: Int = FtpPreferences.DEFAULT_PORT, + val timeout: Int = FtpPreferences.DEFAULT_TIMEOUT, + val path: String, + val username: String? = null, + val password: String? = null, + val isSecure: Boolean = FtpPreferences.DEFAULT_SECURE, + val isReadOnly: Boolean = false, + val useSafFilesystem: Boolean = false, + val useRootFilesystem: Boolean = false, + val keyStoreInputStream: InputStream? = null, + val keyStorePassword: String = "", + val errorMessageProvider: AVBL.ErrorMessageProvider? = null, + val featResponseProvider: (() -> String)? = null + ) + + /** + * Check if the server is currently running + */ + fun isRunning(): Boolean { + val server = server ?: return false + return !server.isStopped + } + + /** + * Start the FTP server with the given configuration + */ + fun start( + context: Context, + config: ServerConfig, + onStarted: (Boolean) -> Unit = {} + ) { + if (isRunning()) { + log.warn("FTP server already running") + onStarted(true) + return + } + + serverThread = Thread { + runServer(context, config, onStarted) + }.apply { start() } + } + + private fun runServer( + context: Context, + config: ServerConfig, + onStarted: (Boolean) -> Unit + ) { + try { + FtpServerFactory().run { + val connectionConfigFactory = ConnectionConfigFactory() + + // Configure filesystem + if (SDK_INT >= KITKAT && config.useSafFilesystem) { + fileSystem = AndroidFileSystemFactory(context) { config.path } + } else if (config.useRootFilesystem) { + fileSystem = RootFileSystemFactory() + } else { + fileSystem = NativeFileSystemFactory() + } + + // Configure commands + if (config.errorMessageProvider != null && config.featResponseProvider != null) { + commandFactory = FtpCommandFactoryFactory.create( + config.useSafFilesystem, + config.errorMessageProvider, + config.featResponseProvider + ) + } + + // Configure user + val user = BaseUser() + if (config.username.isNullOrEmpty()) { + user.name = "anonymous" + connectionConfigFactory.isAnonymousLoginEnabled = true + } else { + user.name = config.username + user.password = config.password + } + user.homeDirectory = config.path + + if (!config.isReadOnly) { + user.authorities = listOf(WritePermission()) + } + + connectionConfig = connectionConfigFactory.createConnectionConfig() + userManager.save(user) + + // Configure listener + val listenerFactory = ListenerFactory() + + if (config.isSecure && config.keyStoreInputStream != null) { + try { + val keyStore = KeyStore.getInstance("BKS") + val keyStorePassword = config.keyStorePassword.toCharArray() + keyStore.load(config.keyStoreInputStream, keyStorePassword) + + val keyManagerFactory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, keyStorePassword) + + val trustManagerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + + listenerFactory.sslConfiguration = DefaultSslConfiguration( + keyManagerFactory, + trustManagerFactory, + ClientAuth.WANT, + "TLS", + FtpCipherSuites.enabledCipherSuites, + "ftpserver" + ) + listenerFactory.isImplicitSsl = true + } catch (e: GeneralSecurityException) { + log.error("Failed to configure SSL", e) + } catch (e: IOException) { + log.error("Failed to load keystore", e) + } + } + + listenerFactory.port = config.port + listenerFactory.idleTimeout = config.timeout + + addListener("default", listenerFactory.createListener()) + + server = createServer().apply { + start() + scope.launch { + FtpEventBus.emit(FtpServerEvent.Started) + } + onStarted(true) + } + } + } catch (e: Exception) { + log.error("Failed to start FTP server", e) + scope.launch { + FtpEventBus.emit(FtpServerEvent.FailedToStart) + } + onStarted(false) + } + } + + /** + * Stop the FTP server + */ + fun stop() { + serverThread?.let { thread -> + thread.interrupt() + thread.join(10000) + + if (!thread.isAlive) { + serverThread = null + } + + server?.stop() + server = null + + scope.launch { + FtpEventBus.emit(FtpServerEvent.Stopped) + } + } + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt new file mode 100644 index 0000000000..3cea56ea32 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.service + +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.ServiceCompat +import com.amaze.filemanager.ftpserver.R +import com.amaze.filemanager.ftpserver.commands.AVBL +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.util.concurrent.TimeUnit + +/** + * FTP Server Android Service. + * + * This service manages the FTP server lifecycle as a foreground service. + */ +abstract class FtpServerService : Service() { + + private val log: Logger = LoggerFactory.getLogger(FtpServerService::class.java) + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private lateinit var wakeLock: PowerManager.WakeLock + private var isStartedByTile = false + + /** + * Get the notification ID for this service + */ + abstract fun getNotificationId(): Int + + /** + * Get the notification channel ID + */ + abstract fun getNotificationChannelId(): String + + /** + * Create the starting notification + */ + abstract fun createStartingNotification(noStopButton: Boolean): android.app.Notification + + /** + * Update the running notification + */ + abstract fun updateRunningNotification(noStopButton: Boolean) + + /** + * Get the keystore input stream for SSL + */ + abstract fun getKeyStoreInputStream(): InputStream? + + /** + * Get the keystore password + */ + abstract fun getKeyStorePassword(): String + + /** + * Get decrypted password for FTP authentication + */ + abstract fun decryptPassword(encryptedPassword: String): String? + + /** + * Check if root mode is enabled + */ + abstract fun isRootModeEnabled(): Boolean + + /** + * Get error message provider for AVBL command + */ + abstract fun getErrorMessageProvider(): AVBL.ErrorMessageProvider + + /** + * Get FEAT response string + */ + abstract fun getFeatResponse(): String + + override fun onCreate() { + super.onCreate() + + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, javaClass.name) + wakeLock.setReferenceCounted(false) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + isStartedByTile = intent?.getBooleanExtra(FtpPreferences.TAG_STARTED_BY_TILE, false) == true + + // Wait for any existing server to stop + var attempts = 10 + while (FtpServerEngine.isRunning()) { + if (attempts > 0) { + attempts-- + try { + Thread.sleep(1000) + } catch (ignored: InterruptedException) { + } + } else { + return START_STICKY + } + } + + // Start as foreground service + val notification = createStartingNotification(isStartedByTile) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + getNotificationId(), + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(getNotificationId(), notification) + } + + // Start the server + startServer() + + return START_STICKY + } + + private fun startServer() { + wakeLock.acquire(TimeUnit.HOURS.toMillis(1L)) + + val prefs = FtpPreferences.getPreferences(this) + + // Get password if authentication is enabled + val username = FtpPreferences.getUsername(this) + val password = if (username.isNotEmpty()) { + val encryptedPassword = prefs.getString(FtpPreferences.KEY_PREFERENCE_PASSWORD, "") ?: "" + if (encryptedPassword.isNotEmpty()) { + decryptPassword(encryptedPassword) + } else null + } else null + + val config = FtpServerEngine.ServerConfig( + port = FtpPreferences.getPort(this), + timeout = FtpPreferences.getTimeout(this), + path = FtpPreferences.getPath(this), + username = username.takeIf { it.isNotEmpty() }, + password = password, + isSecure = FtpPreferences.isSecure(this), + isReadOnly = FtpPreferences.isReadOnly(this), + useSafFilesystem = FtpPreferences.useSafFilesystem(this), + useRootFilesystem = isRootModeEnabled(), + keyStoreInputStream = if (FtpPreferences.isSecure(this)) getKeyStoreInputStream() else null, + keyStorePassword = getKeyStorePassword(), + errorMessageProvider = getErrorMessageProvider(), + featResponseProvider = { getFeatResponse() } + ) + + FtpServerEngine.start(this, config) { success -> + if (success) { + updateRunningNotification(isStartedByTile) + } else { + if (wakeLock.isHeld) { + wakeLock.release() + } + stopSelf() + } + } + } + + override fun onDestroy() { + FtpServerEngine.stop() + + if (wakeLock.isHeld) { + wakeLock.release() + } + + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private val log: Logger = LoggerFactory.getLogger(FtpServerService::class.java) + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt new file mode 100644 index 0000000000..d1be8a48ea --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.ui + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.os.Bundle +import android.text.Spanned +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.text.HtmlCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.amaze.filemanager.ftpserver.R +import com.amaze.filemanager.ftpserver.databinding.FragmentFtpBinding +import com.amaze.filemanager.ftpserver.service.FtpEventBus +import com.amaze.filemanager.ftpserver.service.FtpPreferences +import com.amaze.filemanager.ftpserver.service.FtpServerEngine +import com.amaze.filemanager.ftpserver.service.FtpServerEvent +import kotlinx.coroutines.launch + +/** + * Base fragment for FTP server UI. + * + * This provides the core FTP server UI functionality that can be extended + * by the app module to add app-specific features. + */ +abstract class BaseFtpServerFragment : Fragment() { + + private var _binding: FragmentFtpBinding? = null + protected val binding get() = _binding!! + + private var spannedStatusNoConnection: Spanned? = null + private var spannedStatusConnected: Spanned? = null + private var spannedStatusUrl: Spanned? = null + private var spannedStatusSecure: Spanned? = null + private var spannedStatusNotRunning: Spanned? = null + + /** + * Get the accent color for the UI + */ + abstract fun getAccentColor(): Int + + /** + * Check if device is connected to a local network + */ + abstract fun isConnectedToLocalNetwork(): Boolean + + /** + * Check if device is connected to WiFi + */ + abstract fun isConnectedToWifi(): Boolean + + /** + * Get the local IP address + */ + abstract fun getLocalAddress(): String? + + /** + * Start the FTP service + */ + abstract fun startFtpService(startedByTile: Boolean) + + /** + * Stop the FTP service + */ + abstract fun stopFtpService() + + /** + * Show a snackbar prompting user to enable wireless + */ + abstract fun promptUserToEnableWireless() + + /** + * Dismiss any shown snackbar + */ + abstract fun dismissSnackbar() + + /** + * Get encrypted password from preferences + */ + abstract fun getEncryptedPassword(): String? + + /** + * Decrypt password + */ + abstract fun decryptPassword(encryptedPassword: String): String? + + /** + * Handle path change request + */ + abstract fun onPathChangeRequested() + + /** + * Handle login change request + */ + abstract fun onLoginChangeRequested() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFtpBinding.inflate(inflater, container, false) + + updateSpans() + updateStatus() + updateViews() + + binding.startStopButton.setOnClickListener { + onStartStopButtonClick() + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + FtpEventBus.events.collect { event -> + onFtpServerEvent(event) + } + } + } + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onResume() { + super.onResume() + updateStatus() + registerWifiReceiver() + } + + override fun onPause() { + super.onPause() + unregisterWifiReceiver() + } + + @Deprecated("Deprecated in Java") + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.ftp_server_menu, menu) + menu.findItem(R.id.checkbox_ftp_readonly)?.isChecked = + FtpPreferences.isReadOnly(requireContext()) + menu.findItem(R.id.checkbox_ftp_secure)?.isChecked = + FtpPreferences.isSecure(requireContext()) + menu.findItem(R.id.checkbox_ftp_legacy_filesystem)?.isChecked = + FtpPreferences.useSafFilesystem(requireContext()) + super.onCreateOptionsMenu(menu, inflater) + } + + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.choose_ftp_port -> { + showPortDialog() + return true + } + R.id.ftp_path -> { + onPathChangeRequested() + return true + } + R.id.ftp_login -> { + onLoginChangeRequested() + return true + } + R.id.checkbox_ftp_readonly -> { + val newValue = !item.isChecked + item.isChecked = newValue + setReadonlyPreference(newValue) + updatePathText() + promptUserToRestartServer() + return true + } + R.id.checkbox_ftp_secure -> { + val newValue = !item.isChecked + item.isChecked = newValue + setSecurePreference(newValue) + promptUserToRestartServer() + return true + } + R.id.checkbox_ftp_legacy_filesystem -> { + val newValue = !item.isChecked + item.isChecked = newValue + setSafFilesystemPreference(newValue) + promptUserToRestartServer() + return true + } + R.id.ftp_timeout -> { + showTimeoutDialog() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun onStartStopButtonClick() { + if (!FtpServerEngine.isRunning()) { + if (isConnectedToWifi() || isConnectedToLocalNetwork()) { + startServer() + } else { + binding.textViewFtpStatus.text = spannedStatusNoConnection + } + } else { + stopServer() + } + } + + private fun startServer() { + startFtpService(false) + } + + private fun stopServer() { + stopFtpService() + } + + private fun onFtpServerEvent(event: FtpServerEvent) { + updateSpans() + when (event) { + is FtpServerEvent.Started, is FtpServerEvent.StartedFromTile -> { + val isSecure = FtpPreferences.isSecure(requireContext()) + binding.textViewFtpStatus.text = if (isSecure) { + spannedStatusSecure + } else { + spannedStatusConnected + } + binding.textViewFtpUrl.text = spannedStatusUrl + binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() + } + is FtpServerEvent.FailedToStart -> { + binding.textViewFtpStatus.text = spannedStatusNotRunning + Toast.makeText(context, R.string.ftpmod_unknown_error, Toast.LENGTH_LONG).show() + binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() + binding.textViewFtpUrl.text = "URL: " + } + is FtpServerEvent.Stopped -> { + binding.textViewFtpStatus.text = spannedStatusNotRunning + binding.textViewFtpUrl.text = "URL: " + binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() + } + } + updateStatus() + } + + private fun updateSpans() { + val accentColor = String.format("%06X", 0xFFFFFF and getAccentColor()) + + spannedStatusNoConnection = HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_no_connection)}", + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + + spannedStatusConnected = HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_running)}", + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + + spannedStatusSecure = HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_secure_connection)}", + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + + spannedStatusNotRunning = HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_not_running)}", + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + + val address = getLocalAddress() + val port = FtpPreferences.getPort(requireContext()) + val isSecure = FtpPreferences.isSecure(requireContext()) + val prefix = if (isSecure) FtpPreferences.INITIALS_HOST_SFTP else FtpPreferences.INITIALS_HOST_FTP + val urlText = if (address != null) "$prefix$address:$port/" else "" + + spannedStatusUrl = HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_url_label)} " + + "$urlText", + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + } + + private fun updateStatus() { + if (_binding == null) return + + if (!isConnectedToLocalNetwork() && !isConnectedToWifi()) { + binding.textViewFtpStatus.text = spannedStatusNoConnection + binding.startStopButton.isEnabled = false + } else { + binding.startStopButton.isEnabled = true + if (FtpServerEngine.isRunning()) { + binding.textViewFtpStatus.text = if (FtpPreferences.isSecure(requireContext())) { + spannedStatusSecure + } else { + spannedStatusConnected + } + binding.textViewFtpUrl.text = spannedStatusUrl + binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() + } else { + binding.textViewFtpStatus.text = spannedStatusNotRunning + binding.textViewFtpUrl.text = "URL: " + binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() + } + } + } + + private fun updateViews() { + updateUsernameText() + updatePasswordText() + updatePortText() + updatePathText() + } + + private fun updateUsernameText() { + val username = FtpPreferences.getUsername(requireContext()) + val displayName = if (username.isEmpty()) getString(R.string.ftpmod_anonymous) else username + binding.textViewFtpUsername.text = "${getString(R.string.ftpmod_username_label)}$displayName" + } + + private fun updatePasswordText() { + val username = FtpPreferences.getUsername(requireContext()) + if (username.isEmpty()) { + binding.textViewFtpPassword.text = "${getString(R.string.ftpmod_password_label)}••••••••" + binding.ftpPasswordVisible.visibility = View.GONE + } else { + binding.textViewFtpPassword.text = "${getString(R.string.ftpmod_password_label)}••••••••" + binding.ftpPasswordVisible.visibility = View.VISIBLE + } + } + + private fun updatePortText() { + val port = FtpPreferences.getPort(requireContext()) + binding.textViewFtpPort.text = "${getString(R.string.ftpmod_port_label)}$port" + } + + protected fun updatePathText() { + val path = FtpPreferences.getPath(requireContext()) + val readOnly = if (FtpPreferences.isReadOnly(requireContext())) " (R/O)" else "" + binding.textViewFtpPath.text = "${getString(R.string.ftpmod_path_label)}$path$readOnly" + } + + private fun setReadonlyPreference(value: Boolean) { + FtpPreferences.getPreferences(requireContext()).edit() + .putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, value) + .apply() + } + + private fun setSecurePreference(value: Boolean) { + FtpPreferences.getPreferences(requireContext()).edit() + .putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, value) + .apply() + } + + private fun setSafFilesystemPreference(value: Boolean) { + FtpPreferences.getPreferences(requireContext()).edit() + .putBoolean(FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, value) + .apply() + } + + private fun promptUserToRestartServer() { + if (FtpServerEngine.isRunning()) { + Toast.makeText(context, R.string.ftpmod_prompt_restart_server, Toast.LENGTH_SHORT).show() + } + } + + protected open fun showPortDialog() { + // Override in subclass to show port dialog with material-dialogs + } + + protected open fun showTimeoutDialog() { + // Override in subclass to show timeout dialog with material-dialogs + } + + private val wifiReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (isConnectedToLocalNetwork()) { + binding.startStopButton.isEnabled = true + dismissSnackbar() + } else { + stopServer() + binding.textViewFtpStatus.text = spannedStatusNoConnection + binding.startStopButton.isEnabled = false + binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() + promptUserToEnableWireless() + } + } + } + + private fun registerWifiReceiver() { + val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) + ContextCompat.registerReceiver( + requireContext(), + wifiReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + private fun unregisterWifiReceiver() { + try { + requireContext().unregisterReceiver(wifiReceiver) + } catch (e: IllegalArgumentException) { + // Receiver not registered + } + } + + companion object { + const val TAG = "FtpServerFragment" + } +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt new file mode 100644 index 0000000000..a5cde43c4e --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.ui + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.amaze.filemanager.ftpserver.R +import com.amaze.filemanager.ftpserver.service.FtpPreferences +import com.amaze.filemanager.server.ServerNotification + +/** + * Handles FTP server notifications. + */ +class FtpServerNotification( + private val notificationId: Int, + private val channelId: String, + private val mainActivityIntent: Intent, + private val getLocalAddress: (Context) -> String? +) : ServerNotification { + + override fun getNotificationId(): Int = notificationId + + override fun getChannelId(): String = channelId + + override fun createStartingNotification(context: Context, noStopButton: Boolean): Notification { + ensureNotificationChannel(context) + return buildNotification( + context, + R.string.ftpmod_notif_starting_title, + context.getString(R.string.ftpmod_notif_starting), + noStopButton + ).build() + } + + override fun updateRunningNotification(context: Context, noStopButton: Boolean) { + ensureNotificationChannel(context) + val notificationManager = NotificationManagerCompat.from(context) + + val port = FtpPreferences.getPort(context) + val secureConnection = FtpPreferences.isSecure(context) + + val address = getLocalAddress(context) + val addressText = if (address != null) { + val prefix = if (secureConnection) { + FtpPreferences.INITIALS_HOST_SFTP + } else { + FtpPreferences.INITIALS_HOST_FTP + } + "$prefix$address:$port/" + } else { + "Address not found" + } + + val notification = buildNotification( + context, + R.string.ftpmod_notif_title, + context.getString(R.string.ftpmod_notif_text, addressText), + noStopButton + ).build() + + notificationManager.notify(notificationId, notification) + } + + override fun removeNotification(context: Context) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + private fun buildNotification( + context: Context, + titleResId: Int, + contentText: String, + noStopButton: Boolean + ): NotificationCompat.Builder { + val contentIntent = PendingIntent.getActivity( + context, + 0, + mainActivityIntent.apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + getPendingIntentFlag(0) + ) + + val builder = NotificationCompat.Builder(context, channelId) + .setContentTitle(context.getString(titleResId)) + .setContentText(contentText) + .setContentIntent(contentIntent) + .setSmallIcon(R.drawable.ic_ftp_light) + .setTicker(context.getString(R.string.ftpmod_notif_starting)) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setOnlyAlertOnce(true) + + if (!noStopButton) { + val stopIntent = Intent(FtpPreferences.ACTION_STOP_FTPSERVER) + .setPackage(context.packageName) + val stopPendingIntent = PendingIntent.getBroadcast( + context, + 0, + stopIntent, + getPendingIntentFlag(PendingIntent.FLAG_ONE_SHOT) + ) + builder.addAction( + android.R.drawable.ic_menu_close_clear_cancel, + context.getString(R.string.ftpmod_notif_stop_server), + stopPendingIntent + ) + } + + return builder + } + + private fun ensureNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "FTP Server", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "FTP server status notifications" + setShowBadge(false) + } + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + private fun getPendingIntentFlag(baseFlag: Int): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + baseFlag or PendingIntent.FLAG_IMMUTABLE + } else { + baseFlag + } + } +} diff --git a/ftpserver/src/main/res/drawable/ic_clear_all.xml b/ftpserver/src/main/res/drawable/ic_clear_all.xml new file mode 100644 index 0000000000..0b4883cf28 --- /dev/null +++ b/ftpserver/src/main/res/drawable/ic_clear_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/ftpserver/src/main/res/drawable/ic_eye_grey600_24dp.xml b/ftpserver/src/main/res/drawable/ic_eye_grey600_24dp.xml new file mode 100644 index 0000000000..4f1050af99 --- /dev/null +++ b/ftpserver/src/main/res/drawable/ic_eye_grey600_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/ftpserver/src/main/res/drawable/ic_ftp_dark.xml b/ftpserver/src/main/res/drawable/ic_ftp_dark.xml new file mode 100644 index 0000000000..23c8945b74 --- /dev/null +++ b/ftpserver/src/main/res/drawable/ic_ftp_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/ftpserver/src/main/res/drawable/ic_ftp_light.xml b/ftpserver/src/main/res/drawable/ic_ftp_light.xml new file mode 100644 index 0000000000..3aed91a5ca --- /dev/null +++ b/ftpserver/src/main/res/drawable/ic_ftp_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/ftpserver/src/main/res/layout/fragment_ftp.xml b/ftpserver/src/main/res/layout/fragment_ftp.xml new file mode 100644 index 0000000000..6280780a4a --- /dev/null +++ b/ftpserver/src/main/res/layout/fragment_ftp.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ftpserver/src/main/res/menu/ftp_server_menu.xml b/ftpserver/src/main/res/menu/ftp_server_menu.xml new file mode 100644 index 0000000000..42dd414b96 --- /dev/null +++ b/ftpserver/src/main/res/menu/ftp_server_menu.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/ftpserver/src/main/res/values/colors.xml b/ftpserver/src/main/res/values/colors.xml new file mode 100644 index 0000000000..cf74db6059 --- /dev/null +++ b/ftpserver/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1F000000 + diff --git a/ftpserver/src/main/res/values/strings.xml b/ftpserver/src/main/res/values/strings.xml new file mode 100644 index 0000000000..fa1e6862a1 --- /dev/null +++ b/ftpserver/src/main/res/values/strings.xml @@ -0,0 +1,62 @@ + + + + START + STOP + Status: + FTP Server Running + FTP Server Not Running + No network connection + Secure Connection (FTPS) + URL: + Username: + Password: + Port: + Path: + Anonymous + + + FTP port + FTP path + FTP login + Read-only + Timeout + Use SAF filesystem + seconds + + + Port changed successfully + Invalid port number (must be >= 1024) + Please restart the server to apply changes + Connect to a network to start FTP server + Settings + No Wi-Fi or Ethernet connection + Grant access to the storage location for FTP server + Storage Access Required + + + Extensions supported:\n AVBL\n UTF8 + + + Command not implemented for this filesystem + Access denied + Path is a file, not a directory + Path not found: %1$s + + + FTP Server + Starting FTP server… + FTP Server Running + FTP server is running at %1$s + Stop + + + OK + Cancel + Set + Change + Choose folder + Go up one level + Error + Unknown error occurred + diff --git a/plan-modularizeFtpServer.prompt.md b/plan-modularizeFtpServer.prompt.md new file mode 100644 index 0000000000..7875b46508 --- /dev/null +++ b/plan-modularizeFtpServer.prompt.md @@ -0,0 +1,128 @@ +## Plan: Modularize FTP Server Functionality for Pluggable Server Implementations + +The goal is to extract FTP server functionality from the `app` module into a dedicated module, enabling future pluggable implementations (SSH server, WebDAV server, etc.). Currently, FTP-related code is tightly coupled with the app module, including UI (`FtpServerFragment`), services (`FtpService`, `FtpReceiver`, `FtpTileService`), notifications (`FtpNotification`), filesystem factories, and resources. + +### Implementation Progress + +#### ✅ Completed Steps + +1. **Define a server abstraction layer in a new core module** — Created `server-core` module with: + - `FileServer.kt` - Base interface for server implementations + - `ServerPreferences.kt` - Interface for server preferences management + - `ServerNotification.kt` - Interface for notification handling + - `ServerEvent.kt` - Sealed class for server state events + - `ServerRegistry.kt` - Registry for discovering and managing server providers + +2. **Restructure the existing `ftpserver` module** — Updated module to depend on `server-core` instead of `app`. Created: + - `service/FtpServerEngine.kt` - Core FTP server lifecycle management + - `service/FtpServerService.kt` - Abstract Android Service for FTP + - `service/FtpReceiver.kt` - Abstract BroadcastReceiver for FTP start/stop + - `service/FtpEventBus.kt` - Event bus using Kotlin Flow + - `service/FtpPreferences.kt` - FTP-specific preferences + - `service/FtpCipherSuites.kt` - SSL cipher configuration + - `service/FtpCommandFactoryFactory.kt` - Custom command factory + +3. **Move FTP filesystem factories to the ftpserver module** — Created in `ftpserver/filesystem/`: + - `AndroidFileSystemFactory.kt` + - `AndroidFtpFileSystemView.kt` + - `AndroidFtpFile.kt` + - `RootFileSystemFactory.kt` + - `RootFileSystemView.kt` + - `RootFtpFile.kt` + - `commands/AVBL.kt`, `FEAT.kt`, `PWD.kt` + +4. **Created FtpServerProvider** — Implementation of `ServerProvider` interface in `FtpServerProvider.kt` + +5. **Extract FTP UI components to the ftpserver module** — ✅ Completed: + - `ui/BaseFtpServerFragment.kt` - Abstract base fragment for FTP UI + - `ui/FtpServerNotification.kt` - Notification handler implementation + - `res/layout/fragment_ftp.xml` - FTP fragment layout + - `res/menu/ftp_server_menu.xml` - Menu resources + - `res/values/strings.xml` - FTP strings (with `ftpmod_` prefix to avoid collisions) + - `res/values/colors.xml` - Color resources + - `res/drawable/` - Icon drawables + +6. **Update AndroidManifest and app module integration** — ✅ Partially completed: + - Added abstract FtpServerService and FtpReceiver to ftpserver manifest + - App module still uses its own FtpService, FtpReceiver implementations (coexisting during transition) + +#### 🔄 Remaining Steps + +7. **Migration of app module FTP code** — Still need to: + - Create concrete implementations in app that extend `FtpServerService` and `FtpReceiver` + - Update `FtpServerFragment` in app to extend `BaseFtpServerFragment` + - Gradually deprecate duplicate code in app module + - Update `MainActivity.java` and `Drawer.java` to use `ServerRegistry` for fragment instantiation + +8. **Move FTP tests to the ftpserver module** — Need to relocate: + - `FtpServiceEspressoTest.kt` + - `FtpReceiverTest.kt` + - Integration tests + +### Module Structure Created + +``` +server-core/ +├── build.gradle +├── src/main/ +│ ├── AndroidManifest.xml +│ └── java/com/amaze/filemanager/server/ +│ ├── FileServer.kt +│ ├── ServerEvent.kt +│ ├── ServerNotification.kt +│ ├── ServerPreferences.kt +│ └── ServerRegistry.kt + +ftpserver/ +├── build.gradle +├── src/main/ +│ ├── AndroidManifest.xml +│ ├── java/com/amaze/filemanager/ftpserver/ +│ │ ├── FtpServerProvider.kt +│ │ ├── commands/ +│ │ │ ├── AVBL.kt +│ │ │ ├── FEAT.kt +│ │ │ └── PWD.kt +│ │ ├── filesystem/ +│ │ │ ├── AndroidFileSystemFactory.kt +│ │ │ ├── AndroidFtpFile.kt +│ │ │ ├── AndroidFtpFileSystemView.kt +│ │ │ ├── RootFileSystemFactory.kt +│ │ │ ├── RootFileSystemView.kt +│ │ │ └── RootFtpFile.kt +│ │ ├── service/ +│ │ │ ├── FtpCipherSuites.kt +│ │ │ ├── FtpCommandFactoryFactory.kt +│ │ │ ├── FtpEventBus.kt +│ │ │ ├── FtpPreferences.kt +│ │ │ ├── FtpReceiver.kt +│ │ │ ├── FtpServerEngine.kt +│ │ │ └── FtpServerService.kt +│ │ └── ui/ +│ │ ├── BaseFtpServerFragment.kt +│ │ └── FtpServerNotification.kt +│ └── res/ +│ ├── drawable/ +│ │ ├── ic_clear_all.xml +│ │ ├── ic_eye_grey600_24dp.xml +│ │ ├── ic_ftp_dark.xml +│ │ └── ic_ftp_light.xml +│ ├── layout/ +│ │ └── fragment_ftp.xml +│ ├── menu/ +│ │ └── ftp_server_menu.xml +│ └── values/ +│ ├── colors.xml +│ └── strings.xml +``` + +### Further Considerations + +1. **Dependency inversion** — ✅ Done: `ftpserver` now depends on `server-core`, and `app` depends on both. + +2. **Feature module vs library module** — Implemented as library module (simpler approach). + +3. **Preference storage** — ✅ Done: `ServerPreferences` interface created in server-core, implemented in `FtpServerProvider`. + +4. **String resources** — Used `ftpmod_` prefix for all ftpserver module strings to avoid collision with app module's existing strings during transition period. + diff --git a/server-core/build.gradle b/server-core/build.gradle new file mode 100644 index 0000000000..8e8081ebc1 --- /dev/null +++ b/server-core/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "com.amaze.filemanager.server" + compileSdk libs.versions.compileSdk.get().toInteger() + + defaultConfig { + minSdk libs.versions.minSdk.get().toInteger() + targetSdk libs.versions.targetSdk.get().toInteger() + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation libs.androidX.core + implementation libs.androidX.appcompat + implementation libs.androidX.fragment + implementation libs.androidX.preference + implementation 'org.greenrobot:eventbus:3.3.1' + + testImplementation libs.junit + androidTestImplementation libs.androidX.test.ext.junit + androidTestImplementation libs.androidX.test.expresso +} diff --git a/server-core/consumer-rules.pro b/server-core/consumer-rules.pro new file mode 100644 index 0000000000..081b5c04ac --- /dev/null +++ b/server-core/consumer-rules.pro @@ -0,0 +1 @@ +# Consumer rules for server-core module diff --git a/server-core/proguard-rules.pro b/server-core/proguard-rules.pro new file mode 100644 index 0000000000..fb164d6662 --- /dev/null +++ b/server-core/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/server-core/src/main/AndroidManifest.xml b/server-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9a40236b94 --- /dev/null +++ b/server-core/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt new file mode 100644 index 0000000000..17ed2b7118 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2014-2024 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.server + +import android.content.Context + +/** + * Interface for file server implementations (FTP, SSH, WebDAV, etc.) + * + * Each server implementation should provide its own implementation of this interface + * to handle server lifecycle and configuration. + */ +interface FileServer { + + /** + * Unique identifier for this server type + */ + val serverType: ServerType + + /** + * Human-readable name for this server + */ + val displayName: String + + /** + * Check if the server is currently running + */ + fun isRunning(): Boolean + + /** + * Get the URL to connect to this server + * @return URL string or null if server is not running + */ + fun getServerUrl(): String? + + /** + * Get the default port for this server type + */ + fun getDefaultPort(): Int + + /** + * Get the currently configured port + */ + fun getPort(): Int +} + +/** + * Enum representing different server types + */ +enum class ServerType(val id: String) { + FTP("ftp"), + SFTP("sftp"), + SSH("ssh"), + WEBDAV("webdav") +} diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt new file mode 100644 index 0000000000..2f131f8eb1 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014-2024 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.server + +/** + * Events broadcast when server state changes. + * + * These events are posted via EventBus for UI components to react to server state changes. + */ +sealed class ServerEvent(val serverType: ServerType) { + + /** + * Server has started successfully + */ + class Started(serverType: ServerType, val startedByTile: Boolean = false) : ServerEvent(serverType) + + /** + * Server has stopped + */ + class Stopped(serverType: ServerType) : ServerEvent(serverType) + + /** + * Server failed to start + */ + class FailedToStart(serverType: ServerType, val error: Throwable? = null) : ServerEvent(serverType) +} diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt new file mode 100644 index 0000000000..b755112ed0 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014-2024 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.server + +import android.app.Notification +import android.content.Context + +/** + * Interface for server notification management. + * + * Each server implementation should provide its own notification handler + * to show server status in the notification bar. + */ +interface ServerNotification { + + /** + * Get the notification ID for this server + */ + fun getNotificationId(): Int + + /** + * Get the notification channel ID for this server + */ + fun getChannelId(): String + + /** + * Create the initial notification when server is starting + * @param context Application context + * @param noStopButton Whether to hide the stop button (e.g., when started from tile) + * @return Notification to display + */ + fun createStartingNotification(context: Context, noStopButton: Boolean = false): Notification + + /** + * Update the notification when server is running + * @param context Application context + * @param noStopButton Whether to hide the stop button + */ + fun updateRunningNotification(context: Context, noStopButton: Boolean = false) + + /** + * Remove the notification + * @param context Application context + */ + fun removeNotification(context: Context) +} diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt new file mode 100644 index 0000000000..f5719c7a15 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2014-2024 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.server + +import android.content.Context +import android.content.SharedPreferences + +/** + * Interface for server preferences management. + * + * Each server implementation can have its own preferences for port, path, credentials, etc. + */ +interface ServerPreferences { + + /** + * Get the shared preferences instance for this server + */ + fun getPreferences(context: Context): SharedPreferences + + /** + * Get the configured port + */ + fun getPort(context: Context): Int + + /** + * Set the port + */ + fun setPort(context: Context, port: Int) + + /** + * Get the configured path to share + */ + fun getPath(context: Context): String + + /** + * Set the path to share + */ + fun setPath(context: Context, path: String) + + /** + * Get the configured username (if authentication is enabled) + */ + fun getUsername(context: Context): String? + + /** + * Set the username + */ + fun setUsername(context: Context, username: String?) + + /** + * Check if the server requires authentication + */ + fun isAuthenticationEnabled(context: Context): Boolean + + /** + * Check if the server is configured for secure connection + */ + fun isSecureConnection(context: Context): Boolean + + /** + * Set secure connection preference + */ + fun setSecureConnection(context: Context, secure: Boolean) + + /** + * Check if the server is read-only + */ + fun isReadOnly(context: Context): Boolean + + /** + * Set read-only preference + */ + fun setReadOnly(context: Context, readOnly: Boolean) + + /** + * Get the idle timeout in seconds + */ + fun getTimeout(context: Context): Int + + /** + * Set the idle timeout + */ + fun setTimeout(context: Context, timeout: Int) +} diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt new file mode 100644 index 0000000000..638bc52809 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014-2024 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.server + +import androidx.fragment.app.Fragment + +/** + * Registry for server implementations. + * + * This allows the app to discover and use different server implementations + * without direct dependencies on specific server modules. + */ +object ServerRegistry { + + private val servers = mutableMapOf() + + /** + * Register a server provider + */ + fun register(provider: ServerProvider) { + servers[provider.serverType] = provider + } + + /** + * Unregister a server provider + */ + fun unregister(serverType: ServerType) { + servers.remove(serverType) + } + + /** + * Get a server provider by type + */ + fun getProvider(serverType: ServerType): ServerProvider? { + return servers[serverType] + } + + /** + * Get all registered server providers + */ + fun getAllProviders(): Collection { + return servers.values + } + + /** + * Check if a server type is registered + */ + fun isRegistered(serverType: ServerType): Boolean { + return servers.containsKey(serverType) + } +} + +/** + * Provider interface for creating server-related components. + * + * Each server module should implement this to provide its components. + */ +interface ServerProvider { + + /** + * The server type this provider handles + */ + val serverType: ServerType + + /** + * Display name for this server type + */ + val displayName: String + + /** + * Create the UI fragment for this server + */ + fun createFragment(): Fragment + + /** + * Get the server preferences handler + */ + fun getPreferences(): ServerPreferences + + /** + * Get the notification handler + */ + fun getNotification(): ServerNotification + + /** + * Check if the server is currently running + */ + fun isServerRunning(): Boolean + + /** + * Get the server URL if running + */ + fun getServerUrl(): String? +} diff --git a/settings.gradle b/settings.gradle index 70edc43f9d..09be77a449 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,5 @@ include ':file_operations' include ':portscanner' +include ':server-core' +include ':ftpserver' include ':app', ':commons_compress_7z' From 5e23bf008f4c70dd7ea57af956325a10446e7e7e Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 1 Feb 2026 12:14:51 +0800 Subject: [PATCH 03/15] Move tests to ftpserver module and deprecate classes in app module --- app/src/main/AndroidManifest.xml | 4 +- .../services/ftp/FtpTileService.kt | 16 +- .../ui/fragments/FtpServerFragment.kt | 86 ++++++----- .../ui/notifications/FtpNotification.java | 14 +- ftpserver/build.gradle | 18 ++- .../ftpserver/FtpServerProvider.kt | 83 +++++----- .../filemanager/ftpserver/commands/AVBL.kt | 8 +- .../filemanager/ftpserver/commands/FEAT.kt | 2 +- .../filesystem/AndroidFileSystemFactory.kt | 2 +- .../ftpserver/filesystem/AndroidFtpFile.kt | 1 - .../ftpserver/filesystem/RootFtpFile.kt | 2 +- .../ftpserver/service/FtpCipherSuites.kt | 2 + .../service/FtpCommandFactoryFactory.kt | 3 +- .../ftpserver/service/FtpEventBus.kt | 3 + .../ftpserver/service/FtpPreferences.kt | 9 ++ .../ftpserver/service/FtpReceiver.kt | 18 ++- .../ftpserver/service/FtpServerEngine.kt | 64 ++++---- .../ftpserver/service/FtpServerService.kt | 71 ++++----- .../ftpserver/ui/BaseFtpServerFragment.kt | 142 ++++++++++-------- .../ftpserver/ui/FtpServerNotification.kt | 121 ++++++++------- plan-modularizeFtpServer.prompt.md | 37 +++-- .../amaze/filemanager/server/FileServer.kt | 5 +- .../amaze/filemanager/server/ServerEvent.kt | 1 - .../filemanager/server/ServerNotification.kt | 11 +- .../filemanager/server/ServerPreferences.kt | 31 +++- .../filemanager/server/ServerRegistry.kt | 2 - 26 files changed, 433 insertions(+), 323 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ecc689bc2..901dfb2923 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -281,7 +281,7 @@ android:foregroundServiceType="dataSync" /> diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt index 1ae5638f08..a6584aff73 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt @@ -27,7 +27,9 @@ import android.service.quicksettings.TileService import android.widget.Toast import androidx.annotation.RequiresApi import com.amaze.filemanager.R -import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.isRunning +import com.amaze.filemanager.ftpserver.service.FtpEventBus +import com.amaze.filemanager.ftpserver.service.FtpPreferences +import com.amaze.filemanager.ftpserver.service.FtpServerEngine import com.amaze.filemanager.utils.NetworkUtil.isConnectedToLocalNetwork import com.amaze.filemanager.utils.NetworkUtil.isConnectedToWifi import kotlinx.coroutines.CoroutineScope @@ -37,7 +39,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** - * [FtpService] tile service to start and stop the FTP server. + * [AppFtpService] tile service to start and stop the FTP server. * * Created by vishal on 1/1/17. */ @RequiresApi(Build.VERSION_CODES.N) @@ -65,17 +67,17 @@ class FtpTileService : TileService() { override fun onClick() { unlockAndRun { - if (isRunning()) { + if (FtpServerEngine.isRunning()) { applicationContext .sendBroadcast( - Intent(FtpService.ACTION_STOP_FTPSERVER).setPackage(packageName), + Intent(FtpPreferences.ACTION_STOP_FTPSERVER).setPackage(packageName), ) } else { if (isConnectedToWifi(applicationContext) || isConnectedToLocalNetwork(applicationContext) ) { - val i = Intent(FtpService.ACTION_START_FTPSERVER).setPackage(packageName) - i.putExtra(FtpService.TAG_STARTED_BY_TILE, true) + val i = Intent(FtpPreferences.ACTION_START_FTPSERVER).setPackage(packageName) + i.putExtra(FtpPreferences.TAG_STARTED_BY_TILE, true) applicationContext.sendBroadcast(i) } else { Toast.makeText( @@ -90,7 +92,7 @@ class FtpTileService : TileService() { private fun updateTileState() { val tile = qsTile - if (isRunning()) { + if (FtpServerEngine.isRunning()) { tile.state = Tile.STATE_ACTIVE tile.icon = Icon.createWithResource( 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 1b6c3dc4dc..11fc22fef4 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 @@ -72,15 +72,13 @@ 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.FtpEventBus -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 -import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.isRunning -import com.amaze.filemanager.asynchronous.services.ftp.FtpService.FtpReceiverActions import com.amaze.filemanager.databinding.DialogFtpLoginBinding import com.amaze.filemanager.databinding.FragmentFtpBinding import com.amaze.filemanager.filesystem.files.FileUtils +import com.amaze.filemanager.ftpserver.service.FtpEventBus +import com.amaze.filemanager.ftpserver.service.FtpPreferences +import com.amaze.filemanager.ftpserver.service.FtpServerEngine +import com.amaze.filemanager.ftpserver.service.FtpServerEvent import com.amaze.filemanager.ui.activities.MainActivity import com.amaze.filemanager.ui.notifications.FtpNotification import com.amaze.filemanager.ui.runIfDocumentsUIExists @@ -181,7 +179,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } private fun ftpBtnOnClick() { - if (!isRunning()) { + if (!FtpServerEngine.isRunning()) { if (isConnectedToWifi(requireContext()) || isConnectedToLocalNetwork(requireContext()) ) { @@ -324,7 +322,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) timeoutBuilder.input( ( - FtpService.DEFAULT_TIMEOUT.toString() + + FtpPreferences.DEFAULT_TIMEOUT.toString() + " " + resources.getString(R.string.ftp_seconds) ), @@ -341,7 +339,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } ftpTimeout = if (input.isEmpty() || !isInputInteger) { - FtpService.DEFAULT_TIMEOUT + FtpPreferences.DEFAULT_TIMEOUT } else { Integer.valueOf(input.toString()) } @@ -374,7 +372,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private fun shouldUseSafFileSystem(): Boolean { return mainActivity.prefs.getBoolean( - FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, + FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, false, ) && SDK_INT >= M @@ -404,13 +402,13 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { /** * Handles messages sent from [FtpEventBus]. * - * @param signal as [FtpReceiverActions] + * @param signal as [FtpServerEvent] */ @Suppress("StringLiteralDuplication") - private fun onFtpReceiveActions(signal: FtpReceiverActions) { + private fun onFtpReceiveActions(signal: FtpServerEvent) { updateSpans() when (signal) { - FtpReceiverActions.STARTED, FtpReceiverActions.STARTED_FROM_TILE -> { + FtpServerEvent.Started, FtpServerEvent.StartedFromTile -> { statusText.text = if (securePreference) { spannedStatusSecure @@ -422,16 +420,16 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ftpBtn.text = resources.getString(R.string.stop_ftp).uppercase() FtpNotification.updateNotification( context, - FtpReceiverActions.STARTED_FROM_TILE == signal, + FtpServerEvent.StartedFromTile == signal, ) } - FtpReceiverActions.FAILED_TO_START -> { + FtpServerEvent.FailedToStart -> { statusText.text = spannedStatusNotRunning Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_LONG).show() ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() url.text = "URL: " } - FtpReceiverActions.STOPPED -> { + FtpServerEvent.Stopped -> { statusText.text = spannedStatusNotRunning url.text = "URL: " ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() @@ -460,7 +458,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private fun checkUriAccessIfNecessary(callback: () -> Unit) { val directoryUri: String = mainActivity.prefs - .getString(KEY_PREFERENCE_PATH, defaultPathFromPreferences)!! + .getString(FtpPreferences.KEY_PREFERENCE_PATH, defaultPathFromPreferences)!! if (shouldUseSafFileSystem()) { directoryUri.toUri().run { if (requireContext().checkUriPermission( @@ -541,14 +539,14 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { /** Sends a broadcast to stop ftp server */ private fun stopServer() { requireContext().sendBroadcast( - Intent(FtpService.ACTION_STOP_FTPSERVER) + Intent(FtpPreferences.ACTION_STOP_FTPSERVER) .setPackage(requireContext().packageName), ) } private fun doStartServer() = requireContext().sendBroadcast( - Intent(FtpService.ACTION_START_FTPSERVER) + Intent(FtpPreferences.ACTION_START_FTPSERVER) .setPackage(requireContext().packageName), ) @@ -572,7 +570,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { /** Update UI widgets after change in shared preferences */ private fun updateStatus() { - if (!isRunning()) { + if (!FtpServerEngine.isRunning()) { if (!isConnectedToWifi(requireContext()) && !isConnectedToLocalNetwork(requireContext()) ) { @@ -638,7 +636,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { port.text = "${resources.getString(R.string.ftp_port)}: $defaultPortFromPreferences" updatePathText() - if (defaultPathFromPreferences == FtpService.defaultPath(requireContext())) { + if (defaultPathFromPreferences == FtpPreferences.defaultPath(requireContext())) { sharedPath.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) } else { sharedPath.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_clear_all, 0) @@ -677,7 +675,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private fun resetFTPPath() { mainActivity.prefs .edit() - .putString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext())) + .putString(FtpPreferences.KEY_PREFERENCE_PATH, FtpPreferences.defaultPath(requireContext())) .apply() } @@ -744,7 +742,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } // init dialog views as per preferences - if (usernameFromPreferences == FtpService.DEFAULT_USERNAME) { + if (usernameFromPreferences == FtpPreferences.DEFAULT_USERNAME) { anonymousCheckBox.isChecked = true } else { usernameEditText.setText(usernameFromPreferences) @@ -817,9 +815,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { return ( ( if (securePreference) { - FtpService.INITIALS_HOST_SFTP + FtpPreferences.INITIALS_HOST_SFTP } else { - FtpService.INITIALS_HOST_FTP + FtpPreferences.INITIALS_HOST_FTP } ) + ia.hostAddress + @@ -831,11 +829,11 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private val defaultPortFromPreferences: Int get() = mainActivity.prefs - .getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT) + .getInt(FtpPreferences.PORT_PREFERENCE_KEY, FtpPreferences.DEFAULT_PORT) private val usernameFromPreferences: String get() = mainActivity.prefs - .getString(FtpService.KEY_PREFERENCE_USERNAME, FtpService.DEFAULT_USERNAME)!! + .getString(FtpPreferences.KEY_PREFERENCE_USERNAME, FtpPreferences.DEFAULT_USERNAME)!! // can't decrypt the password saved in preferences, remove the preference altogether private val passwordFromPreferences: String? @@ -843,7 +841,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { runCatching { val encryptedPassword: String = mainActivity.prefs.getString( - FtpService.KEY_PREFERENCE_PASSWORD, + FtpPreferences.KEY_PREFERENCE_PASSWORD, "", )!! if (encryptedPassword == "") { @@ -854,13 +852,13 @@ 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, "") } + mainActivity.prefs.edit { putString(FtpPreferences.KEY_PREFERENCE_PASSWORD, "") } }.getOrNull() private val defaultPathFromPreferences: String get() { return PreferenceManager.getDefaultSharedPreferences(mainActivity) - .getString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext()))!! + .getString(FtpPreferences.KEY_PREFERENCE_PATH, FtpPreferences.defaultPath(requireContext()))!! } private fun pathToDisplayString(path: String): String { @@ -880,7 +878,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } private fun changeFTPServerPort(port: Int) { - mainActivity.prefs.edit { putInt(FtpService.PORT_PREFERENCE_KEY, port) } + mainActivity.prefs.edit { putInt(FtpPreferences.PORT_PREFERENCE_KEY, port) } // first update spans which will point to an updated status updateSpans() @@ -896,9 +894,9 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { fun changeFTPServerPath(path: String) { PreferenceManager.getDefaultSharedPreferences(mainActivity).edit { if (FileUtils.isRunningAboveStorage(path)) { - putBoolean(KEY_PREFERENCE_ROOT_FILESYSTEM, true) + putBoolean(FtpPreferences.KEY_PREFERENCE_ROOT_FILESYSTEM, true) } - putString(KEY_PREFERENCE_PATH, path) + putString(FtpPreferences.KEY_PREFERENCE_PATH, path) } updateStatus() } @@ -907,7 +905,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { mainActivity .prefs .edit { - putString(FtpService.KEY_PREFERENCE_USERNAME, username) + putString(FtpPreferences.KEY_PREFERENCE_USERNAME, username) } updateStatus() } @@ -920,7 +918,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { .prefs .edit { putString( - FtpService.KEY_PREFERENCE_PASSWORD, + FtpPreferences.KEY_PREFERENCE_PASSWORD, PasswordUtil.encryptPassword(this@run, password), ) } @@ -942,46 +940,46 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { get() = mainActivity .prefs - .getInt(FtpService.KEY_PREFERENCE_TIMEOUT, FtpService.DEFAULT_TIMEOUT) + .getInt(FtpPreferences.KEY_PREFERENCE_TIMEOUT, FtpPreferences.DEFAULT_TIMEOUT) private set(seconds) { - mainActivity.prefs.edit { putInt(FtpService.KEY_PREFERENCE_TIMEOUT, seconds) } + mainActivity.prefs.edit { putInt(FtpPreferences.KEY_PREFERENCE_TIMEOUT, seconds) } } private var securePreference: Boolean get() = mainActivity .prefs - .getBoolean(FtpService.KEY_PREFERENCE_SECURE, FtpService.DEFAULT_SECURE) + .getBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, FtpPreferences.DEFAULT_SECURE) private set(isSecureEnabled) { mainActivity .prefs .edit { - putBoolean(FtpService.KEY_PREFERENCE_SECURE, isSecureEnabled) + putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, isSecureEnabled) } } private var readonlyPreference: Boolean - get() = mainActivity.prefs.getBoolean(FtpService.KEY_PREFERENCE_READONLY, false) + get() = mainActivity.prefs.getBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, false) private set(isReadonly) { mainActivity .prefs .edit { - putBoolean(FtpService.KEY_PREFERENCE_READONLY, isReadonly) + putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, isReadonly) } } private var legacyFileSystemPreference: Boolean - get() = mainActivity.prefs.getBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, false) + get() = mainActivity.prefs.getBoolean(FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, false) private set(useSafFileSystem) { mainActivity .prefs .edit { - putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, useSafFileSystem) + putBoolean(FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, useSafFileSystem) } } private fun promptUserToRestartServer() { - if (isRunning()) AppConfig.toast(context, R.string.ftp_prompt_restart_server) + if (FtpServerEngine.isRunning()) AppConfig.toast(context, R.string.ftp_prompt_restart_server) } private fun promptUserToEnableWireless() { diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java index bb45f396c0..69a8d12124 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java +++ b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java @@ -26,7 +26,7 @@ import java.net.InetAddress; import com.amaze.filemanager.R; -import com.amaze.filemanager.asynchronous.services.ftp.FtpService; +import com.amaze.filemanager.ftpserver.service.FtpPreferences; import com.amaze.filemanager.ui.activities.MainActivity; import com.amaze.filemanager.utils.NetworkUtil; @@ -34,12 +34,10 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; import androidx.annotation.StringRes; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; -import androidx.preference.PreferenceManager; /** * Created by yashwanthreddyg on 19-06-2016. @@ -72,7 +70,7 @@ private static NotificationCompat.Builder buildNotification( int stopIcon = android.R.drawable.ic_menu_close_clear_cancel; CharSequence stopText = context.getString(R.string.ftp_notif_stop_server); Intent stopIntent = - new Intent(FtpService.ACTION_STOP_FTPSERVER).setPackage(context.getPackageName()); + new Intent(FtpPreferences.ACTION_STOP_FTPSERVER).setPackage(context.getPackageName()); PendingIntent stopPendingIntent = PendingIntent.getBroadcast(context, 0, stopIntent, getPendingIntentFlag(FLAG_ONE_SHOT)); @@ -98,10 +96,8 @@ public static Notification startNotification(Context context, boolean noStopButt public static void updateNotification(Context context, boolean noStopButton) { NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - int port = sharedPreferences.getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT); - boolean secureConnection = - sharedPreferences.getBoolean(FtpService.KEY_PREFERENCE_SECURE, FtpService.DEFAULT_SECURE); + int port = FtpPreferences.getPort(context); + boolean secureConnection = FtpPreferences.isSecure(context); InetAddress address = NetworkUtil.getLocalInetAddress(context, false); @@ -109,7 +105,7 @@ public static void updateNotification(Context context, boolean noStopButton) { if (address != null) { address_text = - (secureConnection ? FtpService.INITIALS_HOST_SFTP : FtpService.INITIALS_HOST_FTP) + (secureConnection ? FtpPreferences.INITIALS_HOST_SFTP : FtpPreferences.INITIALS_HOST_FTP) + address.getHostAddress() + ":" + port diff --git a/ftpserver/build.gradle b/ftpserver/build.gradle index de246574fc..93a433d66f 100644 --- a/ftpserver/build.gradle +++ b/ftpserver/build.gradle @@ -33,6 +33,18 @@ android { viewBinding true buildConfig true } + + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + all { + // Required for Mockito to work with Java 21 + jvmArgs '-Dnet.bytebuddy.experimental=true' + jvmArgs '-XX:+EnableDynamicAgentLoading' + } + } + } } dependencies { @@ -65,9 +77,11 @@ dependencies { implementation libs.libsu.io testImplementation libs.junit - testImplementation libs.mockito.core testImplementation libs.mockk - testImplementation libs.robolectric + testImplementation libs.mockito.core + testImplementation libs.mockito.inline + testImplementation libs.androidX.test.core + testImplementation libs.androidX.test.ext.junit androidTestImplementation libs.androidX.test.ext.junit androidTestImplementation libs.androidX.test.expresso diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt index e761cfc1d8..ac79497b85 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt @@ -22,11 +22,11 @@ package com.amaze.filemanager.ftpserver import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.amaze.filemanager.ftpserver.service.FtpPreferences import com.amaze.filemanager.ftpserver.service.FtpServerEngine -import com.amaze.filemanager.server.FileServer import com.amaze.filemanager.server.ServerNotification import com.amaze.filemanager.server.ServerPreferences import com.amaze.filemanager.server.ServerProvider @@ -38,9 +38,8 @@ import com.amaze.filemanager.server.ServerType class FtpServerProvider( private val context: Context, private val fragmentFactory: () -> Fragment, - private val notificationHandler: ServerNotification + private val notificationHandler: ServerNotification, ) : ServerProvider { - override val serverType: ServerType = ServerType.FTP override val displayName: String = "FTP Server" @@ -72,56 +71,70 @@ class FtpServerProvider( override fun getPort(context: Context): Int = FtpPreferences.getPort(context) - override fun setPort(context: Context, port: Int) { - getPreferences(context).edit() - .putInt(FtpPreferences.PORT_PREFERENCE_KEY, port) - .apply() + override fun setPort( + context: Context, + port: Int, + ) { + getPreferences(context).edit { + putInt(FtpPreferences.PORT_PREFERENCE_KEY, port) + } } override fun getPath(context: Context): String = FtpPreferences.getPath(context) - override fun setPath(context: Context, path: String) { - getPreferences(context).edit() - .putString(FtpPreferences.KEY_PREFERENCE_PATH, path) - .apply() + override fun setPath( + context: Context, + path: String, + ) { + getPreferences(context).edit { + putString(FtpPreferences.KEY_PREFERENCE_PATH, path) + } } - override fun getUsername(context: Context): String? = - FtpPreferences.getUsername(context).takeIf { it.isNotEmpty() } + override fun getUsername(context: Context): String? = FtpPreferences.getUsername(context).takeIf { it.isNotEmpty() } - override fun setUsername(context: Context, username: String?) { - getPreferences(context).edit() - .putString(FtpPreferences.KEY_PREFERENCE_USERNAME, username ?: "") - .apply() + override fun setUsername( + context: Context, + username: String?, + ) { + getPreferences(context).edit { + putString(FtpPreferences.KEY_PREFERENCE_USERNAME, username ?: "") + } } - override fun isAuthenticationEnabled(context: Context): Boolean = - getUsername(context) != null + override fun isAuthenticationEnabled(context: Context): Boolean = getUsername(context) != null - override fun isSecureConnection(context: Context): Boolean = - FtpPreferences.isSecure(context) + override fun isSecureConnection(context: Context): Boolean = FtpPreferences.isSecure(context) - override fun setSecureConnection(context: Context, secure: Boolean) { - getPreferences(context).edit() - .putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, secure) - .apply() + override fun setSecureConnection( + context: Context, + secure: Boolean, + ) { + getPreferences(context).edit { + putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, secure) + } } - override fun isReadOnly(context: Context): Boolean = - FtpPreferences.isReadOnly(context) + override fun isReadOnly(context: Context): Boolean = FtpPreferences.isReadOnly(context) - override fun setReadOnly(context: Context, readOnly: Boolean) { - getPreferences(context).edit() - .putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, readOnly) - .apply() + override fun setReadOnly( + context: Context, + readOnly: Boolean, + ) { + getPreferences(context).edit { + putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, readOnly) + } } override fun getTimeout(context: Context): Int = FtpPreferences.getTimeout(context) - override fun setTimeout(context: Context, timeout: Int) { - getPreferences(context).edit() - .putInt(FtpPreferences.KEY_PREFERENCE_TIMEOUT, timeout) - .apply() + override fun setTimeout( + context: Context, + timeout: Int, + ) { + getPreferences(context).edit { + putInt(FtpPreferences.KEY_PREFERENCE_TIMEOUT, timeout) + } } } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt index 4c78353e41..ffcd5ef0d7 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt @@ -44,14 +44,16 @@ import java.io.File * See [Draft spec](https://www.ietf.org/archive/id/draft-peterson-streamlined-ftp-command-extensions-10.txt) */ class AVBL( - private val errorMessageProvider: ErrorMessageProvider + private val errorMessageProvider: ErrorMessageProvider, ) : AbstractCommand() { - /** * Interface for providing localized error messages */ interface ErrorMessageProvider { - fun getErrorMessage(subId: String, fileName: String? = null): String + fun getErrorMessage( + subId: String, + fileName: String? = null, + ): String } companion object { diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt index df617e6028..ee70196d82 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt @@ -31,7 +31,7 @@ import org.apache.ftpserver.impl.FtpServerContext * Custom FEAT command to add AVBL command to the list. */ class FEAT( - private val featResponseProvider: () -> String + private val featResponseProvider: () -> String, ) : AbstractCommand() { override fun execute( session: FtpIoSession, diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt index 370104c2d3..0a90fe0c48 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt @@ -30,7 +30,7 @@ import org.apache.ftpserver.ftplet.User @RequiresApi(KITKAT) class AndroidFileSystemFactory( private val context: Context, - private val defaultPathProvider: () -> String + private val defaultPathProvider: () -> String, ) : FileSystemFactory { override fun createFileSystemView(user: User?): FileSystemView = AndroidFtpFileSystemView(context, user?.homeDirectory ?: defaultPathProvider()) diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt index 53445a53cb..c6f5fc482f 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt @@ -20,7 +20,6 @@ package com.amaze.filemanager.ftpserver.filesystem -import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.net.Uri diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt index 41ca8193ea..8c51384969 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt @@ -87,7 +87,7 @@ class RootFtpFile( if (indexOfSlash == 0) { "/" } else { - fullName.substring(0, indexOfSlash) + fullName.take(indexOfSlash) } return backingFile.absoluteFile.parentFile?.run { diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt index b0e7437892..92bd7a1f9c 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.kt @@ -20,6 +20,7 @@ package com.amaze.filemanager.ftpserver.service +import android.annotation.SuppressLint import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.LOLLIPOP import android.os.Build.VERSION_CODES.N @@ -40,6 +41,7 @@ object FtpCipherSuites { * @see [javax.net.ssl.SSLEngine] */ @JvmStatic + @SuppressLint("ObsoleteSdkInt") val enabledCipherSuites: Array = LinkedList().apply { if (SDK_INT >= Q) { diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt index cfaa01aafd..3f084147ca 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt @@ -23,7 +23,6 @@ package com.amaze.filemanager.ftpserver.service import com.amaze.filemanager.ftpserver.commands.AVBL import com.amaze.filemanager.ftpserver.commands.FEAT import com.amaze.filemanager.ftpserver.commands.PWD -import com.amaze.filemanager.ftpserver.filesystem.AndroidFtpFileSystemView import org.apache.ftpserver.command.CommandFactory import org.apache.ftpserver.command.CommandFactoryFactory @@ -38,7 +37,7 @@ object FtpCommandFactoryFactory { fun create( useAndroidFileSystem: Boolean, errorMessageProvider: AVBL.ErrorMessageProvider, - featResponseProvider: () -> String + featResponseProvider: () -> String, ): CommandFactory { val cf = CommandFactoryFactory() if (!useAndroidFileSystem) { diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt index eacea35250..2077956400 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt @@ -43,7 +43,10 @@ object FtpEventBus { */ sealed class FtpServerEvent { data object Started : FtpServerEvent() + data object StartedFromTile : FtpServerEvent() + data object Stopped : FtpServerEvent() + data object FailedToStart : FtpServerEvent() } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt index 35fc786e2c..14a7ebbedc 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt @@ -59,6 +59,7 @@ object FtpPreferences { /** * Get default preferences for FTP server */ + @JvmStatic fun getPreferences(context: Context): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(context) } @@ -66,6 +67,7 @@ object FtpPreferences { /** * Get configured port */ + @JvmStatic fun getPort(context: Context): Int { return getPreferences(context).getInt(PORT_PREFERENCE_KEY, DEFAULT_PORT) } @@ -73,6 +75,7 @@ object FtpPreferences { /** * Get whether secure connection is enabled */ + @JvmStatic fun isSecure(context: Context): Boolean { return getPreferences(context).getBoolean(KEY_PREFERENCE_SECURE, DEFAULT_SECURE) } @@ -80,6 +83,7 @@ object FtpPreferences { /** * Get configured timeout */ + @JvmStatic fun getTimeout(context: Context): Int { return getPreferences(context).getInt(KEY_PREFERENCE_TIMEOUT, DEFAULT_TIMEOUT) } @@ -87,6 +91,7 @@ object FtpPreferences { /** * Get configured path */ + @JvmStatic fun getPath(context: Context): String { return getPreferences(context).getString(KEY_PREFERENCE_PATH, defaultPath(context)) ?: defaultPath(context) @@ -95,6 +100,7 @@ object FtpPreferences { /** * Get configured username */ + @JvmStatic fun getUsername(context: Context): String { return getPreferences(context).getString(KEY_PREFERENCE_USERNAME, DEFAULT_USERNAME) ?: DEFAULT_USERNAME @@ -103,6 +109,7 @@ object FtpPreferences { /** * Check if read-only mode is enabled */ + @JvmStatic fun isReadOnly(context: Context): Boolean { return getPreferences(context).getBoolean(KEY_PREFERENCE_READONLY, false) } @@ -110,6 +117,7 @@ object FtpPreferences { /** * Check if SAF filesystem should be used */ + @JvmStatic fun useSafFilesystem(context: Context): Boolean { return getPreferences(context).getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) } @@ -117,6 +125,7 @@ object FtpPreferences { /** * Derive the FTP server's default share path, depending the user's Android version. */ + @JvmStatic fun defaultPath(context: Context): String { return if (useSafFilesystem(context) && SDK_INT > M) { DocumentsContract.buildTreeDocumentUri( diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt index 6154cf648a..16bd182d30 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt @@ -23,8 +23,9 @@ package com.amaze.filemanager.ftpserver.service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.util.Log import androidx.core.content.ContextCompat +import org.slf4j.Logger +import org.slf4j.LoggerFactory /** * Broadcast receiver for FTP server start/stop commands. @@ -33,16 +34,21 @@ import androidx.core.content.ContextCompat * to provide the concrete FtpServerService class. */ abstract class FtpReceiver : BroadcastReceiver() { - - private val TAG = FtpReceiver::class.java.simpleName + companion object { + @JvmStatic + private val logger: Logger = LoggerFactory.getLogger(FtpReceiver::class.java) + } /** * Get the FTP service class to start/stop */ abstract fun getFtpServiceClass(): Class<*> - override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "Received: ${intent.action}") + override fun onReceive( + context: Context, + intent: Intent, + ) { + logger.debug("Received: ${intent.action}") val serviceIntent = Intent(context, getFtpServiceClass()) serviceIntent.putExtras(intent) @@ -62,7 +68,7 @@ abstract class FtpReceiver : BroadcastReceiver() { else -> Unit } }.onFailure { - Log.e(TAG, "Failed to start/stop on intent: ${it.message}") + logger.error("Failed to start/stop on intent", it) } } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt index 874fb92ca2..508eeee8b5 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt @@ -75,7 +75,7 @@ object FtpServerEngine { val keyStoreInputStream: InputStream? = null, val keyStorePassword: String = "", val errorMessageProvider: AVBL.ErrorMessageProvider? = null, - val featResponseProvider: (() -> String)? = null + val featResponseProvider: (() -> String)? = null, ) /** @@ -92,7 +92,7 @@ object FtpServerEngine { fun start( context: Context, config: ServerConfig, - onStarted: (Boolean) -> Unit = {} + onStarted: (Boolean) -> Unit = {}, ) { if (isRunning()) { log.warn("FTP server already running") @@ -100,15 +100,16 @@ object FtpServerEngine { return } - serverThread = Thread { - runServer(context, config, onStarted) - }.apply { start() } + serverThread = + Thread { + runServer(context, config, onStarted) + }.apply { start() } } private fun runServer( context: Context, config: ServerConfig, - onStarted: (Boolean) -> Unit + onStarted: (Boolean) -> Unit, ) { try { FtpServerFactory().run { @@ -125,11 +126,12 @@ object FtpServerEngine { // Configure commands if (config.errorMessageProvider != null && config.featResponseProvider != null) { - commandFactory = FtpCommandFactoryFactory.create( - config.useSafFilesystem, - config.errorMessageProvider, - config.featResponseProvider - ) + commandFactory = + FtpCommandFactoryFactory.create( + config.useSafFilesystem, + config.errorMessageProvider, + config.featResponseProvider, + ) } // Configure user @@ -159,22 +161,25 @@ object FtpServerEngine { val keyStorePassword = config.keyStorePassword.toCharArray() keyStore.load(config.keyStoreInputStream, keyStorePassword) - val keyManagerFactory = KeyManagerFactory - .getInstance(KeyManagerFactory.getDefaultAlgorithm()) + val keyManagerFactory = + KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()) keyManagerFactory.init(keyStore, keyStorePassword) - val trustManagerFactory = TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + val trustManagerFactory = + TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) trustManagerFactory.init(keyStore) - listenerFactory.sslConfiguration = DefaultSslConfiguration( - keyManagerFactory, - trustManagerFactory, - ClientAuth.WANT, - "TLS", - FtpCipherSuites.enabledCipherSuites, - "ftpserver" - ) + listenerFactory.sslConfiguration = + DefaultSslConfiguration( + keyManagerFactory, + trustManagerFactory, + ClientAuth.WANT, + "TLS", + FtpCipherSuites.enabledCipherSuites, + "ftpserver", + ) listenerFactory.isImplicitSsl = true } catch (e: GeneralSecurityException) { log.error("Failed to configure SSL", e) @@ -188,13 +193,14 @@ object FtpServerEngine { addListener("default", listenerFactory.createListener()) - server = createServer().apply { - start() - scope.launch { - FtpEventBus.emit(FtpServerEvent.Started) + server = + createServer().apply { + start() + scope.launch { + FtpEventBus.emit(FtpServerEvent.Started) + } + onStarted(true) } - onStarted(true) - } } } catch (e: Exception) { log.error("Failed to start FTP server", e) diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt index 3cea56ea32..cd0d461eea 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt @@ -26,13 +26,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.PowerManager -import androidx.core.app.ServiceCompat -import com.amaze.filemanager.ftpserver.R import com.amaze.filemanager.ftpserver.commands.AVBL -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.InputStream @@ -44,10 +38,6 @@ import java.util.concurrent.TimeUnit * This service manages the FTP server lifecycle as a foreground service. */ abstract class FtpServerService : Service() { - - private val log: Logger = LoggerFactory.getLogger(FtpServerService::class.java) - private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private lateinit var wakeLock: PowerManager.WakeLock private var isStartedByTile = false @@ -109,7 +99,11 @@ abstract class FtpServerService : Service() { wakeLock.setReferenceCounted(false) } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { isStartedByTile = intent?.getBooleanExtra(FtpPreferences.TAG_STARTED_BY_TILE, false) == true // Wait for any existing server to stop @@ -119,7 +113,7 @@ abstract class FtpServerService : Service() { attempts-- try { Thread.sleep(1000) - } catch (ignored: InterruptedException) { + } catch (_: InterruptedException) { } } else { return START_STICKY @@ -132,7 +126,7 @@ abstract class FtpServerService : Service() { startForeground( getNotificationId(), notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } else { startForeground(getNotificationId(), notification) @@ -151,28 +145,34 @@ abstract class FtpServerService : Service() { // Get password if authentication is enabled val username = FtpPreferences.getUsername(this) - val password = if (username.isNotEmpty()) { - val encryptedPassword = prefs.getString(FtpPreferences.KEY_PREFERENCE_PASSWORD, "") ?: "" - if (encryptedPassword.isNotEmpty()) { - decryptPassword(encryptedPassword) - } else null - } else null - - val config = FtpServerEngine.ServerConfig( - port = FtpPreferences.getPort(this), - timeout = FtpPreferences.getTimeout(this), - path = FtpPreferences.getPath(this), - username = username.takeIf { it.isNotEmpty() }, - password = password, - isSecure = FtpPreferences.isSecure(this), - isReadOnly = FtpPreferences.isReadOnly(this), - useSafFilesystem = FtpPreferences.useSafFilesystem(this), - useRootFilesystem = isRootModeEnabled(), - keyStoreInputStream = if (FtpPreferences.isSecure(this)) getKeyStoreInputStream() else null, - keyStorePassword = getKeyStorePassword(), - errorMessageProvider = getErrorMessageProvider(), - featResponseProvider = { getFeatResponse() } - ) + val password = + if (username.isNotEmpty()) { + val encryptedPassword = prefs.getString(FtpPreferences.KEY_PREFERENCE_PASSWORD, "") ?: "" + if (encryptedPassword.isNotEmpty()) { + decryptPassword(encryptedPassword) + } else { + null + } + } else { + null + } + + val config = + FtpServerEngine.ServerConfig( + port = FtpPreferences.getPort(this), + timeout = FtpPreferences.getTimeout(this), + path = FtpPreferences.getPath(this), + username = username.takeIf { it.isNotEmpty() }, + password = password, + isSecure = FtpPreferences.isSecure(this), + isReadOnly = FtpPreferences.isReadOnly(this), + useSafFilesystem = FtpPreferences.useSafFilesystem(this), + useRootFilesystem = isRootModeEnabled(), + keyStoreInputStream = if (FtpPreferences.isSecure(this)) getKeyStoreInputStream() else null, + keyStorePassword = getKeyStorePassword(), + errorMessageProvider = getErrorMessageProvider(), + featResponseProvider = { getFeatResponse() }, + ) FtpServerEngine.start(this, config) { success -> if (success) { @@ -199,6 +199,7 @@ abstract class FtpServerService : Service() { override fun onBind(intent: Intent?): IBinder? = null companion object { + @JvmStatic private val log: Logger = LoggerFactory.getLogger(FtpServerService::class.java) } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt index d1be8a48ea..b46458140d 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt @@ -35,6 +35,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.core.content.edit import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle @@ -55,7 +56,6 @@ import kotlinx.coroutines.launch * by the app module to add app-specific features. */ abstract class BaseFtpServerFragment : Fragment() { - private var _binding: FragmentFtpBinding? = null protected val binding get() = _binding!! @@ -133,7 +133,7 @@ abstract class BaseFtpServerFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { _binding = FragmentFtpBinding.inflate(inflater, container, false) @@ -173,7 +173,10 @@ abstract class BaseFtpServerFragment : Fragment() { } @Deprecated("Deprecated in Java") - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + override fun onCreateOptionsMenu( + menu: Menu, + inflater: MenuInflater, + ) { inflater.inflate(R.menu.ftp_server_menu, menu) menu.findItem(R.id.checkbox_ftp_readonly)?.isChecked = FtpPreferences.isReadOnly(requireContext()) @@ -254,11 +257,12 @@ abstract class BaseFtpServerFragment : Fragment() { when (event) { is FtpServerEvent.Started, is FtpServerEvent.StartedFromTile -> { val isSecure = FtpPreferences.isSecure(requireContext()) - binding.textViewFtpStatus.text = if (isSecure) { - spannedStatusSecure - } else { - spannedStatusConnected - } + binding.textViewFtpStatus.text = + if (isSecure) { + spannedStatusSecure + } else { + spannedStatusConnected + } binding.textViewFtpUrl.text = spannedStatusUrl binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() } @@ -280,29 +284,33 @@ abstract class BaseFtpServerFragment : Fragment() { private fun updateSpans() { val accentColor = String.format("%06X", 0xFFFFFF and getAccentColor()) - spannedStatusNoConnection = HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_no_connection)}", - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - - spannedStatusConnected = HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_running)}", - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - - spannedStatusSecure = HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_secure_connection)}", - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - - spannedStatusNotRunning = HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_not_running)}", - HtmlCompat.FROM_HTML_MODE_COMPACT - ) + spannedStatusNoConnection = + HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_no_connection)}", + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + + spannedStatusConnected = + HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_running)}", + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + + spannedStatusSecure = + HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_secure_connection)}", + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + + spannedStatusNotRunning = + HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_status_label)} " + + "${getString(R.string.ftpmod_status_not_running)}", + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) val address = getLocalAddress() val port = FtpPreferences.getPort(requireContext()) @@ -310,11 +318,12 @@ abstract class BaseFtpServerFragment : Fragment() { val prefix = if (isSecure) FtpPreferences.INITIALS_HOST_SFTP else FtpPreferences.INITIALS_HOST_FTP val urlText = if (address != null) "$prefix$address:$port/" else "" - spannedStatusUrl = HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_url_label)} " + - "$urlText", - HtmlCompat.FROM_HTML_MODE_COMPACT - ) + spannedStatusUrl = + HtmlCompat.fromHtml( + "${getString(R.string.ftpmod_url_label)} " + + "$urlText", + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) } private fun updateStatus() { @@ -326,11 +335,12 @@ abstract class BaseFtpServerFragment : Fragment() { } else { binding.startStopButton.isEnabled = true if (FtpServerEngine.isRunning()) { - binding.textViewFtpStatus.text = if (FtpPreferences.isSecure(requireContext())) { - spannedStatusSecure - } else { - spannedStatusConnected - } + binding.textViewFtpStatus.text = + if (FtpPreferences.isSecure(requireContext())) { + spannedStatusSecure + } else { + spannedStatusConnected + } binding.textViewFtpUrl.text = spannedStatusUrl binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() } else { @@ -350,7 +360,7 @@ abstract class BaseFtpServerFragment : Fragment() { private fun updateUsernameText() { val username = FtpPreferences.getUsername(requireContext()) - val displayName = if (username.isEmpty()) getString(R.string.ftpmod_anonymous) else username + val displayName = username.ifEmpty { getString(R.string.ftpmod_anonymous) } binding.textViewFtpUsername.text = "${getString(R.string.ftpmod_username_label)}$displayName" } @@ -377,21 +387,21 @@ abstract class BaseFtpServerFragment : Fragment() { } private fun setReadonlyPreference(value: Boolean) { - FtpPreferences.getPreferences(requireContext()).edit() - .putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, value) - .apply() + FtpPreferences.getPreferences(requireContext()).edit { + putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, value) + } } private fun setSecurePreference(value: Boolean) { - FtpPreferences.getPreferences(requireContext()).edit() - .putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, value) - .apply() + FtpPreferences.getPreferences(requireContext()).edit { + putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, value) + } } private fun setSafFilesystemPreference(value: Boolean) { - FtpPreferences.getPreferences(requireContext()).edit() - .putBoolean(FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, value) - .apply() + FtpPreferences.getPreferences(requireContext()).edit { + putBoolean(FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, value) + } } private fun promptUserToRestartServer() { @@ -408,20 +418,24 @@ abstract class BaseFtpServerFragment : Fragment() { // Override in subclass to show timeout dialog with material-dialogs } - private val wifiReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (isConnectedToLocalNetwork()) { - binding.startStopButton.isEnabled = true - dismissSnackbar() - } else { - stopServer() - binding.textViewFtpStatus.text = spannedStatusNoConnection - binding.startStopButton.isEnabled = false - binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() - promptUserToEnableWireless() + private val wifiReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (isConnectedToLocalNetwork()) { + binding.startStopButton.isEnabled = true + dismissSnackbar() + } else { + stopServer() + binding.textViewFtpStatus.text = spannedStatusNoConnection + binding.startStopButton.isEnabled = false + binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() + promptUserToEnableWireless() + } } } - } private fun registerWifiReceiver() { val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) @@ -429,7 +443,7 @@ abstract class BaseFtpServerFragment : Fragment() { requireContext(), wifiReceiver, filter, - ContextCompat.RECEIVER_NOT_EXPORTED + ContextCompat.RECEIVER_NOT_EXPORTED, ) } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt index a5cde43c4e..eaa46676cb 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt @@ -40,24 +40,29 @@ class FtpServerNotification( private val notificationId: Int, private val channelId: String, private val mainActivityIntent: Intent, - private val getLocalAddress: (Context) -> String? + private val getLocalAddress: (Context) -> String?, ) : ServerNotification { - override fun getNotificationId(): Int = notificationId override fun getChannelId(): String = channelId - override fun createStartingNotification(context: Context, noStopButton: Boolean): Notification { + override fun createStartingNotification( + context: Context, + noStopButton: Boolean, + ): Notification { ensureNotificationChannel(context) return buildNotification( context, R.string.ftpmod_notif_starting_title, context.getString(R.string.ftpmod_notif_starting), - noStopButton + noStopButton, ).build() } - override fun updateRunningNotification(context: Context, noStopButton: Boolean) { + override fun updateRunningNotification( + context: Context, + noStopButton: Boolean, + ) { ensureNotificationChannel(context) val notificationManager = NotificationManagerCompat.from(context) @@ -65,23 +70,26 @@ class FtpServerNotification( val secureConnection = FtpPreferences.isSecure(context) val address = getLocalAddress(context) - val addressText = if (address != null) { - val prefix = if (secureConnection) { - FtpPreferences.INITIALS_HOST_SFTP + val addressText = + if (address != null) { + val prefix = + if (secureConnection) { + FtpPreferences.INITIALS_HOST_SFTP + } else { + FtpPreferences.INITIALS_HOST_FTP + } + "$prefix$address:$port/" } else { - FtpPreferences.INITIALS_HOST_FTP + "Address not found" } - "$prefix$address:$port/" - } else { - "Address not found" - } - val notification = buildNotification( - context, - R.string.ftpmod_notif_title, - context.getString(R.string.ftpmod_notif_text, addressText), - noStopButton - ).build() + val notification = + buildNotification( + context, + R.string.ftpmod_notif_title, + context.getString(R.string.ftpmod_notif_text, addressText), + noStopButton, + ).build() notificationManager.notify(notificationId, notification) } @@ -94,40 +102,44 @@ class FtpServerNotification( context: Context, titleResId: Int, contentText: String, - noStopButton: Boolean + noStopButton: Boolean, ): NotificationCompat.Builder { - val contentIntent = PendingIntent.getActivity( - context, - 0, - mainActivityIntent.apply { - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - }, - getPendingIntentFlag(0) - ) - - val builder = NotificationCompat.Builder(context, channelId) - .setContentTitle(context.getString(titleResId)) - .setContentText(contentText) - .setContentIntent(contentIntent) - .setSmallIcon(R.drawable.ic_ftp_light) - .setTicker(context.getString(R.string.ftpmod_notif_starting)) - .setWhen(System.currentTimeMillis()) - .setOngoing(true) - .setOnlyAlertOnce(true) - - if (!noStopButton) { - val stopIntent = Intent(FtpPreferences.ACTION_STOP_FTPSERVER) - .setPackage(context.packageName) - val stopPendingIntent = PendingIntent.getBroadcast( + val contentIntent = + PendingIntent.getActivity( context, 0, - stopIntent, - getPendingIntentFlag(PendingIntent.FLAG_ONE_SHOT) + mainActivityIntent.apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + getPendingIntentFlag(0), ) + + val builder = + NotificationCompat.Builder(context, channelId) + .setContentTitle(context.getString(titleResId)) + .setContentText(contentText) + .setContentIntent(contentIntent) + .setSmallIcon(R.drawable.ic_ftp_light) + .setTicker(context.getString(R.string.ftpmod_notif_starting)) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setOnlyAlertOnce(true) + + if (!noStopButton) { + val stopIntent = + Intent(FtpPreferences.ACTION_STOP_FTPSERVER) + .setPackage(context.packageName) + val stopPendingIntent = + PendingIntent.getBroadcast( + context, + 0, + stopIntent, + getPendingIntentFlag(PendingIntent.FLAG_ONE_SHOT), + ) builder.addAction( android.R.drawable.ic_menu_close_clear_cancel, context.getString(R.string.ftpmod_notif_stop_server), - stopPendingIntent + stopPendingIntent, ) } @@ -136,14 +148,15 @@ class FtpServerNotification( private fun ensureNotificationChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - channelId, - "FTP Server", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "FTP server status notifications" - setShowBadge(false) - } + val channel = + NotificationChannel( + channelId, + "FTP Server", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "FTP server status notifications" + setShowBadge(false) + } val notificationManager = context.getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) } diff --git a/plan-modularizeFtpServer.prompt.md b/plan-modularizeFtpServer.prompt.md index 7875b46508..d05581ea65 100644 --- a/plan-modularizeFtpServer.prompt.md +++ b/plan-modularizeFtpServer.prompt.md @@ -48,16 +48,24 @@ The goal is to extract FTP server functionality from the `app` module into a ded #### 🔄 Remaining Steps -7. **Migration of app module FTP code** — Still need to: - - Create concrete implementations in app that extend `FtpServerService` and `FtpReceiver` - - Update `FtpServerFragment` in app to extend `BaseFtpServerFragment` - - Gradually deprecate duplicate code in app module - - Update `MainActivity.java` and `Drawer.java` to use `ServerRegistry` for fragment instantiation - -8. **Move FTP tests to the ftpserver module** — Need to relocate: - - `FtpServiceEspressoTest.kt` - - `FtpReceiverTest.kt` - - Integration tests +7. **Migration of app module FTP code** — ✅ Completed: + - Created `AppFtpService.kt` - Concrete implementation extending `FtpServerService` + - Created `AppFtpReceiver.kt` - Concrete implementation extending `FtpReceiver` + - Updated `FtpServerFragment.kt` to use ftpserver module classes (`FtpPreferences`, `FtpServerEngine`, `FtpServerEvent`, `FtpEventBus`) + - Updated `FtpTileService.kt` to use ftpserver module classes + - Updated `FtpNotification.java` to use ftpserver module classes + - Updated `AndroidManifest.xml` to register `AppFtpService` and `AppFtpReceiver` + - Added `@JvmStatic` annotations to `FtpPreferences` for Java interop + - Old `FtpService.kt` and `FtpReceiver.kt` are now deprecated (can be removed in future) + +8. **Move FTP tests to the ftpserver module** — ✅ Completed: + - Created `commands/LogMessageFilter.kt` - Test utility for capturing FTP responses + - Created `commands/AbstractFtpserverCommandTest.kt` - Base test class (plain JUnit) + - Created `commands/AVBLCommandTest.kt` - 8 tests for AVBL command + - Created `commands/PWDCommandTest.kt` - 3 tests for PWD command + - Created `commands/FEATCommandTest.kt` - 1 test for FEAT command + - Total: 12 tests, all passing + - Uses mixed mocking approach: MockK for most mocks, Mockito for `java.io.File` (better final class support) ### Module Structure Created @@ -114,6 +122,15 @@ ftpserver/ │ └── values/ │ ├── colors.xml │ └── strings.xml +├── src/test/ +│ ├── java/com/amaze/filemanager/ftpserver/commands/ +│ │ ├── AbstractFtpserverCommandTest.kt +│ │ ├── AVBLCommandTest.kt +│ │ ├── FEATCommandTest.kt +│ │ ├── LogMessageFilter.kt +│ │ └── PWDCommandTest.kt +│ └── resources/mockito-extensions/ +│ └── org.mockito.plugins.MockMaker ``` ### Further Considerations diff --git a/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt index 17ed2b7118..9e33d4ed44 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt @@ -20,8 +20,6 @@ package com.amaze.filemanager.server -import android.content.Context - /** * Interface for file server implementations (FTP, SSH, WebDAV, etc.) * @@ -29,7 +27,6 @@ import android.content.Context * to handle server lifecycle and configuration. */ interface FileServer { - /** * Unique identifier for this server type */ @@ -69,5 +66,5 @@ enum class ServerType(val id: String) { FTP("ftp"), SFTP("sftp"), SSH("ssh"), - WEBDAV("webdav") + WEBDAV("webdav"), } diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt index 2f131f8eb1..03cb2c5af0 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt @@ -26,7 +26,6 @@ package com.amaze.filemanager.server * These events are posted via EventBus for UI components to react to server state changes. */ sealed class ServerEvent(val serverType: ServerType) { - /** * Server has started successfully */ diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt index b755112ed0..c79ff0745b 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt @@ -30,7 +30,6 @@ import android.content.Context * to show server status in the notification bar. */ interface ServerNotification { - /** * Get the notification ID for this server */ @@ -47,14 +46,20 @@ interface ServerNotification { * @param noStopButton Whether to hide the stop button (e.g., when started from tile) * @return Notification to display */ - fun createStartingNotification(context: Context, noStopButton: Boolean = false): Notification + fun createStartingNotification( + context: Context, + noStopButton: Boolean = false, + ): Notification /** * Update the notification when server is running * @param context Application context * @param noStopButton Whether to hide the stop button */ - fun updateRunningNotification(context: Context, noStopButton: Boolean = false) + fun updateRunningNotification( + context: Context, + noStopButton: Boolean = false, + ) /** * Remove the notification diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt index f5719c7a15..1d388ff970 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt @@ -29,7 +29,6 @@ import android.content.SharedPreferences * Each server implementation can have its own preferences for port, path, credentials, etc. */ interface ServerPreferences { - /** * Get the shared preferences instance for this server */ @@ -43,7 +42,10 @@ interface ServerPreferences { /** * Set the port */ - fun setPort(context: Context, port: Int) + fun setPort( + context: Context, + port: Int, + ) /** * Get the configured path to share @@ -53,7 +55,10 @@ interface ServerPreferences { /** * Set the path to share */ - fun setPath(context: Context, path: String) + fun setPath( + context: Context, + path: String, + ) /** * Get the configured username (if authentication is enabled) @@ -63,7 +68,10 @@ interface ServerPreferences { /** * Set the username */ - fun setUsername(context: Context, username: String?) + fun setUsername( + context: Context, + username: String?, + ) /** * Check if the server requires authentication @@ -78,7 +86,10 @@ interface ServerPreferences { /** * Set secure connection preference */ - fun setSecureConnection(context: Context, secure: Boolean) + fun setSecureConnection( + context: Context, + secure: Boolean, + ) /** * Check if the server is read-only @@ -88,7 +99,10 @@ interface ServerPreferences { /** * Set read-only preference */ - fun setReadOnly(context: Context, readOnly: Boolean) + fun setReadOnly( + context: Context, + readOnly: Boolean, + ) /** * Get the idle timeout in seconds @@ -98,5 +112,8 @@ interface ServerPreferences { /** * Set the idle timeout */ - fun setTimeout(context: Context, timeout: Int) + fun setTimeout( + context: Context, + timeout: Int, + ) } diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt index 638bc52809..9efe7ed583 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt @@ -29,7 +29,6 @@ import androidx.fragment.app.Fragment * without direct dependencies on specific server modules. */ object ServerRegistry { - private val servers = mutableMapOf() /** @@ -74,7 +73,6 @@ object ServerRegistry { * Each server module should implement this to provide its components. */ interface ServerProvider { - /** * The server type this provider handles */ From 82380f24631aa26dc79931ee08b71c9f425dd741 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Tue, 3 Feb 2026 07:20:30 +0800 Subject: [PATCH 04/15] More moves --- .../services/ftp/AppFtpReceiver.kt | 32 +++ .../services/ftp/AppFtpService.kt | 108 ++++++++ .../filesystem/cloud/CloudStreamServer2.kt | 137 ---------- ftpserver/src/main/AndroidManifest.xml | 18 +- .../ftpserver/commands/AVBLCommandTest.kt | 252 ++++++++++++++++++ .../commands/AbstractFtpserverCommandTest.kt | 54 ++++ .../ftpserver/commands/FEATCommandTest.kt | 57 ++++ .../ftpserver/commands/LogMessageFilter.kt | 52 ++++ .../ftpserver/commands/PWDCommandTest.kt | 150 +++++++++++ .../org.mockito.plugins.MockMaker | 1 + 10 files changed, 715 insertions(+), 146 deletions(-) create mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt create mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt delete mode 100644 file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt create mode 100644 ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt create mode 100644 ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt create mode 100644 ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt create mode 100644 ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt create mode 100644 ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt create mode 100644 ftpserver/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt new file mode 100644 index 0000000000..e294fe1bbf --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014-2024 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 com.amaze.filemanager.ftpserver.service.FtpReceiver + +/** + * Concrete implementation of FtpReceiver for the Amaze File Manager app. + * + * This receiver handles start/stop intents for the FTP server service. + */ +class AppFtpReceiver : FtpReceiver() { + override fun getFtpServiceClass(): Class<*> = AppFtpService::class.java +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt new file mode 100644 index 0000000000..246190155a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014-2024 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.app.Notification +import androidx.preference.PreferenceManager +import com.amaze.filemanager.BuildConfig +import com.amaze.filemanager.R +import com.amaze.filemanager.ftpserver.commands.AVBL +import com.amaze.filemanager.ftpserver.service.FtpServerService +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE +import com.amaze.filemanager.ui.notifications.FtpNotification +import com.amaze.filemanager.ui.notifications.NotificationConstants +import com.amaze.filemanager.utils.PasswordUtil +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.InputStream + +/** + * Concrete implementation of FtpServerService for the Amaze File Manager app. + * + * This class provides app-specific implementations for notifications, keystore access, + * password decryption, and error messages. + */ +class AppFtpService : FtpServerService() { + override fun getNotificationId(): Int = NotificationConstants.FTP_ID + + override fun getNotificationChannelId(): String = NotificationConstants.CHANNEL_FTP_ID + + override fun createStartingNotification(noStopButton: Boolean): Notification { + return FtpNotification.startNotification(applicationContext, noStopButton) + } + + override fun updateRunningNotification(noStopButton: Boolean) { + FtpNotification.updateNotification(applicationContext, noStopButton) + } + + override fun getKeyStoreInputStream(): InputStream? { + return try { + resources.openRawResource(R.raw.key) + } catch (e: Exception) { + log.error("Failed to open keystore", e) + null + } + } + + override fun getKeyStorePassword(): String { + return BuildConfig.FTP_SERVER_KEYSTORE_PASSWORD + } + + override fun decryptPassword(encryptedPassword: String): String? { + return try { + PasswordUtil.decryptPassword(applicationContext, encryptedPassword) + } catch (e: Exception) { + log.warn("Failed to decrypt password", e) + null + } + } + + override fun isRootModeEnabled(): Boolean { + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + return preferences.getBoolean(PREFERENCE_ROOTMODE, false) + } + + override fun getErrorMessageProvider(): AVBL.ErrorMessageProvider { + return object : AVBL.ErrorMessageProvider { + override fun getErrorMessage( + subId: String, + fileName: String?, + ): String { + return when (subId) { + "AVBL.notimplemented" -> getString(R.string.ftp_error_AVBL_notimplemented) + "AVBL.accessdenied" -> getString(R.string.ftp_error_AVBL_accessdenied) + "AVBL.isafile" -> getString(R.string.ftp_error_AVBL_isafile) + "AVBL.missing" -> getString(R.string.ftp_error_AVBL_missing) + else -> getString(R.string.unknown_error) + } + } + } + } + + override fun getFeatResponse(): String { + return getString(R.string.ftp_command_FEAT) + } + + companion object { + @JvmStatic + private val log: Logger = LoggerFactory.getLogger(AppFtpService::class.java) + } +} diff --git a/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt b/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt deleted file mode 100644 index a34f8e1283..0000000000 --- a/file_operations/src/test/java/com/amaze/filemanager/fileoperations/filesystem/cloud/CloudStreamServer2.kt +++ /dev/null @@ -1,137 +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.fileoperations.filesystem.cloud - -abstract class CloudStreamServer2(private val port: Int) { -// -// protected val socket: IoAcceptor = NioSocketAcceptor() -// -// companion object { -// @JvmStatic -// private val LOG = LoggerFactory.getLogger(CloudStreamServer2::class.java) -// -// @JvmStatic -// protected val gmtFormat: DateFormat = SimpleDateFormat( -// "E, d MMM yyyy HH:mm:ss 'GMT'", -// Locale.US -// ).also { -// it.timeZone = TimeZone.getTimeZone("GMT") -// } -// -// /** Some HTTP response status codes */ -// const val HTTP_OK = "200 OK" -// const val HTTP_PARTIALCONTENT = "206 Partial Content" -// const val HTTP_RANGE_NOT_SATISFIABLE = "416 Requested Range Not Satisfiable" -// const val HTTP_REDIRECT = "301 Moved Permanently" -// const val HTTP_FORBIDDEN = "403 Forbidden" -// const val HTTP_NOTFOUND = "404 Not Found" -// const val HTTP_BADREQUEST = "400 Bad Request" -// const val HTTP_INTERNALERROR = "500 Internal Server Error" -// const val HTTP_NOTIMPLEMENTED = "501 Not Implemented" -// -// /** Common mime types for dynamic content */ -// const val MIME_PLAINTEXT = "text/plain" -// const val MIME_HTML = "text/html" -// const val MIME_DEFAULT_BINARY = "application/octet-stream" -// const val MIME_XML = "text/xml" -// } -// -// // ================================================== -// // API parts -// // ================================================== -// abstract fun serve( -// uri: String, -// method: String, -// header: Properties, -// params: Properties, -// files: Properties -// ): Response -// -// init { -// socket.filterChain.addLast("logger", LoggingFilter(javaClass.simpleName)) -// socket.filterChain.addLast( -// "message", -// ProtocolCodecFilter(HttpResponseEncoder(), HttpRequestDecoder()) -// ) -// socket.handler = HttpSessionHandler() -// socket.sessionConfig.bothIdleTime = 10 -// socket.sessionConfig.readBufferSize = 8192 -// socket.bind(InetSocketAddress(port)) -// } -// -// fun stop() { -// socket.unbind() -// } -// -// /** -// * Since CloudStreamServer and Streamer both uses the same port, shutdown the Streamer before -// * acquiring the port. -// * -// * @return ServerSocket -// */ -// @Throws(IOException::class) -// private fun tryBind(port: Int): ServerSocket { -// val socket: ServerSocket = try { -// ServerSocket(port) -// } catch (ifPortIsOccupiedByStreamer: BindException) { -// Streamer.getInstance().stop() -// ServerSocket(port) -// } -// return socket -// } -// -// /** HTTP response. Return one of these from serve(). */ -// data class Response( -// private val status: String, -// private val mimeType: String? = null, -// private var data: CloudStreamSource? -// ) { -// /** Default constructor: response = HTTP_OK, data = mime = 'null' */ -// constructor() : this(HTTP_OK, null, null) -// -// /** Adds given line to the header. */ -// fun addHeader(name: String, value: String) { -// header[name] = value -// } -// -// /** Headers for the HTTP response. Use addHeader() to add lines. */ -// var header = Properties() -// } -// -// class HttpRequestDecoder : TextLineDecoder(Charsets.UTF_8, LineDelimiter.AUTO) { -// override fun writeText(session: IoSession?, text: String?, out: ProtocolDecoderOutput?) { -// LOG.debug(text) -// throw NotImplementedError() -// } -// } -// -// class HttpResponseEncoder : ProtocolEncoderAdapter() { -// override fun encode(session: IoSession, message: Any, out: ProtocolEncoderOutput) { -// TODO("Not yet implemented") -// } -// } -// -// class HttpSessionHandler : IoHandlerAdapter() { -// override fun messageReceived(session: IoSession?, message: Any?) { -// super.messageReceived(session, message) -// } -// } -} diff --git a/ftpserver/src/main/AndroidManifest.xml b/ftpserver/src/main/AndroidManifest.xml index 1bcfb46372..066efbc005 100644 --- a/ftpserver/src/main/AndroidManifest.xml +++ b/ftpserver/src/main/AndroidManifest.xml @@ -9,14 +9,14 @@ android:exported="false" android:foregroundServiceType="dataSync" /> - - - - - - + + + + + + + + + diff --git a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt new file mode 100644 index 0000000000..5c3a9c036c --- /dev/null +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import com.amaze.filemanager.ftpserver.filesystem.AndroidFileSystemFactory +import io.mockk.every +import io.mockk.mockk +import org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory +import org.apache.ftpserver.filesystem.nativefs.impl.NativeFileSystemView +import org.apache.ftpserver.ftplet.Authority +import org.apache.ftpserver.ftplet.FileSystemFactory +import org.apache.ftpserver.ftplet.FtpFile +import org.apache.ftpserver.impl.DefaultFtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.apache.ftpserver.usermanager.impl.BaseUser +import org.apache.ftpserver.usermanager.impl.WritePermission +import org.junit.Assert.assertEquals +import org.junit.BeforeClass +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.io.File + +/** + * Unit test for [AVBL]. + */ +class AVBLCommandTest : AbstractFtpserverCommandTest() { + companion object { + private lateinit var fsFactory: FileSystemFactory + private lateinit var fsView: NativeFileSystemView + + // Test error messages + private const val ERROR_NOT_IMPLEMENTED = "Command not implemented for this filesystem" + private const val ERROR_ACCESS_DENIED = "Access denied" + private const val ERROR_IS_A_FILE = "Path is a file, not a directory" + private const val ERROR_MISSING = "Path not found: %s" + + private val errorMessageProvider = + object : AVBL.ErrorMessageProvider { + override fun getErrorMessage( + subId: String, + fileName: String?, + ): String { + return when (subId) { + "AVBL.notimplemented" -> ERROR_NOT_IMPLEMENTED + "AVBL.accessdenied" -> ERROR_ACCESS_DENIED + "AVBL.isafile" -> ERROR_IS_A_FILE + "AVBL.missing" -> String.format(ERROR_MISSING, fileName ?: "") + else -> "Unknown error" + } + } + } + + /** + * Mock [NativeFileSystemView] for testing. + * Uses Mockito for java.io.File mocks (better support for final classes) + * and MockK for FTP-specific mocks. + */ + @JvmStatic + @BeforeClass + fun bootstrap() { + // Use Mockito for File mocks (handles final classes better) + val physicalFile1 = mock(File::class.java) + `when`(physicalFile1.isDirectory).thenReturn(true) + `when`(physicalFile1.freeSpace).thenReturn(12345L) + `when`(physicalFile1.canWrite()).thenReturn(true) + + val physicalFile2 = mock(File::class.java) + `when`(physicalFile2.isDirectory).thenReturn(true) + `when`(physicalFile2.freeSpace).thenReturn(131072L) + `when`(physicalFile2.canWrite()).thenReturn(true) + + val physicalFile3 = mock(File::class.java) + `when`(physicalFile3.isDirectory).thenReturn(true) + `when`(physicalFile3.freeSpace).thenThrow(SecurityException()) + `when`(physicalFile3.canWrite()).thenReturn(false) + + val physicalFile4 = mock(File::class.java) + `when`(physicalFile4.isDirectory).thenReturn(false) + `when`(physicalFile4.isFile).thenReturn(true) + `when`(physicalFile4.canWrite()).thenReturn(true) + + val ftpFile1 = + mockk { + every { physicalFile } returns physicalFile1 + } + val ftpFile2 = + mockk { + every { physicalFile } returns physicalFile2 + } + val ftpFile3 = + mockk { + every { physicalFile } returns physicalFile3 + } + val ftpFile4 = + mockk { + every { physicalFile } returns physicalFile4 + } + + fsView = + mockk { + every { homeDirectory } returns ftpFile1 + every { getFile(any()) } returns null + every { getFile("/") } returns ftpFile1 + every { getFile("/incoming") } returns ftpFile2 + every { getFile("/secure") } returns ftpFile3 + every { getFile("/test.txt") } returns ftpFile4 + } + + fsFactory = + mockk { + every { createFileSystemView(any()) } returns fsView + } + } + } + + /** + * Command should return 502 not implemented if FTP server is using [AndroidFileSystemFactory]. + */ + @Test + fun testWithAndroidFileSystem() { + executeRequest( + "AVBL", + listOf(WritePermission()), + mockk(), + ) + assertEquals(1, logger.messages.size) + assertEquals(502, logger.messages[0].code) + assertEquals(ERROR_NOT_IMPLEMENTED, logger.messages[0].message) + } + + /** + * No path argument should return home directory size. + */ + @Test + fun testHomeDirectory() { + executeRequest("AVBL", listOf(WritePermission())) + assertEquals(1, logger.messages.size) + assertEquals(213, logger.messages[0].code) + assertEquals("12345", logger.messages[0].message) + } + + /** + * Root (/) argument test. + */ + @Test + fun testRoot() { + executeRequest("AVBL /", listOf(WritePermission())) + assertEquals(1, logger.messages.size) + assertEquals(213, logger.messages[0].code) + assertEquals("12345", logger.messages[0].message) + } + + /** + * Test specified path. + */ + @Test + fun testGetPath() { + executeRequest("AVBL /incoming", listOf(WritePermission())) + assertEquals(1, logger.messages.size) + assertEquals(213, logger.messages[0].code) + assertEquals("131072", logger.messages[0].message) + } + + /** + * Command should return 550 if path not found. + */ + @Test + fun testPathNotFound() { + executeRequest("AVBL /foobar", listOf(WritePermission())) + assertEquals(1, logger.messages.size) + assertEquals(550, logger.messages[0].code) + assertEquals(String.format(ERROR_MISSING, "/foobar"), logger.messages[0].message) + } + + /** + * Command should return 550 too if user does not have access to directory. + */ + @Test + fun testAccessDenied() { + executeRequest("AVBL /secure", emptyList()) + assertEquals(1, logger.messages.size) + assertEquals(550, logger.messages[0].code) + assertEquals(ERROR_ACCESS_DENIED, logger.messages[0].message) + } + + /** + * Command should return 550 if [SecurityException] was thrown when calling + * [File.getFreeSpace]. + */ + @Test + fun testSecurityException() { + executeRequest("AVBL /secure", listOf(WritePermission())) + assertEquals(1, logger.messages.size) + assertEquals(550, logger.messages[0].code) + assertEquals(ERROR_ACCESS_DENIED, logger.messages[0].message) + } + + /** + * Command should return 550 if user tried to get free space from a file. + */ + @Test + fun testFile() { + executeRequest("AVBL /test.txt", listOf(WritePermission())) + assertEquals(1, logger.messages.size) + assertEquals(550, logger.messages[0].code) + assertEquals(ERROR_IS_A_FILE, logger.messages[0].message) + } + + private fun executeRequest( + commandLine: String, + permissions: List, + fileSystemFactory: FileSystemFactory = fsFactory, + ) { + val context = + mockk { + every { fileSystemManager } returns fileSystemFactory + } + val ftpSession = FtpIoSession(session, context) + ftpSession.user = + BaseUser().also { + it.homeDirectory = System.getProperty("java.io.tmpdir") + it.authorities = permissions + } + ftpSession.setLogin(fsView) + val command = AVBL(errorMessageProvider) + command.execute( + session = ftpSession, + context = context, + request = DefaultFtpRequest(commandLine), + ) + } +} diff --git a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt new file mode 100644 index 0000000000..bb88279af8 --- /dev/null +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import org.apache.mina.core.session.DummySession +import org.apache.mina.core.session.IoSession +import org.junit.After +import org.junit.Before + +/** + * Base class for ftpserver command unit tests. + */ +abstract class AbstractFtpserverCommandTest { + protected lateinit var logger: LogMessageFilter + + protected lateinit var session: IoSession + + /** + * Test setup. Create dummy [IoSession] and bind logging filter to it. + */ + @Before + open fun setUp() { + logger = LogMessageFilter() + session = DummySession() + session.filterChain.addFirst("logging", logger) + } + + /** + * Post test cleanup + */ + @After + open fun tearDown() { + session.closeNow() + logger.reset() + } +} diff --git a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt new file mode 100644 index 0000000000..7bb76741e6 --- /dev/null +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import io.mockk.mockk +import org.apache.ftpserver.impl.DefaultFtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit test for [FEAT]. + */ +class FEATCommandTest : AbstractFtpserverCommandTest() { + companion object { + private const val FEAT_RESPONSE = "Extensions supported:\n AVBL\n UTF8" + } + + /** + * Test command output. Expect AVBL is among list of extensions implemented. + */ + @Test + fun testCommand() { + val context = mockk(relaxed = true) + val ftpSession = FtpIoSession(session, context) + val command = FEAT { FEAT_RESPONSE } + command.execute( + session = ftpSession, + context = context, + request = DefaultFtpRequest("FEAT"), + ) + assertEquals(1, logger.messages.size) + assertEquals(211, logger.messages[0].code) + assertEquals(FEAT_RESPONSE, logger.messages[0].message) + assertTrue(logger.messages[0].message.contains("AVBL")) + } +} diff --git a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt new file mode 100644 index 0000000000..c2cf52b096 --- /dev/null +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import org.apache.ftpserver.ftplet.FtpReply +import org.apache.mina.core.filterchain.IoFilter +import org.apache.mina.core.filterchain.IoFilterAdapter +import org.apache.mina.core.session.IoSession +import org.apache.mina.core.write.WriteRequest + +/** + * [IoFilter] to store messages written in an [IoSession]. Test use only. + */ +class LogMessageFilter : IoFilterAdapter() { + val messages = mutableListOf() + + override fun messageSent( + nextFilter: IoFilter.NextFilter, + session: IoSession, + writeRequest: WriteRequest, + ) { + writeRequest.message.run { + messages.add(this as FtpReply) + } + super.messageSent(nextFilter, session, writeRequest) + } + + /** + * Expunge all messages sent to this filter. + */ + fun reset() { + messages.clear() + } +} diff --git a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt new file mode 100644 index 0000000000..462aec13f2 --- /dev/null +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2014-2024 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.ftpserver.commands + +import io.mockk.every +import io.mockk.mockk +import org.apache.ftpserver.filesystem.nativefs.impl.NativeFileSystemView +import org.apache.ftpserver.ftplet.User +import org.apache.ftpserver.impl.DefaultFtpRequest +import org.apache.ftpserver.impl.FtpIoSession +import org.apache.ftpserver.impl.FtpServerContext +import org.apache.ftpserver.message.MessageResourceFactory +import org.apache.ftpserver.usermanager.impl.BaseUser +import org.apache.ftpserver.usermanager.impl.WritePermission +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * Unit test for [PWD]. + */ +@Suppress("StringLiteralDuplication") +class PWDCommandTest : AbstractFtpserverCommandTest() { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var fsView: NativeFileSystemView + + private lateinit var user: User + + private lateinit var ftpSession: FtpIoSession + + companion object { + private lateinit var context: FtpServerContext + + /** + * Mock [FtpServerContext] for test, with custom logic to skip effort to create + * [MessageResource]. + */ + @JvmStatic + @BeforeClass + fun bootstrap() { + val messages = MessageResourceFactory().createMessageResource() + context = + mockk { + every { messageResource } returns messages + } + } + } + + /** + * Setup before test. + */ + @Before + override fun setUp() { + super.setUp() + val homeDir = tempFolder.newFolder("home") + val musicDir = java.io.File(homeDir, "Music") + musicDir.mkdirs() + + user = + BaseUser().also { + it.homeDirectory = homeDir.absolutePath + it.authorities = listOf(WritePermission()) + } + fsView = NativeFileSystemView(user, false) + + ftpSession = FtpIoSession(session, context) + ftpSession.user = user + ftpSession.setLogin(fsView) + } + + /** + * PWD should never expose device real path to user. + */ + @Test + fun testRootDir() { + executeRequest() + assertEquals(1, logger.messages.size) + assertEquals(257, logger.messages[0].code) + assertEquals("\"/\" is current directory.", logger.messages[0].message) + } + + /** + * Test scenario after changing working directory. + */ + @Test + fun testChangeDirDown() { + executeRequest() + assertEquals(1, logger.messages.size) + assertEquals(257, logger.messages.last().code) + assertEquals("\"/\" is current directory.", logger.messages.last().message) + ftpSession.fileSystemView.changeWorkingDirectory("/Music") + executeRequest() + assertEquals(2, logger.messages.size) + assertEquals(257, logger.messages.last().code) + assertEquals("\"/Music\" is current directory.", logger.messages.last().message) + } + + /** + * Test scenario after a CDUP. + */ + @Test + fun testChangeDirUp() { + executeRequest() + assertEquals(1, logger.messages.size) + assertEquals(257, logger.messages.last().code) + assertEquals("\"/\" is current directory.", logger.messages.last().message) + ftpSession.fileSystemView.changeWorkingDirectory("/Music") + executeRequest() + assertEquals(2, logger.messages.size) + assertEquals(257, logger.messages.last().code) + assertEquals("\"/Music\" is current directory.", logger.messages.last().message) + ftpSession.fileSystemView.changeWorkingDirectory("/") + executeRequest() + assertEquals(3, logger.messages.size) + assertEquals(257, logger.messages.last().code) + assertEquals("\"/\" is current directory.", logger.messages.last().message) + } + + private fun executeRequest() { + val command = PWD() + command.execute( + session = ftpSession, + context = context, + request = DefaultFtpRequest("PWD"), + ) + } +} diff --git a/ftpserver/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/ftpserver/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/ftpserver/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline From 8c74c63af8d724a5670119abd034074c3f83c5cf Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 11 Apr 2026 23:36:11 +0800 Subject: [PATCH 05/15] Changes per PR feedback and fix Codacy errors --- app/play/release/output-metadata.json | 21 ----- .../GetWebdavHostCertificateTaskCallable.kt | 94 ------------------- .../services/ftp/AppFtpService.kt | 10 +- .../ftpserver/AndroidFtpFileSystemView.kt | 2 +- ...etWebdavHostCertificateTaskCallableTest.kt | 37 -------- ftpserver/build.gradle | 4 +- .../filemanager/ftpserver/commands/AVBL.kt | 3 + .../filesystem/AndroidFtpFileSystemView.kt | 2 +- .../filesystem/RootFileSystemView.kt | 19 ++++ .../ftpserver/service/FtpServerEngine.kt | 18 ++-- .../ftpserver/ui/BaseFtpServerFragment.kt | 1 + 11 files changed, 46 insertions(+), 165 deletions(-) delete mode 100644 app/play/release/output-metadata.json delete mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt diff --git a/app/play/release/output-metadata.json b/app/play/release/output-metadata.json deleted file mode 100644 index 33c1e87580..0000000000 --- a/app/play/release/output-metadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": 3, - "artifactType": { - "type": "APK", - "kind": "Directory" - }, - "applicationId": "com.amaze.filemanager", - "variantName": "playRelease", - "elements": [ - { - "type": "SINGLE", - "filters": [], - "attributes": [], - "versionCode": 121, - "versionName": "3.10", - "outputFile": "app-play-release.apk" - } - ], - "elementType": "File", - "minSdkVersionForDexing": 26 -} \ No newline at end of file diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt deleted file mode 100644 index 1db8684795..0000000000 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallable.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (C) 2014-2024 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.webdav - -import android.annotation.SuppressLint -import com.amaze.filemanager.utils.toHex -import okhttp3.OkHttpClient -import okhttp3.Request -import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator -import org.bouncycastle.util.io.pem.PemWriter -import java.io.StringWriter -import java.security.MessageDigest -import java.security.cert.X509Certificate -import java.util.concurrent.Callable -import javax.net.ssl.SSLContext -import javax.net.ssl.X509TrustManager - -class GetWebdavHostCertificateTaskCallable( - private val url: String, - private val firstContact: Boolean = false, -) : Callable> { - private lateinit var serverCertificate: X509Certificate - - override fun call(): Pair { - val trustManager = createTrustManagerForGettingCertificate() - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(trustManager), null) - - val client = - OkHttpClient.Builder() - .sslSocketFactory(sslContext.socketFactory, trustManager) - .hostnameVerifier { _, _ -> true }.build() - - client.newCall(Request.Builder().head().url(url).build()).execute() - - return Pair(sha256(serverCertificate), toPemString(serverCertificate)) - } - - internal fun sha256(of: X509Certificate): String { - val md = MessageDigest.getInstance("SHA-256") - val der = of.encoded - md.update(der) - val digest = md.digest() - - return digest.toHex(":") - } - - internal fun toPemString(from: X509Certificate): String { - val stringWriter = StringWriter() - PemWriter(stringWriter).runCatching { - val pemGenerator = JcaMiscPEMGenerator(from) - writeObject(pemGenerator) - } - stringWriter.close() - return stringWriter.toString() - } - - @SuppressLint("CustomX509TrustManager") - private fun createTrustManagerForGettingCertificate(): X509TrustManager { - return object : X509TrustManager { - override fun getAcceptedIssuers(): Array = emptyArray() - - override fun checkClientTrusted( - chain: Array?, - authType: String?, - ) = Unit - - override fun checkServerTrusted( - chain: Array?, - authType: String?, - ) { - serverCertificate = chain!![0] - } - } - } -} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt index 246190155a..b04db745e6 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt @@ -21,6 +21,7 @@ package com.amaze.filemanager.asynchronous.services.ftp import android.app.Notification +import android.content.res.Resources import androidx.preference.PreferenceManager import com.amaze.filemanager.BuildConfig import com.amaze.filemanager.R @@ -32,7 +33,9 @@ import com.amaze.filemanager.ui.notifications.NotificationConstants import com.amaze.filemanager.utils.PasswordUtil import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.io.IOException import java.io.InputStream +import java.security.GeneralSecurityException /** * Concrete implementation of FtpServerService for the Amaze File Manager app. @@ -56,7 +59,7 @@ class AppFtpService : FtpServerService() { override fun getKeyStoreInputStream(): InputStream? { return try { resources.openRawResource(R.raw.key) - } catch (e: Exception) { + } catch (e: Resources.NotFoundException) { log.error("Failed to open keystore", e) null } @@ -69,9 +72,12 @@ class AppFtpService : FtpServerService() { override fun decryptPassword(encryptedPassword: String): String? { return try { PasswordUtil.decryptPassword(applicationContext, encryptedPassword) - } catch (e: Exception) { + } catch (e: GeneralSecurityException) { log.warn("Failed to decrypt password", e) null + } catch (e: IOException) { + log.warn("Unexpected error during password decryption", e) + null } } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt index 1a25824733..de09a0cc91 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt @@ -136,7 +136,7 @@ class AndroidFtpFileSystemView(private var context: Context, root: String) : Fil } private fun resolveDocumentFileFromRoot(path: String?): DocumentFile? { - return if (path.isNullOrBlank() or ("/" == path) or ("./" == path)) { + return if (path.isNullOrBlank() || ("/" == path) || ("./" == path)) { rootDocumentFile } else { val pathElements = path!!.split('/') diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt deleted file mode 100644 index 780dacaf5f..0000000000 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/webdav/GetWebdavHostCertificateTaskCallableTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2014-2024 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.webdav - -import org.junit.Assert.assertNotNull -import org.junit.Ignore -import org.junit.Test - -@Ignore -class GetWebdavHostCertificateTaskCallableTest { - @Test - fun testConnect() { - val callable = GetWebdavHostCertificateTaskCallable("https://httpbin.org", false) - val result = callable.call() - assertNotNull(result) - System.err.println(result.first) - System.err.println(result.second) - } -} diff --git a/ftpserver/build.gradle b/ftpserver/build.gradle index 93a433d66f..2cd1607932 100644 --- a/ftpserver/build.gradle +++ b/ftpserver/build.gradle @@ -41,7 +41,9 @@ android { all { // Required for Mockito to work with Java 21 jvmArgs '-Dnet.bytebuddy.experimental=true' - jvmArgs '-XX:+EnableDynamicAgentLoading' + if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) { + jvmArgs '-XX:+EnableDynamicAgentLoading' + } } } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt index ffcd5ef0d7..7be975fe3f 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt @@ -50,6 +50,9 @@ class AVBL( * Interface for providing localized error messages */ interface ErrorMessageProvider { + /** + * Returns a localized error message based on the provided subId and optional filename. + */ fun getErrorMessage( subId: String, fileName: String? = null, diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt index 554a1a9526..027e8c29a1 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt @@ -136,7 +136,7 @@ class AndroidFtpFileSystemView(private var context: Context, root: String) : Fil } private fun resolveDocumentFileFromRoot(path: String?): DocumentFile? { - return if (path.isNullOrBlank() or ("/" == path) or ("./" == path)) { + return if (path.isNullOrBlank() || ("/" == path) || ("./" == path)) { rootDocumentFile } else { val pathElements = path!!.split('/') diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt index 472710a529..1554ae823d 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt @@ -191,21 +191,40 @@ class RootFileSystemView( return path } + /** + * Factory for creating SuFile instances. + */ interface SuFileFactory { + /** + * Create a SuFile instance for the given pathname. + */ fun create(pathname: String): SuFile = SuFile(pathname) + /** + * Create a SuFile instance for the given parent and child paths. + */ fun create( parent: String, child: String, ): SuFile = SuFile(parent, child) + /** + * Create a SuFile instance for the given parent File and child path. + */ fun create( parent: File, child: String, ): SuFile = SuFile(parent, child) + /** + * Create a SuFile instance for the given URI. + */ fun create(uri: URI): SuFile = SuFile(uri) } + /** + * Default implementation of SuFileFactory that creates SuFile instances using the default + * constructors. + */ class DefaultSuFileFactory : SuFileFactory } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt index 508eeee8b5..b6884c8de8 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt @@ -106,6 +106,7 @@ object FtpServerEngine { }.apply { start() } } + @Suppress("LongMethod", "ComplexMethod", "TooGenericExceptionCaught") private fun runServer( context: Context, config: ServerConfig, @@ -116,13 +117,14 @@ object FtpServerEngine { val connectionConfigFactory = ConnectionConfigFactory() // Configure filesystem - if (SDK_INT >= KITKAT && config.useSafFilesystem) { - fileSystem = AndroidFileSystemFactory(context) { config.path } - } else if (config.useRootFilesystem) { - fileSystem = RootFileSystemFactory() - } else { - fileSystem = NativeFileSystemFactory() - } + fileSystem = + if (SDK_INT >= KITKAT && config.useSafFilesystem) { + AndroidFileSystemFactory(context) { config.path } + } else if (config.useRootFilesystem) { + RootFileSystemFactory() + } else { + NativeFileSystemFactory() + } // Configure commands if (config.errorMessageProvider != null && config.featResponseProvider != null) { @@ -202,7 +204,7 @@ object FtpServerEngine { onStarted(true) } } - } catch (e: Exception) { + } catch (e: Throwable) { log.error("Failed to start FTP server", e) scope.launch { FtpEventBus.emit(FtpServerEvent.FailedToStart) diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt index b46458140d..4c06aa2802 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt @@ -55,6 +55,7 @@ import kotlinx.coroutines.launch * This provides the core FTP server UI functionality that can be extended * by the app module to add app-specific features. */ +@Suppress("StringLiteralDuplication") abstract class BaseFtpServerFragment : Fragment() { private var _binding: FragmentFtpBinding? = null protected val binding get() = _binding!! From 4611caebf56185e7b9abdef1f9822ce58c0ba804 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 12 Apr 2026 00:48:00 +0800 Subject: [PATCH 06/15] Remove redundant classes after the move --- .../services/ftp/FtpServiceEspressoTest.kt | 319 -------------- .../ftp/FtpServiceStaticMethodsTest.kt | 67 --- .../services/ftp/CommandFactoryFactory.kt | 47 -- .../asynchronous/services/ftp/FtpEventBus.kt | 28 -- .../asynchronous/services/ftp/FtpReceiver.kt | 56 --- .../asynchronous/services/ftp/FtpService.kt | 407 ------------------ .../ftpserver/AndroidFileSystemFactory.kt | 35 -- .../filesystem/ftpserver/AndroidFtpFile.kt | 219 ---------- .../ftpserver/AndroidFtpFileSystemView.kt | 156 ------- .../ftpserver/RootFileSystemFactory.kt | 32 -- .../ftpserver/RootFileSystemView.kt | 279 ------------ .../filesystem/ftpserver/RootFtpFile.kt | 133 ------ .../filesystem/ftpserver/commands/AVBL.kt | 135 ------ .../filesystem/ftpserver/commands/FEAT.kt | 49 --- .../filesystem/ftpserver/commands/PWD.kt | 64 --- ...ServiceAndroidFileSystemIntegrationTest.kt | 316 -------------- .../FtpServiceSupportedCiphersTest.kt | 78 ---- .../services/ftp/FtpReceiverTest.kt | 117 ----- .../filesystem/ftp/FtpHybridFileTest.kt | 19 +- .../ftpserver/commands/AVBLCommandTest.kt | 222 ---------- .../commands/AbstractFtpserverCommandTest.kt | 66 --- .../ftpserver/commands/FEATCommandTest.kt | 58 --- .../ftpserver/commands/LogMessageFilter.kt | 52 --- .../ftpserver/commands/PWDCommandTest.kt | 144 ------- app/src/test/resources/ftpusers | 1 - 25 files changed, 17 insertions(+), 3082 deletions(-) delete mode 100644 app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt delete mode 100644 app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpEventBus.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt delete mode 100644 app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceAndroidFileSystemIntegrationTest.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceSupportedCiphersTest.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/LogMessageFilter.kt delete mode 100644 app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt delete mode 100644 app/src/test/resources/ftpusers diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt deleted file mode 100644 index 5083db921f..0000000000 --- a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceEspressoTest.kt +++ /dev/null @@ -1,319 +0,0 @@ -/* - * Copyright (C) 2014-2020 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.content.Intent -import android.os.Environment -import android.util.Base64 -import androidx.preference.PreferenceManager -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.ServiceTestRule -import com.amaze.filemanager.utils.ObtainableServiceBinder -import com.amaze.filemanager.utils.PasswordUtil -import org.apache.commons.net.PrintCommandListener -import org.apache.commons.net.ftp.FTP -import org.apache.commons.net.ftp.FTPClient -import org.apache.commons.net.ftp.FTPSClient -import org.awaitility.Awaitility.await -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.FileWriter -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.Socket -import java.net.SocketException -import java.security.SecureRandom -import java.util.concurrent.TimeUnit - -// Require UIAutomator if need to run test on Android 11 -// in order to obtain MANAGE_EXTERNAL_STORAGE permission -@RunWith(AndroidJUnit4::class) -@Suppress("StringLiteralDuplication") -@androidx.test.filters.Suppress -class FtpServiceEspressoTest { - @get:Rule - var serviceTestRule = ServiceTestRule() - - private var service: FtpService? = null - - /** - * Kill running FtpService if there is one. - */ - @After - fun shutDown() { - service?.onDestroy() - } - - /** - * Test FTP service - */ - @Test - fun testFtpService() { - PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) - .edit() - .putBoolean(FtpService.KEY_PREFERENCE_SECURE, false) - .putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, false) - .remove(FtpService.KEY_PREFERENCE_USERNAME) - .remove(FtpService.KEY_PREFERENCE_PASSWORD) - .commit() - service = - create( - Intent(FtpService.ACTION_START_FTPSERVER) - .putExtra(FtpService.TAG_STARTED_BY_TILE, false), - ) - - await().atMost(10, TimeUnit.SECONDS).until { - FtpService.isRunning() && isServerReady() - } - FTPClient().run { - addProtocolCommandListener(PrintCommandListener(System.err)) - loginAndVerifyWith(this) - testUploadWith(this) - testDownloadWith(this) - } - } - - /** - * Test FTP service over SSL - */ - @Test - fun testSecureFtpService() { - PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) - .edit() - .putBoolean(FtpService.KEY_PREFERENCE_SECURE, true) - .putBoolean(FtpService.KEY_PREFERENCE_SAF_FILESYSTEM, false) - .remove(FtpService.KEY_PREFERENCE_USERNAME) - .remove(FtpService.KEY_PREFERENCE_PASSWORD) - .commit() - service = - create( - Intent(FtpService.ACTION_START_FTPSERVER) - .putExtra(FtpService.TAG_STARTED_BY_TILE, false), - ) - - await().atMost(10, TimeUnit.SECONDS).until { - FtpService.isRunning() && isServerReady() - } - - FTPSClient(true).run { - addProtocolCommandListener(PrintCommandListener(System.err)) - loginAndVerifyWith(this) - testUploadWith(this) - testDownloadWith(this) - } - } - - /** - * Test to ensure FTP service cannot login anonymously after username/password is set - */ - @Test - fun testUsernameEnabledAnonymousCannotLogin() { - PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) - .edit() - .putBoolean(FtpService.KEY_PREFERENCE_SECURE, false) - .putString(FtpService.KEY_PREFERENCE_USERNAME, "amazeftp") - .putString( - FtpService.KEY_PREFERENCE_PASSWORD, - PasswordUtil.encryptPassword( - ApplicationProvider.getApplicationContext(), - "passw0rD", - ), - ) - .commit() - service = - create( - Intent(FtpService.ACTION_START_FTPSERVER) - .putExtra(FtpService.TAG_STARTED_BY_TILE, false), - ) - - await().atMost(10, TimeUnit.SECONDS).until { - FtpService.isRunning() && isServerReady() - } - - FTPClient().run { - connect("localhost", FtpService.DEFAULT_PORT) - assertFalse(login("anonymous", "test@example.com")) - assertTrue(login("amazeftp", "passw0rD")) - logout() - } - } - - private fun loginAndVerifyWith(ftpClient: FTPClient) { - ftpClient.connect("localhost", FtpService.DEFAULT_PORT) - ftpClient.login("anonymous", "test@example.com") - ftpClient.changeWorkingDirectory("/") - val files = ftpClient.listFiles() - assertNotNull(files) - assertTrue( - "No files found on device? It is also possible that app doesn't have " + - "permission to access storage, which may occur on broken Android emulators", - files.isNotEmpty(), - ) - var downloadFolderExists = false - for (f in files) { - if (f.name.equals("download", ignoreCase = true)) downloadFolderExists = true - } - ftpClient.logout() - ftpClient.disconnect() - assertTrue( - "Download folder not found on device. Either storage is not available, " + - "or something is really wrong with FtpService. Check logcat.", - downloadFolderExists, - ) - } - - private fun testUploadWith(ftpClient: FTPClient) { - val bytes1 = ByteArray(32) - val bytes2 = ByteArray(32) - SecureRandom().run { - setSeed(System.currentTimeMillis()) - nextBytes(bytes1) - nextBytes(bytes2) - } - - val randomString = Base64.encodeToString(bytes1, Base64.DEFAULT) - ftpClient.run { - connect("localhost", FtpService.DEFAULT_PORT) - login("anonymous", "test@example.com") - changeWorkingDirectory("/") - enterLocalPassiveMode() - setFileType(FTP.ASCII_FILE_TYPE) - ByteArrayInputStream(randomString.toByteArray(charset("utf-8"))).run { - this.copyTo(storeFileStream("test.txt")) - close() - } - ByteArrayInputStream(bytes2).run { - assertTrue(setFileType(FTP.BINARY_FILE_TYPE)) - this.copyTo(storeFileStream("test.bin")) - close() - } - logout() - disconnect() - - File(Environment.getExternalStorageDirectory(), "test.txt").run { - assertTrue(exists()) - val verifyContent = ByteArrayOutputStream() - FileInputStream(this).copyTo(verifyContent) - assertEquals(randomString, verifyContent.toString("utf-8")) - delete() - } - - File(Environment.getExternalStorageDirectory(), "test.bin").run { - assertTrue(exists()) - val verifyContent = ByteArrayOutputStream() - FileInputStream(this).copyTo(verifyContent) - assertArrayEquals(bytes2, verifyContent.toByteArray()) - delete() - } - } - } - - private fun testDownloadWith(ftpClient: FTPClient) { - val testFile1 = File(Environment.getExternalStorageDirectory(), "test.txt") - val testFile2 = File(Environment.getExternalStorageDirectory(), "test.bin") - val bytes1 = ByteArray(32) - val bytes2 = ByteArray(32) - SecureRandom().run { - setSeed(System.currentTimeMillis()) - nextBytes(bytes1) - nextBytes(bytes2) - } - - val randomString = Base64.encodeToString(bytes1, Base64.DEFAULT) - FileWriter(testFile1).run { - write(randomString) - close() - } - - FileOutputStream(testFile2).run { - write(bytes2, 0, bytes2.size) - close() - } - - ftpClient.run { - connect("localhost", FtpService.DEFAULT_PORT) - login("anonymous", "test@example.com") - changeWorkingDirectory("/") - enterLocalPassiveMode() - setFileType(FTP.ASCII_FILE_TYPE) - - ByteArrayOutputStream().run { - retrieveFile("test.txt", this) - close() - assertEquals(randomString, toString("utf-8")) - } - - setFileType(FTP.BINARY_FILE_TYPE) - - ByteArrayOutputStream().run { - retrieveFile("test.bin", this) - close() - assertArrayEquals(bytes2, toByteArray()) - } - - logout() - disconnect() - } - - testFile1.delete() - testFile2.delete() - } - - private fun create(intent: Intent): FtpService { - val binder = - serviceTestRule - .bindService( - intent.setClass( - ApplicationProvider.getApplicationContext(), - FtpService::class.java, - ), - ) - return ((binder as ObtainableServiceBinder).service as FtpService).also { - it.onStartCommand(intent, 0, 0) - } - } - - private fun isServerReady(): Boolean { - return Socket().let { - try { - it.connect(InetSocketAddress(InetAddress.getLocalHost(), FtpService.DEFAULT_PORT)) - true - } catch (e: SocketException) { - false - } finally { - it.close() - } - } - } -} diff --git a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt b/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt deleted file mode 100644 index 85fc91332a..0000000000 --- a/app/src/androidTest/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServiceStaticMethodsTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2014-2020 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.content.Context -import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.N_MR1 -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import com.amaze.filemanager.utils.NetworkUtil -import org.junit.Assert.assertNotNull -import org.junit.Assert.fail -import org.junit.Test -import org.junit.runner.RunWith - -/** - * This test is separated from FtpServiceEspressoTest since it does not actually requires the FTP - * service itself. - * - * - * It is expected that you are not running all the cases in one go. **You have been warned**. - */ -@SmallTest -@RunWith(AndroidJUnit4::class) -class FtpServiceStaticMethodsTest { - /** To test [FtpService.getLocalInetAddress] must not return an empty string. */ - @Test - fun testGetLocalInetAddressMustNotBeEmpty() { - /* Android emulator's "wifi connectivity" only exists from API 25. - * On the other hand, we don't do wifi AP in code, either it's not possible - * for lower APIs, nor we currently have no plans on doing this - see #515, #2720 - - * therefore we only run this test from API 25 or above. - * - TranceLove - */ - if (SDK_INT >= N_MR1) { - ApplicationProvider.getApplicationContext().run { - if (!NetworkUtil.isConnectedToLocalNetwork(this)) { - fail("Please connect your device to network to run this test!") - } - - NetworkUtil.getLocalInetAddress(this).also { - assertNotNull(it) - assertNotNull(it?.hostAddress) - } - } - } - } -} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt deleted file mode 100644 index ef13285490..0000000000 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt +++ /dev/null @@ -1,47 +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.asynchronous.services.ftp - -import com.amaze.filemanager.filesystem.ftpserver.AndroidFtpFileSystemView -import com.amaze.filemanager.filesystem.ftpserver.commands.AVBL -import com.amaze.filemanager.filesystem.ftpserver.commands.FEAT -import com.amaze.filemanager.filesystem.ftpserver.commands.PWD -import org.apache.ftpserver.command.CommandFactory -import org.apache.ftpserver.command.CommandFactoryFactory - -/** - * Custom [CommandFactory] factory with custom commands. - */ -object CommandFactoryFactory { - /** - * Encapsulate custom [CommandFactory] construction logic. Append custom AVBL and PWD command, - * as well as feature flag in FEAT command if not using [AndroidFtpFileSystemView]. - */ - fun create(useAndroidFileSystem: Boolean): CommandFactory { - val cf = CommandFactoryFactory() - if (!useAndroidFileSystem) { - cf.addCommand("AVBL", AVBL()) - cf.addCommand("FEAT", FEAT()) - cf.addCommand("PWD", PWD()) - } - return cf.createCommandFactory() - } -} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpEventBus.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpEventBus.kt deleted file mode 100644 index be4d11d568..0000000000 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpEventBus.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.amaze.filemanager.asynchronous.services.ftp - -import com.amaze.filemanager.ui.fragments.FtpServerFragment -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -/** - * Replacement event bus to handle [FtpService] events using Kotlin's Flow. - * - * Original idea: https://mirchev.medium.com/its-21st-century-stop-using-eventbus-3ff5d9c6a00f - * - * @see [FtpService] - * @see [FtpTileService] - * @see [FtpServerFragment] - */ -object FtpEventBus { - private val _events = MutableSharedFlow(replay = 0) - val events = _events.asSharedFlow() - - /** - * Emit the event signal to the event bus as [MutableSharedFlow]. - * - * @param event The event to be emitted. - */ - suspend fun emit(event: FtpService.FtpReceiverActions) { - _events.emit(event) - } -} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt deleted file mode 100644 index 9d71e405a9..0000000000 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiver.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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.services.ftp - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log -import androidx.core.content.ContextCompat -import com.amaze.filemanager.BuildConfig.DEBUG -import com.amaze.filemanager.asynchronous.services.ftp.FtpService.Companion.isRunning - -/** Created by yashwanthreddyg on 09-06-2016. */ -class FtpReceiver : BroadcastReceiver() { - private val TAG = FtpReceiver::class.java.simpleName - - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (DEBUG) { - Log.v(TAG, "Received: ${intent.action}") - } - val service = Intent(context, FtpService::class.java) - service.putExtras(intent) - runCatching { - if (intent.action == FtpService.ACTION_START_FTPSERVER && !isRunning()) { - ContextCompat.startForegroundService(context, service) - } else if (intent.action == FtpService.ACTION_STOP_FTPSERVER) { - context.stopService(service) - } else { - Unit - } - }.onFailure { - Log.e(TAG, "Failed to start/stop on intent ${it.message}") - } - } -} 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 deleted file mode 100644 index a5a630e278..0000000000 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpService.kt +++ /dev/null @@ -1,407 +0,0 @@ -/* - * 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.services.ftp - -import android.app.AlarmManager -import android.app.PendingIntent -import android.app.PendingIntent.FLAG_ONE_SHOT -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.ServiceInfo -import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.KITKAT -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.Q -import android.os.Environment -import android.os.IBinder -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 -import com.amaze.filemanager.application.AppConfig -import com.amaze.filemanager.asynchronous.services.AbstractProgressiveService.getPendingIntentFlag -import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory -import com.amaze.filemanager.filesystem.ftpserver.RootFileSystemFactory -import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE -import com.amaze.filemanager.ui.notifications.FtpNotification -import com.amaze.filemanager.ui.notifications.NotificationConstants -import com.amaze.filemanager.utils.ObtainableServiceBinder -import com.amaze.filemanager.utils.PasswordUtil -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import org.apache.ftpserver.ConnectionConfigFactory -import org.apache.ftpserver.FtpServer -import org.apache.ftpserver.FtpServerFactory -import org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory -import org.apache.ftpserver.listener.ListenerFactory -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.slf4j.Logger -import org.slf4j.LoggerFactory -import java.io.IOException -import java.security.GeneralSecurityException -import java.security.KeyStore -import java.util.LinkedList -import java.util.concurrent.TimeUnit -import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.TrustManagerFactory -import kotlin.concurrent.thread - -/** - * Created by yashwanthreddyg on 09-06-2016. - * - * - * Edited by zent-co on 30-07-2019 Edited by bowiechen on 2019-10-19. - */ -class FtpService : Service(), Runnable { - private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val binder: IBinder = ObtainableServiceBinder(this) - - // Service will broadcast via event bus when server start/stop - enum class FtpReceiverActions { - STARTED, - STARTED_FROM_TILE, - STOPPED, - FAILED_TO_START, - } - - private var username: String? = null - private var password: String? = null - private var isPasswordProtected = false - private var isStartedByTile = false - private lateinit var wakeLock: PowerManager.WakeLock - - private fun publishEvent(event: FtpReceiverActions) { - serviceScope.launch { - FtpEventBus.emit(event) - } - } - - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int, - ): Int { - isStartedByTile = true == intent?.getBooleanExtra(TAG_STARTED_BY_TILE, false) - var attempts = 10 - while (serverThread != null) { - if (attempts > 0) { - attempts-- - try { - Thread.sleep(1000) - } catch (ignored: InterruptedException) { - } - } else { - return START_STICKY - } - } - - serverThread = thread(block = this::run) - val notification = FtpNotification.startNotification(applicationContext, isStartedByTile) - if (SDK_INT >= Q) { - ServiceCompat.startForeground( - this, - NotificationConstants.FTP_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } else { - startForeground(NotificationConstants.FTP_ID, notification) - } - return START_STICKY - } - - override fun onCreate() { - super.onCreate() - - val powerManager = getSystemService(POWER_SERVICE) as PowerManager - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, javaClass.name) - wakeLock.setReferenceCounted(false) - } - - override fun onBind(intent: Intent): IBinder { - return binder - } - - @Suppress("LongMethod") - override fun run() { - // Acquire the WakeLock for 1 hour, per recommended. - wakeLock.acquire(TimeUnit.HOURS.toMillis(1L)) - val preferences = PreferenceManager.getDefaultSharedPreferences(this) - FtpServerFactory().run { - val connectionConfigFactory = ConnectionConfigFactory() - val shouldUseAndroidFileSystem = - preferences.getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) - if (SDK_INT >= KITKAT && shouldUseAndroidFileSystem) { - fileSystem = AndroidFileSystemFactory(applicationContext) - } else if (preferences.getBoolean(PREFERENCE_ROOTMODE, false)) { - fileSystem = RootFileSystemFactory() - } else { - fileSystem = NativeFileSystemFactory() - } - - commandFactory = CommandFactoryFactory.create(shouldUseAndroidFileSystem) - - val usernamePreference = - preferences.getString( - KEY_PREFERENCE_USERNAME, - DEFAULT_USERNAME, - ) - if (usernamePreference != DEFAULT_USERNAME) { - username = usernamePreference - runCatching { - password = - PasswordUtil.decryptPassword( - applicationContext, - preferences.getString(KEY_PREFERENCE_PASSWORD, "")!!, - ) - isPasswordProtected = true - }.onFailure { - log.warn("failed to decrypt password in ftp service", it) - AppConfig.toast(applicationContext, R.string.error) - preferences.edit { putString(KEY_PREFERENCE_PASSWORD, "") } - isPasswordProtected = false - } - } - val user = BaseUser() - if (!isPasswordProtected) { - user.name = "anonymous" - connectionConfigFactory.isAnonymousLoginEnabled = true - } else { - user.name = username - user.password = password - } - user.homeDirectory = - preferences.getString( - KEY_PREFERENCE_PATH, - defaultPath(this@FtpService), - ) - if (!preferences.getBoolean(KEY_PREFERENCE_READONLY, false)) { - user.authorities = listOf(WritePermission()) - } - - connectionConfig = connectionConfigFactory.createConnectionConfig() - userManager.save(user) - - 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 keyManagerFactory = - KeyManagerFactory - .getInstance(KeyManagerFactory.getDefaultAlgorithm()) - keyManagerFactory.init(keyStore, keyStorePassword) - val trustManagerFactory = - TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(keyStore) - fac.sslConfiguration = - DefaultSslConfiguration( - keyManagerFactory, - trustManagerFactory, - ClientAuth.WANT, - "TLS", - enabledCipherSuites, - "ftpserver", - ) - fac.isImplicitSsl = true - } catch (e: GeneralSecurityException) { - preferences.edit { putBoolean(KEY_PREFERENCE_SECURE, false) } - } catch (e: IOException) { - preferences.edit { putBoolean(KEY_PREFERENCE_SECURE, false) } - } - } - fac.port = getPort(preferences) - fac.idleTimeout = preferences.getInt(KEY_PREFERENCE_TIMEOUT, DEFAULT_TIMEOUT) - - addListener("default", fac.createListener()) - runCatching { - server = - createServer().apply { - start() - publishEvent( - if (isStartedByTile) { - FtpReceiverActions.STARTED_FROM_TILE - } else { - FtpReceiverActions.STARTED - }, - ) - } - }.onFailure { - wakeLock.release() - publishEvent(FtpReceiverActions.FAILED_TO_START) - } - } - } - - override fun onDestroy() { - serverThread?.let { serverThread -> - serverThread.interrupt() - // wait 10 sec for server thread to finish - serverThread.join(10000) - - if (!serverThread.isAlive) { - Companion.serverThread = null - } - server?.stop().also { - publishEvent(FtpReceiverActions.STOPPED) - } - } - - if (wakeLock.isHeld) { - wakeLock.release() - } - } - - // Restart the service if the app is closed from the recent list - override fun onTaskRemoved(rootIntent: Intent) { - super.onTaskRemoved(rootIntent) - val restartService = Intent(applicationContext, this.javaClass).setPackage(packageName) - val flag = getPendingIntentFlag(FLAG_ONE_SHOT) - val restartServicePI = - PendingIntent.getService( - applicationContext, - 1, - restartService, - flag, - ) - val alarmService = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager - alarmService[AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 2000] = - restartServicePI - } - - companion object { - private val log: Logger = LoggerFactory.getLogger(FtpService::class.java) - - const val DEFAULT_PORT = 2211 - const val DEFAULT_USERNAME = "" - const val DEFAULT_TIMEOUT = 600 // default timeout, in sec - const val DEFAULT_SECURE = true - const val PORT_PREFERENCE_KEY = "ftpPort" - const val KEY_PREFERENCE_PATH = "ftp_path" - const val KEY_PREFERENCE_USERNAME = "ftp_username" - const val KEY_PREFERENCE_PASSWORD = "ftp_password_encrypted" - const val KEY_PREFERENCE_TIMEOUT = "ftp_timeout" - const val KEY_PREFERENCE_SECURE = "ftp_secure" - const val KEY_PREFERENCE_READONLY = "ftp_readonly" - const val KEY_PREFERENCE_SAF_FILESYSTEM = "ftp_saf_filesystem" - const val KEY_PREFERENCE_ROOT_FILESYSTEM = "ftp_root_filesystem" - const val INITIALS_HOST_FTP = "ftp://" - const val INITIALS_HOST_SFTP = "ftps://" - - // RequestStartStopReceiver listens for these actions to start/stop this server - const val ACTION_START_FTPSERVER = - "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_START_FTPSERVER" - const val ACTION_STOP_FTPSERVER = - "com.amaze.filemanager.services.ftpservice.FTPReceiver.ACTION_STOP_FTPSERVER" - const val TAG_STARTED_BY_TILE = "started_by_tile" - // attribute of action_started, used by notification - - /** - * Return a list of available ciphers for ftpserver. - * - * Added SDK detection since some ciphers are available only on higher versions, and they - * have to be on top of the list to make a more secure SSL - * - * @see [org.apache.ftpserver.ssl.SslConfiguration] - * @see [javax.net.ssl.SSLEngine] - */ - @JvmStatic - val 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 serverThread: Thread? = null - private var server: FtpServer? = null - - /** - * Derive the FTP server's default share path, depending the user's Android version. - * - * Default it's the internal storage's root as java.io.File; otherwise it's content:// - * based URI if it's running on Android 7.0 or above. - */ - @JvmStatic - fun defaultPath(context: Context): String { - return if (PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(KEY_PREFERENCE_SAF_FILESYSTEM, false) && SDK_INT > M - ) { - DocumentsContract.buildTreeDocumentUri( - "com.android.externalstorage.documents", - "primary:", - ).toString() - } else { - Environment.getExternalStorageDirectory().absolutePath - } - } - - /** - * Indicator whether FTP service is running - */ - @JvmStatic - fun isRunning(): Boolean { - val server = server ?: return false - return !server.isStopped - } - - private fun getPort(preferences: SharedPreferences): Int { - return preferences.getInt(FtpService.PORT_PREFERENCE_KEY, FtpService.DEFAULT_PORT) - } - } -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt deleted file mode 100644 index af53162e3e..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.filesystem.ftpserver - -import android.content.Context -import android.os.Build.VERSION_CODES.KITKAT -import androidx.annotation.RequiresApi -import com.amaze.filemanager.asynchronous.services.ftp.FtpService -import org.apache.ftpserver.ftplet.FileSystemFactory -import org.apache.ftpserver.ftplet.FileSystemView -import org.apache.ftpserver.ftplet.User - -@RequiresApi(KITKAT) -class AndroidFileSystemFactory(private val context: Context) : FileSystemFactory { - override fun createFileSystemView(user: User?): FileSystemView = - AndroidFtpFileSystemView(context, user?.homeDirectory ?: FtpService.defaultPath(context)) -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt deleted file mode 100644 index 9f10594e3c..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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.filesystem.ftpserver - -import android.content.ContentResolver -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import android.os.Build.VERSION_CODES.KITKAT -import android.provider.DocumentsContract -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile -import org.apache.ftpserver.ftplet.FtpFile -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.lang.ref.WeakReference - -@RequiresApi(KITKAT) -@Suppress("TooManyFunctions") // Don't ask me. Ask Apache why. -class AndroidFtpFile( - context: Context, - private val parentDocument: DocumentFile, - private val backingDocument: DocumentFile?, - private val path: String, -) : FtpFile { - private val _context: WeakReference = WeakReference(context) - private val context: Context - get() = _context.get()!! - - override fun getAbsolutePath(): String { - return path - } - - /** - * @see FtpFile.getName - * @see DocumentFile.getName - */ - override fun getName(): String = backingDocument?.name ?: path.substringAfterLast('/') - - /** - * @see FtpFile.isHidden - */ - override fun isHidden(): Boolean = name.startsWith(".") && name != "." - - /** - * @see FtpFile.isDirectory - * @see DocumentFile.isDirectory - */ - override fun isDirectory(): Boolean = backingDocument?.isDirectory ?: false - - /** - * @see FtpFile.isFile - * @see DocumentFile.isFile - */ - override fun isFile(): Boolean = backingDocument?.isFile ?: false - - /** - * @see FtpFile.doesExist - * @see DocumentFile.exists - */ - override fun doesExist(): Boolean = backingDocument?.exists() ?: false - - /** - * @see FtpFile.isReadable - * @see DocumentFile.canRead - */ - override fun isReadable(): Boolean = backingDocument?.canRead() ?: false - - /** - * @see FtpFile.isWritable - * @see DocumentFile.canWrite - */ - override fun isWritable(): Boolean = backingDocument?.canWrite() ?: true - - /** - * @see FtpFile.isRemovable - * @see DocumentFile.canWrite - */ - override fun isRemovable(): Boolean = backingDocument?.canWrite() ?: true - - /** - * @see FtpFile.getOwnerName - */ - override fun getOwnerName(): String = "user" - - /** - * @see FtpFile.getGroupName - */ - override fun getGroupName(): String = "user" - - /** - * @see FtpFile.getLinkCount - */ - override fun getLinkCount(): Int = 0 - - /** - * @see FtpFile.getLastModified - * @see DocumentFile.lastModified - */ - override fun getLastModified(): Long = backingDocument?.lastModified() ?: 0L - - /** - * @see FtpFile.setLastModified - * @see DocumentsContract.Document.COLUMN_LAST_MODIFIED - * @see ContentResolver.update - */ - override fun setLastModified(time: Long): Boolean { - return if (doesExist()) { - val updateValues = - ContentValues().also { - it.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, time) - } - val docUri: Uri = backingDocument!!.uri - val updated: Int = - context.contentResolver.update( - docUri, - updateValues, - null, - null, - ) - return updated == 1 - } else { - false - } - } - - /** - * @see FtpFile.getSize - * @see DocumentFile.length - */ - override fun getSize(): Long = backingDocument?.length() ?: 0L - - /** - * @see FtpFile.getPhysicalFile - */ - override fun getPhysicalFile(): Any = backingDocument!! - - /** - * @see FtpFile.mkdir - * @see DocumentFile.createDirectory - */ - override fun mkdir(): Boolean = parentDocument.createDirectory(name) != null - - /** - * @see FtpFile.delete - * @see DocumentFile.delete - */ - override fun delete(): Boolean = backingDocument?.delete() ?: false - - /** - * @see FtpFile.move - * @see DocumentFile.renameTo - */ - override fun move(destination: FtpFile): Boolean = backingDocument?.renameTo(destination.name) ?: false - - /** - * @see FtpFile.listFiles - * @see DocumentFile.listFiles - */ - override fun listFiles(): MutableList = - if (doesExist()) { - backingDocument!!.listFiles().map { - AndroidFtpFile(context, backingDocument, it, it.name!!) - }.toMutableList() - } else { - mutableListOf() - } - - /** - * @see FtpFile.createOutputStream - * @see ContentResolver.openOutputStream - */ - override fun createOutputStream(offset: Long): OutputStream? = - runCatching { - val uri = - if (doesExist()) { - backingDocument!!.uri - } else { - val newFile = parentDocument.createFile("", name) - newFile?.uri ?: throw IOException("Cannot create file at $path") - } - context.contentResolver.openOutputStream(uri) - }.getOrThrow() - - /** - * @see FtpFile.createInputStream - * @see ContentResolver.openInputStream - */ - override fun createInputStream(offset: Long): InputStream? = - runCatching { - if (doesExist()) { - context.contentResolver.openInputStream(backingDocument!!.uri).also { - it?.skip(offset) - } - } else { - throw FileNotFoundException(path) - } - }.getOrThrow() -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt deleted file mode 100644 index de09a0cc91..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt +++ /dev/null @@ -1,156 +0,0 @@ -/* - * 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.filesystem.ftpserver - -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Build.VERSION_CODES.KITKAT -import android.os.Build.VERSION_CODES.M -import androidx.annotation.RequiresApi -import androidx.documentfile.provider.DocumentFile -import org.apache.ftpserver.ftplet.FileSystemView -import org.apache.ftpserver.ftplet.FtpFile -import java.io.File -import java.net.URI - -@RequiresApi(KITKAT) -class AndroidFtpFileSystemView(private var context: Context, root: String) : FileSystemView { - private val rootPath = root - private val rootDocumentFile = createDocumentFileFrom(rootPath) - private var currentPath: String? = "/" - - override fun getHomeDirectory(): FtpFile = AndroidFtpFile(context, rootDocumentFile, resolveDocumentFileFromRoot("/"), "/") - - override fun getWorkingDirectory(): FtpFile { - return AndroidFtpFile( - context, - rootDocumentFile, - resolveDocumentFileFromRoot(currentPath!!), - currentPath!!, - ) - } - - override fun changeWorkingDirectory(dir: String?): Boolean { - return when { - dir.isNullOrBlank() -> false - dir == "/" -> { - currentPath = "/" - true - } - dir.startsWith("..") -> { - if (currentPath.isNullOrEmpty() || currentPath == "/") { - false - } else { - currentPath = normalizePath("$currentPath/$dir") - resolveDocumentFileFromRoot(currentPath) != null - } - } - else -> { - currentPath = - when { - currentPath.isNullOrEmpty() || currentPath == "/" -> dir - !dir.startsWith("/") -> normalizePath("$currentPath/$dir") - else -> normalizePath(dir) - } - resolveDocumentFileFromRoot(currentPath) != null - } - } - } - - override fun getFile(file: String): FtpFile { - val path = - if (currentPath.isNullOrEmpty() || currentPath == "/") { - "/$file" - } else if (file.startsWith('/')) { - file - } else { - "$currentPath/$file" - } - return normalizePath(path).let { normalizedPath -> - AndroidFtpFile( - context, - resolveDocumentFileFromRoot(getParentFrom(normalizedPath))!!, // rootDocumentFile, - resolveDocumentFileFromRoot(normalizedPath), - normalizedPath, - ) - } - } - - override fun isRandomAccessible(): Boolean = false - - override fun dispose() { - // context = null!! - } - - private fun normalizePath(path: String): String { - return when { - path == "\\" || path == "/" -> { - "/" - } - path.length <= 1 -> { - path - } - else -> { - Uri.decode( - URI(Uri.encode(path, "/")) - .normalize() - .toString(), - ).replace("//", "/") - } - } - } - - private fun getParentFrom(normalizedPath: String): String { - return if (normalizedPath.length <= 1) { - normalizedPath - } else { - normalizedPath.substringBeforeLast('/') - } - } - - private fun createDocumentFileFrom(path: String): DocumentFile { - return if (Build.VERSION.SDK_INT in KITKAT until M) { - DocumentFile.fromFile(File(path)) - } else { - DocumentFile.fromTreeUri(context, Uri.parse(path))!! - } - } - - private fun resolveDocumentFileFromRoot(path: String?): DocumentFile? { - return if (path.isNullOrBlank() || ("/" == path) || ("./" == path)) { - rootDocumentFile - } else { - val pathElements = path!!.split('/') - if (pathElements.isEmpty()) { - rootDocumentFile - } else { - var retval: DocumentFile? = rootDocumentFile - pathElements.forEach { pathElement -> - if (pathElement.isNotBlank()) { - retval = retval?.findFile(pathElement) - } - } - retval - } - } - } -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt deleted file mode 100644 index 808b8dc15b..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt +++ /dev/null @@ -1,32 +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.filesystem.ftpserver - -import org.apache.ftpserver.ftplet.FileSystemFactory -import org.apache.ftpserver.ftplet.FileSystemView -import org.apache.ftpserver.ftplet.User - -class RootFileSystemFactory( - private val fileFactory: RootFileSystemView.SuFileFactory = - RootFileSystemView.DefaultSuFileFactory(), -) : FileSystemFactory { - override fun createFileSystemView(user: User): FileSystemView = RootFileSystemView(user, fileFactory) -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt deleted file mode 100644 index 90f4eaaf68..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt +++ /dev/null @@ -1,279 +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.filesystem.ftpserver - -import android.util.Log -import com.topjohnwu.superuser.io.SuFile -import org.apache.ftpserver.ftplet.FileSystemView -import org.apache.ftpserver.ftplet.FtpFile -import org.apache.ftpserver.ftplet.User -import java.io.File -import java.net.URI -import java.util.StringTokenizer - -class RootFileSystemView( - private val user: User, - private val fileFactory: SuFileFactory, -) : FileSystemView { - private var currDir: String - private var rootDir: String - - companion object { - private const val TAG = "RootFileSystemView" - } - - init { - requireNotNull(user.homeDirectory) { "User home directory can not be null" } - - // add last '/' if necessary - var rootDir = user.homeDirectory - rootDir = normalizeSeparateChar(rootDir) - rootDir = appendSlash(rootDir) - - Log.d( - TAG, - "Native filesystem view created for user \"${user.name}\" with root \"${rootDir}\"", - ) - - this.rootDir = rootDir - currDir = "/" - } - - override fun getHomeDirectory(): FtpFile { - return RootFtpFile("/", fileFactory.create(rootDir), user) - } - - override fun getWorkingDirectory(): FtpFile { - return if (currDir == "/") { - RootFtpFile("/", fileFactory.create(rootDir), user) - } else { - val file = fileFactory.create(rootDir, currDir.substring(1)) - RootFtpFile(currDir, file, user) - } - } - - override fun changeWorkingDirectory(dirArg: String): Boolean { - var dir = dirArg - - // not a directory - return false - dir = getPhysicalName(rootDir, currDir, dir) - val dirObj = fileFactory.create(dir) - if (!dirObj.isDirectory) { - return false - } - - // strip user root and add last '/' if necessary - dir = dir.substring(rootDir.length - 1) - if (dir[dir.length - 1] != '/') { - dir = "$dir/" - } - - currDir = dir - return true - } - - override fun getFile(file: String): FtpFile { - // get actual file object - val physicalName = getPhysicalName(rootDir, currDir, file) - val fileObj = fileFactory.create(physicalName) - - // strip the root directory and return - val userFileName = physicalName.substring(rootDir.length - 1) - return RootFtpFile(userFileName, fileObj, user) - } - - override fun isRandomAccessible(): Boolean = false - - override fun dispose() = Unit - - /** - * Get the physical canonical file name. It works like - * File.getCanonicalPath(). - * - * @param rootDir - * The root directory. - * @param currDir - * The current directory. It will always be with respect to the - * root directory. - * @param fileName - * The input file name. - * @return The return string will always begin with the root directory. It - * will never be null. - */ - private fun getPhysicalName( - rootDir: String, - currDir: String, - fileName: String, - ): String { - // normalize root dir - var normalizedRootDir: String = normalizeSeparateChar(rootDir) - normalizedRootDir = appendSlash(normalizedRootDir) - - // normalize file name - val normalizedFileName = normalizeSeparateChar(fileName) - var result: String? - - // if file name is relative, set resArg to root dir + curr dir - // if file name is absolute, set resArg to root dir - result = - if (normalizedFileName[0] != '/') { - // file name is relative - val normalizedCurrDir = normalize(currDir) - normalizedRootDir + normalizedCurrDir.substring(1) - } else { - normalizedRootDir - } - - // strip last '/' - result = trimTrailingSlash(result) - - // replace ., ~ and .. - // in this loop resArg will never end with '/' - val st = StringTokenizer(normalizedFileName, "/") - while (st.hasMoreTokens()) { - val tok = st.nextToken() - - // . => current directory - if (tok == ".") { - // ignore and move on - } else if (tok == "..") { - // .. => parent directory (if not root) - if (result!!.startsWith(normalizedRootDir)) { - val slashIndex = result.lastIndexOf('/') - if (slashIndex != -1) { - result = result.substring(0, slashIndex) - } - } - } else if (tok == "~") { - // ~ => home directory (in this case the root directory) - result = trimTrailingSlash(normalizedRootDir) - continue - } else { - result = "$result/$tok" - } - } - - // add last slash if necessary - if (result!!.length + 1 == normalizedRootDir.length) { - result += '/' - } - - // make sure we did not end up above root dir - if (!result.startsWith(normalizedRootDir)) { - result = normalizedRootDir - } - return result - } - - /** - * Append trailing slash ('/') if missing - */ - private fun appendSlash(path: String): String { - return if (!path.endsWith("/")) { - "$path/" - } else { - path - } - } - - /** - * Prepend leading slash ('/') if missing - */ - private fun prependSlash(path: String): String { - return if (!path.startsWith("/")) { - "/$path" - } else { - path - } - } - - /** - * Trim trailing slash ('/') if existing - */ - private fun trimTrailingSlash(path: String?): String { - return if (path!![path.length - 1] == '/') { - path.substring(0, path.length - 1) - } else { - path - } - } - - /** - * Normalize separate character. Separate character should be '/' always. - */ - private fun normalizeSeparateChar(pathName: String): String { - return pathName - .replace(File.separatorChar, '/') - .replace('\\', '/') - } - - /** - * Normalize separator char, append and prepend slashes. Default to - * defaultPath if null or empty - */ - private fun normalize(pathArg: String?): String { - var path: String? = pathArg - if (path == null || path.trim { it <= ' ' }.isEmpty()) { - path = "/" - } - path = normalizeSeparateChar(path) - path = prependSlash(appendSlash(path)) - return path - } - - /** - * Interface responsible for creating [SuFile] instances. - * - * Mainly for facilitating tests. - */ - interface SuFileFactory { - /** - * Create SuFile. - */ - fun create(pathname: String): SuFile = SuFile(pathname) - - /** - * Create SuFile. - */ - fun create( - parent: String, - child: String, - ): SuFile = SuFile(parent, child) - - /** - * Create SuFile. - */ - fun create( - parent: File, - child: String, - ): SuFile = SuFile(parent, child) - - /** - * Create SuFile. - */ - fun create(uri: URI): SuFile = SuFile(uri) - } - - /** - * Marker class as default implementation of [SuFileFactory]. - */ - class DefaultSuFileFactory : SuFileFactory -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt deleted file mode 100644 index 0a85fac8dd..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt +++ /dev/null @@ -1,133 +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.filesystem.ftpserver - -import com.topjohnwu.superuser.io.SuFile -import com.topjohnwu.superuser.io.SuFileInputStream -import com.topjohnwu.superuser.io.SuFileOutputStream -import org.apache.ftpserver.ftplet.FtpFile -import org.apache.ftpserver.ftplet.User -import org.apache.ftpserver.usermanager.impl.WriteRequest -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.io.InputStream -import java.io.OutputStream - -class RootFtpFile( - private val fileName: String, - private val backingFile: SuFile, - private val user: User, -) : FtpFile { - companion object { - @JvmStatic - private val logger: Logger = LoggerFactory.getLogger(RootFtpFile::class.java) - } - - override fun getAbsolutePath(): String = backingFile.absolutePath - - override fun getName(): String = backingFile.name - - override fun isHidden(): Boolean = backingFile.isHidden - - override fun isDirectory(): Boolean = backingFile.isDirectory - - override fun isFile(): Boolean = backingFile.isFile - - override fun doesExist(): Boolean = backingFile.exists() - - override fun isReadable(): Boolean = backingFile.canRead() - - override fun isWritable(): Boolean { - logger.debug("Checking authorization for $absolutePath") - if (user.authorize(WriteRequest(absolutePath)) == null) { - logger.debug("Not authorized") - return false - } - - logger.debug("Checking if file exists") - if (backingFile.exists()) { - logger.debug("Checking can write: " + backingFile.canWrite()) - return backingFile.canWrite() - } - - logger.debug("Authorized") - return true - } - - override fun isRemovable(): Boolean { - // root cannot be deleted - if ("/" == fileName) { - return false - } - - val fullName = absolutePath - // we check FTPServer's write permission for this file. - if (user.authorize(WriteRequest(fullName)) == null) { - return false - } - // In order to maintain consistency, when possible we delete the last '/' character in the String - val indexOfSlash = fullName.lastIndexOf('/') - val parentFullName: String = - if (indexOfSlash == 0) { - "/" - } else { - fullName.substring(0, indexOfSlash) - } - - // we check if the parent FileObject is writable. - return backingFile.absoluteFile.parentFile?.run { - RootFtpFile( - parentFullName, - this, - user, - ).isWritable - } ?: false - } - - override fun getOwnerName(): String = "user" - - override fun getGroupName(): String = "user" - - override fun getLinkCount(): Int = if (backingFile.isDirectory) 3 else 1 - - override fun getLastModified(): Long = backingFile.lastModified() - - override fun setLastModified(time: Long): Boolean = backingFile.setLastModified(time) - - override fun getSize(): Long = backingFile.length() - - override fun getPhysicalFile(): Any = backingFile - - override fun mkdir(): Boolean = backingFile.mkdirs() - - override fun delete(): Boolean = backingFile.delete() - - override fun move(destination: FtpFile): Boolean = backingFile.renameTo(destination.physicalFile as SuFile) - - override fun listFiles(): MutableList = - backingFile.listFiles()?.map { - RootFtpFile(it.name, it, user) - }?.toMutableList() ?: emptyList().toMutableList() - - override fun createOutputStream(offset: Long): OutputStream = SuFileOutputStream.open(backingFile.absolutePath) - - override fun createInputStream(offset: Long): InputStream = SuFileInputStream.open(backingFile.absolutePath) -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt deleted file mode 100644 index 8d5809b589..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt +++ /dev/null @@ -1,135 +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.filesystem.ftpserver.commands - -import com.amaze.filemanager.application.AppConfig -import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory -import org.apache.ftpserver.command.AbstractCommand -import org.apache.ftpserver.ftplet.DefaultFtpReply -import org.apache.ftpserver.ftplet.FtpFile -import org.apache.ftpserver.ftplet.FtpReply.REPLY_213_FILE_STATUS -import org.apache.ftpserver.ftplet.FtpReply.REPLY_502_COMMAND_NOT_IMPLEMENTED -import org.apache.ftpserver.ftplet.FtpReply.REPLY_550_REQUESTED_ACTION_NOT_TAKEN -import org.apache.ftpserver.ftplet.FtpRequest -import org.apache.ftpserver.impl.FtpIoSession -import org.apache.ftpserver.impl.FtpServerContext -import org.apache.ftpserver.usermanager.impl.WriteRequest -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.io.File - -/** - * Implements FTP extension AVBL command, to answer device remaining space in FTP command. - * - * Only supports [com.amaze.filemanager.filesystem.ftpserver.RootFileSystemFactory] and - * [org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory]. Otherwise will simply return - * 550 Access Denied. - * - * See [Draft spec](https://www.ietf.org/archive/id/draft-peterson-streamlined-ftp-command-extensions-10.txt) - */ -class AVBL : AbstractCommand() { - companion object { - private val LOG: Logger = LoggerFactory.getLogger(AVBL::class.java) - } - - override fun execute( - session: FtpIoSession, - context: FtpServerContext, - request: FtpRequest, - ) { - // argument check - val fileName: String? = request.argument - if (context.fileSystemManager is AndroidFileSystemFactory) { - doWriteReply( - session, - REPLY_502_COMMAND_NOT_IMPLEMENTED, - "AVBL.notimplemented", - ) - } else { - val ftpFile: FtpFile? = - if (true == fileName?.isNotBlank()) { - runCatching { - session.fileSystemView.getFile(fileName) - }.getOrNull() - } else { - session.fileSystemView.homeDirectory - } - if (ftpFile != null) { - if (session.user.authorize( - if (true == fileName?.isNotBlank()) { - WriteRequest(fileName) - } else { - WriteRequest() - }, - ) != null || - !(ftpFile.physicalFile as File).canWrite() - ) { - (ftpFile.physicalFile as File).apply { - if (this.isDirectory) { - runCatching { - freeSpace.let { - session.write( - DefaultFtpReply(REPLY_213_FILE_STATUS, it.toString()), - ) - } - }.onFailure { - LOG.error("Error getting directory free space", it) - replyError(session, "AVBL.accessdenied") - return - } - } else { - replyError(session, "AVBL.isafile") - } - } - } else { - replyError(session, "AVBL.accessdenied") - } - } else { - replyError(session, "AVBL.missing", fileName) - } - } - } - - private fun replyError( - session: FtpIoSession, - subId: String, - fileName: String? = null, - ) = doWriteReply(session, REPLY_550_REQUESTED_ACTION_NOT_TAKEN, subId, fileName) - - private fun doWriteReply( - session: FtpIoSession, - code: Int, - subId: String, - fileName: String? = null, - ) { - val packageName = AppConfig.getInstance().packageName - val resources = AppConfig.getInstance().resources - session.write( - DefaultFtpReply( - code, - resources.getString( - resources.getIdentifier("$packageName:string/ftp_error_$subId", null, null), - fileName, - ), - ), - ) - } -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt deleted file mode 100644 index ee284fd11e..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt +++ /dev/null @@ -1,49 +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.filesystem.ftpserver.commands - -import com.amaze.filemanager.R -import com.amaze.filemanager.application.AppConfig -import org.apache.ftpserver.command.AbstractCommand -import org.apache.ftpserver.ftplet.DefaultFtpReply -import org.apache.ftpserver.ftplet.FtpReply -import org.apache.ftpserver.ftplet.FtpRequest -import org.apache.ftpserver.impl.FtpIoSession -import org.apache.ftpserver.impl.FtpServerContext - -/** - * Custom [org.apache.ftpserver.command.impl.FEAT] to add [AVBL] command to the list. - */ -class FEAT : AbstractCommand() { - override fun execute( - session: FtpIoSession, - context: FtpServerContext, - request: FtpRequest, - ) { - session.resetState() - session.write( - DefaultFtpReply( - FtpReply.REPLY_211_SYSTEM_STATUS_REPLY, - AppConfig.getInstance().getString(R.string.ftp_command_FEAT), - ), - ) - } -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt b/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt deleted file mode 100644 index eb270d168f..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt +++ /dev/null @@ -1,64 +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.filesystem.ftpserver.commands - -import org.apache.ftpserver.command.AbstractCommand -import org.apache.ftpserver.ftplet.FtpException -import org.apache.ftpserver.ftplet.FtpReply -import org.apache.ftpserver.ftplet.FtpRequest -import org.apache.ftpserver.impl.FtpIoSession -import org.apache.ftpserver.impl.FtpServerContext -import org.apache.ftpserver.impl.LocalizedFtpReply -import java.io.IOException - -/** - * Monkey-patch [org.apache.ftpserver.command.impl.PWD] to prevent true path exposed to end user. - */ -class PWD : AbstractCommand() { - @Throws(IOException::class, FtpException::class) - override fun execute( - session: FtpIoSession, - context: FtpServerContext, - request: FtpRequest, - ) { - session.resetState() - val fsView = session.fileSystemView - var currDir = - fsView.workingDirectory.absolutePath - .substringAfter(fsView.homeDirectory.absolutePath) - if (currDir.isEmpty()) { - currDir = "/" - } - if (!currDir.startsWith("/")) { - currDir = "/$currDir" - } - session.write( - LocalizedFtpReply.translate( - session, - request, - context, - FtpReply.REPLY_257_PATHNAME_CREATED, - "PWD", - currDir, - ), - ) - } -} 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 deleted file mode 100644 index 3dbfc48c0d..0000000000 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceAndroidFileSystemIntegrationTest.kt +++ /dev/null @@ -1,316 +0,0 @@ -/* - * 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.services - -import android.content.Context -import android.net.ConnectivityManager -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.Environment -import androidx.preference.PreferenceManager -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.amaze.filemanager.asynchronous.services.ftp.FtpService -import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory -import com.amaze.filemanager.shadows.ShadowMultiDex -import org.apache.commons.net.ftp.FTPClient -import org.apache.ftpserver.ConnectionConfigFactory -import org.apache.ftpserver.FtpServer -import org.apache.ftpserver.FtpServerFactory -import org.apache.ftpserver.listener.ListenerFactory -import org.apache.ftpserver.usermanager.impl.BaseUser -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import org.robolectric.annotation.LooperMode -import org.robolectric.shadows.ShadowNetworkInfo -import org.robolectric.util.ReflectionHelpers -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.net.InetAddress -import kotlin.random.Random - -@Ignore("Pending fix for testing against newer Androids") -@RunWith(AndroidJUnit4::class) -@Config(sdk = [LOLLIPOP], shadows = [ShadowMultiDex::class]) -@LooperMode(LooperMode.Mode.PAUSED) -@Suppress("StringLiteralDuplication") -class FtpServiceAndroidFileSystemIntegrationTest { - private val FTP_PORT = 62222 - - private var server: FtpServer? = null - - private val randomContent = Random.nextBytes(16) - - private var ftpClient: FTPClient? = null - - companion object { - val directories = - arrayOf( - Environment.DIRECTORY_MUSIC, - Environment.DIRECTORY_PODCASTS, - Environment.DIRECTORY_RINGTONES, - Environment.DIRECTORY_ALARMS, - Environment.DIRECTORY_NOTIFICATIONS, - Environment.DIRECTORY_PICTURES, - Environment.DIRECTORY_MOVIES, - Environment.DIRECTORY_DOWNLOADS, - Environment.DIRECTORY_DCIM, - Environment.DIRECTORY_DOCUMENTS, - "1/2/3/4/5/6/7", - "lost+found", - ) - } - - /** - * Test setup - */ - @Before - fun setUp() { - Environment.getExternalStorageDirectory().run { - directories.forEach { dir -> - File(this, dir).mkdirs() - } - } - - setupNetwork() - PreferenceManager.getDefaultSharedPreferences(ApplicationProvider.getApplicationContext()) - .run { - edit().putString( - FtpService.KEY_PREFERENCE_PATH, - Environment.getExternalStorageDirectory().absolutePath, - ) - .apply() - } - - File(Environment.getExternalStorageDirectory(), "test.bin").let { file -> - file.writeBytes(randomContent) - shadowOf(ApplicationProvider.getApplicationContext().contentResolver) - .registerInputStream(Uri.fromFile(file), FileInputStream(file)) - } - - FtpServerFactory().run { - val connectionConfigFactory = ConnectionConfigFactory() - val user = BaseUser() - user.name = "anonymous" - user.homeDirectory = Environment.getExternalStorageDirectory().absolutePath - connectionConfigFactory.isAnonymousLoginEnabled = true - connectionConfig = connectionConfigFactory.createConnectionConfig() - userManager.save(user) - - fileSystem = - AndroidFileSystemFactory( - ApplicationProvider.getApplicationContext(), - ) - addListener( - "default", - ListenerFactory().also { - it.port = FTP_PORT - }.createListener(), - ) - - server = - createServer().apply { - start() - } - } - - ftpClient = - FTPClient().also { - it.connect("127.0.0.1", FTP_PORT) - it.login("anonymous", "no@e.mail") - it.enterLocalPassiveMode() - } - } - - private fun setupNetwork() { - val cm = - shadowOf( - ApplicationProvider.getApplicationContext() - .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, - ) - val wifiManager = - shadowOf( - ApplicationProvider.getApplicationContext() - .getSystemService(Context.WIFI_SERVICE) as WifiManager, - ) - cm.setActiveNetworkInfo( - ShadowNetworkInfo.newInstance( - NetworkInfo.DetailedState.CONNECTED, - ConnectivityManager.TYPE_WIFI, - -1, - true, - NetworkInfo.State.CONNECTED, - ), - ) - ReflectionHelpers.callInstanceMethod( - wifiManager, - "setWifiEnabled", - ReflectionHelpers.ClassParameter.from(Boolean::class.java, true), - ) - ReflectionHelpers.callInstanceMethod( - wifiManager, - "getConnectionInfo", - ).run { - ReflectionHelpers.callInstanceMethod( - this, - "setInetAddress", - ReflectionHelpers.ClassParameter.from( - InetAddress::class.java, - InetAddress.getLoopbackAddress(), - ), - ) - } - } - - /** - * Kill FTP server if there is one running - */ - @After - fun tearDown() { - ftpClient?.logout() - server?.stop() - } - - /** - * Test on change directory functions - */ - @Test - fun testChdir() { - ftpClient!!.run { - assertEquals(directories.size + 1, listFiles().size) - assertTrue(changeWorkingDirectory("Download")) - assertEquals(0, listFiles().size) - assertTrue(changeWorkingDirectory("..")) - assertEquals(directories.size + 1, listFiles().size) - assertTrue(changeWorkingDirectory("/1/2/3/4/5/6/7")) - assertEquals(0, listFiles().size) - assertTrue(changeWorkingDirectory("../")) - assertTrue(printWorkingDirectory().startsWith("/1/2/3/4/5/6")) - assertTrue(changeToParentDirectory()) - assertTrue(printWorkingDirectory().startsWith("/1/2/3/4/5")) - assertTrue(changeWorkingDirectory("../../..")) - assertTrue(printWorkingDirectory().startsWith("/1/2")) - } - } - - /** - * Test remove directory function - */ - @Test - fun testRmDir() { - ftpClient!!.run { - assertTrue(changeWorkingDirectory("/")) - assertTrue(makeDirectory("foobar")) - assertEquals(directories.size + 2, listFiles().size) - assertTrue(removeDirectory("foobar")) - assertEquals(directories.size + 1, listFiles().size) - assertFalse(changeWorkingDirectory("foobar")) - assertTrue(listFiles("/foobar").isNullOrEmpty()) - } - } - - /** - * Test download file - */ - @Test - fun testDownloadFile() { - ftpClient!!.run { - assertFalse(deleteFile("/nonexist.file.txt")) - assertNull(retrieveFileStream("/not/existing/file")) - assertFalse( - retrieveFile( - "/barrier/nonexist.file.jpg", - FileOutputStream(File.createTempFile("notused", "output")), - ), - ) - assertTrue(changeWorkingDirectory("/")) - retrieveFileStream("test.bin").let { - assertNotNull(it) - it.close() - } - completePendingCommand() - assertTrue(printWorkingDirectory() == "/") - } - } - - /** - * Test upload file - */ - @Test - fun testUploadFile() { - ftpClient!!.run { - storeFileStream("/test2.bin").let { - ByteArrayInputStream(Random.nextBytes(24)).copyTo(it) - it.flush() - it.close() - } - completePendingCommand() - assertTrue(rename("/test2.bin", "/test2.arc.bin")) - } - } - - /** - * Test working with files and folders at subfolders - */ - @Test - fun testUploadFileToSubDir() { - ftpClient!!.run { - assertTrue(makeDirectory("/CROSS OVER")) - storeFileStream("/CROSS OVER/test3.bin").let { - ByteArrayInputStream(Random.nextBytes(24)).copyTo(it) - it.flush() - it.close() - } - completePendingCommand() - assertTrue(changeWorkingDirectory("/CROSS OVER")) - listFiles().let { - assertEquals(1, it.size) - assertEquals("test3.bin", it[0].name) - } - assertTrue(makeDirectory("/CROSS OVER/multiple")) - assertTrue(makeDirectory("/CROSS OVER/multiple/levels down")) - assertTrue(changeWorkingDirectory("/CROSS OVER/multiple")) - assertTrue(changeWorkingDirectory("levels down")) - assertEquals("/CROSS OVER/multiple/levels down", printWorkingDirectory()) - assertTrue(deleteFile("/CROSS OVER/test3.bin")) - assertTrue(changeWorkingDirectory("/CROSS OVER")) - listFiles().let { - assertEquals(1, it.size) - assertEquals("multiple", it[0].name) - } - } - } -} diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceSupportedCiphersTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceSupportedCiphersTest.kt deleted file mode 100644 index 9ee4a56c3c..0000000000 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/FtpServiceSupportedCiphersTest.kt +++ /dev/null @@ -1,78 +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.asynchronous.services - -import android.os.Build -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.amaze.filemanager.asynchronous.services.ftp.FtpService -import com.amaze.filemanager.shadows.ShadowMultiDex -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config - -/** - * Test [FtpService.enabledCipherSuites]. - * - * This test is deliberately set to run on all available SDKs to ensure no one is missing out. - */ -@RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class]) -class FtpServiceSupportedCiphersTest { - /** - * Check for enabled ciphers, to ensure no unsupported ciphers on the list. - * - * @see FtpService.enabledCipherSuites - * @see javax.net.ssl.SSLEngine - */ - @Test - fun testSupportedCiphers() { - val verify = FtpService.enabledCipherSuites - assertNotNull(verify) - assertTrue(verify.isNotEmpty()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - assertTrue(verify.contains("TLS_AES_128_GCM_SHA256")) - assertTrue(verify.contains("TLS_AES_256_GCM_SHA384")) - assertTrue(verify.contains("TLS_CHACHA20_POLY1305_SHA256")) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - assertTrue(verify.contains("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256")) - assertTrue(verify.contains("TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256")) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - assertTrue(verify.contains("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA")) - assertTrue(verify.contains("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")) - assertTrue(verify.contains("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA")) - assertTrue(verify.contains("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")) - assertTrue(verify.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA")) - assertTrue(verify.contains("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256")) - assertTrue(verify.contains("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA")) - assertTrue(verify.contains("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384")) - assertTrue(verify.contains("TLS_RSA_WITH_AES_128_GCM_SHA256")) - assertTrue(verify.contains("TLS_RSA_WITH_AES_256_GCM_SHA384")) - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - assertTrue(verify.contains("TLS_RSA_WITH_AES_128_CBC_SHA")) - assertTrue(verify.contains("TLS_RSA_WITH_AES_256_CBC_SHA")) - } - } -} 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 deleted file mode 100644 index b08a421056..0000000000 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2014-2023 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.content.Intent -import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP -import android.os.Build.VERSION_CODES.N -import android.os.Build.VERSION_CODES.O -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 -import io.mockk.spyk -import io.mockk.unmockkObject -import io.mockk.verify -import org.junit.After -import org.junit.Assert.assertFalse -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config - -@RunWith(AndroidJUnit4::class) -@Config(shadows = [ShadowMultiDex::class], sdk = [LOLLIPOP, P, Build.VERSION_CODES.R]) -@Suppress("StringLiteralDuplication") -class FtpReceiverTest { - private lateinit var receiver: FtpReceiver - - /** - * Pre-test setup. - */ - @Before - fun setUp() { - mockkObject(FtpService) - receiver = FtpReceiver() - } - - /** - * Post test teardown. - */ - @After - fun tearDown() { - unmockkObject(FtpService) - } - - /** - * Test when an invalid Intent is passed into the [FtpReceiver]. - */ - @Test - fun testWhenNoActionSpecified() { - every { FtpService.isRunning() } returns false - assertFalse(FtpService.isRunning()) - receiver.onReceive(AppConfig.getInstance(), Intent()) - assertFalse(FtpService.isRunning()) - } - - /** - * Test [Context.startService()] called for pre-Oreo Androids. - */ - @Test - @Config(sdk = [N]) - fun testStartServiceCalled() { - val ctx = AppConfig.getInstance() - val spy = spyk(ctx) - val capturedIntent = slot() - every { spy.startService(capture(capturedIntent)) } answers { callOriginal() } - val intent = Intent(FtpService.ACTION_START_FTPSERVER) - receiver.onReceive(spy, intent) - - verify { - spy.startService(capturedIntent.captured) - } - } - - /** - * Test [Context.startForegroundService()] called for post-Nougat Androids. - */ - @Test - @Config(minSdk = O) - fun testStartForegroundServiceCalled() { - val ctx = AppConfig.getInstance() - val spy = spyk(ctx) - val capturedIntent = slot() - every { spy.startService(capture(capturedIntent)) } answers { callOriginal() } - every { spy.startForegroundService(capture(capturedIntent)) } answers { callOriginal() } - val intent = Intent(FtpService.ACTION_START_FTPSERVER) - receiver.onReceive(spy, intent) - - verify { - spy.startService(capturedIntent.captured)?.wasNot(Called) - spy.startForegroundService(capturedIntent.captured) - } - } -} 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..38b3d6d939 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 @@ -25,7 +25,6 @@ import android.os.Environment import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.FlakyTest import com.amaze.filemanager.application.AppConfig -import com.amaze.filemanager.asynchronous.services.FtpServiceAndroidFileSystemIntegrationTest import com.amaze.filemanager.fileoperations.filesystem.OpenMode import com.amaze.filemanager.filesystem.HybridFile import com.amaze.filemanager.filesystem.Operations @@ -96,6 +95,22 @@ open class FtpHybridFileTest { const val PASSWORD = "passw0rD" private const val PORT = 2221 + + val directories = + arrayOf( + Environment.DIRECTORY_MUSIC, + Environment.DIRECTORY_PODCASTS, + Environment.DIRECTORY_RINGTONES, + Environment.DIRECTORY_ALARMS, + Environment.DIRECTORY_NOTIFICATIONS, + Environment.DIRECTORY_PICTURES, + Environment.DIRECTORY_MOVIES, + Environment.DIRECTORY_DOWNLOADS, + Environment.DIRECTORY_DCIM, + Environment.DIRECTORY_DOCUMENTS, + "1/2/3/4/5/6/7", + "lost+found", + ) } /** @@ -118,7 +133,7 @@ open class FtpHybridFileTest { Schedulers.trampoline() } Environment.getExternalStorageDirectory().run { - FtpServiceAndroidFileSystemIntegrationTest.directories.forEach { dir -> + directories.forEach { dir -> File(this, dir).mkdirs() } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt deleted file mode 100644 index 8b0e7fa9ac..0000000000 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt +++ /dev/null @@ -1,222 +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.filesystem.ftpserver.commands - -import android.os.Environment -import com.amaze.filemanager.R -import com.amaze.filemanager.application.AppConfig -import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory -import org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory -import org.apache.ftpserver.filesystem.nativefs.impl.NativeFileSystemView -import org.apache.ftpserver.ftplet.Authority -import org.apache.ftpserver.ftplet.FileSystemFactory -import org.apache.ftpserver.ftplet.FtpFile -import org.apache.ftpserver.impl.DefaultFtpRequest -import org.apache.ftpserver.impl.FtpIoSession -import org.apache.ftpserver.impl.FtpServerContext -import org.apache.ftpserver.usermanager.impl.BaseUser -import org.apache.ftpserver.usermanager.impl.WritePermission -import org.junit.Assert.assertEquals -import org.junit.BeforeClass -import org.junit.Test -import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import java.io.File - -/** - * Unit test for [AVBL]. - */ -class AVBLCommandTest : AbstractFtpserverCommandTest() { - companion object { - private lateinit var fsFactory: FileSystemFactory - - private lateinit var fsView: NativeFileSystemView - - /** - * Mock [NativeFileSystemView] for testing. - */ - @JvmStatic - @BeforeClass - fun bootstrap() { - fsFactory = mock(NativeFileSystemFactory::class.java) - fsView = mock(NativeFileSystemView::class.java) - val physicalFile1 = mock(File::class.java) - val physicalFile2 = mock(File::class.java) - val physicalFile3 = mock(File::class.java) - val physicalFile4 = mock(File::class.java) - val ftpFile1 = mock(FtpFile::class.java) - val ftpFile2 = mock(FtpFile::class.java) - val ftpFile3 = mock(FtpFile::class.java) - val ftpFile4 = mock(FtpFile::class.java) - `when`(physicalFile1.isDirectory).thenReturn(true) - `when`(physicalFile2.isDirectory).thenReturn(true) - `when`(physicalFile3.isDirectory).thenReturn(true) - `when`(physicalFile4.isDirectory).thenReturn(false) - `when`(physicalFile4.isFile).thenReturn(true) - `when`(physicalFile1.freeSpace).thenReturn(12345L) - `when`(physicalFile2.freeSpace).thenReturn(131072L) - `when`(physicalFile3.freeSpace).thenThrow(SecurityException()) - `when`(ftpFile1.physicalFile).thenReturn(physicalFile1) - `when`(ftpFile2.physicalFile).thenReturn(physicalFile2) - `when`(ftpFile3.physicalFile).thenReturn(physicalFile3) - `when`(ftpFile4.physicalFile).thenReturn(physicalFile4) - `when`(fsView.homeDirectory).thenReturn(ftpFile1) - `when`(fsView.getFile(anyString())).thenReturn(null) - `when`(fsView.getFile("/")).thenReturn(ftpFile1) - `when`(fsView.getFile("/incoming")).thenReturn(ftpFile2) - `when`(fsView.getFile("/secure")).thenReturn(ftpFile3) - `when`(fsView.getFile("/test.txt")).thenReturn(ftpFile4) - `when`(fsFactory.createFileSystemView(any())).thenReturn(fsView) - } - } - - /** - * Command should return 502 not implemented if FTP server is using [AndroidFileSystemFactory]. - */ - @Test - fun testWithAndroidFileSystem() { - executeRequest( - "AVBL", - listOf(WritePermission()), - mock(AndroidFileSystemFactory::class.java), - ) - assertEquals(1, logger.messages.size) - assertEquals(502, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_error_AVBL_notimplemented), - logger.messages[0].message, - ) - } - - /** - * No path argument should return home directory size. - */ - @Test - fun testHomeDirectory() { - executeRequest("AVBL", listOf(WritePermission())) - assertEquals(1, logger.messages.size) - assertEquals(213, logger.messages[0].code) - assertEquals("12345", logger.messages[0].message) - } - - /** - * Root (/) argument test. - */ - @Test - fun testRoot() { - executeRequest("AVBL /", listOf(WritePermission())) - assertEquals(1, logger.messages.size) - assertEquals(213, logger.messages[0].code) - assertEquals("12345", logger.messages[0].message) - } - - /** - * Test specified path. - */ - @Test - fun testGetPath() { - executeRequest("AVBL /incoming", listOf(WritePermission())) - assertEquals(1, logger.messages.size) - assertEquals(213, logger.messages[0].code) - assertEquals("131072", logger.messages[0].message) - } - - /** - * Command should return 550 if path not found. - */ - @Test - fun testPathNotFound() { - executeRequest("AVBL /foobar", listOf(WritePermission())) - assertEquals(1, logger.messages.size) - assertEquals(550, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_error_AVBL_missing), - logger.messages[0].message, - ) - } - - /** - * Command should return 550 too if user does not have access to directory. - */ - @Test - fun testAccessDenied() { - executeRequest("AVBL /secure", emptyList()) - assertEquals(1, logger.messages.size) - assertEquals(550, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_error_AVBL_accessdenied), - logger.messages[0].message, - ) - } - - /** - * Command should return 550 if [SecurityException] was thrown when calling - * [File.getFreeSpace]. - */ - @Test - fun testSecurityException() { - executeRequest("AVBL /secure", listOf(WritePermission())) - assertEquals(1, logger.messages.size) - assertEquals(550, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_error_AVBL_accessdenied), - logger.messages[0].message, - ) - } - - /** - * Command should return 550 if user tried to get free space from a file. - */ - @Test - fun testFile() { - executeRequest("AVBL /test.txt", listOf(WritePermission())) - assertEquals(1, logger.messages.size) - assertEquals(550, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_error_AVBL_isafile), - logger.messages[0].message, - ) - } - - private fun executeRequest( - commandLine: String, - permissions: List, - fileSystemFactory: FileSystemFactory = fsFactory, - ) { - val context = mock(FtpServerContext::class.java) - val ftpSession = FtpIoSession(session, context) - ftpSession.user = - BaseUser().also { - it.homeDirectory = Environment.getExternalStorageDirectory().absolutePath - it.authorities = permissions - } - ftpSession.setLogin(fsView) - `when`(context.fileSystemManager).thenReturn(fileSystemFactory) - val command = AVBL() - command.execute( - session = ftpSession, - context = context, - request = DefaultFtpRequest(commandLine), - ) - } -} 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 deleted file mode 100644 index 6aed30ecb4..0000000000 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt +++ /dev/null @@ -1,66 +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.filesystem.ftpserver.commands - -import android.os.Build -import android.os.Build.VERSION_CODES.LOLLIPOP -import android.os.Build.VERSION_CODES.P -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.amaze.filemanager.shadows.ShadowMultiDex -import org.apache.mina.core.session.DummySession -import org.apache.mina.core.session.IoSession -import org.junit.After -import org.junit.Before -import org.junit.runner.RunWith -import org.robolectric.annotation.Config - -/** - * Base class for ftpserver command unit tests. - */ -@RunWith(AndroidJUnit4::class) -@Config( - shadows = [ShadowMultiDex::class], - sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], -) -abstract class AbstractFtpserverCommandTest { - protected lateinit var logger: LogMessageFilter - - protected lateinit var session: IoSession - - /** - * Test setup. Create dummy [IoSession] and bind logging filter to it. - */ - @Before - open fun setUp() { - logger = LogMessageFilter() - session = DummySession() - session.filterChain.addFirst("logging", logger) - } - - /** - * Post test cleanup - */ - @After - open fun tearDown() { - session.closeNow() - logger.reset() - } -} diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt deleted file mode 100644 index be466613d4..0000000000 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt +++ /dev/null @@ -1,58 +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.filesystem.ftpserver.commands - -import com.amaze.filemanager.R -import com.amaze.filemanager.application.AppConfig -import org.apache.ftpserver.impl.DefaultFtpRequest -import org.apache.ftpserver.impl.FtpIoSession -import org.apache.ftpserver.impl.FtpServerContext -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.Mockito - -/** - * Unit test for [FEAT]. - */ -class FEATCommandTest : AbstractFtpserverCommandTest() { - /** - * Test command output. Expect AVBL is among list of extensions implemented. - */ - @Test - fun testCommand() { - val context = Mockito.mock(FtpServerContext::class.java) - val ftpSession = FtpIoSession(session, context) - val command = FEAT() - command.execute( - session = ftpSession, - context = context, - request = DefaultFtpRequest("FEAT"), - ) - assertEquals(1, logger.messages.size) - assertEquals(211, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_command_FEAT), - logger.messages[0].message, - ) - assertTrue(logger.messages[0].message.contains("AVBL")) - } -} diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/LogMessageFilter.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/LogMessageFilter.kt deleted file mode 100644 index 4f97e99047..0000000000 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/LogMessageFilter.kt +++ /dev/null @@ -1,52 +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.filesystem.ftpserver.commands - -import org.apache.ftpserver.ftplet.FtpReply -import org.apache.mina.core.filterchain.IoFilter -import org.apache.mina.core.filterchain.IoFilterAdapter -import org.apache.mina.core.session.IoSession -import org.apache.mina.core.write.WriteRequest - -/** - * [IoFilter] to store messages written in an [IoSession]. Test use only. - */ -class LogMessageFilter : IoFilterAdapter() { - val messages = mutableListOf() - - override fun messageSent( - nextFilter: IoFilter.NextFilter, - session: IoSession, - writeRequest: WriteRequest, - ) { - writeRequest.message.run { - messages.add(this as FtpReply) - } - super.messageSent(nextFilter, session, writeRequest) - } - - /** - * Expunge all messages sent to this filter. - */ - fun reset() { - messages.clear() - } -} diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt deleted file mode 100644 index 61955ddb1a..0000000000 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt +++ /dev/null @@ -1,144 +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.filesystem.ftpserver.commands - -import android.os.Environment -import org.apache.ftpserver.filesystem.nativefs.impl.NativeFileSystemView -import org.apache.ftpserver.ftplet.User -import org.apache.ftpserver.impl.DefaultFtpRequest -import org.apache.ftpserver.impl.FtpIoSession -import org.apache.ftpserver.impl.FtpServerContext -import org.apache.ftpserver.message.MessageResourceFactory -import org.apache.ftpserver.usermanager.impl.BaseUser -import org.apache.ftpserver.usermanager.impl.WritePermission -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.BeforeClass -import org.junit.Test -import org.mockito.Mockito -import org.mockito.Mockito.`when` -import java.io.File - -/** - * Unit test for [PWD]. - */ -@Suppress("StringLiteralDuplication") -class PWDCommandTest : AbstractFtpserverCommandTest() { - private lateinit var fsView: NativeFileSystemView - - private lateinit var user: User - - private lateinit var ftpSession: FtpIoSession - - companion object { - // Nobody is interested in this, nor asking it any question. - // Of course in reality FtpServerContext will never be static - private lateinit var context: FtpServerContext - - /** - * Mock [FtpServerContext] for test, with custom logic to skip effort to create - * [MessageResource]. - */ - @JvmStatic - @BeforeClass - fun bootstrap() { - context = Mockito.mock(FtpServerContext::class.java) - val messages = MessageResourceFactory().createMessageResource() - `when`(context.messageResource).thenReturn(messages) - } - } - - /** - * Setup before test. - */ - @Before - override fun setUp() { - super.setUp() - File(Environment.getExternalStorageDirectory(), "Music").mkdirs() - user = - BaseUser().also { - it.homeDirectory = Environment.getExternalStorageDirectory().absolutePath - it.authorities = listOf(WritePermission()) - } - fsView = NativeFileSystemView(user, false) - - ftpSession = FtpIoSession(session, context) - ftpSession.user = user - ftpSession.setLogin(fsView) - } - - /** - * PWD should never expose device real path to user. - */ - @Test - fun testRootDir() { - executeRequest() - assertEquals(1, logger.messages.size) - assertEquals(257, logger.messages[0].code) - assertEquals("\"/\" is current directory.", logger.messages[0].message) - } - - /** - * Test scenario after changing working directory. - */ - @Test - fun testChangeDirDown() { - executeRequest() - assertEquals(1, logger.messages.size) - assertEquals(257, logger.messages.last().code) - assertEquals("\"/\" is current directory.", logger.messages.last().message) - ftpSession.fileSystemView.changeWorkingDirectory("/Music") - executeRequest() - assertEquals(2, logger.messages.size) - assertEquals(257, logger.messages.last().code) - assertEquals("\"/Music\" is current directory.", logger.messages.last().message) - } - - /** - * Test scenario after a CDUP. - */ - @Test - fun testChangeDirUp() { - executeRequest() - assertEquals(1, logger.messages.size) - assertEquals(257, logger.messages.last().code) - assertEquals("\"/\" is current directory.", logger.messages.last().message) - ftpSession.fileSystemView.changeWorkingDirectory("/Music") - executeRequest() - assertEquals(2, logger.messages.size) - assertEquals(257, logger.messages.last().code) - assertEquals("\"/Music\" is current directory.", logger.messages.last().message) - ftpSession.fileSystemView.changeWorkingDirectory("/") - executeRequest() - assertEquals(3, logger.messages.size) - assertEquals(257, logger.messages.last().code) - assertEquals("\"/\" is current directory.", logger.messages.last().message) - } - - private fun executeRequest() { - val command = PWD() - command.execute( - session = ftpSession, - context = context, - request = DefaultFtpRequest("PWD"), - ) - } -} diff --git a/app/src/test/resources/ftpusers b/app/src/test/resources/ftpusers deleted file mode 100644 index 4ec37cb749..0000000000 --- a/app/src/test/resources/ftpusers +++ /dev/null @@ -1 +0,0 @@ -ftpuser:passw0rD From 6a3ad9544f96eb2f50b2f5f00c2d67baa95ae36a Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 12 Apr 2026 10:15:13 +0800 Subject: [PATCH 07/15] Fix cross-test contamination causing FilesOnSshdTest regression SshAuthenticationTaskTest.prepareSshConnectionPool() was storing a raw SSHClient Mockito mock directly into NetCopyClientConnectionPool.connections without wrapping it in SSHClientImpl. This caused a ClassCastException when shutdown() was called in AbstractSftpServerTest.tearDown(), since the lambda tried to cast the raw SSHClient (which is not a NetCopyClient) to NetCopyClient. The unhandled exception from the fire-and-forget runInBackground call was then rethrown as OnErrorNotImplementedException, leaving connections un-cleared. On the next test's setUp() call, the stale connection from SshAuthenticationTaskTest was still present in the pool. When validate() found it invalid and tried to reconnect via createSshClient(url), it required a DB lookup that found no matching entry (because the SshAuthenticationTaskTest connection used a different URL), causing FilesOnSshdTest.testListFilesWithSpecialChars to time out waiting for SFTP results. Fixes: - Wrap mock SSHClient in SSHClientImpl in prepareSshConnectionPool() so the connections map always holds proper NetCopyClient instances - Add tearDown() to SshAuthenticationTaskTest to reset sshClientFactory and clear the connections map after each test, preventing state leakage - Add sshClientFactory reset in AbstractSftpServerTest.setUp() as an additional safeguard against contamination from any preceding test class - Minor: refactor FilesOnSshdTest to use SAM lambda syntax for OnFileFound callbacks; extend atMost timeout to 120 seconds for testListFilesWithSpecialChars --- .../ssh/SshAuthenticationTaskTest.kt | 22 ++++- .../filesystem/ssh/AbstractSftpServerTest.kt | 8 +- .../filesystem/ssh/FilesOnSshdTest.kt | 91 +++++++------------ 3 files changed, 59 insertions(+), 62 deletions(-) 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..780484f3a5 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 @@ -29,6 +29,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.R import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool +import com.amaze.filemanager.filesystem.ftp.SSHClientImpl import com.amaze.filemanager.filesystem.ssh.test.TestKeyProvider import com.amaze.filemanager.shadows.ShadowMultiDex import com.amaze.filemanager.test.ShadowPasswordUtil @@ -42,6 +43,7 @@ import net.schmizz.sshj.SSHClient import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.userauth.UserAuthException import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -81,6 +83,24 @@ class SshAuthenticationTaskTest { RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } } + /** + * Post-test cleanup to prevent Robolectric sandbox state pollution. + * + * Resets [NetCopyClientConnectionPool.sshClientFactory] to the default implementation and + * clears the connections pool, so that tests sharing the same Robolectric sandbox (same + * [org.robolectric.annotation.Config]) are not affected by the mock factory set up here. + */ + @After + fun tearDown() { + NetCopyClientConnectionPool.sshClientFactory = + NetCopyClientConnectionPool.DefaultSSHClientFactory() + NetCopyClientConnectionPool::class.java.getDeclaredField("connections").run { + this.isAccessible = true + @Suppress("UNCHECKED_CAST") + (this.get(NetCopyClientConnectionPool) as MutableMap).clear() + } + } + /** * Test SSH authentication with username/password success scenario */ @@ -342,7 +362,7 @@ class SshAuthenticationTaskTest { this.set( NetCopyClientConnectionPool, mutableMapOf( - Pair("ssh://user:password@127.0.0.1:22222", sshClient), + Pair("ssh://user:password@127.0.0.1:22222", SSHClientImpl(sshClient)), ), ) } 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..2aa5d7678c 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 @@ -26,6 +26,7 @@ import android.os.Build.VERSION_CODES.P import android.os.Environment import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.application.AppConfig +import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.SSH_URI_PREFIX import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.getConnection import com.amaze.filemanager.filesystem.ftp.NetCopyClientConnectionPool.shutdown @@ -79,6 +80,11 @@ abstract class AbstractSftpServerTest { @Before @Throws(IOException::class) open fun setUp() { + // Reset sshClientFactory to the default implementation to prevent cross-test pollution + // from other test classes sharing the same Robolectric sandbox (e.g. + // SshAuthenticationTaskTest). + NetCopyClientConnectionPool.sshClientFactory = + NetCopyClientConnectionPool.DefaultSSHClientFactory() serverPort = createSshServer( VirtualFileSystemFactory( @@ -141,7 +147,7 @@ abstract class AbstractSftpServerTest { server.port = startPort server.start() startPort - } catch (ifPortIsUnavailable: BindException) { + } catch (_: BindException) { createSshServer(fileSystemFactory, startPort + 1) } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/FilesOnSshdTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/FilesOnSshdTest.kt index 076e7149ee..044d0af9f0 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ssh/FilesOnSshdTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/ssh/FilesOnSshdTest.kt @@ -25,9 +25,7 @@ import androidx.test.core.app.ApplicationProvider import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.fileoperations.filesystem.OpenMode import com.amaze.filemanager.filesystem.HybridFile -import com.amaze.filemanager.filesystem.HybridFileParcelable import com.amaze.filemanager.test.randomBytes -import com.amaze.filemanager.utils.OnFileFound import org.awaitility.Awaitility.await import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -125,13 +123,10 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - assertTrue("${fileFound.path} not seen as directory", fileFound.isDirectory) - result.add(fileFound.name) - } - }, - ) + ) { fileFound -> + assertTrue("${fileFound.path} not seen as directory", fileFound.isDirectory) + result.add(fileFound.name) + } await().until { result.size == 8 } assertThat>( result, @@ -150,13 +145,8 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - result.add(fileFound.name) - } - }, - ) - await().atMost(90, TimeUnit.SECONDS).until { result.size == 2 } + ) { fileFound -> result.add(fileFound.name) } + await().atMost(120, TimeUnit.SECONDS).until { result.size == 2 } assertThat>( result, Matchers.hasItems("test+file.bin", "D:"), @@ -170,12 +160,7 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - result.add(fileFound.name) - } - }, - ) + ) { fileFound -> result.add(fileFound.name) } await().until { result.size == 1 } assertThat>( result, @@ -190,12 +175,7 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - result.add(fileFound.name) - } - }, - ) + ) { fileFound -> result.add(fileFound.name) } await().until { result.size == 1 } assertThat>( result, @@ -234,24 +214,21 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - if (!fileFound.name.endsWith(".txt")) { - assertTrue( - fileFound.path + " not seen as directory", - fileFound.isDirectory, - ) - dirs.add(fileFound.name) - } else { - assertFalse( - fileFound.path + " not seen as file", - fileFound.isDirectory, - ) - files.add(fileFound.name) - } - } - }, - ) + ) { fileFound -> + if (!fileFound.name.endsWith(".txt")) { + assertTrue( + fileFound.path + " not seen as directory", + fileFound.isDirectory, + ) + dirs.add(fileFound.name) + } else { + assertFalse( + fileFound.path + " not seen as file", + fileFound.isDirectory, + ) + files.add(fileFound.name) + } + } await().until { dirs.size == 8 } assertThat>( dirs, @@ -311,13 +288,10 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - assertFalse("${fileFound.path} not seen as directory", fileFound.isDirectory) - result.add(fileFound.name) - } - }, - ) + ) { fileFound -> + assertFalse("${fileFound.path} not seen as directory", fileFound.isDirectory) + result.add(fileFound.name) + } await().until { result.size == 4 } assertThat>( result, @@ -329,13 +303,10 @@ class FilesOnSshdTest : AbstractSftpServerTest() { file.forEachChildrenFile( ApplicationProvider.getApplicationContext(), false, - object : OnFileFound { - override fun onFileFound(fileFound: HybridFileParcelable) { - assertTrue("${fileFound.path} not seen as directory", fileFound.isDirectory) - result2.add(fileFound.name) - } - }, - ) + ) { fileFound -> + assertTrue("${fileFound.path} not seen as directory", fileFound.isDirectory) + result2.add(fileFound.name) + } await().until { result2.size == 8 } assertThat>( result2, From d3ec5caba206521b1bebe9c654f0086b59e714e2 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 12 Apr 2026 22:53:27 +0800 Subject: [PATCH 08/15] Changes per PR feedback (again) --- ftpserver/build.gradle | 1 - ftpserver/src/main/AndroidManifest.xml | 22 +--------- .../ftpserver/service/FtpServerEngine.kt | 26 +++++++----- .../ftpserver/service/FtpServerService.kt | 42 +++++++++++-------- .../ftpserver/ui/BaseFtpServerFragment.kt | 4 +- .../ftpserver/ui/FtpServerNotification.kt | 18 ++++---- .../src/main/res/layout/fragment_ftp.xml | 2 +- ftpserver/src/main/res/values/strings.xml | 12 +++--- 8 files changed, 61 insertions(+), 66 deletions(-) diff --git a/ftpserver/build.gradle b/ftpserver/build.gradle index 2cd1607932..d06d5c05b4 100644 --- a/ftpserver/build.gradle +++ b/ftpserver/build.gradle @@ -61,7 +61,6 @@ dependencies { implementation libs.androidX.material implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'org.greenrobot:eventbus:3.3.1' implementation libs.apache.mina.core implementation libs.apache.ftpserver.ftplet.api implementation libs.apache.ftpserver.core diff --git a/ftpserver/src/main/AndroidManifest.xml b/ftpserver/src/main/AndroidManifest.xml index 066efbc005..568741e54f 100644 --- a/ftpserver/src/main/AndroidManifest.xml +++ b/ftpserver/src/main/AndroidManifest.xml @@ -1,22 +1,2 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt index b6884c8de8..3c2c6c8a40 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt @@ -76,6 +76,7 @@ object FtpServerEngine { val keyStorePassword: String = "", val errorMessageProvider: AVBL.ErrorMessageProvider? = null, val featResponseProvider: (() -> String)? = null, + val startedByTile: Boolean = false, ) /** @@ -199,7 +200,13 @@ object FtpServerEngine { createServer().apply { start() scope.launch { - FtpEventBus.emit(FtpServerEvent.Started) + val event = + if (config.startedByTile) { + FtpServerEvent.StartedFromTile + } else { + FtpServerEvent.Started + } + FtpEventBus.emit(event) } onStarted(true) } @@ -217,20 +224,19 @@ object FtpServerEngine { * Stop the FTP server */ fun stop() { - serverThread?.let { thread -> - thread.interrupt() - thread.join(10000) - - if (!thread.isAlive) { - serverThread = null + scope.launch { + serverThread?.let { thread -> + thread.interrupt() + thread.join(10000) + if (!thread.isAlive) { + serverThread = null + } } server?.stop() server = null - scope.launch { - FtpEventBus.emit(FtpServerEvent.Stopped) - } + FtpEventBus.emit(FtpServerEvent.Stopped) } } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt index cd0d461eea..902d2387f4 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt @@ -27,6 +27,12 @@ import android.os.Build import android.os.IBinder import android.os.PowerManager import com.amaze.filemanager.ftpserver.commands.AVBL +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.InputStream @@ -40,6 +46,7 @@ import java.util.concurrent.TimeUnit abstract class FtpServerService : Service() { private lateinit var wakeLock: PowerManager.WakeLock private var isStartedByTile = false + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) /** * Get the notification ID for this service @@ -106,21 +113,7 @@ abstract class FtpServerService : Service() { ): Int { isStartedByTile = intent?.getBooleanExtra(FtpPreferences.TAG_STARTED_BY_TILE, false) == true - // Wait for any existing server to stop - var attempts = 10 - while (FtpServerEngine.isRunning()) { - if (attempts > 0) { - attempts-- - try { - Thread.sleep(1000) - } catch (_: InterruptedException) { - } - } else { - return START_STICKY - } - } - - // Start as foreground service + // Start as foreground service immediately to avoid ANR val notification = createStartingNotification(isStartedByTile) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( @@ -132,8 +125,21 @@ abstract class FtpServerService : Service() { startForeground(getNotificationId(), notification) } - // Start the server - startServer() + // Wait for any existing server to stop off the main thread, then start + serviceScope.launch(Dispatchers.IO) { + var attempts = 10 + while (FtpServerEngine.isRunning()) { + if (attempts > 0) { + attempts-- + delay(1000) + } else { + log.warn("FTP server did not stop in time; aborting start.") + stopSelf() + return@launch + } + } + startServer() + } return START_STICKY } @@ -172,6 +178,7 @@ abstract class FtpServerService : Service() { keyStorePassword = getKeyStorePassword(), errorMessageProvider = getErrorMessageProvider(), featResponseProvider = { getFeatResponse() }, + startedByTile = isStartedByTile, ) FtpServerEngine.start(this, config) { success -> @@ -187,6 +194,7 @@ abstract class FtpServerService : Service() { } override fun onDestroy() { + serviceScope.cancel() FtpServerEngine.stop() if (wakeLock.isHeld) { diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt index 4c06aa2802..8a25c86365 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt @@ -271,11 +271,11 @@ abstract class BaseFtpServerFragment : Fragment() { binding.textViewFtpStatus.text = spannedStatusNotRunning Toast.makeText(context, R.string.ftpmod_unknown_error, Toast.LENGTH_LONG).show() binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() - binding.textViewFtpUrl.text = "URL: " + binding.textViewFtpUrl.text = getString(R.string.ftpmod_url_label) } is FtpServerEvent.Stopped -> { binding.textViewFtpStatus.text = spannedStatusNotRunning - binding.textViewFtpUrl.text = "URL: " + binding.textViewFtpUrl.text = getString(R.string.ftpmod_url_label) binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt index eaa46676cb..e1c9d0bf4b 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt @@ -53,8 +53,8 @@ class FtpServerNotification( ensureNotificationChannel(context) return buildNotification( context, - R.string.ftpmod_notif_starting_title, - context.getString(R.string.ftpmod_notif_starting), + R.string.ftpmod_notification_title, + context.getString(R.string.ftpmod_notification_starting), noStopButton, ).build() } @@ -80,14 +80,14 @@ class FtpServerNotification( } "$prefix$address:$port/" } else { - "Address not found" + context.getString(R.string.ftpmod_notification_error_address_not_found) } val notification = buildNotification( context, - R.string.ftpmod_notif_title, - context.getString(R.string.ftpmod_notif_text, addressText), + R.string.ftpmod_notification_running_title, + context.getString(R.string.ftpmod_notification_running_text, addressText), noStopButton, ).build() @@ -120,7 +120,7 @@ class FtpServerNotification( .setContentText(contentText) .setContentIntent(contentIntent) .setSmallIcon(R.drawable.ic_ftp_light) - .setTicker(context.getString(R.string.ftpmod_notif_starting)) + .setTicker(context.getString(R.string.ftpmod_notification_starting)) .setWhen(System.currentTimeMillis()) .setOngoing(true) .setOnlyAlertOnce(true) @@ -138,7 +138,7 @@ class FtpServerNotification( ) builder.addAction( android.R.drawable.ic_menu_close_clear_cancel, - context.getString(R.string.ftpmod_notif_stop_server), + context.getString(R.string.ftpmod_notification_stop_server), stopPendingIntent, ) } @@ -151,10 +151,10 @@ class FtpServerNotification( val channel = NotificationChannel( channelId, - "FTP Server", + context.getString(R.string.ftpmod_notification_title), NotificationManager.IMPORTANCE_LOW, ).apply { - description = "FTP server status notifications" + description = context.getString(R.string.ftpmod_notification_channel_desc) setShowBadge(false) } val notificationManager = context.getSystemService(NotificationManager::class.java) diff --git a/ftpserver/src/main/res/layout/fragment_ftp.xml b/ftpserver/src/main/res/layout/fragment_ftp.xml index 6280780a4a..a9bc8136a1 100644 --- a/ftpserver/src/main/res/layout/fragment_ftp.xml +++ b/ftpserver/src/main/res/layout/fragment_ftp.xml @@ -13,7 +13,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintStart_toStartOf="parent" android:padding="16dp" app:cardUseCompatPadding="true" android:focusable="true" diff --git a/ftpserver/src/main/res/values/strings.xml b/ftpserver/src/main/res/values/strings.xml index fa1e6862a1..98186ec62b 100644 --- a/ftpserver/src/main/res/values/strings.xml +++ b/ftpserver/src/main/res/values/strings.xml @@ -44,11 +44,13 @@ Path not found: %1$s - FTP Server - Starting FTP server… - FTP Server Running - FTP server is running at %1$s - Stop + FTP server status notifications + FTP Server + Starting FTP server… + FTP Server Running + FTP server is running at %1$s + Stop + Address not found OK From 63102857a3d1c54ab7330c66b6a6f315c36befc2 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Tue, 14 Apr 2026 22:13:19 +0800 Subject: [PATCH 09/15] Wire FtpServerProvider into ServerRegistry --- .../filemanager/application/AppConfig.java | 19 +++++++ .../notifications/FtpNotificationAdapter.kt | 55 +++++++++++++++++++ .../application/AppConfigTest.java | 13 +++++ .../ftpserver/FtpServerProvider.kt | 5 +- .../filemanager/server/ServerRegistry.kt | 9 +++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt diff --git a/app/src/main/java/com/amaze/filemanager/application/AppConfig.java b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java index 79a27e7414..3000e6e1d8 100644 --- a/app/src/main/java/com/amaze/filemanager/application/AppConfig.java +++ b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java @@ -22,6 +22,7 @@ import java.io.File; import java.lang.ref.WeakReference; +import java.net.InetAddress; import java.util.concurrent.Callable; import org.acra.ACRA; @@ -42,8 +43,13 @@ import com.amaze.filemanager.fileoperations.filesystem.OpenMode; import com.amaze.filemanager.filesystem.HybridFile; import com.amaze.filemanager.filesystem.ssh.CustomSshJConfig; +import com.amaze.filemanager.ftpserver.FtpServerProvider; +import com.amaze.filemanager.server.ServerRegistry; +import com.amaze.filemanager.ui.fragments.FtpServerFragment; import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; +import com.amaze.filemanager.ui.notifications.FtpNotificationAdapter; import com.amaze.filemanager.ui.provider.UtilitiesProvider; +import com.amaze.filemanager.utils.NetworkUtil; import com.amaze.filemanager.utils.ScreenUtils; import com.amaze.trashbin.TrashBin; import com.amaze.trashbin.TrashBinConfig; @@ -112,6 +118,18 @@ public void onCreate() { // disabling file exposure method check for api n+ StrictMode.VmPolicy.Builder builder = new StrictMode.VmPolicy.Builder(); StrictMode.setVmPolicy(builder.build()); + + // Register FtpServerProvider into ServerRegistry so FTP is discoverable via the registry. + // Using application context (this) here is safe — no Activity leak. + ServerRegistry.INSTANCE.register( + new FtpServerProvider( + this, + FtpServerFragment::new, + new FtpNotificationAdapter(), + ctx -> { + InetAddress addr = NetworkUtil.getLocalInetAddress(ctx, false); + return addr != null ? addr.getHostAddress() : null; + })); } @Override @@ -123,6 +141,7 @@ protected void attachBaseContext(Context base) { @Override public void onTerminate() { super.onTerminate(); + ServerRegistry.INSTANCE.clearAll(); } /** diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt new file mode 100644 index 0000000000..9878cc43b7 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014-2024 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.notifications + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.amaze.filemanager.server.ServerNotification + +/** + * Adapter that bridges [ServerNotification] to the existing [FtpNotification] static methods. + * + * This allows [FtpServerProvider][com.amaze.filemanager.ftpserver.FtpServerProvider] to be wired + * into [ServerRegistry][com.amaze.filemanager.server.ServerRegistry] without replacing the + * existing notification infrastructure. In a future consolidation step this adapter can be removed + * in favour of [FtpServerNotification][com.amaze.filemanager.ftpserver.ui.FtpServerNotification] + * taking over entirely (Option A). + */ +class FtpNotificationAdapter : ServerNotification { + override fun getNotificationId(): Int = NotificationConstants.FTP_ID + + override fun getChannelId(): String = NotificationConstants.CHANNEL_FTP_ID + + override fun createStartingNotification( + context: Context, + noStopButton: Boolean, + ): Notification = FtpNotification.startNotification(context, noStopButton) + + override fun updateRunningNotification( + context: Context, + noStopButton: Boolean, + ) = FtpNotification.updateNotification(context, noStopButton) + + override fun removeNotification(context: Context) { + NotificationManagerCompat.from(context).cancel(NotificationConstants.FTP_ID) + } +} 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 d232eb8dcb..48a5e2c583 100644 --- a/app/src/test/java/com/amaze/filemanager/application/AppConfigTest.java +++ b/app/src/test/java/com/amaze/filemanager/application/AppConfigTest.java @@ -27,6 +27,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.robolectric.Shadows.shadowOf; @@ -40,6 +41,8 @@ import org.robolectric.shadows.ShadowToast; import com.amaze.filemanager.R; +import com.amaze.filemanager.server.ServerRegistry; +import com.amaze.filemanager.server.ServerType; import com.amaze.filemanager.ui.activities.MainActivity; import com.bumptech.glide.Glide; import com.bumptech.glide.MemoryCategory; @@ -57,6 +60,16 @@ public class AppConfigTest { @After public void tearDown() { ShadowToast.reset(); + ServerRegistry.INSTANCE.clearAll(); + } + + @Test + public void testFtpServerProviderRegistered() { + assertTrue(ServerRegistry.INSTANCE.isRegistered(ServerType.FTP)); + assertEquals( + "FTP Server", ServerRegistry.INSTANCE.getProvider(ServerType.FTP).getDisplayName()); + assertEquals( + ServerType.FTP, ServerRegistry.INSTANCE.getProvider(ServerType.FTP).getServerType()); } @Test diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt index ac79497b85..35356c7647 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt @@ -39,6 +39,7 @@ class FtpServerProvider( private val context: Context, private val fragmentFactory: () -> Fragment, private val notificationHandler: ServerNotification, + private val getLocalAddress: (Context) -> String? = { null }, ) : ServerProvider { override val serverType: ServerType = ServerType.FTP @@ -57,8 +58,8 @@ class FtpServerProvider( val port = FtpPreferences.getPort(context) val secure = FtpPreferences.isSecure(context) val prefix = if (secure) FtpPreferences.INITIALS_HOST_SFTP else FtpPreferences.INITIALS_HOST_FTP - // Note: actual IP address needs to be obtained from NetworkUtil in the app module - return "${prefix}localhost:$port/" + val address = getLocalAddress(context) ?: return null + return "$prefix$address:$port/" } /** diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt index 9efe7ed583..f5dc32886e 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt @@ -65,6 +65,15 @@ object ServerRegistry { fun isRegistered(serverType: ServerType): Boolean { return servers.containsKey(serverType) } + + /** + * Clear all registered server providers. + * + * Primarily intended for test teardown to ensure a clean state between tests. + */ + fun clearAll() { + servers.clear() + } } /** From f2294e047636e8ef48e60c0fc319a637f7b691d1 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 15 Apr 2026 00:10:07 +0800 Subject: [PATCH 10/15] Migrate from FtpNotification to FtpServerNotification.kt To complete the registry base approach --- .../filemanager/application/AppConfig.java | 23 +++- .../services/ftp/AppFtpService.kt | 15 +- .../ui/fragments/FtpServerFragment.kt | 13 +- .../ui/notifications/FtpNotification.java | 128 ------------------ .../notifications/FtpNotificationAdapter.kt | 55 -------- .../notifications/NotificationConstants.java | 34 +---- .../NotificationConstantsTest.java | 46 ------- .../ftpserver/service/FtpServerService.kt | 1 + .../ftpserver/ui/FtpServerNotification.kt | 6 +- 9 files changed, 40 insertions(+), 281 deletions(-) delete mode 100644 app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java delete mode 100644 app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt diff --git a/app/src/main/java/com/amaze/filemanager/application/AppConfig.java b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java index 3000e6e1d8..bdd8d6d6da 100644 --- a/app/src/main/java/com/amaze/filemanager/application/AppConfig.java +++ b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java @@ -44,10 +44,12 @@ import com.amaze.filemanager.filesystem.HybridFile; import com.amaze.filemanager.filesystem.ssh.CustomSshJConfig; import com.amaze.filemanager.ftpserver.FtpServerProvider; +import com.amaze.filemanager.ftpserver.ui.FtpServerNotification; import com.amaze.filemanager.server.ServerRegistry; +import com.amaze.filemanager.ui.activities.MainActivity; import com.amaze.filemanager.ui.fragments.FtpServerFragment; import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants; -import com.amaze.filemanager.ui.notifications.FtpNotificationAdapter; +import com.amaze.filemanager.ui.notifications.NotificationConstants; import com.amaze.filemanager.ui.provider.UtilitiesProvider; import com.amaze.filemanager.utils.NetworkUtil; import com.amaze.filemanager.utils.ScreenUtils; @@ -57,6 +59,7 @@ import android.app.Activity; import android.app.Application; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.os.Environment; import android.os.StrictMode; @@ -73,6 +76,7 @@ import io.reactivex.schedulers.Schedulers; import jcifs.Config; import jcifs.smb.SmbException; +import kotlin.jvm.functions.Function1; public class AppConfig extends GlideApplication { @@ -121,15 +125,22 @@ public void onCreate() { // Register FtpServerProvider into ServerRegistry so FTP is discoverable via the registry. // Using application context (this) here is safe — no Activity leak. + // Extract the lambda once to share between FtpServerNotification and FtpServerProvider. + Function1 getLocalAddress = + ctx -> { + InetAddress addr = NetworkUtil.getLocalInetAddress(ctx, false); + return addr != null ? addr.getHostAddress() : null; + }; ServerRegistry.INSTANCE.register( new FtpServerProvider( this, FtpServerFragment::new, - new FtpNotificationAdapter(), - ctx -> { - InetAddress addr = NetworkUtil.getLocalInetAddress(ctx, false); - return addr != null ? addr.getHostAddress() : null; - })); + new FtpServerNotification( + NotificationConstants.FTP_ID, + NotificationConstants.CHANNEL_FTP_ID, + new Intent(this, MainActivity.class), + getLocalAddress), + getLocalAddress)); } @Override diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt index b04db745e6..5207469683 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt @@ -27,9 +27,9 @@ import com.amaze.filemanager.BuildConfig import com.amaze.filemanager.R import com.amaze.filemanager.ftpserver.commands.AVBL import com.amaze.filemanager.ftpserver.service.FtpServerService +import com.amaze.filemanager.server.ServerRegistry +import com.amaze.filemanager.server.ServerType import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE -import com.amaze.filemanager.ui.notifications.FtpNotification -import com.amaze.filemanager.ui.notifications.NotificationConstants import com.amaze.filemanager.utils.PasswordUtil import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -44,16 +44,19 @@ import java.security.GeneralSecurityException * password decryption, and error messages. */ class AppFtpService : FtpServerService() { - override fun getNotificationId(): Int = NotificationConstants.FTP_ID + private val serverNotification + get() = ServerRegistry.getProvider(ServerType.FTP)!!.getNotification() - override fun getNotificationChannelId(): String = NotificationConstants.CHANNEL_FTP_ID + override fun getNotificationId(): Int = serverNotification.getNotificationId() + + override fun getNotificationChannelId(): String = serverNotification.getChannelId() override fun createStartingNotification(noStopButton: Boolean): Notification { - return FtpNotification.startNotification(applicationContext, noStopButton) + return serverNotification.createStartingNotification(applicationContext, noStopButton) } override fun updateRunningNotification(noStopButton: Boolean) { - FtpNotification.updateNotification(applicationContext, noStopButton) + serverNotification.updateRunningNotification(applicationContext, noStopButton) } override fun getKeyStoreInputStream(): InputStream? { 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 11fc22fef4..922853da6e 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 @@ -79,8 +79,9 @@ import com.amaze.filemanager.ftpserver.service.FtpEventBus import com.amaze.filemanager.ftpserver.service.FtpPreferences import com.amaze.filemanager.ftpserver.service.FtpServerEngine import com.amaze.filemanager.ftpserver.service.FtpServerEvent +import com.amaze.filemanager.server.ServerRegistry +import com.amaze.filemanager.server.ServerType import com.amaze.filemanager.ui.activities.MainActivity -import com.amaze.filemanager.ui.notifications.FtpNotification import com.amaze.filemanager.ui.runIfDocumentsUIExists import com.amaze.filemanager.ui.theme.AppTheme import com.amaze.filemanager.utils.NetworkUtil.getLocalInetAddress @@ -418,10 +419,12 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { url.text = spannedStatusUrl ftpBtn.text = resources.getString(R.string.stop_ftp).uppercase() - FtpNotification.updateNotification( - context, - FtpServerEvent.StartedFromTile == signal, - ) + ServerRegistry.getProvider(ServerType.FTP) + ?.getNotification() + ?.updateRunningNotification( + context ?: return, + FtpServerEvent.StartedFromTile == signal, + ) } FtpServerEvent.FailedToStart -> { statusText.text = spannedStatusNotRunning diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java deleted file mode 100644 index 69a8d12124..0000000000 --- a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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.notifications; - -import static android.app.PendingIntent.FLAG_ONE_SHOT; -import static com.amaze.filemanager.asynchronous.services.AbstractProgressiveService.getPendingIntentFlag; - -import java.net.InetAddress; - -import com.amaze.filemanager.R; -import com.amaze.filemanager.ftpserver.service.FtpPreferences; -import com.amaze.filemanager.ui.activities.MainActivity; -import com.amaze.filemanager.utils.NetworkUtil; - -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; - -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -/** - * Created by yashwanthreddyg on 19-06-2016. - * - *

Edited by zent-co on 30-07-2019 - */ -public class FtpNotification { - - private static NotificationCompat.Builder buildNotification( - Context context, @StringRes int contentTitleRes, String contentText, boolean noStopButton) { - Intent notificationIntent = new Intent(context, MainActivity.class); - notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); - PendingIntent contentIntent = - PendingIntent.getActivity(context, 0, notificationIntent, getPendingIntentFlag(0)); - - long when = System.currentTimeMillis(); - - NotificationCompat.Builder builder = - new NotificationCompat.Builder(context, NotificationConstants.CHANNEL_FTP_ID) - .setContentTitle(context.getString(contentTitleRes)) - .setContentText(contentText) - .setContentIntent(contentIntent) - .setSmallIcon(R.drawable.ic_ftp_light) - .setTicker(context.getString(R.string.ftp_notif_starting)) - .setWhen(when) - .setOngoing(true) - .setOnlyAlertOnce(true); - - if (!noStopButton) { - int stopIcon = android.R.drawable.ic_menu_close_clear_cancel; - CharSequence stopText = context.getString(R.string.ftp_notif_stop_server); - Intent stopIntent = - new Intent(FtpPreferences.ACTION_STOP_FTPSERVER).setPackage(context.getPackageName()); - PendingIntent stopPendingIntent = - PendingIntent.getBroadcast(context, 0, stopIntent, getPendingIntentFlag(FLAG_ONE_SHOT)); - - builder.addAction(stopIcon, stopText, stopPendingIntent); - } - - NotificationConstants.setMetadata(context, builder, NotificationConstants.TYPE_FTP); - - return builder; - } - - public static Notification startNotification(Context context, boolean noStopButton) { - NotificationCompat.Builder builder = - buildNotification( - context, - R.string.ftp_notif_starting_title, - context.getString(R.string.ftp_notif_starting), - noStopButton); - - return builder.build(); - } - - public static void updateNotification(Context context, boolean noStopButton) { - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - - int port = FtpPreferences.getPort(context); - boolean secureConnection = FtpPreferences.isSecure(context); - - InetAddress address = NetworkUtil.getLocalInetAddress(context, false); - - String address_text = "Address not found"; - - if (address != null) { - address_text = - (secureConnection ? FtpPreferences.INITIALS_HOST_SFTP : FtpPreferences.INITIALS_HOST_FTP) - + address.getHostAddress() - + ":" - + port - + "/"; - } - - NotificationCompat.Builder builder = - buildNotification( - context, - R.string.ftp_notif_title, - context.getString(R.string.ftp_notif_text, address_text), - noStopButton); - - notificationManager.notify(NotificationConstants.FTP_ID, builder.build()); - } - - private static void removeNotification(Context context) { - NotificationManagerCompat.from(context).cancelAll(); - } -} diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt b/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt deleted file mode 100644 index 9878cc43b7..0000000000 --- a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotificationAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2014-2024 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.notifications - -import android.app.Notification -import android.content.Context -import androidx.core.app.NotificationManagerCompat -import com.amaze.filemanager.server.ServerNotification - -/** - * Adapter that bridges [ServerNotification] to the existing [FtpNotification] static methods. - * - * This allows [FtpServerProvider][com.amaze.filemanager.ftpserver.FtpServerProvider] to be wired - * into [ServerRegistry][com.amaze.filemanager.server.ServerRegistry] without replacing the - * existing notification infrastructure. In a future consolidation step this adapter can be removed - * in favour of [FtpServerNotification][com.amaze.filemanager.ftpserver.ui.FtpServerNotification] - * taking over entirely (Option A). - */ -class FtpNotificationAdapter : ServerNotification { - override fun getNotificationId(): Int = NotificationConstants.FTP_ID - - override fun getChannelId(): String = NotificationConstants.CHANNEL_FTP_ID - - override fun createStartingNotification( - context: Context, - noStopButton: Boolean, - ): Notification = FtpNotification.startNotification(context, noStopButton) - - override fun updateRunningNotification( - context: Context, - noStopButton: Boolean, - ) = FtpNotification.updateNotification(context, noStopButton) - - override fun removeNotification(context: Context) { - NotificationManagerCompat.from(context).cancel(NotificationConstants.FTP_ID) - } -} diff --git a/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java b/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java index 6dcb7f5059..5d0de718ac 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java +++ b/app/src/main/java/com/amaze/filemanager/ui/notifications/NotificationConstants.java @@ -45,7 +45,7 @@ public class NotificationConstants { public static final int FAILED_ID = 6; public static final int WAIT_ID = 7; - public static final int TYPE_NORMAL = 0, TYPE_FTP = 1; + public static final int TYPE_NORMAL = 0; public static final String CHANNEL_NORMAL_ID = "normalChannel"; public static final String CHANNEL_FTP_ID = "ftpChannel"; @@ -60,9 +60,6 @@ public static void setMetadata( case TYPE_NORMAL: createNormalChannel(context); break; - case TYPE_FTP: - createFtpChannel(context); - break; default: throw new IllegalArgumentException("Unrecognized type:" + type); } @@ -76,41 +73,12 @@ public static void setMetadata( notification.setPriority(Notification.PRIORITY_MIN); } break; - case TYPE_FTP: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - notification.setCategory(Notification.CATEGORY_SERVICE); - notification.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - notification.setPriority(Notification.PRIORITY_MAX); - } - break; default: throw new IllegalArgumentException("Unrecognized type:" + type); } } } - /** - * You CANNOT call this from android < O. THis channel is set so it doesn't bother the user, but - * it has importance. - */ - @RequiresApi(api = Build.VERSION_CODES.O) - private static void createFtpChannel(Context context) { - NotificationManager mNotificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - if (mNotificationManager.getNotificationChannel(CHANNEL_FTP_ID) == null) { - NotificationChannel mChannel = - new NotificationChannel( - CHANNEL_FTP_ID, - context.getString(R.string.channel_name_ftp), - NotificationManager.IMPORTANCE_HIGH); - // Configure the notification channel. - mChannel.setDescription(context.getString(R.string.channel_description_ftp)); - mNotificationManager.createNotificationChannel(mChannel); - } - } - /** * You CANNOT call this from android < O. THis channel is set so it doesn't bother the user, with * the lowest importance. 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 041bb0596f..6d12986f50 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 @@ -20,13 +20,10 @@ package com.amaze.filemanager.ui.notifications; -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.P; -import static com.amaze.filemanager.ui.notifications.NotificationConstants.CHANNEL_FTP_ID; import static com.amaze.filemanager.ui.notifications.NotificationConstants.CHANNEL_NORMAL_ID; -import static com.amaze.filemanager.ui.notifications.NotificationConstants.TYPE_FTP; import static com.amaze.filemanager.ui.notifications.NotificationConstants.TYPE_NORMAL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -82,10 +79,6 @@ public void testSetMetadataIllegalType() { NotificationConstants.setMetadata(context, builder, -1); NotificationConstants.setMetadata(context, builder, 2); NotificationConstants.setMetadata(context, builder, Integer.MAX_VALUE); - builder = new NotificationCompat.Builder(context, CHANNEL_FTP_ID); - NotificationConstants.setMetadata(context, builder, -1); - NotificationConstants.setMetadata(context, builder, 2); - NotificationConstants.setMetadata(context, builder, Integer.MAX_VALUE); } @Test @@ -111,30 +104,6 @@ public void testNormalNotification() { } } - @Test - @Config(sdk = {LOLLIPOP}) // max sdk is N - public void testFtpNotification() { - NotificationCompat.Builder builder = - new NotificationCompat.Builder(context, CHANNEL_FTP_ID) - .setContentTitle("FTP server test") - .setContentText("FTP listening at 127.0.0.1:22") - .setSmallIcon(R.drawable.ic_ftp_light) - .setTicker(context.getString(R.string.ftp_notif_starting)) - .setOngoing(true) - .setOnlyAlertOnce(true); - NotificationConstants.setMetadata(context, builder, TYPE_FTP); - Notification result = builder.build(); - if (Build.VERSION.SDK_INT >= LOLLIPOP) { - assertEquals(Notification.CATEGORY_SERVICE, result.category); - assertEquals(Notification.VISIBILITY_PUBLIC, result.visibility); - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - assertEquals(Notification.PRIORITY_MAX, result.priority); - } else { - assertEquals(Notification.PRIORITY_DEFAULT, result.priority); - } - } - @Test @Config(sdk = {P}) // min sdk is O public void testCreateNormalChannel() { @@ -149,19 +118,4 @@ public void testCreateNormalChannel() { assertEquals(context.getString(R.string.channel_name_normal), channel.getName()); assertEquals(context.getString(R.string.channel_description_normal), channel.getDescription()); } - - @Test - @Config(sdk = {P}) // min sdk is O - public void testCreateFtpChannel() { - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_FTP_ID); - NotificationConstants.setMetadata(context, builder, TYPE_FTP); - List channels = shadowNotificationManager.getNotificationChannels(); - assertNotNull(channels); - assertEquals(1, channels.size()); - NotificationChannel channel = (NotificationChannel) channels.get(0); - assertEquals(IMPORTANCE_HIGH, channel.getImportance()); - assertEquals(CHANNEL_FTP_ID, channel.getId()); - assertEquals(context.getString(R.string.channel_name_ftp), channel.getName()); - assertEquals(context.getString(R.string.channel_description_ftp), channel.getDescription()); - } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt index 902d2387f4..33de5c1f36 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt @@ -43,6 +43,7 @@ import java.util.concurrent.TimeUnit * * This service manages the FTP server lifecycle as a foreground service. */ +@Suppress("LabeledExpression") abstract class FtpServerService : Service() { private lateinit var wakeLock: PowerManager.WakeLock private var isStartedByTile = false diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt index e1c9d0bf4b..de026e601f 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt @@ -124,6 +124,9 @@ class FtpServerNotification( .setWhen(System.currentTimeMillis()) .setOngoing(true) .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_MAX) if (!noStopButton) { val stopIntent = @@ -152,10 +155,9 @@ class FtpServerNotification( NotificationChannel( channelId, context.getString(R.string.ftpmod_notification_title), - NotificationManager.IMPORTANCE_LOW, + NotificationManager.IMPORTANCE_HIGH, ).apply { description = context.getString(R.string.ftpmod_notification_channel_desc) - setShowBadge(false) } val notificationManager = context.getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) From e5037c9d82cb13691f0eb486dc0dba635870cfa4 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Thu, 16 Apr 2026 22:50:01 +0800 Subject: [PATCH 11/15] Add battery optimization prompt To prompt users to disable battery optimization for better network I/O performance when running FTP server. Fixes #4603 --- .../services/ftp/FtpTileService.kt | 9 + .../ui/fragments/FtpServerFragment.kt | 67 ++++- .../BehaviorPrefsFragment.kt | 25 ++ .../PreferencesConstants.kt | 3 + app/src/main/res/values/strings.xml | 9 + app/src/main/res/xml/behavior_prefs.xml | 7 + ...tpServerFragmentBatteryOptimizationTest.kt | 252 ++++++++++++++++++ ftpserver/build.gradle | 1 + .../ftpserver/service/FtpPreferences.kt | 1 + .../ftpserver/ui/BaseFtpServerFragment.kt | 14 +- 10 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt index a6584aff73..404892fe0b 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt @@ -22,6 +22,7 @@ package com.amaze.filemanager.asynchronous.services.ftp import android.content.Intent import android.graphics.drawable.Icon import android.os.Build +import android.os.PowerManager import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.widget.Toast @@ -76,6 +77,14 @@ class FtpTileService : TileService() { if (isConnectedToWifi(applicationContext) || isConnectedToLocalNetwork(applicationContext) ) { + val pm = getSystemService(POWER_SERVICE) as PowerManager + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + Toast.makeText( + applicationContext, + R.string.ftp_battery_optimization_tile_warning, + Toast.LENGTH_LONG, + ).show() + } val i = Intent(FtpPreferences.ACTION_START_FTPSERVER).setPackage(packageName) i.putExtra(FtpPreferences.TAG_STARTED_BY_TILE, true) applicationContext.sendBroadcast(i) 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 922853da6e..b87bdda496 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 @@ -34,10 +34,12 @@ import android.os.Build.VERSION_CODES.LOLLIPOP import android.os.Build.VERSION_CODES.M import android.os.Build.VERSION_CODES.O import android.os.Bundle +import android.os.PowerManager import android.os.Process import android.provider.DocumentsContract import android.provider.DocumentsContract.EXTRA_INITIAL_URI import android.provider.Settings +import android.provider.Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS import android.text.InputType import android.text.Spanned import android.view.KeyEvent @@ -122,6 +124,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private var spannedStatusSecure: Spanned? = null private var spannedStatusNotRunning: Spanned? = null private var snackbar: Snackbar? = null + private var pendingBatteryOptimizationResult = false private var _binding: FragmentFtpBinding? = null private val binding get() = _binding!! @@ -457,6 +460,53 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } } + /** + * On API 23+, checks whether the app is exempt from battery optimizations before starting + * the FTP server. If already exempt, or if the user has previously dismissed the prompt, + * [callback] is invoked directly. Otherwise a [MaterialDialog] is shown with options to + * open battery optimization settings, skip, or suppress future prompts. + */ + private fun checkBatteryOptimizationIfNecessary(callback: () -> Unit) { + if (SDK_INT < M) { + callback() + return + } + val pm = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + val alreadyAsked = + FtpPreferences.getPreferences(requireContext()) + .getBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, false) + if (pm.isIgnoringBatteryOptimizations(requireContext().packageName) || alreadyAsked) { + callback() + return + } + MaterialDialog.Builder(requireContext()) + .title(R.string.ftp_battery_optimization_title) + .content(R.string.ftp_battery_optimization_message) + .positiveText(R.string.ftp_battery_optimization_action_settings) + .negativeText(R.string.ftp_battery_optimization_action_skip) + .neutralText(R.string.ftp_battery_optimization_action_dont_ask) + .onPositive { dialog, _ -> + pendingBatteryOptimizationResult = true + startActivity( + Intent(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + ) + dialog.dismiss() + } + .onNegative { dialog, _ -> + dialog.dismiss() + callback() + } + .onNeutral { dialog, _ -> + FtpPreferences.getPreferences(requireContext()).edit { + putBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, true) + } + dialog.dismiss() + callback() + } + .build() + .show() + } + /** Check URI access. Prompt user to DocumentsUI if necessary */ private fun checkUriAccessIfNecessary(callback: () -> Unit) { val directoryUri: String = @@ -534,8 +584,10 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { /** Sends a broadcast to start ftp server */ private fun startServer() { - checkUriAccessIfNecessary { - doStartServer() + checkBatteryOptimizationIfNecessary { + checkUriAccessIfNecessary { + doStartServer() + } } } @@ -562,6 +614,17 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { wifiFilter, ContextCompat.RECEIVER_NOT_EXPORTED, ) + if (pendingBatteryOptimizationResult) { + pendingBatteryOptimizationResult = false + if (SDK_INT >= M) { + val pm = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + if (pm.isIgnoringBatteryOptimizations(requireContext().packageName) && + (isConnectedToWifi(requireContext()) || isConnectedToLocalNetwork(requireContext())) + ) { + checkUriAccessIfNecessary { doStartServer() } + } + } + } updateStatus() } diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt index 189b77b104..7c05634f0a 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/BehaviorPrefsFragment.kt @@ -20,11 +20,16 @@ package com.amaze.filemanager.ui.fragments.preferencefragments +import android.content.Intent +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.M import android.os.Bundle import android.os.Environment +import android.provider.Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS import android.text.InputType import androidx.preference.Preference import androidx.preference.PreferenceManager +import androidx.preference.TwoStatePreference import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.folderselector.FolderChooserDialog import com.amaze.filemanager.R @@ -91,6 +96,26 @@ class BehaviorPrefsFragment : BasePrefsFragment(), FolderChooserDialog.FolderCal trashBinCleanupInterval() true } + + val batteryOptPref = + findPreference( + PreferencesConstants.PREFERENCE_FTP_BATTERY_OPTIMIZATION_ASKED, + ) + if (SDK_INT < M) { + batteryOptPref?.isVisible = false + } else { + batteryOptPref?.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + // When user unchecks the toggle (re-enabling the prompt), deep-link to + // battery optimization settings so they can act on it right away. + if (newValue == false) { + startActivity( + Intent(ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), + ) + } + true + } + } } override fun onFolderSelection( diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt index 891c66b485..5b8454fbb9 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt @@ -86,6 +86,9 @@ object PreferencesConstants { const val PREFERENCE_REGEX = "regex" const val PREFERENCE_REGEX_MATCHES = "matches" + // ftp preferences (shared key with FtpPreferences in ftpserver module) + const val PREFERENCE_FTP_BATTERY_OPTIMIZATION_ASKED = "ftp_battery_optimization_asked" + // security_prefs.xml const val PREFERENCE_CRYPT_FINGERPRINT = "crypt_fingerprint" const val PREFERENCE_CRYPT_MASTER_PASSWORD = "crypt_password" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11fca688cd..526162fb2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -761,6 +761,15 @@ 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? + Battery Optimization + Amaze FTP Server runs as a long-lived background service. Battery optimization may cause the OS to stop it unexpectedly or slow transfer speeds.\n\nFor best reliability, consider disabling battery optimization. + Open Settings + Skip + Don\'t ask again + Battery optimization is enabled — FTP server may be stopped by the OS + Disable battery optimization prompt + Skip battery optimization reminder when starting the FTP server + FTP Server Error opening URI \"%s\" for reading. \"%s\" is an empty archive. \"%s\" is a corrupted archive. diff --git a/app/src/main/res/xml/behavior_prefs.xml b/app/src/main/res/xml/behavior_prefs.xml index 9443d9741a..afb3a25234 100644 --- a/app/src/main/res/xml/behavior_prefs.xml +++ b/app/src/main/res/xml/behavior_prefs.xml @@ -105,4 +105,11 @@ app:summary="@string/root_mode_summary" app:title="@string/root_mode" /> + + + \ No newline at end of file diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt new file mode 100644 index 0000000000..9f28267b68 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt @@ -0,0 +1,252 @@ +package com.amaze.filemanager.asynchronous.services.ftp + +import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES.M +import android.os.PowerManager +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.ftpserver.service.FtpPreferences +import com.amaze.filemanager.ftpserver.ui.BaseFtpServerFragment +import com.amaze.filemanager.shadows.ShadowMultiDex +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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.shadows.ShadowPowerManager + +/** + * Unit tests for the battery optimization prompt logic related to the FTP server. + * + * These tests validate: + * - [FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED] preference saves and reads correctly. + * - The Robolectric [ShadowPowerManager] correctly simulates the battery exemption state that + * `FtpServerFragment.checkBatteryOptimizationIfNecessary` reads. + * - [BaseFtpServerFragment.onBeforeStartServer] invokes its `proceed` lambda immediately by default. + * + * Full integration tests for the dialog being shown / dismissed are covered by instrumented tests + * that launch [com.amaze.filemanager.ui.activities.MainActivity] and navigate to the FTP fragment. + */ +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.N, Build.VERSION_CODES.P, Build.VERSION_CODES.R], + shadows = [ShadowMultiDex::class], +) +class FtpServerFragmentBatteryOptimizationTest { + private lateinit var context: Context + private lateinit var shadowPowerManager: ShadowPowerManager + + /** + * Minimal concrete [BaseFtpServerFragment] subclass for testing [onBeforeStartServer]. + * All abstract members are no-ops; the default [onBeforeStartServer] is NOT overridden. + */ + private class DefaultFragment : BaseFtpServerFragment() { + var proceedCalled = false + + fun callOnBeforeStartServer() { + onBeforeStartServer { proceedCalled = true } + } + + override fun getAccentColor(): Int = 0 + + override fun isConnectedToLocalNetwork(): Boolean = true + + override fun isConnectedToWifi(): Boolean = true + + override fun getLocalAddress(): String = "127.0.0.1" + + override fun startFtpService(startedByTile: Boolean) {} + + override fun stopFtpService() {} + + override fun promptUserToEnableWireless() {} + + override fun dismissSnackbar() {} + + override fun getEncryptedPassword(): String? = null + + override fun decryptPassword(encryptedPassword: String): String? = null + + override fun onPathChangeRequested() {} + + override fun onLoginChangeRequested() {} + } + + /** + * Concrete [BaseFtpServerFragment] subclass that overrides [onBeforeStartServer] to + * simulate gating (e.g. showing a dialog) without calling [proceed]. + */ + private class GatingFragment : BaseFtpServerFragment() { + var proceedCalled = false + var intercepted = false + + fun callOnBeforeStartServer() { + onBeforeStartServer { proceedCalled = true } + } + + override fun onBeforeStartServer(proceed: () -> Unit) { + intercepted = true + // Deliberately do NOT call proceed — simulating a dialog gate. + } + + override fun getAccentColor(): Int = 0 + + override fun isConnectedToLocalNetwork(): Boolean = true + + override fun isConnectedToWifi(): Boolean = true + + override fun getLocalAddress(): String = "127.0.0.1" + + override fun startFtpService(startedByTile: Boolean) {} + + override fun stopFtpService() {} + + override fun promptUserToEnableWireless() {} + + override fun dismissSnackbar() {} + + override fun getEncryptedPassword(): String? = null + + override fun decryptPassword(encryptedPassword: String): String? = null + + override fun onPathChangeRequested() {} + + override fun onLoginChangeRequested() {} + } + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + shadowPowerManager = Shadows.shadowOf(powerManager) + // Reset the "don't ask again" preference before each test. + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .remove(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED) + .apply() + } + + @After + fun tearDown() { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .remove(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED) + .apply() + } + + // ---- FtpPreferences key tests ---- + + /** + * When [FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED] is absent the preference + * returns `false` — i.e. the user has NOT yet suppressed the prompt. + */ + @Test + fun testBatteryOptimizationPreferenceDefaultIsFalse() { + val asked = + FtpPreferences.getPreferences(context) + .getBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, false) + assertFalse("Battery optimization preference should default to false (prompt not suppressed)", asked) + } + + /** + * After the user clicks "Don't ask again", the preference is persisted as `true`. + */ + @Test + fun testBatteryOptimizationPreferencePersistence() { + FtpPreferences.getPreferences(context) + .edit() + .putBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, true) + .apply() + + val asked = + FtpPreferences.getPreferences(context) + .getBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, false) + assertTrue("Battery optimization preference should be true after user suppresses it", asked) + } + + // ---- ShadowPowerManager simulation tests ---- + + /** + * Verifies that the Robolectric [ShadowPowerManager] correctly simulates the app being + * exempt from battery optimizations. When `isIgnoringBatteryOptimizations()` returns `true` + * for the app package, `FtpServerFragment.checkBatteryOptimizationIfNecessary` should + * bypass the dialog entirely. + * + * This test verifies the Robolectric shadow behaves as expected so that integration tests + * relying on it are meaningful. + */ + @Config(sdk = [M]) + @Test + fun testShadowPowerManagerExemptionSimulation() { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + + // Before exemption: optimization is active (app is NOT ignoring battery optimizations). + assertFalse( + "App should not be exempt by default", + powerManager.isIgnoringBatteryOptimizations(context.packageName), + ) + + // Simulate the user granting battery optimization exemption. + shadowPowerManager.setIgnoringBatteryOptimizations(context.packageName, true) + + assertTrue( + "App should be exempt after ShadowPowerManager grants exemption", + powerManager.isIgnoringBatteryOptimizations(context.packageName), + ) + } + + /** + * Verifies that when the app IS already exempt from battery optimizations, + * the "don't ask again" preference remains unset (no preference side-effect on bypass). + */ + @Config(sdk = [M]) + @Test + fun testExemptAppDoesNotSetDontAskPref() { + shadowPowerManager.setIgnoringBatteryOptimizations(context.packageName, true) + + // The bypass due to exemption should NOT persist the "don't ask again" flag itself. + val asked = + FtpPreferences.getPreferences(context) + .getBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, false) + assertFalse( + "Bypassing the check due to system exemption must not persist the 'don't ask' flag", + asked, + ) + } + + // ---- BaseFtpServerFragment.onBeforeStartServer tests ---- + + /** + * The default [BaseFtpServerFragment.onBeforeStartServer] must invoke the `proceed` + * lambda immediately without any interception. + */ + @Test + fun testDefaultOnBeforeStartServerCallsProceed() { + val fragment = DefaultFragment() + fragment.callOnBeforeStartServer() + assertTrue( + "Default onBeforeStartServer must invoke proceed immediately", + fragment.proceedCalled, + ) + } + + /** + * A subclass that overrides [BaseFtpServerFragment.onBeforeStartServer] and does NOT call + * `proceed` can gate the server start — e.g. to show a battery optimization dialog first. + */ + @Test + fun testOverriddenOnBeforeStartServerCanIntercept() { + val fragment = GatingFragment() + fragment.callOnBeforeStartServer() + assertTrue("Overridden onBeforeStartServer must be invoked", fragment.intercepted) + assertFalse( + "Proceed must NOT be called when the override intercepts it", + fragment.proceedCalled, + ) + } +} diff --git a/ftpserver/build.gradle b/ftpserver/build.gradle index d06d5c05b4..bbb8389d2f 100644 --- a/ftpserver/build.gradle +++ b/ftpserver/build.gradle @@ -81,6 +81,7 @@ dependencies { testImplementation libs.mockk testImplementation libs.mockito.core testImplementation libs.mockito.inline + testImplementation libs.robolectric testImplementation libs.androidX.test.core testImplementation libs.androidX.test.ext.junit diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt index 14a7ebbedc..b94e9b16cd 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt @@ -46,6 +46,7 @@ object FtpPreferences { const val KEY_PREFERENCE_READONLY = "ftp_readonly" const val KEY_PREFERENCE_SAF_FILESYSTEM = "ftp_saf_filesystem" const val KEY_PREFERENCE_ROOT_FILESYSTEM = "ftp_root_filesystem" + const val KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED = "ftp_battery_optimization_asked" const val INITIALS_HOST_FTP = "ftp://" const val INITIALS_HOST_SFTP = "ftps://" diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt index 8a25c86365..a5008f9c6e 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt @@ -245,8 +245,20 @@ abstract class BaseFtpServerFragment : Fragment() { } } + /** + * Called just before the FTP service is started. Override in subclasses to insert + * pre-start checks (e.g. battery optimization prompt). The default implementation + * calls [proceed] immediately. + * + * @param proceed Lambda to invoke when the subclass is ready to proceed with starting + * the server. + */ + protected open fun onBeforeStartServer(proceed: () -> Unit) { + proceed() + } + private fun startServer() { - startFtpService(false) + onBeforeStartServer { startFtpService(false) } } private fun stopServer() { From 3893a89aceb8972f451ff12b10f76cc24cff55ba Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sun, 10 May 2026 12:10:09 +0800 Subject: [PATCH 12/15] Changes per PR feedback --- .../services/ftp/AppFtpReceiver.kt | 3 +- .../services/ftp/AppFtpService.kt | 14 +++--- .../typeconverters/JsonTypeConverter.kt | 47 ------------------- .../ui/fragments/FtpServerFragment.kt | 23 ++++----- .../PreferencesConstants.kt | 2 +- app/src/main/res/values/strings.xml | 6 +-- file_operations/src/main/AndroidManifest.xml | 2 - .../filemanager/ftpserver/commands/AVBL.kt | 4 +- .../filemanager/ftpserver/commands/FEAT.kt | 2 +- .../service/FtpCommandFactoryFactory.kt | 6 +-- .../ftpserver/service/FtpEventBus.kt | 21 +++------ .../ftpserver/service/FtpReceiver.kt | 6 +-- .../ftpserver/service/FtpServerEvent.kt | 14 ++++++ .../ftpserver/ui/BaseFtpServerFragment.kt | 2 +- ftpserver/src/main/res/values/strings.xml | 2 +- .../commands/AbstractFtpserverCommandTest.kt | 12 +++++ .../amaze/filemanager/server/FileServer.kt | 5 -- .../filemanager/server/ServerProvider.kt | 43 +++++++++++++++++ .../filemanager/server/ServerRegistry.kt | 44 ----------------- 19 files changed, 110 insertions(+), 148 deletions(-) delete mode 100644 app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt delete mode 100644 file_operations/src/main/AndroidManifest.xml create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEvent.kt create mode 100644 server-core/src/main/java/com/amaze/filemanager/server/ServerProvider.kt diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt index e294fe1bbf..73bca2c2eb 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt @@ -21,6 +21,7 @@ package com.amaze.filemanager.asynchronous.services.ftp import com.amaze.filemanager.ftpserver.service.FtpReceiver +import com.amaze.filemanager.ftpserver.service.FtpServerService /** * Concrete implementation of FtpReceiver for the Amaze File Manager app. @@ -28,5 +29,5 @@ import com.amaze.filemanager.ftpserver.service.FtpReceiver * This receiver handles start/stop intents for the FTP server service. */ class AppFtpReceiver : FtpReceiver() { - override fun getFtpServiceClass(): Class<*> = AppFtpService::class.java + override fun getFtpServiceClass(): Class = AppFtpService::class.java } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt index 5207469683..2b10169797 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt @@ -38,14 +38,19 @@ import java.io.InputStream import java.security.GeneralSecurityException /** - * Concrete implementation of FtpServerService for the Amaze File Manager app. + * Concrete implementation of [FtpServerService]. * * This class provides app-specific implementations for notifications, keystore access, * password decryption, and error messages. */ class AppFtpService : FtpServerService() { + companion object { + @JvmStatic + private val log: Logger = LoggerFactory.getLogger(AppFtpService::class.java) + } + private val serverNotification - get() = ServerRegistry.getProvider(ServerType.FTP)!!.getNotification() + get() = requireNotNull(ServerRegistry.getProvider(ServerType.FTP)).getNotification() override fun getNotificationId(): Int = serverNotification.getNotificationId() @@ -109,9 +114,4 @@ class AppFtpService : FtpServerService() { override fun getFeatResponse(): String { return getString(R.string.ftp_command_FEAT) } - - companion object { - @JvmStatic - private val log: Logger = LoggerFactory.getLogger(AppFtpService::class.java) - } } diff --git a/app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt b/app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt deleted file mode 100644 index b83c8c38bc..0000000000 --- a/app/src/main/java/com/amaze/filemanager/database/typeconverters/JsonTypeConverter.kt +++ /dev/null @@ -1,47 +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.database.typeconverters - -import androidx.room.TypeConverter -import org.json.JSONObject - -/** - * [TypeConverter] between [JSONObject] as object and String representation. - */ -object JsonTypeConverter { - /** - * Convert from [JSONObject] to string. - */ - @JvmStatic - @TypeConverter - fun fromJsonObject(value: JSONObject): String { - return value.toString() - } - - /** - * Convert from string to [JSONObject]. - */ - @JvmStatic - @TypeConverter - fun fromJsonString(value: String): JSONObject { - return JSONObject(value) - } -} 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 b87bdda496..090c648ef8 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 @@ -102,8 +102,7 @@ import java.io.IOException import java.security.GeneralSecurityException /** - * Created by yashwanthreddyg on 10-06-2016. Edited by Luca D'Amico (Luca91) on 25 Jul 2017 (Fixed - * FTP Server while usi + * Created by yashwanthreddyg on 10-06-2016. Edited by Luca D'Amico (Luca91) on 25 Jul 2017 */ @Suppress("TooManyFunctions") class FtpServerFragment : Fragment(R.layout.fragment_ftp) { @@ -425,7 +424,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ServerRegistry.getProvider(ServerType.FTP) ?.getNotification() ?.updateRunningNotification( - context ?: return, + requireContext() ?: return, FtpServerEvent.StartedFromTile == signal, ) } @@ -646,7 +645,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { statusText.text = spannedStatusNotRunning ftpBtn.isEnabled = true } - url.text = "URL: " + url.text = getString(com.amaze.filemanager.ftpserver.R.string.ftpmod_url_label, "") ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() } else { accentColor = mainActivity.accent @@ -740,9 +739,12 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private fun resetFTPPath() { mainActivity.prefs - .edit() - .putString(FtpPreferences.KEY_PREFERENCE_PATH, FtpPreferences.defaultPath(requireContext())) - .apply() + .edit { + putString( + FtpPreferences.KEY_PREFERENCE_PATH, + FtpPreferences.defaultPath(requireContext()), + ) + } } /** Updates the status spans */ @@ -766,7 +768,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) spannedStatusUrl = HtmlCompat.fromHtml( - "URL: $ftpAddress", + getString(com.amaze.filemanager.ftpserver.R.string.ftpmod_url_label, ftpAddress), FROM_HTML_MODE_COMPACT, ) spannedStatusNoConnection = @@ -791,11 +793,6 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { "${resources.getString(R.string.ftp_status_secure_connection)}", FROM_HTML_MODE_COMPACT, ) - spannedStatusUrl = - HtmlCompat.fromHtml( - "URL: $ftpAddress", - FROM_HTML_MODE_COMPACT, - ) } private fun initLoginDialogViews(loginDialogView: DialogFtpLoginBinding) { diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt index 5b8454fbb9..d854b9e004 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/preferencefragments/PreferencesConstants.kt @@ -86,7 +86,7 @@ object PreferencesConstants { const val PREFERENCE_REGEX = "regex" const val PREFERENCE_REGEX_MATCHES = "matches" - // ftp preferences (shared key with FtpPreferences in ftpserver module) + // ftp preferences const val PREFERENCE_FTP_BATTERY_OPTIMIZATION_ASKED = "ftp_battery_optimization_asked" // security_prefs.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 526162fb2a..4b45007fd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -701,8 +701,10 @@ Choose This Folder .. Some files failed due to invalid operation + Read-only access Connect to a network or enable AP Open settings + You will need to restart the FTP server for changes to take effect. Move Select location to save Default Path (Optional) @@ -735,11 +737,9 @@ Choose different application Clear cache Clears selected default file opening apps - Read-only access Use legacy listing for root If enabled, uses legacy method to list files RAR archive \"%s\" is unsupported RAR v5 archive. - You will need to restart the FTP server for changes to take effect. Permission denied Wrong archive password. Cannot extract archive entry \"%s\" to \"%s\". @@ -766,7 +766,7 @@ You only need to do this once, until the next time you select a new location for Open Settings Skip Don\'t ask again - Battery optimization is enabled — FTP server may be stopped by the OS + Battery optimization is enabled. You may experience lower transfer speeds when using the FTP server. Disable battery optimization prompt Skip battery optimization reminder when starting the FTP server FTP Server diff --git a/file_operations/src/main/AndroidManifest.xml b/file_operations/src/main/AndroidManifest.xml deleted file mode 100644 index f71066fedd..0000000000 --- a/file_operations/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt index 7be975fe3f..694f80dd63 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt @@ -21,7 +21,9 @@ package com.amaze.filemanager.ftpserver.commands import com.amaze.filemanager.ftpserver.filesystem.AndroidFileSystemFactory +import com.amaze.filemanager.ftpserver.filesystem.RootFileSystemFactory import org.apache.ftpserver.command.AbstractCommand +import org.apache.ftpserver.filesystem.nativefs.NativeFileSystemFactory import org.apache.ftpserver.ftplet.DefaultFtpReply import org.apache.ftpserver.ftplet.FtpFile import org.apache.ftpserver.ftplet.FtpReply.REPLY_213_FILE_STATUS @@ -38,7 +40,7 @@ import java.io.File /** * Implements FTP extension AVBL command, to answer device remaining space in FTP command. * - * Only supports RootFileSystemFactory and NativeFileSystemFactory. Otherwise will simply return + * Only supports [RootFileSystemFactory] and [NativeFileSystemFactory]. Otherwise will simply return * 550 Access Denied. * * See [Draft spec](https://www.ietf.org/archive/id/draft-peterson-streamlined-ftp-command-extensions-10.txt) diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt index ee70196d82..7d86fd5cf2 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt @@ -28,7 +28,7 @@ import org.apache.ftpserver.impl.FtpIoSession import org.apache.ftpserver.impl.FtpServerContext /** - * Custom FEAT command to add AVBL command to the list. + * Custom [org.apache.ftpserver.command.impl.FEAT] command to add [AVBL] command to the list. */ class FEAT( private val featResponseProvider: () -> String, diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt index 3f084147ca..a38f99e486 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt @@ -27,12 +27,12 @@ import org.apache.ftpserver.command.CommandFactory import org.apache.ftpserver.command.CommandFactoryFactory /** - * Custom CommandFactory factory with custom commands. + * Custom [CommandFactory] factory with custom commands. */ object FtpCommandFactoryFactory { /** - * Encapsulate custom CommandFactory construction logic. Append custom AVBL and PWD command, - * as well as feature flag in FEAT command if not using AndroidFtpFileSystemView. + * Encapsulate custom [CommandFactory] construction logic. Append custom [AVBL] and [PWD] command, + * as well as feature flag in [FEAT] command if not using AndroidFtpFileSystemView. */ fun create( useAndroidFileSystem: Boolean, diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt index 2077956400..364232b205 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt @@ -24,7 +24,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow /** - * Event bus for FTP server events using Kotlin's Flow. + * Replacement event bus to handle [FtpServerService] events using Kotlin's Flow. + * + * Original idea: https://mirchev.medium.com/its-21st-century-stop-using-eventbus-3ff5d9c6a00f + * + * @see [FtpServerService] */ object FtpEventBus { private val _events = MutableSharedFlow(replay = 0) @@ -32,21 +36,10 @@ object FtpEventBus { /** * Emit the event signal to the event bus. + * + * @param event The event to be emitted. */ suspend fun emit(event: FtpServerEvent) { _events.emit(event) } } - -/** - * Events broadcast when FTP server state changes. - */ -sealed class FtpServerEvent { - data object Started : FtpServerEvent() - - data object StartedFromTile : FtpServerEvent() - - data object Stopped : FtpServerEvent() - - data object FailedToStart : FtpServerEvent() -} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt index 16bd182d30..2979996fa2 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt @@ -36,20 +36,18 @@ import org.slf4j.LoggerFactory abstract class FtpReceiver : BroadcastReceiver() { companion object { @JvmStatic - private val logger: Logger = LoggerFactory.getLogger(FtpReceiver::class.java) + protected val logger: Logger = LoggerFactory.getLogger(FtpReceiver::class.java) } /** * Get the FTP service class to start/stop */ - abstract fun getFtpServiceClass(): Class<*> + abstract fun getFtpServiceClass(): Class override fun onReceive( context: Context, intent: Intent, ) { - logger.debug("Received: ${intent.action}") - val serviceIntent = Intent(context, getFtpServiceClass()) serviceIntent.putExtras(intent) diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEvent.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEvent.kt new file mode 100644 index 0000000000..5d8c4d7463 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEvent.kt @@ -0,0 +1,14 @@ +package com.amaze.filemanager.ftpserver.service + +/** + * Events broadcast when FTP server state changes. + */ +sealed class FtpServerEvent { + data object Started : FtpServerEvent() + + data object StartedFromTile : FtpServerEvent() + + data object Stopped : FtpServerEvent() + + data object FailedToStart : FtpServerEvent() +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt index a5008f9c6e..f6f9b2e159 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt @@ -358,7 +358,7 @@ abstract class BaseFtpServerFragment : Fragment() { binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() } else { binding.textViewFtpStatus.text = spannedStatusNotRunning - binding.textViewFtpUrl.text = "URL: " + binding.textViewFtpUrl.text = getString(R.string.ftpmod_url_label, "") binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() } } diff --git a/ftpserver/src/main/res/values/strings.xml b/ftpserver/src/main/res/values/strings.xml index 98186ec62b..c1662b356d 100644 --- a/ftpserver/src/main/res/values/strings.xml +++ b/ftpserver/src/main/res/values/strings.xml @@ -8,7 +8,7 @@ FTP Server Not Running No network connection Secure Connection (FTPS) - URL: + URL: %s Username: Password: Port: diff --git a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt index bb88279af8..b248f680d0 100644 --- a/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt @@ -20,14 +20,26 @@ package com.amaze.filemanager.ftpserver.commands +import android.os.Build +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.P +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.shadows.ShadowMultiDex import org.apache.mina.core.session.DummySession import org.apache.mina.core.session.IoSession import org.junit.After import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.annotation.Config /** * Base class for ftpserver command unit tests. */ +@RunWith(AndroidJUnit4::class) +@Config( + shadows = [ShadowMultiDex::class], + sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], +) abstract class AbstractFtpserverCommandTest { protected lateinit var logger: LogMessageFilter diff --git a/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt index 9e33d4ed44..a1aa724a7b 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt @@ -23,8 +23,6 @@ package com.amaze.filemanager.server /** * Interface for file server implementations (FTP, SSH, WebDAV, etc.) * - * Each server implementation should provide its own implementation of this interface - * to handle server lifecycle and configuration. */ interface FileServer { /** @@ -64,7 +62,4 @@ interface FileServer { */ enum class ServerType(val id: String) { FTP("ftp"), - SFTP("sftp"), - SSH("ssh"), - WEBDAV("webdav"), } diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerProvider.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerProvider.kt new file mode 100644 index 0000000000..13bcce129f --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerProvider.kt @@ -0,0 +1,43 @@ +package com.amaze.filemanager.server + +import androidx.fragment.app.Fragment + +/** + * Provider interface for creating server-related components. + */ +interface ServerProvider { + /** + * The server type this provider handles + */ + val serverType: ServerType + + /** + * Display name for this server type + */ + val displayName: String + + /** + * Create the UI fragment for this server + */ + fun createFragment(): Fragment + + /** + * Get the server preferences handler + */ + fun getPreferences(): ServerPreferences + + /** + * Get the notification handler + */ + fun getNotification(): ServerNotification + + /** + * Check if the server is currently running + */ + fun isServerRunning(): Boolean + + /** + * Get the server URL if running + */ + fun getServerUrl(): String? +} diff --git a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt index f5dc32886e..bb22799af8 100644 --- a/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt @@ -20,8 +20,6 @@ package com.amaze.filemanager.server -import androidx.fragment.app.Fragment - /** * Registry for server implementations. * @@ -75,45 +73,3 @@ object ServerRegistry { servers.clear() } } - -/** - * Provider interface for creating server-related components. - * - * Each server module should implement this to provide its components. - */ -interface ServerProvider { - /** - * The server type this provider handles - */ - val serverType: ServerType - - /** - * Display name for this server type - */ - val displayName: String - - /** - * Create the UI fragment for this server - */ - fun createFragment(): Fragment - - /** - * Get the server preferences handler - */ - fun getPreferences(): ServerPreferences - - /** - * Get the notification handler - */ - fun getNotification(): ServerNotification - - /** - * Check if the server is currently running - */ - fun isServerRunning(): Boolean - - /** - * Get the server URL if running - */ - fun getServerUrl(): String? -} From d37c452de2ac6e65f8a39198b8353d70cad24e5f Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Mon, 11 May 2026 00:14:34 +0800 Subject: [PATCH 13/15] Consolidate FTP fragment ownership in app --- .../ui/fragments/FtpServerFragment.kt | 4 +- app/src/main/res/values/strings.xml | 1 + ...tpServerFragmentBatteryOptimizationTest.kt | 121 +---- .../ftpserver/ui/BaseFtpServerFragment.kt | 474 ------------------ .../src/main/res/layout/fragment_ftp.xml | 153 ------ .../src/main/res/menu/ftp_server_menu.xml | 27 - ...onsolidateFtpFragmentAbstraction.prompt.md | 186 +++++++ 7 files changed, 197 insertions(+), 769 deletions(-) delete mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt delete mode 100644 ftpserver/src/main/res/layout/fragment_ftp.xml delete mode 100644 ftpserver/src/main/res/menu/ftp_server_menu.xml create mode 100644 plan-consolidateFtpFragmentAbstraction.prompt.md 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 090c648ef8..aba3efc094 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 @@ -645,7 +645,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { statusText.text = spannedStatusNotRunning ftpBtn.isEnabled = true } - url.text = getString(com.amaze.filemanager.ftpserver.R.string.ftpmod_url_label, "") + url.text = getString(R.string.ftp_url_label, "") ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() } else { accentColor = mainActivity.accent @@ -768,7 +768,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) spannedStatusUrl = HtmlCompat.fromHtml( - getString(com.amaze.filemanager.ftpserver.R.string.ftpmod_url_label, ftpAddress), + getString(R.string.ftp_url_label, ftpAddress), FROM_HTML_MODE_COMPACT, ) spannedStatusNoConnection = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b45007fd5..bfb4aa9174 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,6 +316,7 @@ FTP port FTP port changed successfully Invalid port number selected + URL: %s Primary Color (Right Tab) Set color to large UI elements for the right tab Support Development diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt index 9f28267b68..1e8d1344d2 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt @@ -8,7 +8,6 @@ import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.amaze.filemanager.ftpserver.service.FtpPreferences -import com.amaze.filemanager.ftpserver.ui.BaseFtpServerFragment import com.amaze.filemanager.shadows.ShadowMultiDex import org.junit.After import org.junit.Assert.assertFalse @@ -27,7 +26,6 @@ import org.robolectric.shadows.ShadowPowerManager * - [FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED] preference saves and reads correctly. * - The Robolectric [ShadowPowerManager] correctly simulates the battery exemption state that * `FtpServerFragment.checkBatteryOptimizationIfNecessary` reads. - * - [BaseFtpServerFragment.onBeforeStartServer] invokes its `proceed` lambda immediately by default. * * Full integration tests for the dialog being shown / dismissed are covered by instrumented tests * that launch [com.amaze.filemanager.ui.activities.MainActivity] and navigate to the FTP fragment. @@ -41,84 +39,6 @@ class FtpServerFragmentBatteryOptimizationTest { private lateinit var context: Context private lateinit var shadowPowerManager: ShadowPowerManager - /** - * Minimal concrete [BaseFtpServerFragment] subclass for testing [onBeforeStartServer]. - * All abstract members are no-ops; the default [onBeforeStartServer] is NOT overridden. - */ - private class DefaultFragment : BaseFtpServerFragment() { - var proceedCalled = false - - fun callOnBeforeStartServer() { - onBeforeStartServer { proceedCalled = true } - } - - override fun getAccentColor(): Int = 0 - - override fun isConnectedToLocalNetwork(): Boolean = true - - override fun isConnectedToWifi(): Boolean = true - - override fun getLocalAddress(): String = "127.0.0.1" - - override fun startFtpService(startedByTile: Boolean) {} - - override fun stopFtpService() {} - - override fun promptUserToEnableWireless() {} - - override fun dismissSnackbar() {} - - override fun getEncryptedPassword(): String? = null - - override fun decryptPassword(encryptedPassword: String): String? = null - - override fun onPathChangeRequested() {} - - override fun onLoginChangeRequested() {} - } - - /** - * Concrete [BaseFtpServerFragment] subclass that overrides [onBeforeStartServer] to - * simulate gating (e.g. showing a dialog) without calling [proceed]. - */ - private class GatingFragment : BaseFtpServerFragment() { - var proceedCalled = false - var intercepted = false - - fun callOnBeforeStartServer() { - onBeforeStartServer { proceedCalled = true } - } - - override fun onBeforeStartServer(proceed: () -> Unit) { - intercepted = true - // Deliberately do NOT call proceed — simulating a dialog gate. - } - - override fun getAccentColor(): Int = 0 - - override fun isConnectedToLocalNetwork(): Boolean = true - - override fun isConnectedToWifi(): Boolean = true - - override fun getLocalAddress(): String = "127.0.0.1" - - override fun startFtpService(startedByTile: Boolean) {} - - override fun stopFtpService() {} - - override fun promptUserToEnableWireless() {} - - override fun dismissSnackbar() {} - - override fun getEncryptedPassword(): String? = null - - override fun decryptPassword(encryptedPassword: String): String? = null - - override fun onPathChangeRequested() {} - - override fun onLoginChangeRequested() {} - } - @Before fun setUp() { context = ApplicationProvider.getApplicationContext() @@ -150,7 +70,10 @@ class FtpServerFragmentBatteryOptimizationTest { val asked = FtpPreferences.getPreferences(context) .getBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, false) - assertFalse("Battery optimization preference should default to false (prompt not suppressed)", asked) + assertFalse( + "Battery optimization preference should default to false (prompt not suppressed)", + asked, + ) } /** @@ -166,7 +89,10 @@ class FtpServerFragmentBatteryOptimizationTest { val asked = FtpPreferences.getPreferences(context) .getBoolean(FtpPreferences.KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED, false) - assertTrue("Battery optimization preference should be true after user suppresses it", asked) + assertTrue( + "Battery optimization preference should be true after user suppresses it", + asked, + ) } // ---- ShadowPowerManager simulation tests ---- @@ -218,35 +144,4 @@ class FtpServerFragmentBatteryOptimizationTest { asked, ) } - - // ---- BaseFtpServerFragment.onBeforeStartServer tests ---- - - /** - * The default [BaseFtpServerFragment.onBeforeStartServer] must invoke the `proceed` - * lambda immediately without any interception. - */ - @Test - fun testDefaultOnBeforeStartServerCallsProceed() { - val fragment = DefaultFragment() - fragment.callOnBeforeStartServer() - assertTrue( - "Default onBeforeStartServer must invoke proceed immediately", - fragment.proceedCalled, - ) - } - - /** - * A subclass that overrides [BaseFtpServerFragment.onBeforeStartServer] and does NOT call - * `proceed` can gate the server start — e.g. to show a battery optimization dialog first. - */ - @Test - fun testOverriddenOnBeforeStartServerCanIntercept() { - val fragment = GatingFragment() - fragment.callOnBeforeStartServer() - assertTrue("Overridden onBeforeStartServer must be invoked", fragment.intercepted) - assertFalse( - "Proceed must NOT be called when the override intercepts it", - fragment.proceedCalled, - ) - } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt deleted file mode 100644 index f6f9b2e159..0000000000 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt +++ /dev/null @@ -1,474 +0,0 @@ -/* - * Copyright (C) 2014-2024 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.ftpserver.ui - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.os.Bundle -import android.text.Spanned -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.text.HtmlCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.amaze.filemanager.ftpserver.R -import com.amaze.filemanager.ftpserver.databinding.FragmentFtpBinding -import com.amaze.filemanager.ftpserver.service.FtpEventBus -import com.amaze.filemanager.ftpserver.service.FtpPreferences -import com.amaze.filemanager.ftpserver.service.FtpServerEngine -import com.amaze.filemanager.ftpserver.service.FtpServerEvent -import kotlinx.coroutines.launch - -/** - * Base fragment for FTP server UI. - * - * This provides the core FTP server UI functionality that can be extended - * by the app module to add app-specific features. - */ -@Suppress("StringLiteralDuplication") -abstract class BaseFtpServerFragment : Fragment() { - private var _binding: FragmentFtpBinding? = null - protected val binding get() = _binding!! - - private var spannedStatusNoConnection: Spanned? = null - private var spannedStatusConnected: Spanned? = null - private var spannedStatusUrl: Spanned? = null - private var spannedStatusSecure: Spanned? = null - private var spannedStatusNotRunning: Spanned? = null - - /** - * Get the accent color for the UI - */ - abstract fun getAccentColor(): Int - - /** - * Check if device is connected to a local network - */ - abstract fun isConnectedToLocalNetwork(): Boolean - - /** - * Check if device is connected to WiFi - */ - abstract fun isConnectedToWifi(): Boolean - - /** - * Get the local IP address - */ - abstract fun getLocalAddress(): String? - - /** - * Start the FTP service - */ - abstract fun startFtpService(startedByTile: Boolean) - - /** - * Stop the FTP service - */ - abstract fun stopFtpService() - - /** - * Show a snackbar prompting user to enable wireless - */ - abstract fun promptUserToEnableWireless() - - /** - * Dismiss any shown snackbar - */ - abstract fun dismissSnackbar() - - /** - * Get encrypted password from preferences - */ - abstract fun getEncryptedPassword(): String? - - /** - * Decrypt password - */ - abstract fun decryptPassword(encryptedPassword: String): String? - - /** - * Handle path change request - */ - abstract fun onPathChangeRequested() - - /** - * Handle login change request - */ - abstract fun onLoginChangeRequested() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - _binding = FragmentFtpBinding.inflate(inflater, container, false) - - updateSpans() - updateStatus() - updateViews() - - binding.startStopButton.setOnClickListener { - onStartStopButtonClick() - } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - FtpEventBus.events.collect { event -> - onFtpServerEvent(event) - } - } - } - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onResume() { - super.onResume() - updateStatus() - registerWifiReceiver() - } - - override fun onPause() { - super.onPause() - unregisterWifiReceiver() - } - - @Deprecated("Deprecated in Java") - override fun onCreateOptionsMenu( - menu: Menu, - inflater: MenuInflater, - ) { - inflater.inflate(R.menu.ftp_server_menu, menu) - menu.findItem(R.id.checkbox_ftp_readonly)?.isChecked = - FtpPreferences.isReadOnly(requireContext()) - menu.findItem(R.id.checkbox_ftp_secure)?.isChecked = - FtpPreferences.isSecure(requireContext()) - menu.findItem(R.id.checkbox_ftp_legacy_filesystem)?.isChecked = - FtpPreferences.useSafFilesystem(requireContext()) - super.onCreateOptionsMenu(menu, inflater) - } - - @Deprecated("Deprecated in Java") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.choose_ftp_port -> { - showPortDialog() - return true - } - R.id.ftp_path -> { - onPathChangeRequested() - return true - } - R.id.ftp_login -> { - onLoginChangeRequested() - return true - } - R.id.checkbox_ftp_readonly -> { - val newValue = !item.isChecked - item.isChecked = newValue - setReadonlyPreference(newValue) - updatePathText() - promptUserToRestartServer() - return true - } - R.id.checkbox_ftp_secure -> { - val newValue = !item.isChecked - item.isChecked = newValue - setSecurePreference(newValue) - promptUserToRestartServer() - return true - } - R.id.checkbox_ftp_legacy_filesystem -> { - val newValue = !item.isChecked - item.isChecked = newValue - setSafFilesystemPreference(newValue) - promptUserToRestartServer() - return true - } - R.id.ftp_timeout -> { - showTimeoutDialog() - return true - } - } - return super.onOptionsItemSelected(item) - } - - private fun onStartStopButtonClick() { - if (!FtpServerEngine.isRunning()) { - if (isConnectedToWifi() || isConnectedToLocalNetwork()) { - startServer() - } else { - binding.textViewFtpStatus.text = spannedStatusNoConnection - } - } else { - stopServer() - } - } - - /** - * Called just before the FTP service is started. Override in subclasses to insert - * pre-start checks (e.g. battery optimization prompt). The default implementation - * calls [proceed] immediately. - * - * @param proceed Lambda to invoke when the subclass is ready to proceed with starting - * the server. - */ - protected open fun onBeforeStartServer(proceed: () -> Unit) { - proceed() - } - - private fun startServer() { - onBeforeStartServer { startFtpService(false) } - } - - private fun stopServer() { - stopFtpService() - } - - private fun onFtpServerEvent(event: FtpServerEvent) { - updateSpans() - when (event) { - is FtpServerEvent.Started, is FtpServerEvent.StartedFromTile -> { - val isSecure = FtpPreferences.isSecure(requireContext()) - binding.textViewFtpStatus.text = - if (isSecure) { - spannedStatusSecure - } else { - spannedStatusConnected - } - binding.textViewFtpUrl.text = spannedStatusUrl - binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() - } - is FtpServerEvent.FailedToStart -> { - binding.textViewFtpStatus.text = spannedStatusNotRunning - Toast.makeText(context, R.string.ftpmod_unknown_error, Toast.LENGTH_LONG).show() - binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() - binding.textViewFtpUrl.text = getString(R.string.ftpmod_url_label) - } - is FtpServerEvent.Stopped -> { - binding.textViewFtpStatus.text = spannedStatusNotRunning - binding.textViewFtpUrl.text = getString(R.string.ftpmod_url_label) - binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() - } - } - updateStatus() - } - - private fun updateSpans() { - val accentColor = String.format("%06X", 0xFFFFFF and getAccentColor()) - - spannedStatusNoConnection = - HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_no_connection)}", - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - - spannedStatusConnected = - HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_running)}", - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - - spannedStatusSecure = - HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_secure_connection)}", - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - - spannedStatusNotRunning = - HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_status_label)} " + - "${getString(R.string.ftpmod_status_not_running)}", - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - - val address = getLocalAddress() - val port = FtpPreferences.getPort(requireContext()) - val isSecure = FtpPreferences.isSecure(requireContext()) - val prefix = if (isSecure) FtpPreferences.INITIALS_HOST_SFTP else FtpPreferences.INITIALS_HOST_FTP - val urlText = if (address != null) "$prefix$address:$port/" else "" - - spannedStatusUrl = - HtmlCompat.fromHtml( - "${getString(R.string.ftpmod_url_label)} " + - "$urlText", - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - } - - private fun updateStatus() { - if (_binding == null) return - - if (!isConnectedToLocalNetwork() && !isConnectedToWifi()) { - binding.textViewFtpStatus.text = spannedStatusNoConnection - binding.startStopButton.isEnabled = false - } else { - binding.startStopButton.isEnabled = true - if (FtpServerEngine.isRunning()) { - binding.textViewFtpStatus.text = - if (FtpPreferences.isSecure(requireContext())) { - spannedStatusSecure - } else { - spannedStatusConnected - } - binding.textViewFtpUrl.text = spannedStatusUrl - binding.startStopButton.text = getString(R.string.ftpmod_stop).uppercase() - } else { - binding.textViewFtpStatus.text = spannedStatusNotRunning - binding.textViewFtpUrl.text = getString(R.string.ftpmod_url_label, "") - binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() - } - } - } - - private fun updateViews() { - updateUsernameText() - updatePasswordText() - updatePortText() - updatePathText() - } - - private fun updateUsernameText() { - val username = FtpPreferences.getUsername(requireContext()) - val displayName = username.ifEmpty { getString(R.string.ftpmod_anonymous) } - binding.textViewFtpUsername.text = "${getString(R.string.ftpmod_username_label)}$displayName" - } - - private fun updatePasswordText() { - val username = FtpPreferences.getUsername(requireContext()) - if (username.isEmpty()) { - binding.textViewFtpPassword.text = "${getString(R.string.ftpmod_password_label)}••••••••" - binding.ftpPasswordVisible.visibility = View.GONE - } else { - binding.textViewFtpPassword.text = "${getString(R.string.ftpmod_password_label)}••••••••" - binding.ftpPasswordVisible.visibility = View.VISIBLE - } - } - - private fun updatePortText() { - val port = FtpPreferences.getPort(requireContext()) - binding.textViewFtpPort.text = "${getString(R.string.ftpmod_port_label)}$port" - } - - protected fun updatePathText() { - val path = FtpPreferences.getPath(requireContext()) - val readOnly = if (FtpPreferences.isReadOnly(requireContext())) " (R/O)" else "" - binding.textViewFtpPath.text = "${getString(R.string.ftpmod_path_label)}$path$readOnly" - } - - private fun setReadonlyPreference(value: Boolean) { - FtpPreferences.getPreferences(requireContext()).edit { - putBoolean(FtpPreferences.KEY_PREFERENCE_READONLY, value) - } - } - - private fun setSecurePreference(value: Boolean) { - FtpPreferences.getPreferences(requireContext()).edit { - putBoolean(FtpPreferences.KEY_PREFERENCE_SECURE, value) - } - } - - private fun setSafFilesystemPreference(value: Boolean) { - FtpPreferences.getPreferences(requireContext()).edit { - putBoolean(FtpPreferences.KEY_PREFERENCE_SAF_FILESYSTEM, value) - } - } - - private fun promptUserToRestartServer() { - if (FtpServerEngine.isRunning()) { - Toast.makeText(context, R.string.ftpmod_prompt_restart_server, Toast.LENGTH_SHORT).show() - } - } - - protected open fun showPortDialog() { - // Override in subclass to show port dialog with material-dialogs - } - - protected open fun showTimeoutDialog() { - // Override in subclass to show timeout dialog with material-dialogs - } - - private val wifiReceiver = - object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (isConnectedToLocalNetwork()) { - binding.startStopButton.isEnabled = true - dismissSnackbar() - } else { - stopServer() - binding.textViewFtpStatus.text = spannedStatusNoConnection - binding.startStopButton.isEnabled = false - binding.startStopButton.text = getString(R.string.ftpmod_start).uppercase() - promptUserToEnableWireless() - } - } - } - - private fun registerWifiReceiver() { - val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) - ContextCompat.registerReceiver( - requireContext(), - wifiReceiver, - filter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - } - - private fun unregisterWifiReceiver() { - try { - requireContext().unregisterReceiver(wifiReceiver) - } catch (e: IllegalArgumentException) { - // Receiver not registered - } - } - - companion object { - const val TAG = "FtpServerFragment" - } -} diff --git a/ftpserver/src/main/res/layout/fragment_ftp.xml b/ftpserver/src/main/res/layout/fragment_ftp.xml deleted file mode 100644 index a9bc8136a1..0000000000 --- a/ftpserver/src/main/res/layout/fragment_ftp.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ftpserver/src/main/res/menu/ftp_server_menu.xml b/ftpserver/src/main/res/menu/ftp_server_menu.xml deleted file mode 100644 index 42dd414b96..0000000000 --- a/ftpserver/src/main/res/menu/ftp_server_menu.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/plan-consolidateFtpFragmentAbstraction.prompt.md b/plan-consolidateFtpFragmentAbstraction.prompt.md new file mode 100644 index 0000000000..48b6b52cc0 --- /dev/null +++ b/plan-consolidateFtpFragmentAbstraction.prompt.md @@ -0,0 +1,186 @@ +## Plan: Delete BaseFtpServerFragment, Move App to Single FTP UI Owner + +Consolidate `BaseFtpServerFragment` logic into `FtpServerFragment`, move all FTP resources from `ftpserver` module to `app` module, rewrite tests for concrete fragment behavior, and remove abstraction layer. All flavor-specific UI logic will stay inside `FtpServerFragment`; pluggability remains in `server-core` service layer. + +### Rationale + +- `BaseFtpServerFragment` was created as UI abstraction for reusability, but FTP server is module-specific and not truly reusable. +- Flavor/provider pluggability is now handled in `server-core` via `ServerRegistry`/service contracts, not fragment inheritance. +- Keeping both base and app fragment creates duplicate UI logic, split resource ownership, and maintenance drift risk. +- One concrete `FtpServerFragment` with internal gating hooks (`onBeforeStartServer`) is simpler and clearer. +- Different server implementations can still have their own UI logic baked into `FtpServerFragment` conditional blocks based on `server-core` provider state. + +### Implementation Steps + +#### 1. Consolidate core logic into `FtpServerFragment` + +Copy the following from `BaseFtpServerFragment.kt` into `FtpServerFragment.kt`: +- `onCreate()`, `onCreateView()`, `onDestroyView()` lifecycle wiring +- `onResume()` / `onPause()` receiver registration/cleanup +- `onCreateOptionsMenu()` / `onOptionsItemSelected()` menu handling +- Event bus collection and `onFtpServerEvent()` handler +- All `update*()` methods: `updateSpans()`, `updateStatus()`, `updateViews()`, `updatePathText()`, `updatePasswordText()`, `updateUsernameText()`, `updatePortText()` +- Span initialization and URL/status rendering +- `wifiReceiver` broadcast receiver and registration logic +- Protected `onBeforeStartServer(proceed: () -> Unit)` hook (keep as local method, not abstract) +- Protected `showPortDialog()` / `showTimeoutDialog()` stubs (move to concrete `FtpServerFragment`) +- Preference setter helpers: `setReadonlyPreference()`, `setSecurePreference()`, `setSafFilesystemPreference()` + +**Remove from `FtpServerFragment.kt`** (now duplicate): +- Candidate duplicates: any overlap in span/status/preference handling already in base + +**Keep abstract method implementations** in `FtpServerFragment` (now concrete): +- `getAccentColor()` → use `mainActivity.accent` +- `isConnectedToLocalNetwork()` → use `isConnectedToLocalNetwork(requireContext())` +- `isConnectedToWifi()` → use `isConnectedToWifi(requireContext())` +- `getLocalAddress()` → use `getLocalInetAddress(requireContext())` +- `startFtpService(startedByTile)` → keep `doStartServer()` logic +- `stopFtpService()` → keep broadcast stop logic +- `promptUserToEnableWireless()` → keep snackbar prompt +- `dismissSnackbar()` → keep snackbar?.dismiss() +- `getEncryptedPassword()` → use preference lookup +- `decryptPassword()` → use `PasswordUtil.decryptPassword()` +- `onPathChangeRequested()` → keep SAF/folder dialog +- `onLoginChangeRequested()` → keep login dialog + +#### 2. Move FTP resources from `ftpserver` to app module + +**Source files to copy/consolidate:** +- `ftpserver/src/main/res/menu/ftp_server_menu.xml` → `app/src/main/res/menu/ftp_server_menu.xml` + - App version already exists; ftpserver version uses `ftpmod_*` string refs (different naming) + - **Decision: Keep app version (uses `ftp_*` keys), merge any missing items from ftpserver version if needed** + +- `ftpserver/src/main/res/layout/fragment_ftp.xml` → `app/src/main/res/layout/fragment_ftp.xml` + - App version already exists; verify identical or pick best version + - **Recommendation: Use one version (likely app already has correct bindings)** + +- `ftpserver/src/main/res/values/strings.xml` (FTP-related keys) + - Copy or alias `ftpmod_*` string definitions to `app/src/main/res/values/strings.xml` if not already present + - **Keys to preserve: `ftpmod_url_label`, `ftpmod_status_*`, `ftpmod_port_*`, `ftpmod_path`, `ftpmod_login`, etc.** + - Rename usage in code from `com.amaze.filemanager.ftpserver.R.ftpmod_*` to local `R.ftp*` or keep cross-module refs for backward compat if preferences use them + +**After copy:** +- Delete `ftpserver/src/main/res/menu/ftp_server_menu.xml` +- Delete `ftpserver/src/main/res/layout/fragment_ftp.xml` (if not shared with other fragments) +- Keep `ftpserver/src/main/res/values/strings.xml` but remove FTP UI string resources (keep service-level strings if any) + +#### 3. Update resource references in `FtpServerFragment` + +Search-replace patterns: +- `com.amaze.filemanager.ftpserver.R.string.ftpmod_*` → `R.string.ftpmod_*` or `getString(R.string.ftpmod_*)` +- `com.amaze.filemanager.ftpserver.R.menu.ftp_server_menu` → `R.menu.ftp_server_menu` +- `com.amaze.filemanager.ftpserver.R.layout.fragment_ftp` → keep as `Fragment(R.layout.fragment_ftp)` +- Ensure all string keys used in `FtpServerFragment` are defined in `app/src/main/res/values/strings.xml` + +**Option:** If keeping `ftpmod_*` keys in ftpserver strings for backward compat, reference via `com.amaze.filemanager.ftpserver.R.string.*` but note that as tech debt for later unification. + +#### 4. Rewrite `FtpServerFragmentBatteryOptimizationTest.kt` + +**Replace abstraction test pattern:** + +Current structure: +```kotlin +private class DefaultFragment : BaseFtpServerFragment() { + // overrides all abstract methods +} +private class GatingFragment : BaseFtpServerFragment() { + override fun onBeforeStartServer(proceed) { /* intercept */ } +} +``` + +New structure: +```kotlin +// Use a mock/spy of FtpServerFragment or test startServer() flow directly +// Test battery optimization check inside startServer() method +// Validate that checkBatteryOptimizationIfNecessary is called before doStartServer() +// Validate gating behavior (dialog shown vs skipped) based on exemption state +``` + +**Steps:** +1. Remove `DefaultFragment` and `GatingFragment` subclasses +2. Create a test fixture that can invoke `FtpServerFragment.startServer()` (make it testable or extract `checkBatteryOptimizationIfNecessary` into testable helper) +3. Rewrite tests to validate the concrete battery + SAF + start sequence +4. Keep test assertions for preference persistence and PowerManager mocking + +#### 5. Delete abstraction files + +**Files to delete:** +- `/home/airwave/git/AmazeFileManager/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/BaseFtpServerFragment.kt` +- `/home/airwave/git/AmazeFileManager/ftpserver/src/main/res/layout/fragment_ftp.xml` (if not shared) +- `/home/airwave/git/AmazeFileManager/ftpserver/src/main/res/menu/ftp_server_menu.xml` (after merge) +- Any stub/adapter files in `ftpserver/src/main/java/.../ui/` directory if they only served as bridge + +**Update `ftpserver/build.gradle`:** +- Remove any UI-specific dependencies if no longer needed (AndroidX fragments, databinding, etc.) +- May still need service/preference/engine dependencies +- Verify module can compile without Android UI libraries if appropriate + +#### 6. Validate imports and compile + +**Checklist:** +- [ ] Search codebase for remaining `BaseFtpServerFragment` references → should find only deletions +- [ ] Search for `com.amaze.filemanager.ftpserver.ui` imports → should only be `FtpServerEngine`, `FtpPreferences`, `FtpEventBus`, not UI classes +- [ ] Verify `FragmentFtpBinding` imports point to `com.amaze.filemanager.databinding.FragmentFtpBinding` (app module) +- [ ] Run `./gradlew :app:compileDebugKotlin` to catch syntax/import errors +- [ ] Run `./gradlew :ftpserver:build` to verify module still builds +- [ ] Run full `./gradlew build` to catch downstream issues +- [ ] Rerun `FtpServerFragmentBatteryOptimizationTest.kt` to validate test rewrites + +### Resource & Key Naming Decisions + +**Option A: Preserve `ftpmod_*` keys indefinitely** +- Pro: No preference migration, backward compat with any stored values +- Con: Code carries cross-module resource references forever +- Recommendation: Keep for now, document as tech debt + +**Option B: Rename to `ftp_*` keys and migrate preferences** +- Pro: Cleaner, single-module ownership +- Con: Requires migration on app upgrade +- Recommendation: Consider in future cleanup if app has migration framework + +**Decision for this PR: Go with Option A** — keep `ftpmod_*` keys in `app/src/main/res/values/strings.xml` after copy, reference locally without cross-module indirection. + +### Testing Strategy + +1. **Unit tests**: Rewrite `FtpServerFragmentBatteryOptimizationTest.kt` as concrete behavior tests +2. **Instrumented tests**: If any exist, ensure they still reference `FtpServerFragment` directly (not base class) +3. **Manual smoke test**: Launch app, navigate to FTP fragment, verify UI renders, menus work, dialogs trigger +4. **Preference migration**: Check that existing stored preferences still load (no key changes) + +### Rollout Risk & Mitigation + +**Risk**: Duplicate logic in base + app ends up with behavioral divergence if only one path changes +- **Mitigation**: Remove base class entirely; all logic now in one place + +**Risk**: Preference keys or string resources break if migration incomplete +- **Mitigation**: Keep exact key names, add aliases if needed; validate in UI tests + +**Risk**: Test subclasses become unmaintainable +- **Mitigation**: Rewrite tests to be concrete, easier to mock/stub fragments + +### Success Criteria + +- [ ] `BaseFtpServerFragment.kt` deleted +- [ ] `FtpServerFragment` compiles without abstraction dependencies +- [ ] All FTP UI strings/menu/layout in app module, none in ftpserver module (except service layer) +- [ ] `FtpServerFragmentBatteryOptimizationTest.kt` passes with new concrete tests +- [ ] Full app build succeeds (`./gradlew build`) +- [ ] Manual FTP UI smoke test: fragment loads, menu items visible, dialogs trigger, start/stop works +- [ ] Git diff shows only one FtpServerFragment (consolidation), no duplicates or orphans + +### Commit Strategy (Single PR) + +One logical commit covering: +1. Consolidate logic into `FtpServerFragment` +2. Move resources to app module +3. Delete `BaseFtpServerFragment` and ftpserver UI resources +4. Rewrite tests +5. Update imports/references + +Or split into 2–3 commits if you want to preserve history per component: +- Commit 1: Copy logic from base to app, make app self-contained (behavior equivalent) +- Commit 2: Delete base class and ftpserver UI resources +- Commit 3: Move resources and rewrite tests + +Recommend single commit for this scope to keep PR atomic and easy to review. + From bda88696719b5560b8a9fa2bb4e50ac0f831392c Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 23 May 2026 00:40:09 +0800 Subject: [PATCH 14/15] Changes per PR feedback --- ...tpServerFragmentBatteryOptimizationTest.kt | 6 ++ .../ftpserver/filesystem/AndroidFtpFile.kt | 82 +++++++++++++++++++ .../filesystem/RootFileSystemView.kt | 65 +++++++++++++-- .../ftpserver/filesystem/RootFtpFile.kt | 5 +- 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt index 1e8d1344d2..21936b18d4 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt @@ -39,6 +39,9 @@ class FtpServerFragmentBatteryOptimizationTest { private lateinit var context: Context private lateinit var shadowPowerManager: ShadowPowerManager + /** + * setup before tests. + */ @Before fun setUp() { context = ApplicationProvider.getApplicationContext() @@ -51,6 +54,9 @@ class FtpServerFragmentBatteryOptimizationTest { .apply() } + /** + * clean up after tests. + */ @After fun tearDown() { PreferenceManager.getDefaultSharedPreferences(context) diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt index c6f5fc482f..46298746bc 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt @@ -20,6 +20,7 @@ package com.amaze.filemanager.ftpserver.filesystem +import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.net.Uri @@ -50,30 +51,79 @@ class AndroidFtpFile( return path } + /** + * @see FtpFile.getName + * @see DocumentFile.getName + */ override fun getName(): String = backingDocument?.name ?: path.substringAfterLast('/') + /** + * @see FtpFile.isHidden + */ override fun isHidden(): Boolean = name.startsWith(".") && name != "." + /** + * @see FtpFile.isDirectory + * @see DocumentFile.isDirectory + */ override fun isDirectory(): Boolean = backingDocument?.isDirectory ?: false + /** + * @see FtpFile.isFile + * @see DocumentFile.isFile + */ override fun isFile(): Boolean = backingDocument?.isFile ?: false + /** + * @see FtpFile.doesExist + * @see DocumentFile.exists + */ override fun doesExist(): Boolean = backingDocument?.exists() ?: false + /** + * @see FtpFile.isReadable + * @see DocumentFile.canRead + */ override fun isReadable(): Boolean = backingDocument?.canRead() ?: false + /** + * @see FtpFile.isWritable + * @see DocumentFile.canWrite + */ override fun isWritable(): Boolean = backingDocument?.canWrite() ?: true + /** + * @see FtpFile.isRemovable + * @see DocumentFile.canWrite + */ override fun isRemovable(): Boolean = backingDocument?.canWrite() ?: true + /** + * @see FtpFile.getOwnerName + */ override fun getOwnerName(): String = "user" + /** + * @see FtpFile.getGroupName + */ override fun getGroupName(): String = "user" + /** + * @see FtpFile.getLinkCount + */ override fun getLinkCount(): Int = 0 + /** + * @see FtpFile.getLastModified + * @see DocumentFile.lastModified + */ override fun getLastModified(): Long = backingDocument?.lastModified() ?: 0L + /** + * @see FtpFile.setLastModified + * @see DocumentsContract.Document.COLUMN_LAST_MODIFIED + * @see ContentResolver.update + */ override fun setLastModified(time: Long): Boolean { return if (doesExist()) { val updateValues = @@ -94,16 +144,39 @@ class AndroidFtpFile( } } + /** + * @see FtpFile.getSize + * @see DocumentFile.length + */ override fun getSize(): Long = backingDocument?.length() ?: 0L + /** + * @see FtpFile.getPhysicalFile + */ override fun getPhysicalFile(): Any = backingDocument!! + /** + * @see FtpFile.mkdir + * @see DocumentFile.createDirectory + */ override fun mkdir(): Boolean = parentDocument.createDirectory(name) != null + /** + * @see FtpFile.delete + * @see DocumentFile.delete + */ override fun delete(): Boolean = backingDocument?.delete() ?: false + /** + * @see FtpFile.move + * @see DocumentFile.renameTo + */ override fun move(destination: FtpFile): Boolean = backingDocument?.renameTo(destination.name) ?: false + /** + * @see FtpFile.listFiles + * @see DocumentFile.listFiles + */ override fun listFiles(): MutableList = if (doesExist()) { backingDocument!!.listFiles().map { @@ -113,6 +186,11 @@ class AndroidFtpFile( mutableListOf() } + /** + * @see FtpFile.createOutputStream + * @see ContentResolver.openOutputStream + * @see DocumentFile.createFile + */ override fun createOutputStream(offset: Long): OutputStream? = runCatching { val uri = @@ -125,6 +203,10 @@ class AndroidFtpFile( context.contentResolver.openOutputStream(uri) }.getOrThrow() + /** + * @see FtpFile.createInputStream + * @see ContentResolver.openInputStream + */ override fun createInputStream(offset: Long): InputStream? = runCatching { if (doesExist()) { diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt index 1554ae823d..b335d62e9a 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt @@ -43,6 +43,7 @@ class RootFileSystemView( init { requireNotNull(user.homeDirectory) { "User home directory can not be null" } + // add last '/' if necessary var rootDir = user.homeDirectory rootDir = normalizeSeparateChar(rootDir) rootDir = appendSlash(rootDir) @@ -72,12 +73,14 @@ class RootFileSystemView( override fun changeWorkingDirectory(dirArg: String): Boolean { var dir = dirArg + // not a directory - return false dir = getPhysicalName(rootDir, currDir, dir) val dirObj = fileFactory.create(dir) if (!dirObj.isDirectory) { return false } + // strip user root and add last '/' if necessary dir = dir.substring(rootDir.length - 1) if (dir[dir.length - 1] != '/') { dir = "$dir/" @@ -88,9 +91,11 @@ class RootFileSystemView( } override fun getFile(file: String): FtpFile { + // get actual file object val physicalName = getPhysicalName(rootDir, currDir, file) val fileObj = fileFactory.create(physicalName) + // strip the root directory and return val userFileName = physicalName.substring(rootDir.length - 1) return RootFtpFile(userFileName, fileObj, user) } @@ -99,34 +104,58 @@ class RootFileSystemView( override fun dispose() = Unit + /** + * Get the physical canonical file name. It works like + * File.getCanonicalPath(). + * + * @param rootDir + * The root directory. + * @param currDir + * The current directory. It will always be with respect to the + * root directory. + * @param fileName + * The input file name. + * @return The return string will always begin with the root directory. It + * will never be null. + */ private fun getPhysicalName( rootDir: String, currDir: String, fileName: String, ): String { + // normalize root dir var normalizedRootDir: String = normalizeSeparateChar(rootDir) normalizedRootDir = appendSlash(normalizedRootDir) + // normalize file name val normalizedFileName = normalizeSeparateChar(fileName) var result: String? + // if file name is relative, set resArg to root dir + curr dir + // if file name is absolute, set resArg to root dir result = if (normalizedFileName[0] != '/') { + // file name is relative val normalizedCurrDir = normalize(currDir) normalizedRootDir + normalizedCurrDir.substring(1) } else { normalizedRootDir } + // strip last '/' result = trimTrailingSlash(result) + // replace ., ~ and .. + // in this loop resArg will never end with '/' val st = StringTokenizer(normalizedFileName, "/") while (st.hasMoreTokens()) { val tok = st.nextToken() + // . => current directory if (tok == ".") { - // ignore + // ignore and move on } else if (tok == "..") { + // .. => parent directory (if not root) if (result!!.startsWith(normalizedRootDir)) { val slashIndex = result.lastIndexOf('/') if (slashIndex != -1) { @@ -134,6 +163,7 @@ class RootFileSystemView( } } } else if (tok == "~") { + // ~ => home directory (in this case the root directory) result = trimTrailingSlash(normalizedRootDir) continue } else { @@ -141,16 +171,21 @@ class RootFileSystemView( } } + // add last slash if necessary if (result!!.length + 1 == normalizedRootDir.length) { result += '/' } + // make sure we did not end up above root dir if (!result.startsWith(normalizedRootDir)) { result = normalizedRootDir } return result } + /** + * Append trailing slash ('/') if missing + */ private fun appendSlash(path: String): String { return if (!path.endsWith("/")) { "$path/" @@ -159,6 +194,9 @@ class RootFileSystemView( } } + /** + * Prepend leading slash ('/') if missing + */ private fun prependSlash(path: String): String { return if (!path.startsWith("/")) { "/$path" @@ -167,6 +205,9 @@ class RootFileSystemView( } } + /** + * Trim trailing slash ('/') if existing + */ private fun trimTrailingSlash(path: String?): String { return if (path!![path.length - 1] == '/') { path.substring(0, path.length - 1) @@ -175,12 +216,19 @@ class RootFileSystemView( } } + /** + * Normalize separate character. Separate character should be '/' always. + */ private fun normalizeSeparateChar(pathName: String): String { return pathName .replace(File.separatorChar, '/') .replace('\\', '/') } + /** + * Normalize separator char, append and prepend slashes. Default to + * defaultPath if null or empty + */ private fun normalize(pathArg: String?): String { var path: String? = pathArg if (path == null || path.trim { it <= ' ' }.isEmpty()) { @@ -192,16 +240,18 @@ class RootFileSystemView( } /** - * Factory for creating SuFile instances. + * Interface responsible for creating [SuFile] instances. + * + * Mainly for facilitating tests. */ interface SuFileFactory { /** - * Create a SuFile instance for the given pathname. + * Create SuFile. */ fun create(pathname: String): SuFile = SuFile(pathname) /** - * Create a SuFile instance for the given parent and child paths. + * Create SuFile. */ fun create( parent: String, @@ -209,7 +259,7 @@ class RootFileSystemView( ): SuFile = SuFile(parent, child) /** - * Create a SuFile instance for the given parent File and child path. + * Create SuFile. */ fun create( parent: File, @@ -217,14 +267,13 @@ class RootFileSystemView( ): SuFile = SuFile(parent, child) /** - * Create a SuFile instance for the given URI. + * Create SuFile. */ fun create(uri: URI): SuFile = SuFile(uri) } /** - * Default implementation of SuFileFactory that creates SuFile instances using the default - * constructors. + * Marker class as default implementation of [SuFileFactory]. */ class DefaultSuFileFactory : SuFileFactory } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt index 8c51384969..291d193f8e 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt @@ -73,15 +73,17 @@ class RootFtpFile( } override fun isRemovable(): Boolean { + // root cannot be deleted if ("/" == fileName) { return false } val fullName = absolutePath + // we check FTPServer's write permission for this file. if (user.authorize(WriteRequest(fullName)) == null) { return false } - + // In order to maintain consistency, when possible we delete the last '/' character in the String val indexOfSlash = fullName.lastIndexOf('/') val parentFullName: String = if (indexOfSlash == 0) { @@ -90,6 +92,7 @@ class RootFtpFile( fullName.take(indexOfSlash) } + // we check if the parent FileObject is writable. return backingFile.absoluteFile.parentFile?.run { RootFtpFile( parentFullName, From d4e5527af7547ab859e1265816939d5779b6929d Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 23 May 2026 18:06:59 +0800 Subject: [PATCH 15/15] Made FtpCommandMessageProvider an individual interface Shared by AVBL and FEAT commands Also update the prompt file for the implementation with class diagram. --- .../plan-modularizeFtpServer.prompt.md | 107 ++++++++++++++++++ .../services/ftp/AppFtpService.kt | 17 ++- .../filemanager/ftpserver/commands/AVBL.kt | 20 +--- .../filemanager/ftpserver/commands/FEAT.kt | 4 +- .../commands/FtpCommandMessageProvider.kt | 19 ++++ .../service/FtpCommandFactoryFactory.kt | 8 +- .../ftpserver/service/FtpServerEngine.kt | 18 ++- .../ftpserver/service/FtpServerService.kt | 12 +- 8 files changed, 153 insertions(+), 52 deletions(-) rename plan-modularizeFtpServer.prompt.md => .github/prompts/plan-modularizeFtpServer.prompt.md (73%) create mode 100644 ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FtpCommandMessageProvider.kt diff --git a/plan-modularizeFtpServer.prompt.md b/.github/prompts/plan-modularizeFtpServer.prompt.md similarity index 73% rename from plan-modularizeFtpServer.prompt.md rename to .github/prompts/plan-modularizeFtpServer.prompt.md index d05581ea65..a546bd94e2 100644 --- a/plan-modularizeFtpServer.prompt.md +++ b/.github/prompts/plan-modularizeFtpServer.prompt.md @@ -133,6 +133,113 @@ ftpserver/ │ └── org.mockito.plugins.MockMaker ``` +### FTP Server Class Diagram + +```mermaid +classDiagram +direction LR + +namespace server_core { + class FileServer { + <> + } + class ServerPreferences { + <> + } + class ServerNotification { + <> + } + class ServerEvent { + <> + } + class ServerRegistry + class ServerProvider { + <> + } +} + +namespace ftpserver_service { + class FtpServerProvider + class FtpServerEngine + class FtpServerService { + <> + } + class FtpReceiver { + <> + } + class FtpEventBus + class FtpPreferences + class FtpCommandFactoryFactory + class FtpCipherSuites +} + +namespace ftpserver_ui { + class BaseFtpServerFragment { + <> + } + class FtpServerNotification +} + +namespace ftpserver_filesystem { + class AndroidFileSystemFactory + class AndroidFtpFileSystemView + class AndroidFtpFile + class RootFileSystemFactory + class RootFileSystemView + class RootFtpFile +} + +namespace ftpserver_commands { + class AVBL + class FEAT + class PWD +} + +namespace app_integration { + class AppFtpService + class AppFtpReceiver + class FtpServerFragment + class FtpTileService + class FtpNotification +} + +FtpServerProvider ..|> ServerProvider +FtpServerProvider ..> ServerPreferences +FtpServerProvider ..> FileServer +ServerRegistry --> ServerProvider : registers + +FtpServerService --> FtpServerEngine : owns +FtpServerService --> FtpEventBus : publishes +FtpReceiver --> FtpServerEngine : start/stop +FtpServerEngine ..> FtpPreferences : reads config +FtpServerEngine ..> FtpCommandFactoryFactory : command factory +FtpCommandFactoryFactory ..> AVBL +FtpCommandFactoryFactory ..> FEAT +FtpCommandFactoryFactory ..> PWD +FtpServerEngine ..> FtpCipherSuites + +FtpServerEngine ..> AndroidFileSystemFactory +FtpServerEngine ..> RootFileSystemFactory +AndroidFileSystemFactory --> AndroidFtpFileSystemView +AndroidFtpFileSystemView --> AndroidFtpFile +RootFileSystemFactory --> RootFileSystemView +RootFileSystemView --> RootFtpFile + +FtpServerNotification ..|> ServerNotification +BaseFtpServerFragment --> FtpServerNotification +BaseFtpServerFragment ..> FtpEventBus : observes +BaseFtpServerFragment ..> FtpPreferences +BaseFtpServerFragment ..> FtpServerEngine +BaseFtpServerFragment ..> ServerEvent + +AppFtpService --|> FtpServerService +AppFtpReceiver --|> FtpReceiver +FtpServerFragment --|> BaseFtpServerFragment +FtpTileService ..> FtpServerEngine +FtpTileService ..> FtpEventBus +FtpNotification ..> FtpServerNotification +``` + ### Further Considerations 1. **Dependency inversion** — ✅ Done: `ftpserver` now depends on `server-core`, and `app` depends on both. diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt index 2b10169797..e9a4c6da84 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt @@ -25,7 +25,7 @@ import android.content.res.Resources import androidx.preference.PreferenceManager import com.amaze.filemanager.BuildConfig import com.amaze.filemanager.R -import com.amaze.filemanager.ftpserver.commands.AVBL +import com.amaze.filemanager.ftpserver.commands.FtpCommandMessageProvider import com.amaze.filemanager.ftpserver.service.FtpServerService import com.amaze.filemanager.server.ServerRegistry import com.amaze.filemanager.server.ServerType @@ -94,13 +94,14 @@ class AppFtpService : FtpServerService() { return preferences.getBoolean(PREFERENCE_ROOTMODE, false) } - override fun getErrorMessageProvider(): AVBL.ErrorMessageProvider { - return object : AVBL.ErrorMessageProvider { - override fun getErrorMessage( - subId: String, + override fun getMessageProvider(): FtpCommandMessageProvider { + return object : FtpCommandMessageProvider { + override fun getMessage( + command: String, fileName: String?, ): String { - return when (subId) { + return when (command) { + "FEAT" -> getString(R.string.ftp_command_FEAT) "AVBL.notimplemented" -> getString(R.string.ftp_error_AVBL_notimplemented) "AVBL.accessdenied" -> getString(R.string.ftp_error_AVBL_accessdenied) "AVBL.isafile" -> getString(R.string.ftp_error_AVBL_isafile) @@ -110,8 +111,4 @@ class AppFtpService : FtpServerService() { } } } - - override fun getFeatResponse(): String { - return getString(R.string.ftp_command_FEAT) - } } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt index 694f80dd63..2d67732d0b 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt @@ -46,22 +46,10 @@ import java.io.File * See [Draft spec](https://www.ietf.org/archive/id/draft-peterson-streamlined-ftp-command-extensions-10.txt) */ class AVBL( - private val errorMessageProvider: ErrorMessageProvider, + private val ftpCommandMessageProvider: FtpCommandMessageProvider, ) : AbstractCommand() { - /** - * Interface for providing localized error messages - */ - interface ErrorMessageProvider { - /** - * Returns a localized error message based on the provided subId and optional filename. - */ - fun getErrorMessage( - subId: String, - fileName: String? = null, - ): String - } - companion object { + @JvmStatic private val LOG: Logger = LoggerFactory.getLogger(AVBL::class.java) } @@ -131,13 +119,13 @@ class AVBL( private fun doWriteReply( session: FtpIoSession, code: Int, - subId: String, + command: String, fileName: String? = null, ) { session.write( DefaultFtpReply( code, - errorMessageProvider.getErrorMessage(subId, fileName), + ftpCommandMessageProvider.getMessage(command, fileName), ), ) } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt index 7d86fd5cf2..deccb01f29 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt @@ -31,7 +31,7 @@ import org.apache.ftpserver.impl.FtpServerContext * Custom [org.apache.ftpserver.command.impl.FEAT] command to add [AVBL] command to the list. */ class FEAT( - private val featResponseProvider: () -> String, + private val ftpCommandMessageProvider: FtpCommandMessageProvider, ) : AbstractCommand() { override fun execute( session: FtpIoSession, @@ -42,7 +42,7 @@ class FEAT( session.write( DefaultFtpReply( FtpReply.REPLY_211_SYSTEM_STATUS_REPLY, - featResponseProvider(), + ftpCommandMessageProvider.getMessage("FEAT"), ), ) } diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FtpCommandMessageProvider.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FtpCommandMessageProvider.kt new file mode 100644 index 0000000000..b3c297151c --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FtpCommandMessageProvider.kt @@ -0,0 +1,19 @@ +package com.amaze.filemanager.ftpserver.commands + +/** + * Interface for providing localized error messages for custom FTP commands. + */ +interface FtpCommandMessageProvider { + /** + * Provides a localized message for the given command and subId. + * + * @param command The FTP command for which the message is requested. + * @param subId A specific identifier for the message, allowing for more granular messages. + * @param fileName An optional filename that can be included in the message if relevant. + * @return A localized message string. + */ + fun getMessage( + command: String, + fileName: String? = null, + ): String +} diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt index a38f99e486..3d47cff74f 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt @@ -22,6 +22,7 @@ package com.amaze.filemanager.ftpserver.service import com.amaze.filemanager.ftpserver.commands.AVBL import com.amaze.filemanager.ftpserver.commands.FEAT +import com.amaze.filemanager.ftpserver.commands.FtpCommandMessageProvider import com.amaze.filemanager.ftpserver.commands.PWD import org.apache.ftpserver.command.CommandFactory import org.apache.ftpserver.command.CommandFactoryFactory @@ -36,13 +37,12 @@ object FtpCommandFactoryFactory { */ fun create( useAndroidFileSystem: Boolean, - errorMessageProvider: AVBL.ErrorMessageProvider, - featResponseProvider: () -> String, + ftpCommandMessageProvider: FtpCommandMessageProvider, ): CommandFactory { val cf = CommandFactoryFactory() if (!useAndroidFileSystem) { - cf.addCommand("AVBL", AVBL(errorMessageProvider)) - cf.addCommand("FEAT", FEAT(featResponseProvider)) + cf.addCommand("AVBL", AVBL(ftpCommandMessageProvider)) + cf.addCommand("FEAT", FEAT(ftpCommandMessageProvider)) cf.addCommand("PWD", PWD()) } return cf.createCommandFactory() diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt index 3c2c6c8a40..d0b5a30f8f 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt @@ -23,7 +23,7 @@ package com.amaze.filemanager.ftpserver.service import android.content.Context import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.KITKAT -import com.amaze.filemanager.ftpserver.commands.AVBL +import com.amaze.filemanager.ftpserver.commands.FtpCommandMessageProvider import com.amaze.filemanager.ftpserver.filesystem.AndroidFileSystemFactory import com.amaze.filemanager.ftpserver.filesystem.RootFileSystemFactory import kotlinx.coroutines.CoroutineScope @@ -74,8 +74,7 @@ object FtpServerEngine { val useRootFilesystem: Boolean = false, val keyStoreInputStream: InputStream? = null, val keyStorePassword: String = "", - val errorMessageProvider: AVBL.ErrorMessageProvider? = null, - val featResponseProvider: (() -> String)? = null, + val ftpCommandMessageProvider: FtpCommandMessageProvider, val startedByTile: Boolean = false, ) @@ -128,14 +127,11 @@ object FtpServerEngine { } // Configure commands - if (config.errorMessageProvider != null && config.featResponseProvider != null) { - commandFactory = - FtpCommandFactoryFactory.create( - config.useSafFilesystem, - config.errorMessageProvider, - config.featResponseProvider, - ) - } + commandFactory = + FtpCommandFactoryFactory.create( + config.useSafFilesystem, + config.ftpCommandMessageProvider, + ) // Configure user val user = BaseUser() diff --git a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt index 33de5c1f36..de22309fe5 100644 --- a/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt @@ -26,7 +26,7 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.PowerManager -import com.amaze.filemanager.ftpserver.commands.AVBL +import com.amaze.filemanager.ftpserver.commands.FtpCommandMessageProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -92,12 +92,7 @@ abstract class FtpServerService : Service() { /** * Get error message provider for AVBL command */ - abstract fun getErrorMessageProvider(): AVBL.ErrorMessageProvider - - /** - * Get FEAT response string - */ - abstract fun getFeatResponse(): String + abstract fun getMessageProvider(): FtpCommandMessageProvider override fun onCreate() { super.onCreate() @@ -177,8 +172,7 @@ abstract class FtpServerService : Service() { useRootFilesystem = isRootModeEnabled(), keyStoreInputStream = if (FtpPreferences.isSecure(this)) getKeyStoreInputStream() else null, keyStorePassword = getKeyStorePassword(), - errorMessageProvider = getErrorMessageProvider(), - featResponseProvider = { getFeatResponse() }, + ftpCommandMessageProvider = getMessageProvider(), startedByTile = isStartedByTile, )