diff --git a/.github/prompts/plan-modularizeFtpServer.prompt.md b/.github/prompts/plan-modularizeFtpServer.prompt.md new file mode 100644 index 0000000000..a546bd94e2 --- /dev/null +++ b/.github/prompts/plan-modularizeFtpServer.prompt.md @@ -0,0 +1,252 @@ +## 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** — ✅ 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 + +``` +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 +├── 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 +``` + +### 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. + +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/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/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/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/application/AppConfig.java b/app/src/main/java/com/amaze/filemanager/application/AppConfig.java index 79a27e7414..bdd8d6d6da 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,15 @@ 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.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.NotificationConstants; 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; @@ -51,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; @@ -67,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 { @@ -112,6 +122,25 @@ 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. + // 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 FtpServerNotification( + NotificationConstants.FTP_ID, + NotificationConstants.CHANNEL_FTP_ID, + new Intent(this, MainActivity.class), + getLocalAddress), + getLocalAddress)); } @Override @@ -123,6 +152,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/asynchronous/services/ftp/AppFtpReceiver.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt new file mode 100644 index 0000000000..73bca2c2eb --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpReceiver.kt @@ -0,0 +1,33 @@ +/* + * 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 +import com.amaze.filemanager.ftpserver.service.FtpServerService + +/** + * 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..e9a4c6da84 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/AppFtpService.kt @@ -0,0 +1,114 @@ +/* + * 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 android.content.res.Resources +import androidx.preference.PreferenceManager +import com.amaze.filemanager.BuildConfig +import com.amaze.filemanager.R +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 +import com.amaze.filemanager.ui.fragments.preferencefragments.PreferencesConstants.PREFERENCE_ROOTMODE +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]. + * + * 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() = requireNotNull(ServerRegistry.getProvider(ServerType.FTP)).getNotification() + + override fun getNotificationId(): Int = serverNotification.getNotificationId() + + override fun getNotificationChannelId(): String = serverNotification.getChannelId() + + override fun createStartingNotification(noStopButton: Boolean): Notification { + return serverNotification.createStartingNotification(applicationContext, noStopButton) + } + + override fun updateRunningNotification(noStopButton: Boolean) { + serverNotification.updateRunningNotification(applicationContext, noStopButton) + } + + override fun getKeyStoreInputStream(): InputStream? { + return try { + resources.openRawResource(R.raw.key) + } catch (e: Resources.NotFoundException) { + 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: GeneralSecurityException) { + log.warn("Failed to decrypt password", e) + null + } catch (e: IOException) { + log.warn("Unexpected error during password decryption", e) + null + } + } + + override fun isRootModeEnabled(): Boolean { + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + return preferences.getBoolean(PREFERENCE_ROOTMODE, false) + } + + override fun getMessageProvider(): FtpCommandMessageProvider { + return object : FtpCommandMessageProvider { + override fun getMessage( + command: String, + fileName: String?, + ): String { + 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) + "AVBL.missing" -> getString(R.string.ftp_error_AVBL_missing) + else -> getString(R.string.unknown_error) + } + } + } + } +} 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/asynchronous/services/ftp/FtpTileService.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/FtpTileService.kt index 1ae5638f08..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,12 +22,15 @@ 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 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 +40,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 +68,25 @@ 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 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) } else { Toast.makeText( @@ -90,7 +101,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..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 @@ -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 @@ -72,17 +74,16 @@ 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.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 @@ -101,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) { @@ -123,6 +123,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!! @@ -181,7 +182,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { } private fun ftpBtnOnClick() { - if (!isRunning()) { + if (!FtpServerEngine.isRunning()) { if (isConnectedToWifi(requireContext()) || isConnectedToLocalNetwork(requireContext()) ) { @@ -324,7 +325,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 +342,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 +375,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 +405,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 @@ -420,18 +421,20 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { url.text = spannedStatusUrl ftpBtn.text = resources.getString(R.string.stop_ftp).uppercase() - FtpNotification.updateNotification( - context, - FtpReceiverActions.STARTED_FROM_TILE == signal, - ) + ServerRegistry.getProvider(ServerType.FTP) + ?.getNotification() + ?.updateRunningNotification( + requireContext() ?: return, + 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() @@ -456,11 +459,58 @@ 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 = mainActivity.prefs - .getString(KEY_PREFERENCE_PATH, defaultPathFromPreferences)!! + .getString(FtpPreferences.KEY_PREFERENCE_PATH, defaultPathFromPreferences)!! if (shouldUseSafFileSystem()) { directoryUri.toUri().run { if (requireContext().checkUriPermission( @@ -533,22 +583,24 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { /** Sends a broadcast to start ftp server */ private fun startServer() { - checkUriAccessIfNecessary { - doStartServer() + checkBatteryOptimizationIfNecessary { + checkUriAccessIfNecessary { + doStartServer() + } } } /** 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), ) @@ -561,6 +613,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() } @@ -572,7 +635,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()) ) { @@ -582,7 +645,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { statusText.text = spannedStatusNotRunning ftpBtn.isEnabled = true } - url.text = "URL: " + url.text = getString(R.string.ftp_url_label, "") ftpBtn.text = resources.getString(R.string.start_ftp).uppercase() } else { accentColor = mainActivity.accent @@ -638,7 +701,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) @@ -676,9 +739,12 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { private fun resetFTPPath() { mainActivity.prefs - .edit() - .putString(KEY_PREFERENCE_PATH, FtpService.defaultPath(requireContext())) - .apply() + .edit { + putString( + FtpPreferences.KEY_PREFERENCE_PATH, + FtpPreferences.defaultPath(requireContext()), + ) + } } /** Updates the status spans */ @@ -702,7 +768,7 @@ class FtpServerFragment : Fragment(R.layout.fragment_ftp) { ) spannedStatusUrl = HtmlCompat.fromHtml( - "URL: $ftpAddress", + getString(R.string.ftp_url_label, ftpAddress), FROM_HTML_MODE_COMPACT, ) spannedStatusNoConnection = @@ -727,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) { @@ -744,7 +805,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 +878,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 +892,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 +904,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 +915,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 +941,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 +957,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 +968,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 +981,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 +1003,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/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..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,6 +86,9 @@ object PreferencesConstants { const val PREFERENCE_REGEX = "regex" const val PREFERENCE_REGEX_MATCHES = "matches" + // ftp preferences + 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/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 bb45f396c0..0000000000 --- a/app/src/main/java/com/amaze/filemanager/ui/notifications/FtpNotification.java +++ /dev/null @@ -1,132 +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.asynchronous.services.ftp.FtpService; -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 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. - * - *

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(FtpService.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); - - 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); - - InetAddress address = NetworkUtil.getLocalInetAddress(context, false); - - String address_text = "Address not found"; - - if (address != null) { - address_text = - (secureConnection ? FtpService.INITIALS_HOST_SFTP : FtpService.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/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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11fca688cd..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 @@ -701,8 +702,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 +738,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\". @@ -761,6 +762,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. 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 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/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/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/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/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt new file mode 100644 index 0000000000..21936b18d4 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpServerFragmentBatteryOptimizationTest.kt @@ -0,0 +1,153 @@ +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.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. + * + * 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 + + /** + * setup before tests. + */ + @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() + } + + /** + * clean up after tests. + */ + @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, + ) + } +} 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/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, 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/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/.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..bbb8389d2f --- /dev/null +++ b/ftpserver/build.gradle @@ -0,0 +1,90 @@ +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 + } + + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + all { + // Required for Mockito to work with Java 21 + jvmArgs '-Dnet.bytebuddy.experimental=true' + if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) { + jvmArgs '-XX:+EnableDynamicAgentLoading' + } + } + } + } +} + +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 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.mockk + testImplementation libs.mockito.core + testImplementation libs.mockito.inline + testImplementation libs.robolectric + testImplementation libs.androidX.test.core + testImplementation libs.androidX.test.ext.junit + + 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..568741e54f --- /dev/null +++ b/ftpserver/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file 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..35356c7647 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/FtpServerProvider.kt @@ -0,0 +1,141 @@ +/* + * 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.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.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, + private val getLocalAddress: (Context) -> String? = { null }, +) : 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 + val address = getLocalAddress(context) ?: return null + return "$prefix$address:$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) + } + } + + 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) + } + } + + 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 ?: "") + } + } + + 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) + } + } + + 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) + } + } + + 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) + } + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt similarity index 84% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt index 8d5809b589..2d67732d0b 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBL.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/AVBL.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,11 +18,12 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.ftpserver.commands -import com.amaze.filemanager.application.AppConfig -import com.amaze.filemanager.filesystem.ftpserver.AndroidFileSystemFactory +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 @@ -39,14 +40,16 @@ 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 + * 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 : AbstractCommand() { +class AVBL( + private val ftpCommandMessageProvider: FtpCommandMessageProvider, +) : AbstractCommand() { companion object { + @JvmStatic private val LOG: Logger = LoggerFactory.getLogger(AVBL::class.java) } @@ -55,7 +58,6 @@ class AVBL : AbstractCommand() { context: FtpServerContext, request: FtpRequest, ) { - // argument check val fileName: String? = request.argument if (context.fileSystemManager is AndroidFileSystemFactory) { doWriteReply( @@ -117,18 +119,13 @@ class AVBL : AbstractCommand() { private fun doWriteReply( session: FtpIoSession, code: Int, - subId: String, + command: 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, - ), + ftpCommandMessageProvider.getMessage(command, fileName), ), ) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt similarity index 78% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt index ee284fd11e..deccb01f29 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEAT.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/FEAT.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,10 +18,8 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.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 @@ -30,9 +28,11 @@ 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. + * Custom [org.apache.ftpserver.command.impl.FEAT] command to add [AVBL] command to the list. */ -class FEAT : AbstractCommand() { +class FEAT( + private val ftpCommandMessageProvider: FtpCommandMessageProvider, +) : AbstractCommand() { override fun execute( session: FtpIoSession, context: FtpServerContext, @@ -42,7 +42,7 @@ class FEAT : AbstractCommand() { session.write( DefaultFtpReply( FtpReply.REPLY_211_SYSTEM_STATUS_REPLY, - AppConfig.getInstance().getString(R.string.ftp_command_FEAT), + 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/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt similarity index 90% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt index eb270d168f..e813b2cbe4 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWD.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/commands/PWD.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.ftpserver.commands import org.apache.ftpserver.command.AbstractCommand import org.apache.ftpserver.ftplet.FtpException @@ -30,7 +30,7 @@ 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. + * Monkey-patch PWD to prevent true path exposed to end user. */ class PWD : AbstractCommand() { @Throws(IOException::class, FtpException::class) diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt similarity index 81% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt index af53162e3e..0a90fe0c48 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFileSystemFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFileSystemFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,18 +18,20 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver +package com.amaze.filemanager.ftpserver.filesystem 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 { +class AndroidFileSystemFactory( + private val context: Context, + private val defaultPathProvider: () -> String, +) : FileSystemFactory { override fun createFileSystemView(user: User?): FileSystemView = - AndroidFtpFileSystemView(context, user?.homeDirectory ?: FtpService.defaultPath(context)) + AndroidFtpFileSystemView(context, user?.homeDirectory ?: defaultPathProvider()) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt similarity index 97% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt index 9f10594e3c..46298746bc 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFile.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFile.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver +package com.amaze.filemanager.ftpserver.filesystem import android.content.ContentResolver import android.content.ContentValues @@ -36,7 +36,7 @@ import java.io.OutputStream import java.lang.ref.WeakReference @RequiresApi(KITKAT) -@Suppress("TooManyFunctions") // Don't ask me. Ask Apache why. +@Suppress("TooManyFunctions") class AndroidFtpFile( context: Context, private val parentDocument: DocumentFile, @@ -189,6 +189,7 @@ class AndroidFtpFile( /** * @see FtpFile.createOutputStream * @see ContentResolver.openOutputStream + * @see DocumentFile.createFile */ override fun createOutputStream(offset: Long): OutputStream? = runCatching { diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt similarity index 95% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt index 1a25824733..027e8c29a1 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/AndroidFtpFileSystemView.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/AndroidFtpFileSystemView.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver +package com.amaze.filemanager.ftpserver.filesystem import android.content.Context import android.net.Uri @@ -88,7 +88,7 @@ class AndroidFtpFileSystemView(private var context: Context, root: String) : Fil return normalizePath(path).let { normalizedPath -> AndroidFtpFile( context, - resolveDocumentFileFromRoot(getParentFrom(normalizedPath))!!, // rootDocumentFile, + resolveDocumentFileFromRoot(getParentFrom(normalizedPath))!!, resolveDocumentFileFromRoot(normalizedPath), normalizedPath, ) @@ -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/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt similarity index 91% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt index 808b8dc15b..849e67f02e 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver +package com.amaze.filemanager.ftpserver.filesystem import org.apache.ftpserver.ftplet.FileSystemFactory import org.apache.ftpserver.ftplet.FileSystemView diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt similarity index 98% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt index 90f4eaaf68..b335d62e9a 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFileSystemView.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFileSystemView.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver +package com.amaze.filemanager.ftpserver.filesystem import android.util.Log import com.topjohnwu.superuser.io.SuFile diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt similarity index 96% rename from app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt index 0a85fac8dd..291d193f8e 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/ftpserver/RootFtpFile.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/filesystem/RootFtpFile.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver +package com.amaze.filemanager.ftpserver.filesystem import com.topjohnwu.superuser.io.SuFile import com.topjohnwu.superuser.io.SuFileInputStream @@ -89,7 +89,7 @@ class RootFtpFile( if (indexOfSlash == 0) { "/" } else { - fullName.substring(0, indexOfSlash) + fullName.take(indexOfSlash) } // we check if the parent FileObject is writable. 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..92bd7a1f9c --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCipherSuites.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.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 +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 + @SuppressLint("ObsoleteSdkInt") + 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/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt similarity index 62% rename from app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt rename to ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt index ef13285490..3d47cff74f 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ftp/CommandFactoryFactory.kt +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpCommandFactoryFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,28 +18,31 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.asynchronous.services.ftp +package com.amaze.filemanager.ftpserver.service -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 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 /** * Custom [CommandFactory] factory with custom commands. */ -object CommandFactoryFactory { +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): CommandFactory { + fun create( + useAndroidFileSystem: Boolean, + ftpCommandMessageProvider: FtpCommandMessageProvider, + ): CommandFactory { val cf = CommandFactoryFactory() if (!useAndroidFileSystem) { - cf.addCommand("AVBL", AVBL()) - cf.addCommand("FEAT", FEAT()) + 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/FtpEventBus.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt new file mode 100644 index 0000000000..364232b205 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpEventBus.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** + * 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) + val events = _events.asSharedFlow() + + /** + * Emit the event signal to the event bus. + * + * @param event The event to be emitted. + */ + suspend fun emit(event: FtpServerEvent) { + _events.emit(event) + } +} 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..b94e9b16cd --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpPreferences.kt @@ -0,0 +1,140 @@ +/* + * 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 KEY_PREFERENCE_BATTERY_OPTIMIZATION_ASKED = "ftp_battery_optimization_asked" + + 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 + */ + @JvmStatic + fun getPreferences(context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + /** + * Get configured port + */ + @JvmStatic + fun getPort(context: Context): Int { + return getPreferences(context).getInt(PORT_PREFERENCE_KEY, DEFAULT_PORT) + } + + /** + * Get whether secure connection is enabled + */ + @JvmStatic + fun isSecure(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_PREFERENCE_SECURE, DEFAULT_SECURE) + } + + /** + * Get configured timeout + */ + @JvmStatic + fun getTimeout(context: Context): Int { + return getPreferences(context).getInt(KEY_PREFERENCE_TIMEOUT, DEFAULT_TIMEOUT) + } + + /** + * Get configured path + */ + @JvmStatic + fun getPath(context: Context): String { + return getPreferences(context).getString(KEY_PREFERENCE_PATH, defaultPath(context)) + ?: defaultPath(context) + } + + /** + * Get configured username + */ + @JvmStatic + fun getUsername(context: Context): String { + return getPreferences(context).getString(KEY_PREFERENCE_USERNAME, DEFAULT_USERNAME) + ?: DEFAULT_USERNAME + } + + /** + * Check if read-only mode is enabled + */ + @JvmStatic + fun isReadOnly(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_PREFERENCE_READONLY, false) + } + + /** + * Check if SAF filesystem should be used + */ + @JvmStatic + 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. + */ + @JvmStatic + 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..2979996fa2 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpReceiver.kt @@ -0,0 +1,72 @@ +/* + * 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 androidx.core.content.ContextCompat +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * 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() { + companion object { + @JvmStatic + protected 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, + ) { + 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 { + 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 new file mode 100644 index 0000000000..d0b5a30f8f --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerEngine.kt @@ -0,0 +1,238 @@ +/* + * 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.FtpCommandMessageProvider +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 ftpCommandMessageProvider: FtpCommandMessageProvider, + val startedByTile: Boolean = false, + ) + + /** + * 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() } + } + + @Suppress("LongMethod", "ComplexMethod", "TooGenericExceptionCaught") + private fun runServer( + context: Context, + config: ServerConfig, + onStarted: (Boolean) -> Unit, + ) { + try { + FtpServerFactory().run { + val connectionConfigFactory = ConnectionConfigFactory() + + // Configure filesystem + fileSystem = + if (SDK_INT >= KITKAT && config.useSafFilesystem) { + AndroidFileSystemFactory(context) { config.path } + } else if (config.useRootFilesystem) { + RootFileSystemFactory() + } else { + NativeFileSystemFactory() + } + + // Configure commands + commandFactory = + FtpCommandFactoryFactory.create( + config.useSafFilesystem, + config.ftpCommandMessageProvider, + ) + + // 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 { + val event = + if (config.startedByTile) { + FtpServerEvent.StartedFromTile + } else { + FtpServerEvent.Started + } + FtpEventBus.emit(event) + } + onStarted(true) + } + } + } catch (e: Throwable) { + log.error("Failed to start FTP server", e) + scope.launch { + FtpEventBus.emit(FtpServerEvent.FailedToStart) + } + onStarted(false) + } + } + + /** + * Stop the FTP server + */ + fun stop() { + scope.launch { + serverThread?.let { thread -> + thread.interrupt() + thread.join(10000) + if (!thread.isAlive) { + serverThread = null + } + } + + server?.stop() + server = null + + FtpEventBus.emit(FtpServerEvent.Stopped) + } + } +} 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/service/FtpServerService.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt new file mode 100644 index 0000000000..de22309fe5 --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/service/FtpServerService.kt @@ -0,0 +1,208 @@ +/* + * 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 com.amaze.filemanager.ftpserver.commands.FtpCommandMessageProvider +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 +import java.util.concurrent.TimeUnit + +/** + * FTP Server Android Service. + * + * 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 + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + /** + * 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 getMessageProvider(): FtpCommandMessageProvider + + 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 + + // Start as foreground service immediately to avoid ANR + 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) + } + + // 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 + } + + 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(), + ftpCommandMessageProvider = getMessageProvider(), + startedByTile = isStartedByTile, + ) + + FtpServerEngine.start(this, config) { success -> + if (success) { + updateRunningNotification(isStartedByTile) + } else { + if (wakeLock.isHeld) { + wakeLock.release() + } + stopSelf() + } + } + } + + override fun onDestroy() { + serviceScope.cancel() + FtpServerEngine.stop() + + if (wakeLock.isHeld) { + wakeLock.release() + } + + super.onDestroy() + } + + 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/FtpServerNotification.kt b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt new file mode 100644 index 0000000000..de026e601f --- /dev/null +++ b/ftpserver/src/main/java/com/amaze/filemanager/ftpserver/ui/FtpServerNotification.kt @@ -0,0 +1,174 @@ +/* + * 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_notification_title, + context.getString(R.string.ftpmod_notification_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 { + context.getString(R.string.ftpmod_notification_error_address_not_found) + } + + val notification = + buildNotification( + context, + R.string.ftpmod_notification_running_title, + context.getString(R.string.ftpmod_notification_running_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_notification_starting)) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_MAX) + + 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_notification_stop_server), + stopPendingIntent, + ) + } + + return builder + } + + private fun ensureNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + channelId, + context.getString(R.string.ftpmod_notification_title), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.ftpmod_notification_channel_desc) + } + 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/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..c1662b356d --- /dev/null +++ b/ftpserver/src/main/res/values/strings.xml @@ -0,0 +1,64 @@ + + + + START + STOP + Status: + FTP Server Running + FTP Server Not Running + No network connection + Secure Connection (FTPS) + URL: %s + 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 status notifications + FTP Server + Starting FTP server… + FTP Server Running + FTP server is running at %1$s + Stop + Address not found + + + OK + Cancel + Set + Change + Choose folder + Go up one level + Error + Unknown error occurred + diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt similarity index 61% rename from app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt rename to ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt index 8b0e7fa9ac..5c3a9c036c 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AVBLCommandTest.kt +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AVBLCommandTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,12 +18,11 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.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 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 @@ -37,8 +36,6 @@ 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 @@ -49,44 +46,90 @@ import java.io.File 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() { - fsFactory = mock(NativeFileSystemFactory::class.java) - fsView = mock(NativeFileSystemView::class.java) + // Use Mockito for File mocks (handles final classes better) 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`(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`(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) + `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 + } } } @@ -98,14 +141,11 @@ class AVBLCommandTest : AbstractFtpserverCommandTest() { executeRequest( "AVBL", listOf(WritePermission()), - mock(AndroidFileSystemFactory::class.java), + mockk(), ) 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, - ) + assertEquals(ERROR_NOT_IMPLEMENTED, logger.messages[0].message) } /** @@ -149,10 +189,7 @@ class AVBLCommandTest : AbstractFtpserverCommandTest() { 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, - ) + assertEquals(String.format(ERROR_MISSING, "/foobar"), logger.messages[0].message) } /** @@ -163,10 +200,7 @@ class AVBLCommandTest : AbstractFtpserverCommandTest() { 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, - ) + assertEquals(ERROR_ACCESS_DENIED, logger.messages[0].message) } /** @@ -178,10 +212,7 @@ class AVBLCommandTest : AbstractFtpserverCommandTest() { 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, - ) + assertEquals(ERROR_ACCESS_DENIED, logger.messages[0].message) } /** @@ -192,10 +223,7 @@ class AVBLCommandTest : AbstractFtpserverCommandTest() { 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, - ) + assertEquals(ERROR_IS_A_FILE, logger.messages[0].message) } private fun executeRequest( @@ -203,16 +231,18 @@ class AVBLCommandTest : AbstractFtpserverCommandTest() { permissions: List, fileSystemFactory: FileSystemFactory = fsFactory, ) { - val context = mock(FtpServerContext::class.java) + val context = + mockk { + every { fileSystemManager } returns fileSystemFactory + } val ftpSession = FtpIoSession(session, context) ftpSession.user = BaseUser().also { - it.homeDirectory = Environment.getExternalStorageDirectory().absolutePath + it.homeDirectory = System.getProperty("java.io.tmpdir") it.authorities = permissions } ftpSession.setLogin(fsView) - `when`(context.fileSystemManager).thenReturn(fileSystemFactory) - val command = AVBL() + val command = AVBL(errorMessageProvider) command.execute( session = ftpSession, context = context, diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt similarity index 94% rename from app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt rename to ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt index 6aed30ecb4..b248f680d0 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/AbstractFtpserverCommandTest.kt +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/AbstractFtpserverCommandTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.ftpserver.commands import android.os.Build import android.os.Build.VERSION_CODES.LOLLIPOP diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt similarity index 78% rename from app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt rename to ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt index be466613d4..7bb76741e6 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/FEATCommandTest.kt +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/FEATCommandTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,30 +18,32 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.ftpserver.commands -import com.amaze.filemanager.R -import com.amaze.filemanager.application.AppConfig +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 -import org.mockito.Mockito /** * 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 = Mockito.mock(FtpServerContext::class.java) + val context = mockk(relaxed = true) val ftpSession = FtpIoSession(session, context) - val command = FEAT() + val command = FEAT { FEAT_RESPONSE } command.execute( session = ftpSession, context = context, @@ -49,10 +51,7 @@ class FEATCommandTest : AbstractFtpserverCommandTest() { ) assertEquals(1, logger.messages.size) assertEquals(211, logger.messages[0].code) - assertEquals( - AppConfig.getInstance().getString(R.string.ftp_command_FEAT), - logger.messages[0].message, - ) + assertEquals(FEAT_RESPONSE, 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/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt similarity index 93% rename from app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/LogMessageFilter.kt rename to ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt index 4f97e99047..c2cf52b096 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/LogMessageFilter.kt +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/LogMessageFilter.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.ftpserver.commands import org.apache.ftpserver.ftplet.FtpReply import org.apache.mina.core.filterchain.IoFilter diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt similarity index 87% rename from app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt rename to ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt index 61955ddb1a..462aec13f2 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/ftpserver/commands/PWDCommandTest.kt +++ b/ftpserver/src/test/java/com/amaze/filemanager/ftpserver/commands/PWDCommandTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014-2022 Arpit Khurana , Vishal Nehra , + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , * Emmanuel Messulam, Raymond Lai and Contributors. * * This file is part of Amaze File Manager. @@ -18,9 +18,10 @@ * along with this program. If not, see . */ -package com.amaze.filemanager.filesystem.ftpserver.commands +package com.amaze.filemanager.ftpserver.commands -import android.os.Environment +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 @@ -32,16 +33,18 @@ 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.mockito.Mockito -import org.mockito.Mockito.`when` -import java.io.File +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 @@ -49,8 +52,6 @@ class PWDCommandTest : AbstractFtpserverCommandTest() { 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 /** @@ -60,9 +61,11 @@ class PWDCommandTest : AbstractFtpserverCommandTest() { @JvmStatic @BeforeClass fun bootstrap() { - context = Mockito.mock(FtpServerContext::class.java) val messages = MessageResourceFactory().createMessageResource() - `when`(context.messageResource).thenReturn(messages) + context = + mockk { + every { messageResource } returns messages + } } } @@ -72,10 +75,13 @@ class PWDCommandTest : AbstractFtpserverCommandTest() { @Before override fun setUp() { super.setUp() - File(Environment.getExternalStorageDirectory(), "Music").mkdirs() + val homeDir = tempFolder.newFolder("home") + val musicDir = java.io.File(homeDir, "Music") + musicDir.mkdirs() + user = BaseUser().also { - it.homeDirectory = Environment.getExternalStorageDirectory().absolutePath + it.homeDirectory = homeDir.absolutePath it.authorities = listOf(WritePermission()) } fsView = NativeFileSystemView(user, false) 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 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" 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. + 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..a1aa724a7b --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/FileServer.kt @@ -0,0 +1,65 @@ +/* + * 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 + +/** + * Interface for file server implementations (FTP, SSH, WebDAV, etc.) + * + */ +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"), +} 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..03cb2c5af0 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerEvent.kt @@ -0,0 +1,43 @@ +/* + * 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..c79ff0745b --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerNotification.kt @@ -0,0 +1,69 @@ +/* + * 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..1d388ff970 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerPreferences.kt @@ -0,0 +1,119 @@ +/* + * 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/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 new file mode 100644 index 0000000000..bb22799af8 --- /dev/null +++ b/server-core/src/main/java/com/amaze/filemanager/server/ServerRegistry.kt @@ -0,0 +1,75 @@ +/* + * 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 + +/** + * 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) + } + + /** + * Clear all registered server providers. + * + * Primarily intended for test teardown to ensure a clean state between tests. + */ + fun clearAll() { + servers.clear() + } +} 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'