diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57154336e..0a1149e2d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ import java.io.FileInputStream plugins { alias(libs.plugins.android) alias(libs.plugins.detekt) + alias(libs.plugins.ksp) } val keystorePropertiesFile: File = rootProject.file("keystore.properties") @@ -28,7 +29,11 @@ base { android { compileSdk = project.libs.versions.app.build.compileSDKVersion.get().toInt() - + packaging { + resources { + excludes += listOf("META-INF/**") + } + } defaultConfig { applicationId = project.property("APP_ID").toString() minSdk = project.libs.versions.app.build.minimumSDK.get().toInt() @@ -137,6 +142,13 @@ detekt { } dependencies { + implementation(libs.androidx.documentfile) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.roottools) + implementation(libs.rootshell) + implementation(libs.gestureviews) + implementation(libs.autofittextview) + implementation(libs.zip4j) implementation(libs.fossify.commons) implementation(libs.androidx.documentfile) implementation(libs.androidx.swiperefreshlayout) @@ -145,5 +157,21 @@ dependencies { implementation(libs.gestureviews) implementation(libs.autofittextview) implementation(libs.zip4j) + implementation(libs.jcifs.ng) { + exclude(group = "org.bouncycastle", module = "bcprov-jdk18on") + exclude(group = "org.bouncycastle", module = "bcpkix-jdk18on") + } detektPlugins(libs.compose.detekt) + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.nanohttpd) + implementation(libs.sardine.android) + implementation(libs.sshj) { + exclude(group = "org.bouncycastle", module = "bcprov-jdk18on") + exclude(group = "org.bouncycastle", module = "bcpkix-jdk18on") + } + implementation(libs.commons.net) + implementation(libs.bouncycastle.provider) + implementation(libs.bouncycastle.pkix) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2684edd7f..16015c281 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + + + - + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true"> + + diff --git a/app/src/main/kotlin/org/fossify/filemanager/App.kt b/app/src/main/kotlin/org/fossify/filemanager/App.kt index 499db7a17..48941474b 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/App.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/App.kt @@ -2,10 +2,13 @@ package org.fossify.filemanager import com.github.ajalt.reprint.core.Reprint import org.fossify.commons.FossifyApp +import org.fossify.filemanager.dependencies.AppComposition class App : FossifyApp() { + val appComposition: AppComposition by lazy { + AppComposition(this) + } override val isAppLockFeatureAvailable = true - override fun onCreate() { super.onCreate() Reprint.initialize(this) diff --git a/app/src/main/kotlin/org/fossify/filemanager/activities/CloudActivity.kt b/app/src/main/kotlin/org/fossify/filemanager/activities/CloudActivity.kt new file mode 100644 index 000000000..9928ebc0b --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/activities/CloudActivity.kt @@ -0,0 +1,442 @@ +package org.fossify.filemanager.activities + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.commons.extensions.viewBinding +import org.fossify.commons.helpers.NavigationIcon +import org.fossify.filemanager.App +import org.fossify.filemanager.adapters.ConnectionItemsAdapter +import org.fossify.filemanager.databinding.CloudActivityBinding +import org.fossify.filemanager.dialogs.ConnectionDialog +import org.fossify.filemanager.fileSystems.HttpServer +import org.fossify.filemanager.helpers.CONNECTION_TYPE +import org.fossify.filemanager.helpers.PATH +import org.fossify.filemanager.helpers.PORT_SFTP +import org.fossify.filemanager.helpers.PORT_WEBDAV +import org.fossify.filemanager.models.NetworkConnection +import org.fossify.filemanager.viewmodels.NetworkBrowserViewModel +import java.security.Security +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.fossify.commons.extensions.toast +import org.fossify.filemanager.dependencies.AppComposition +import org.fossify.filemanager.enums.Protocols +import org.fossify.filemanager.helpers.DAVX5_PATH_NAME +import org.fossify.filemanager.helpers.Helpers +import org.fossify.filemanager.helpers.PORT_FTP +import org.fossify.filemanager.interfaces.CertificateRepository +import java.security.Provider + + +class CloudActivity : SimpleActivity() { + private val binding by viewBinding(CloudActivityBinding::inflate) + private lateinit var viewModel: NetworkBrowserViewModel + private var onCertPicked: ((Uri) -> Unit)? = null + private var onPrivateKeyPicked: ((Uri) -> Unit)? = null + + private lateinit var certificateRepository: CertificateRepository + private lateinit var composition: AppComposition + private var https: HttpServer? = null + private var isConnecting: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + binding.apply { + setupMaterialScrollListener(binding.connectionsList, binding.cloudAppbar) + } + setupToolBar() + registerAddConnectionListener() + initializeCompositionAndViewModel() + startConnectionsCollection() + getAllSavedNetworks() + } + + private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + stopServer() + } + + private fun initializeCompositionAndViewModel() { + composition = (application as App).appComposition + certificateRepository = composition.certificateRepository + val factory = composition.provideNetworkBrowserViewModelFactory() + viewModel = ViewModelProvider(this, factory) + .get(NetworkBrowserViewModel::class.java) + } + + private val openDocumentTreeLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { + contentResolver.takePersistableUriPermission( + it, + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + val storage = DocumentFile.fromTreeUri(this, it) + storage?.let { s -> + if (s.name != null && it.path != null) { + viewModel.updateConnection( + NetworkConnection( + displayName = s.name!!, + sharedPath = s.uri.toString(), + connectionType = ConnectionTypes.DAVx5 + ) + ) + } + } + } + } + + private val pickCert = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + onCertPicked?.invoke(it) + } + } + + private val pickPrivateKey = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + onPrivateKeyPicked?.invoke(it) + } + } + + fun promptUserToSelectStorage() { + openDocumentTreeLauncher.launch(null) + } + + + fun openFileLinkForCert(dispatch: (Uri) -> Unit) { + pickCert.launch("*/*") + onCertPicked = dispatch + } + + fun openFileLinkForPrivateKey(dispatch: (Uri) -> Unit) { + pickPrivateKey.launch("*/*") + onPrivateKeyPicked = dispatch + } + + private fun setupToolBar() { + setupTopAppBar(binding.cloudAppbar, NavigationIcon.Arrow) + } + + private fun showConnectionDialog() { + ConnectionDialog(this@CloudActivity) { connection, certUri -> + saveNetwork(connection, certUri, isAddOperation = true) + } + } + + + private fun registerAddConnectionListener() { + binding.addButton.setOnClickListener { + showConnectionDialog() + } + } + + private fun saveNetwork( + connection: NetworkConnection, + certUri: Uri?, + isAddOperation: Boolean = true + ) { + if (connection.connectionType == ConnectionTypes.SMB) { + viewModel.verifySMBNetwork(connection, true, isAddOperation) + } else if (connection.connectionType == ConnectionTypes.WebDav) { + if (connection.protocols == Protocols.HTTPS) { + saveCertificate(certUri, connection.host) + } + val url = Helpers.createProtocolPath(connection.protocols, connection.host, connection.port, connection.sharedPath) + connection.url = url + viewModel.connectAndAuthenticateWebDav(connection, true, this@CloudActivity, isAddOperation) + } else if (connection.connectionType == ConnectionTypes.SFTP) { + viewModel.connectSFTP(connection, true, isAddOperation) + } else if (connection.connectionType == ConnectionTypes.FTP) { + viewModel.connectFTP(connection, true, isAddOperation) + } + } + + private fun getAllSavedNetworks() { + viewModel.getAllSavedNetworks() + collectDbCalls() + } + + private fun collectDbCalls() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.savedNetworks.collectLatest { + if (it.isNotEmpty()) { + binding.emptyView.visibility = View.GONE + updateAdapter(it.toMutableList()) + } + else { + binding.emptyView.visibility = View.VISIBLE + updateAdapter(mutableListOf()) + } + + } + } + launch { + viewModel.addConnection.collectLatest { + it.exception?.let { exp -> toast(exp.message.toString()) } + } + } + launch { + viewModel.deleteConnection.collectLatest { + it.exception?.let { exp -> toast(exp.message.toString()) } + } + } + launch { + viewModel.updateConnection.collectLatest { + it.exception?.let { exp -> toast(exp.message.toString()) } + } + } + } + } + } + + private fun handleConnection(item: NetworkConnection, connectionType: ConnectionTypes) { + when (connectionType) { + ConnectionTypes.SMB -> { + viewModel.verifySMBNetwork(item, false) + } + + ConnectionTypes.DAVx5 -> { + launchMainActivity(ConnectionTypes.DAVx5, item.sharedPath, item.displayName) + } + + ConnectionTypes.WebDav -> { + viewModel.connectAndAuthenticateWebDav( + item, + false, + this@CloudActivity + + ) + } + + ConnectionTypes.SFTP -> { + viewModel.connectSFTP(item, false) + } + + ConnectionTypes.FTP -> { + viewModel.connectFTP(item, false) + } + + else -> Unit + } + } + + private fun updateAdapter(listItems: MutableList) { + if (isConnecting) return + ConnectionItemsAdapter(this, listItems, binding.connectionsList, ::deleteConnection, ::updateConnection) { item -> + if (isConnecting) return@ConnectionItemsAdapter + isConnecting = true + binding.loader.isVisible = true + lifecycleScope.launch { + val itm = item as NetworkConnection + handleConnection(itm, itm.connectionType) + } + }.apply { + binding.connectionsList.adapter = this + } + } + + private fun deleteConnection(connection: NetworkConnection) { + viewModel.deleteConnection(connection) + } + + private fun updateConnection(connection: NetworkConnection) { + ConnectionDialog(this@CloudActivity, connection) { con, uri -> + saveNetwork(con, uri, isAddOperation = false) + } + } + + private fun launchMainActivity(connectionType: ConnectionTypes, path: String, name: String = "") { + val intent = Intent(this@CloudActivity, MainActivity::class.java).apply { + putExtra(PATH, path) + putExtra(CONNECTION_TYPE, connectionType) + if (name != "") { + putExtra(DAVX5_PATH_NAME, name) + } + } + launcher.launch(intent) + } + + private fun startServer( + connection: NetworkConnection, + port: Int = 7871, + connectionType: ConnectionTypes, + machinePort: Int, + protocol: Protocols = Protocols.HTTP + ) { + https = HttpServer(port, connection.host, connectionType, composition, machinePort, protocol) { + it?.message?.let { exp -> + toast(exp, Toast.LENGTH_LONG) + } + } + https?.start() + } + + private fun stopServer() { + https?.stop() + https = null + } + + private fun startConnectionsCollection() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + handleResponse(ConnectionTypes.SMB) + } + launch { + handleResponse(ConnectionTypes.WebDav) + } + + launch { + handleResponse(ConnectionTypes.SFTP) + } + + launch { + handleResponse(ConnectionTypes.FTP) + } + } + } + } + + private suspend fun handleResponse(connectionType: ConnectionTypes) { + when (connectionType) { + ConnectionTypes.WebDav -> { + viewModel.verifyWebDav.collectLatest { + if (it.success) { + if (!it.saveInfo) { + startServer( + it.item, + PORT_WEBDAV, + connectionType = ConnectionTypes.WebDav, + machinePort = it.item.port, + it.item.protocols!! + ) + launchMainActivity(ConnectionTypes.WebDav, it.item.url) + } else { + it.item.connectionType = connectionType + if (it.isAddCallOperation) { + viewModel.addConnection(it.item) + } else { + viewModel.updateConnection( + it.item + ) + } + } + } else { + it.exception?.let { exception -> + toast(exception.message.toString()) + } + } + hideLoader() + } + } + + ConnectionTypes.SMB -> { + viewModel.verifyNetwork.collectLatest { + if (it.success) { + if (!it.saveInfo) { + val path = "smb://${it.item.host.trimEnd('/')}:${it.item.port}/${it.item.sharedPath.trimStart('/')}" + startServer(it.item, connectionType = ConnectionTypes.SMB, machinePort = it.item.port) + launchMainActivity(ConnectionTypes.SMB, path) + } else { + it.item.connectionType = connectionType + if (it.isAddCallOperation) { + viewModel.addConnection(it.item) + } else { + viewModel.updateConnection( + it.item + ) + } + } + } else { + it.exception?.let { exception -> + toast(exception.message.toString()) + } + } + hideLoader() + } + } + + ConnectionTypes.SFTP -> { + viewModel.verifySFTP.collectLatest { + if (it.success) { + if (!it.saveInfo) { + startServer(it.item, PORT_SFTP, connectionType = ConnectionTypes.SFTP, machinePort = it.item.port) + launchMainActivity(ConnectionTypes.SFTP, it.item.url) + } else { + it.item.url = "/" + if (it.isAddCallOperation) { + viewModel.addConnection(it.item) + } else { + viewModel.updateConnection( + it.item + ) + } + } + } else { + it.exception?.let { exception -> + toast(exception.message.toString()) + } + } + hideLoader() + } + } + + ConnectionTypes.FTP -> { + viewModel.verifyFTP.collectLatest { + if (it.success) { + if (!it.saveInfo) { + startServer(it.item, PORT_FTP, connectionType = ConnectionTypes.FTP, machinePort = it.item.port) + launchMainActivity(ConnectionTypes.FTP, it.item.url) + } else { + it.item.url = "/" + if (it.isAddCallOperation) { + viewModel.addConnection(it.item) + } else { + viewModel.updateConnection( + it.item + ) + } + } + } else { + it.exception?.let { exception -> + toast(exception.message.toString()) + } + } + hideLoader() + } + } + + ConnectionTypes.DAVx5 -> Unit + else -> Unit + } + } + + private fun hideLoader() { + binding.loader.isVisible = false + isConnecting = false + } + + + private fun saveCertificate(uri: Uri?, name: String) { + uri?.let { + val cert = certificateRepository.loadCertificate(it, this) + certificateRepository.saveCertificate(name, cert, this) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/filemanager/activities/MainActivity.kt index 4a6e4be77..97f8d70ec 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/activities/MainActivity.kt @@ -7,12 +7,14 @@ import android.graphics.drawable.Drawable import android.media.RingtoneManager import android.os.Bundle import android.os.Handler +import android.util.Log import android.widget.ImageView import android.widget.TextView import androidx.viewpager.widget.ViewPager import com.stericson.RootTools.RootTools import me.grantland.widget.AutofitHelper import org.fossify.commons.dialogs.RadioGroupDialog +import org.fossify.commons.enums.ConnectionTypes import org.fossify.commons.extensions.appLaunched import org.fossify.commons.extensions.appLockManager import org.fossify.commons.extensions.beGoneIf @@ -21,7 +23,6 @@ import org.fossify.commons.extensions.getBottomNavigationBackgroundColor import org.fossify.commons.extensions.getColoredDrawableWithColor import org.fossify.commons.extensions.getFilePublicUri import org.fossify.commons.extensions.getMimeType -import org.fossify.commons.extensions.getProperBackgroundColor import org.fossify.commons.extensions.getProperTextColor import org.fossify.commons.extensions.getRealPathFromURI import org.fossify.commons.extensions.getStorageDirectories @@ -69,24 +70,25 @@ import org.fossify.filemanager.fragments.ItemsFragment import org.fossify.filemanager.fragments.MyViewPagerFragment import org.fossify.filemanager.fragments.RecentsFragment import org.fossify.filemanager.fragments.StorageFragment +import org.fossify.filemanager.helpers.CONNECTION_TYPE +import org.fossify.filemanager.helpers.DAVX5_PATH_NAME import org.fossify.filemanager.helpers.MAX_COLUMN_COUNT +import org.fossify.filemanager.helpers.NETWORK_PATH +import org.fossify.filemanager.helpers.PATH import org.fossify.filemanager.helpers.RootHelpers import org.fossify.filemanager.interfaces.ItemOperationsListener import java.io.File class MainActivity : SimpleActivity() { override var isSearchBarEnabled = true - + companion object { private const val BACK_PRESS_TIMEOUT = 5000 private const val PICKED_PATH = "picked_path" } - private val binding by viewBinding(ActivityMainBinding::inflate) - private var wasBackJustPressed = false private var mTabsToShow = ArrayList() - private var mStoredFontSize = 0 private var mStoredDateFormat = "" private var mStoredTimeFormat = "" @@ -115,13 +117,24 @@ class MainActivity : SimpleActivity() { if (savedInstanceState == null) { config.temporarilyShowHidden = false initFragments() - tryInitFileManager() + val data = getIntentDataIfAny() + tryInitFileManager(data.first, connectionType = data.second) checkWhatsNewDialog() checkIfRootAvailable() checkInvalidFavorites() } } + private fun getIntentDataIfAny(): Pair { + val path = intent.getStringExtra(PATH) + val connectionType = intent.getSerializableExtra(CONNECTION_TYPE) as? ConnectionTypes ?: ConnectionTypes.Default + return Pair(path ?: "",connectionType) + } + + private fun getDavX5PathName(): String{ + return intent.getStringExtra(DAVX5_PATH_NAME) ?: "" + } + override fun onResume() { super.onResume() if (mStoredShowTabs != config.showTabs) { @@ -183,7 +196,22 @@ class MainActivity : SimpleActivity() { } } else { currentFragment.getBreadcrumbs().removeBreadcrumb() - openPath(currentFragment.getBreadcrumbs().getLastItem().path) + var path = "" + val lastItem = currentFragment.getBreadcrumbs().getLastItem() + if (lastItem.connectionType == ConnectionTypes.WebDav || lastItem.connectionType == ConnectionTypes.SMB){ + val fileItems = currentFragment.getBreadcrumbs().getAllItems() + fileItems.forEach { + path += "${it.path}/" + } + + } + else{ + path = lastItem.path + } + openPath( + path, + connectionType = lastItem.connectionType + ) return true } } @@ -253,6 +281,7 @@ class MainActivity : SimpleActivity() { R.id.more_apps_from_us -> launchMoreAppsFromUsIntent() R.id.settings -> launchSettings() R.id.about -> launchAbout() + R.id.cloud -> launchCloudActivity() else -> return@setOnMenuItemClickListener false } return@setOnMenuItemClickListener true @@ -260,6 +289,10 @@ class MainActivity : SimpleActivity() { } } + private fun launchCloudActivity() { + startActivity(Intent(applicationContext, CloudActivity::class.java)) + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(PICKED_PATH, getItemsFragment()?.currentPath ?: "") @@ -291,7 +324,7 @@ class MainActivity : SimpleActivity() { } } - private fun tryInitFileManager() { + private fun tryInitFileManager(path: String, connectionType: ConnectionTypes = ConnectionTypes.Default) { val hadPermission = hasStoragePermission() handleStoragePermission { checkOTGPath() @@ -301,7 +334,7 @@ class MainActivity : SimpleActivity() { } binding.mainViewPager.onGlobalLayout { - initFileManager(!hadPermission) + initFileManager(!hadPermission,path,connectionType) } } else { toast(R.string.no_storage_permissions) @@ -310,17 +343,18 @@ class MainActivity : SimpleActivity() { } } - private fun initFileManager(refreshRecents: Boolean) { + private fun initFileManager(refreshRecents: Boolean, path: String, connectionType: ConnectionTypes) { + val pathName = getDavX5PathName() if (intent.action == Intent.ACTION_VIEW && intent.data != null) { val data = intent.data if (data?.scheme == "file") { - openPath(data.path!!) + openPath(data.path!!, connectionType = connectionType, pathName = pathName) } else { val path = getRealPathFromURI(data!!) if (path != null) { - openPath(path) + openPath(path, connectionType = connectionType, pathName = pathName) } else { - openPath(config.homeFolder) + openPath(config.homeFolder, connectionType = connectionType, pathName = pathName) } } @@ -330,7 +364,7 @@ class MainActivity : SimpleActivity() { binding.mainViewPager.currentItem = 0 } else { - openPath(config.homeFolder) + openPath(if(path.isNotEmpty()) path else config.homeFolder, connectionType = connectionType, pathName = pathName) } if (refreshRecents) { @@ -367,8 +401,8 @@ class MainActivity : SimpleActivity() { binding.mainTabsHolder.removeAllTabs() val action = intent.action val isPickFileIntent = action == RingtoneManager.ACTION_RINGTONE_PICKER - || action == Intent.ACTION_GET_CONTENT - || action == Intent.ACTION_PICK + || action == Intent.ACTION_GET_CONTENT + || action == Intent.ACTION_PICK val isCreateDocumentIntent = action == Intent.ACTION_CREATE_DOCUMENT if (isPickFileIntent) { @@ -456,18 +490,18 @@ class MainActivity : SimpleActivity() { } } - private fun openPath(path: String, forceRefresh: Boolean = false) { + private fun openPath(path: String, forceRefresh: Boolean = false, pathName: String = "",connectionType: ConnectionTypes = ConnectionTypes.Default) { var newPath = path val file = File(path) if (config.OTGPath.isNotEmpty() && config.OTGPath == path.trimEnd('/')) { newPath = path - } else if (file.exists() && !file.isDirectory) { + } else if (file.exists() && !file.isDirectory && connectionType == ConnectionTypes.Default) { newPath = file.parent - } else if (!file.exists() && !isPathOnOTG(newPath)) { + } else if (!file.exists() && !isPathOnOTG(newPath) && connectionType.equals(ConnectionTypes.Default)) { newPath = internalStoragePath } - getItemsFragment()?.openPath(newPath, forceRefresh) + getItemsFragment()?.openPath(newPath, forceRefresh,connectionType=connectionType, pathName = pathName) } private fun goHome() { diff --git a/app/src/main/kotlin/org/fossify/filemanager/activities/MimeTypesActivity.kt b/app/src/main/kotlin/org/fossify/filemanager/activities/MimeTypesActivity.kt index a8ff25e13..66c9026f6 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/activities/MimeTypesActivity.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/activities/MimeTypesActivity.kt @@ -225,6 +225,17 @@ class MimeTypesActivity : SimpleActivity(), ItemOperationsListener { } override fun finishActMode() {} + override fun shareFile(paths: ArrayList) { + TODO("Not yet implemented") + } + + override fun openWith(path: String,mimType: String?) { + TODO("Not yet implemented") + } + + override fun setAs(path: String) { + TODO("Not yet implemented") + } private fun setupSearch(menu: Menu) { val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager diff --git a/app/src/main/kotlin/org/fossify/filemanager/adapters/ConnectionItemsAdapter.kt b/app/src/main/kotlin/org/fossify/filemanager/adapters/ConnectionItemsAdapter.kt new file mode 100644 index 000000000..db11ea1d2 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/adapters/ConnectionItemsAdapter.kt @@ -0,0 +1,96 @@ +package org.fossify.filemanager.adapters + +import android.util.TypedValue +import android.view.Menu +import android.view.View +import android.view.ViewGroup +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestOptions +import org.fossify.commons.adapters.MyRecyclerViewAdapter +import org.fossify.commons.dialogs.ConfirmationDialog +import org.fossify.commons.views.MyRecyclerView +import org.fossify.filemanager.R +import org.fossify.filemanager.activities.SimpleActivity +import org.fossify.filemanager.databinding.ItemDecompressionListFileDirBinding +import org.fossify.filemanager.databinding.ItemNetworkConnectionBinding +import org.fossify.filemanager.models.ListItem +import org.fossify.filemanager.models.NetworkConnection +import java.util.Locale + +class ConnectionItemsAdapter( + activity: SimpleActivity, + var listItems: MutableList, + recyclerView: MyRecyclerView, + val deleteClick: (NetworkConnection) -> Unit, + val updateConnection: (NetworkConnection) -> Unit, + itemClick: (Any) -> Unit +) : + MyRecyclerViewAdapter(activity, recyclerView, itemClick) { + override fun getActionMenuId(): Int { + TODO("Not yet implemented") + } + + override fun prepareActionMode(menu: Menu) { + TODO("Not yet implemented") + } + + override fun actionItemPressed(id: Int) { + TODO("Not yet implemented") + } + + override fun getSelectableItemCount(): Int { + TODO("Not yet implemented") + } + + override fun getIsItemSelectable(position: Int): Boolean { + TODO("Not yet implemented") + } + + override fun getItemSelectionKey(position: Int): Int? { + TODO("Not yet implemented") + } + + override fun getItemKeyPosition(key: Int): Int { + TODO("Not yet implemented") + } + + override fun onActionModeCreated() { + TODO("Not yet implemented") + } + + override fun onActionModeDestroyed() { + TODO("Not yet implemented") + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return createViewHolder(ItemNetworkConnectionBinding.inflate(layoutInflater, parent, false).root) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val fileDirItem = listItems[position] + holder.bindView(fileDirItem, true, false) { itemView, layoutPosition -> + setupView(itemView, fileDirItem) + } + bindViewHolder(holder) + } + + override fun getItemCount() = listItems.size + + private fun setupView(view: View, listItem: NetworkConnection) { + ItemNetworkConnectionBinding.bind(view).apply { + tvHost.text = listItem.host + tvType.text = listItem.connectionType.toString() + tvDisplayName.text = listItem.displayName + tvSharedPath.text = listItem.sharedPath + btnDelete.setOnClickListener { + val question = String.format(resources.getString(R.string.deletion_confirmation_connection),listItem.displayName) + ConfirmationDialog(activity, question) { + deleteClick(listItem) + } + } + btnEdit.setOnClickListener { updateConnection(listItem) } + } + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/adapters/ItemsAdapter.kt b/app/src/main/kotlin/org/fossify/filemanager/adapters/ItemsAdapter.kt index 90b471f4b..9faa58628 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/adapters/ItemsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/adapters/ItemsAdapter.kt @@ -42,6 +42,7 @@ import org.fossify.commons.dialogs.RadioGroupDialog import org.fossify.commons.dialogs.RenameDialog import org.fossify.commons.dialogs.RenameItemDialog import org.fossify.commons.dialogs.RenameItemsDialog +import org.fossify.commons.enums.ConnectionTypes import org.fossify.commons.extensions.applyColorFilter import org.fossify.commons.extensions.beGone import org.fossify.commons.extensions.beVisible @@ -105,6 +106,7 @@ import org.fossify.filemanager.extensions.setLastModified import org.fossify.filemanager.extensions.sharePaths import org.fossify.filemanager.extensions.toggleItemVisibility import org.fossify.filemanager.extensions.tryOpenPathIntent +import org.fossify.filemanager.fileSystems.MimeTypes import org.fossify.filemanager.helpers.OPEN_AS_AUDIO import org.fossify.filemanager.helpers.OPEN_AS_IMAGE import org.fossify.filemanager.helpers.OPEN_AS_OTHER @@ -353,7 +355,13 @@ class ItemsAdapter( selectedItems.forEach { addFileUris(it.path, paths) } - activity.sharePaths(paths) + + if (selectedItems.first().connectionType != ConnectionTypes.Default){ + listener?.shareFile(paths) + } + else{ + activity.sharePaths(paths) + } } private fun toggleFileVisibility(hide: Boolean) { @@ -478,11 +486,23 @@ class ItemsAdapter( } private fun setAs() { - activity.setAs(getFirstSelectedItemPath()) + val item = getSelectedFileDirItems().first() + if (item.connectionType != ConnectionTypes.Default){ + listener?.setAs(item.path) + } + else { + activity.setAs(item.path) + } } private fun openWith() { - activity.tryOpenPathIntent(getFirstSelectedItemPath(), true) + val item = getSelectedFileDirItems().first() + if (item.connectionType != ConnectionTypes.Default){ + listener?.openWith(item.path) + } + else { + activity.tryOpenPathIntent(item.path, true) + } } private fun openAs() { @@ -495,8 +515,15 @@ class ItemsAdapter( RadioItem(OPEN_AS_OTHER, res.getString(R.string.other_file)) ) + val item = getSelectedFileDirItems().first() + RadioGroupDialog(activity, items) { - activity.tryOpenPathIntent(getFirstSelectedItemPath(), false, it as Int) + if (item.connectionType != ConnectionTypes.Default){ + listener?.openWith(item.path, MimeTypes.getMimeType(it as Int)) + } + else { + activity.tryOpenPathIntent(item.path, false,it as Int) + } } } @@ -915,7 +942,6 @@ class ItemsAdapter( } else { resources.getQuantityString(R.plurals.delete_items, itemsCnt, itemsCnt) } - val question = String.format(resources.getString(R.string.deletion_confirmation), items) ConfirmationDialog(activity, question) { deleteFiles() @@ -929,7 +955,8 @@ class ItemsAdapter( } val SAFPath = getFirstSelectedItemPath() - if (activity.isPathOnRoot(SAFPath) && !RootTools.isRootAvailable()) { + val firstSelectedItem = getSelectedFileDirItems().first() + if (activity.isPathOnRoot(SAFPath) && !RootTools.isRootAvailable() && firstSelectedItem.connectionType == ConnectionTypes.Default) { activity.toast(R.string.rooted_device_only) return } @@ -1079,7 +1106,12 @@ class ItemsAdapter( if (listItem.isDirectory) { itemIcon?.setImageDrawable(folderDrawable) - itemDetails?.text = getChildrenCnt(listItem) + if(listItem.connectionType == ConnectionTypes.Default || listItem.connectionType == ConnectionTypes.SMB){ + itemDetails?.text = getChildrenCnt(listItem) + } + else{ + itemDetails?.text = noItemsText() + } itemDate?.beGone() } else { itemDetails?.text = listItem.size.formatSize() @@ -1114,6 +1146,10 @@ class ItemsAdapter( return activity.resources.getQuantityString(R.plurals.items, children, children) } + private fun noItemsText(): String { + return activity.resources.getString(R.string.unknown_items) + } + private fun getOTGPublicPath(itemToLoad: String): String { return "${baseConfig.OTGTreeUri}/document/${baseConfig.OTGPartition}%3A${ itemToLoad.substring(baseConfig.OTGPath.length).replace("/", "%2F") diff --git a/app/src/main/kotlin/org/fossify/filemanager/dao/NetworkConnectionDao.kt b/app/src/main/kotlin/org/fossify/filemanager/dao/NetworkConnectionDao.kt new file mode 100644 index 000000000..4953c1596 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/dao/NetworkConnectionDao.kt @@ -0,0 +1,25 @@ +package org.fossify.filemanager.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import org.fossify.filemanager.entity.NetworkConnectionEntity + +@Dao +interface NetworkConnectionDao { + @Query("SELECT * FROM network_connections") + fun getAll(): Flow> + + @Update + suspend fun updateConnection(connection: NetworkConnectionEntity): Int + + @Delete + suspend fun delete(connection: NetworkConnectionEntity) + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun addConnection(connection: NetworkConnectionEntity): Long +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/database/Database.kt b/app/src/main/kotlin/org/fossify/filemanager/database/Database.kt new file mode 100644 index 000000000..f9126a848 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/database/Database.kt @@ -0,0 +1,11 @@ +package org.fossify.filemanager.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.fossify.filemanager.dao.NetworkConnectionDao +import org.fossify.filemanager.entity.NetworkConnectionEntity + +@Database(entities = [NetworkConnectionEntity::class], version = 1) +abstract class Database : RoomDatabase() { + abstract fun networkConnectionDao(): NetworkConnectionDao +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/dependencies/AppComposition.kt b/app/src/main/kotlin/org/fossify/filemanager/dependencies/AppComposition.kt new file mode 100644 index 000000000..b2400563d --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/dependencies/AppComposition.kt @@ -0,0 +1,54 @@ +package org.fossify.filemanager.dependencies + +import android.content.Context +import androidx.room.Room +import org.fossify.filemanager.database.Database +import org.fossify.filemanager.factory.NetworkBrowserViewModelFactory +import org.fossify.filemanager.helpers.DB_NAME +import org.fossify.filemanager.repository.CertificateRepositoryImpl +import org.fossify.filemanager.repository.FTPApiImpl +import org.fossify.filemanager.repository.NetworkConnectionRepositoryDbImpl +import org.fossify.filemanager.repository.SFTPApiImpl +import org.fossify.filemanager.repository.SMBApiImpl +import org.fossify.filemanager.repository.WebDavApiImpl + +class AppComposition (private val context: Context) { + + private fun createDataBase(context: Context): Database { + return Room.databaseBuilder(context.applicationContext, Database::class.java,DB_NAME).build() + } + + private val database by lazy { + createDataBase(context) + } + + val networkDbRepository by lazy { + NetworkConnectionRepositoryDbImpl(database.networkConnectionDao()) + } + + + val webDavApiRepository by lazy { + WebDavApiImpl() + } + + val smbApiRepository by lazy { + SMBApiImpl() + } + + val sftpApiRepository by lazy { + SFTPApiImpl() + } + + val ftpApiRepository by lazy { + FTPApiImpl() + } + + + val certificateRepository by lazy { + CertificateRepositoryImpl() + } + + fun provideNetworkBrowserViewModelFactory(): NetworkBrowserViewModelFactory{ + return NetworkBrowserViewModelFactory(networkDbRepository,webDavApiRepository,sftpApiRepository,ftpApiRepository,smbApiRepository) + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/dialogs/ConnectionDialog.kt b/app/src/main/kotlin/org/fossify/filemanager/dialogs/ConnectionDialog.kt new file mode 100644 index 000000000..b7c1025a9 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/dialogs/ConnectionDialog.kt @@ -0,0 +1,390 @@ +package org.fossify.filemanager.dialogs + +import android.net.Uri +import android.view.View +import android.widget.ArrayAdapter +import androidx.core.widget.doAfterTextChanged +import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.isVisible +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.value +import org.fossify.filemanager.R +import org.fossify.filemanager.activities.CloudActivity +import org.fossify.filemanager.databinding.DialogAddConnectionBinding +import org.fossify.filemanager.enums.Authentication +import org.fossify.filemanager.enums.Protocols +import org.fossify.filemanager.helpers.DEFAULT_FTP_PORT +import org.fossify.filemanager.helpers.DEFAULT_SFTP_PORT +import org.fossify.filemanager.helpers.DEFAULT_SMB_PORT +import org.fossify.filemanager.helpers.DEFAULT_WEBDAV_HTTPS_PORT +import org.fossify.filemanager.helpers.DEFAULT_WEBDAV_HTTP_PORT +import org.fossify.filemanager.models.NetworkConnection + +class ConnectionDialog( + val activity: BaseSimpleActivity, + val connection: NetworkConnection? = null, + dispatch: (NetworkConnection, Uri?) -> Unit +) { + private var binding: DialogAddConnectionBinding + val items = listOf(ConnectionTypes.DAVx5.type, ConnectionTypes.SMB.type, ConnectionTypes.WebDav.type, ConnectionTypes.SFTP.type, ConnectionTypes.FTP.type) + private var certUri: Uri? = null + private var privateKeyUri: Uri? = null + + val protocols = listOf(Protocols.HTTP, Protocols.HTTPS) + + val authentications = listOf(Authentication.Password, Authentication.Anonymous) + val sftpAuthentications = listOf(Authentication.Password, Authentication.PrivateKey) + + + init { + binding = DialogAddConnectionBinding.inflate(activity.layoutInflater) + val dialog = activity.getAlertDialogBuilder() + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.cancel, null) + .apply { + activity.setupDialogStuff(binding.root, this) { alertDialog -> + alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener(View.OnClickListener { + val isValid = validateFields() + if (isValid) { + val connection = createConnection() + dispatch(connection, certUri) + alertDialog.dismiss() + } + }) + } + } + .create() + dropDownItemSelected() + initializeDropDownList() + registerAuthClickListener() + attachCertBtnClickListener() + attachPrivateKeyBtnClickListener() + dropDownMenuProtocolItemClickListener() + populateDialogValues() + textFieldsListener() + } + + + private fun initializeDropDownList() { + initializeConnectionsDropDown() + initializeAuthDropdown() + initializeProtocolDropDown() + } + + private fun createConnection():NetworkConnection { + val networkConnection = NetworkConnection( + host = binding.hostEt.value, + port = binding.portEt.value.toIntOrNull() ?: 445, + username = binding.userEt.value, + password = binding.passwordEt.value, + displayName = binding.displayEt.value, + connectionType = ConnectionTypes.fromType(binding.dropdownMenu.value), + sharedPath = binding.sharedPathEt.value, + url = "", + privateKeyText = binding.privateKeyEt.value.trimIndent(), + privateKeyPass = binding.privateKeyPassEt.value, + authentication = Authentication.valueOf( + binding.authDropDownMenu.text.toString() + ), + protocols = binding.dropdownMenuProtocol.text + ?.toString() + ?.takeIf { it.isNotBlank() } + ?.let { Protocols.valueOf(it) }, + ) + + connection?.let { + networkConnection.id = it.id + } + return networkConnection + } + + private fun initializeConnectionsDropDown() { + val adapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, items) + binding.dropdownMenu.setAdapter(adapter) + binding.dropdownMenu.setText(items[1], false) + } + + private fun initializeProtocolDropDown() { + val protocolsAdapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, protocols) + binding.dropdownMenuProtocol.setAdapter(protocolsAdapter) + binding.dropdownMenuProtocol.setText(protocols[0].toString(), false) + } + + private fun initializeAuthDropdown() { + val authAdapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, authentications) + binding.authDropDownMenu.setAdapter(authAdapter) + binding.authDropDownMenu.setText(authentications[0].toString(), false) + } + + private fun onAuthSelected(selectedItem: String) { + if (binding.dropdownMenu.value == ConnectionTypes.SMB.type) { + if (Authentication.valueOf(selectedItem) == Authentication.Anonymous) { + toggleCredentialsVisibility(View.GONE) + toggleSFTPAuthVisibility(View.GONE) + } else { + toggleCredentialsVisibility(View.VISIBLE) + toggleSFTPAuthVisibility(View.GONE) + } + } else if (binding.dropdownMenu.value == ConnectionTypes.SFTP.type) { + if (Authentication.valueOf(selectedItem) == Authentication.PrivateKey) { + binding.userTf.visibility = View.VISIBLE + binding.passwordTf.visibility = View.GONE + toggleSFTPAuthVisibility(View.VISIBLE) + } else { + toggleCredentialsVisibility(View.VISIBLE) + toggleSFTPAuthVisibility(View.GONE) + } + } else if (binding.dropdownMenu.value == ConnectionTypes.FTP.type) { + if (Authentication.valueOf(selectedItem) == Authentication.Password) { + toggleCredentialsVisibility(View.VISIBLE) + toggleSFTPAuthVisibility(View.GONE) + } else { + toggleCredentialsVisibility(View.GONE) + toggleSFTPAuthVisibility(View.GONE) + } + } + } + + private fun registerAuthClickListener() { + binding.authDropDownMenu.setOnItemClickListener { parent, view, position, id -> + val selectedItem = parent.getItemAtPosition(position).toString() + onAuthSelected(selectedItem) + } + } + + private fun toggleSFTPAuthVisibility(visibility: Int) { + binding.privateKeyTf.visibility = visibility + binding.privateKeyPassTf.visibility = visibility + } + + private fun toggleSharedPathVisibility(visibility: Int) { + binding.sharedPathTf.visibility = visibility + } + + private fun toggleCredentialsVisibility(visibility: Int) { + binding.userTf.visibility = visibility + binding.passwordTf.visibility = visibility + } + + private fun promptUserToSelectStorage() { + (activity as CloudActivity).promptUserToSelectStorage() + } + + + private fun dropDownItemSelected() { + binding.dropdownMenu.setOnItemClickListener { parent, view, position, id -> + val selectedItem = parent.getItemAtPosition(position).toString() + handleConnectionTypeSelection(selectedItem) + } + } + + private fun handleConnectionTypeSelection(selectedItem: String) { + togglePortValue( + ConnectionTypes.valueOf(selectedItem), + if (binding.dropdownMenuProtocol.value != "") Protocols.valueOf(binding.dropdownMenuProtocol.value) else Protocols.HTTP + ) + if (selectedItem == ConnectionTypes.DAVx5.type) { + binding.allFieldsExceptConnection.visibility = View.GONE + promptUserToSelectStorage() + } else if (selectedItem == ConnectionTypes.WebDav.type) { + binding.allFieldsExceptConnection.visibility = View.VISIBLE + binding.dropdownProtocol.visibility = View.VISIBLE + binding.authDropDownLayout.visibility = View.GONE + toggleSFTPAuthVisibility(View.GONE) + toggleCredentialsVisibility(View.VISIBLE) + toggleSharedPathVisibility(View.VISIBLE) + } else if (selectedItem == ConnectionTypes.SMB.type) { + binding.dropdownProtocol.visibility = View.GONE + binding.certRow.visibility = View.GONE + binding.allFieldsExceptConnection.visibility = View.VISIBLE + binding.authDropDownLayout.visibility = View.VISIBLE + toggleSFTPAuthVisibility(View.GONE) + binding.authDropDownMenu.setAdapter(ArrayAdapter(activity, android.R.layout.simple_list_item_1, authentications)) + binding.authDropDownMenu.setText(authentications[0].toString(), false) + toggleSharedPathVisibility(View.VISIBLE) + onAuthSelected(authentications[0].toString()) + } else if (selectedItem == ConnectionTypes.SFTP.type) { + binding.allFieldsExceptConnection.visibility = View.VISIBLE + binding.authDropDownLayout.visibility = View.VISIBLE + binding.dropdownProtocol.visibility = View.GONE + binding.certRow.visibility = View.GONE + if (Authentication.valueOf(binding.authDropDownMenu.value) == Authentication.Password) { + toggleSFTPAuthVisibility(View.GONE) + } else { + toggleSFTPAuthVisibility(View.VISIBLE) + } + binding.authDropDownMenu.setAdapter(ArrayAdapter(activity, android.R.layout.simple_list_item_1, sftpAuthentications)) + binding.authDropDownMenu.setText(sftpAuthentications[0].toString(), false) + onAuthSelected(sftpAuthentications[0].toString()) + toggleSharedPathVisibility(View.GONE) + + } else if (selectedItem == ConnectionTypes.FTP.type) { + binding.allFieldsExceptConnection.visibility = View.VISIBLE + binding.authDropDownLayout.visibility = View.VISIBLE + binding.dropdownProtocol.visibility = View.GONE + binding.certRow.visibility = View.GONE + toggleSFTPAuthVisibility(View.GONE) + if (Authentication.valueOf(binding.authDropDownMenu.value) == Authentication.Password) { + toggleCredentialsVisibility(View.VISIBLE) + } else { + toggleCredentialsVisibility(View.GONE) + } + binding.authDropDownMenu.setAdapter(ArrayAdapter(activity, android.R.layout.simple_list_item_1, authentications)) + binding.authDropDownMenu.setText(authentications[0].toString(), false) + onAuthSelected(authentications[0].toString()) + toggleSharedPathVisibility(View.GONE) + } + } + + private fun dropDownMenuProtocolItemClickListener() { + binding.dropdownMenuProtocol.setOnItemClickListener { parent, view, position, id -> + val selectedItem = parent.getItemAtPosition(position).toString() + val item = Protocols.valueOf(selectedItem) + if (item == Protocols.HTTP) { + binding.certRow.visibility = View.GONE + } else { + binding.certRow.visibility = View.VISIBLE + } + togglePortValue(ConnectionTypes.WebDav, item) + } + } + + private fun togglePortValue(connectionTypes: ConnectionTypes, protocols: Protocols) { + when (connectionTypes) { + ConnectionTypes.SMB -> binding.portEt.setText(DEFAULT_SMB_PORT.toString()) + ConnectionTypes.FTP -> binding.portEt.setText(DEFAULT_FTP_PORT.toString()) + ConnectionTypes.SFTP -> binding.portEt.setText(DEFAULT_SFTP_PORT.toString()) + ConnectionTypes.WebDav -> { + if (protocols == Protocols.HTTP) { + binding.portEt.setText(DEFAULT_WEBDAV_HTTP_PORT.toString()) + } else { + binding.portEt.setText(DEFAULT_WEBDAV_HTTPS_PORT.toString()) + } + } + + else -> Unit + } + } + + private fun attachCertBtnClickListener() { + binding.certAttachBtn.setOnClickListener { + (activity as CloudActivity).openFileLinkForCert { + certUri = it + binding.certStatusTv.text = it.path + } + + } + } + + private fun attachPrivateKeyBtnClickListener() { + binding.privateKeyTf.setEndIconOnClickListener { + (activity as CloudActivity).openFileLinkForPrivateKey { + privateKeyUri = it + privateKeyUri?.let { path -> + val inputStream = activity.contentResolver.openInputStream(path) + val keyText = inputStream?.bufferedReader().use { it?.readText() } ?: "" + binding.privateKeyEt.setText(keyText) + } + + } + } + } + + + private fun validateFields(): Boolean { + binding.apply { + if (hostTf.isVisible() && hostEt.value.isEmpty()) { + hostTf.error = activity.getString(R.string.host_name_error) + return false + } + if (userTf.isVisible() && userEt.value.isNullOrEmpty()) { + userTf.error = activity.getString(R.string.user_name_error) + return false + } + if (passwordTf.isVisible() && passwordEt.value.isNullOrEmpty()) { + passwordTf.error = activity.getString(R.string.password_error) + return false + } + if (sharedPathTf.isVisible() && sharedPathEt.value.isNullOrEmpty()) { + sharedPathTf.error = activity.getString(R.string.shared_path_error) + return false + } + if (displayTf.isVisible() && displayEt.value.isNullOrEmpty()) { + displayTf.error = activity.getString(R.string.display_name_error) + return false + } + if (portTf.isVisible() && portEt.value.isNullOrEmpty()) { + portTf.error = activity.getString(R.string.port_error) + return false + } + if (privateKeyTf.isVisible() && privateKeyEt.value.isNullOrEmpty()) { + privateKeyTf.error = activity.getString(R.string.private_key_error) + return false + } + if (privateKeyPassTf.isVisible() && privateKeyPassEt.value.isNullOrEmpty()) { + privateKeyPassTf.error = activity.getString(R.string.private_key_pass_error) + return false + } + } + return true + } + + private fun textFieldsListener() { + binding.apply { + hostEt.doAfterTextChanged { editable -> + hostTf.error = null + } + + userEt.doAfterTextChanged { editable -> + userTf.error = null + } + + passwordEt.doAfterTextChanged { editable -> + passwordTf.error = null + } + + sharedPathEt.doAfterTextChanged { editable -> + sharedPathTf.error = null + } + + displayEt.doAfterTextChanged { editable -> + displayTf.error = null + } + + portEt.doAfterTextChanged { editable -> + portTf.error = null + } + + privateKeyEt.doAfterTextChanged { editable -> + privateKeyTf.error = null + } + + privateKeyPassEt.doAfterTextChanged { editable -> + privateKeyPassTf.error = null + } + } + } + + private fun populateDialogValues() { + connection?.let { + binding.apply { + hostEt.setText(it.host) + userEt.setText(it.username) + passwordEt.setText(it.password) + sharedPathEt.setText(it.sharedPath) + displayEt.setText(it.displayName) + dropdownMenu.setText(it.connectionType.type, false) + privateKeyEt.setText(it.privateKeyText) + privateKeyPassEt.setText(it.privateKeyPass) + dropdownMenuProtocol.setText(it.protocols.toString(), false) + handleConnectionTypeSelection(it.connectionType.type) + authDropDownMenu.setText(it.authentication.toString(), false) + onAuthSelected(it.authentication.toString()) + portEt.setText(it.port.toString()) + } + } + } + +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/dialogs/CreateNewItemDialog.kt b/app/src/main/kotlin/org/fossify/filemanager/dialogs/CreateNewItemDialog.kt index e2aa6f525..67cb6081a 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/dialogs/CreateNewItemDialog.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/dialogs/CreateNewItemDialog.kt @@ -2,16 +2,28 @@ package org.fossify.filemanager.dialogs import android.view.View import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.fossify.commons.enums.ConnectionTypes import org.fossify.commons.extensions.* import org.fossify.commons.helpers.isRPlus import org.fossify.filemanager.R import org.fossify.filemanager.activities.SimpleActivity import org.fossify.filemanager.databinding.DialogCreateNewBinding import org.fossify.filemanager.helpers.RootHelpers +import org.fossify.filemanager.viewmodels.NetworkBrowserViewModel import java.io.File import java.io.IOException -class CreateNewItemDialog(val activity: SimpleActivity, val path: String, val callback: (success: Boolean) -> Unit) { +class CreateNewItemDialog( + val activity: SimpleActivity, + val path: String, + val connectionTypes: ConnectionTypes = ConnectionTypes.Default, + val viewModel: NetworkBrowserViewModel, + val callback: (success: Boolean) -> Unit +) { private val binding = DialogCreateNewBinding.inflate(activity.layoutInflater) init { @@ -32,6 +44,11 @@ class CreateNewItemDialog(val activity: SimpleActivity, val path: String, val ca return@OnClickListener } + if (connectionTypes != ConnectionTypes.Default) { + collectLatest(alertDialog) + createFileOrFolder(name, binding.dialogRadioGroup.checkedRadioButtonId == R.id.dialog_radio_directory) + return@OnClickListener + } if (binding.dialogRadioGroup.checkedRadioButtonId == R.id.dialog_radio_directory) { createDirectory(newPath, alertDialog) { callback(it) @@ -160,6 +177,89 @@ class CreateNewItemDialog(val activity: SimpleActivity, val path: String, val ca } } + + private fun createFileOrFolder(name: String, isFolder: Boolean) { + when (connectionTypes) { + ConnectionTypes.SMB -> { + viewModel.createFolderOrFileSMB(path, isFolder, name) + } + + ConnectionTypes.WebDav -> { + viewModel.createItem(path, isFolder, name) + } + + ConnectionTypes.SFTP -> { + viewModel.createItemSFTP(path, isFolder, name) + } + + ConnectionTypes.FTP -> { + viewModel.createItemFTP(path, isFolder, name) + } + + else -> Unit + } + } + + private fun collectLatest(alertDialog: AlertDialog) { + CoroutineScope(Dispatchers.IO).launch { + when (connectionTypes) { + ConnectionTypes.SMB -> { + viewModel.smbFolderOrFile.collectLatest { + if (it.response as Boolean) { + success(alertDialog) + } else { + it.exception?.message?.let { msg -> + activity.toast(msg) + success(alertDialog) + } + } + } + } + + ConnectionTypes.WebDav -> { + viewModel.webDavFolderOrFile.collectLatest { + if (it.response as Boolean) { + success(alertDialog) + } else { + it.exception?.message?.let { msg -> + activity.toast(msg) + success(alertDialog) + } + } + } + } + + ConnectionTypes.SFTP -> { + viewModel.sftpFolderOrFile.collectLatest { + if (it.response as Boolean) { + success(alertDialog) + } else { + it.exception?.message?.let { msg -> + activity.toast(msg) + success(alertDialog) + } + } + } + } + + ConnectionTypes.FTP -> { + viewModel.ftpFolderOrFile.collectLatest { + if (it.response as Boolean) { + success(alertDialog) + } else { + it.exception?.message?.let { msg -> + activity.toast(msg) + success(alertDialog) + } + } + } + } + + else -> Unit + } + } + } + private fun success(alertDialog: AlertDialog) { alertDialog.dismiss() callback(true) diff --git a/app/src/main/kotlin/org/fossify/filemanager/entity/NetworkConnectionEntity.kt b/app/src/main/kotlin/org/fossify/filemanager/entity/NetworkConnectionEntity.kt new file mode 100644 index 000000000..df0d4cc3a --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/entity/NetworkConnectionEntity.kt @@ -0,0 +1,25 @@ +package org.fossify.filemanager.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import org.fossify.filemanager.enums.Authentication + +@Entity(tableName = "network_connections", + indices = [Index(value = ["displayName"], unique = true)] +) +data class NetworkConnectionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val host: String, + val port: Int = 445, + val username: String?, + val password: String?, + val displayName: String, + val connectionType: String, + val sharedPath: String, + val url: String, + val authentication: String, + val privateKey: String = "", + val privateKeyPass: String = "", + val protocols: String? = null +) diff --git a/app/src/main/kotlin/org/fossify/filemanager/enums/Authentication.kt b/app/src/main/kotlin/org/fossify/filemanager/enums/Authentication.kt new file mode 100644 index 000000000..9d1c2609d --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/enums/Authentication.kt @@ -0,0 +1,8 @@ +package org.fossify.filemanager.enums + +enum class Authentication { + Password, + Anonymous, + PrivateKey +} + diff --git a/app/src/main/kotlin/org/fossify/filemanager/enums/Protocols.kt b/app/src/main/kotlin/org/fossify/filemanager/enums/Protocols.kt new file mode 100644 index 000000000..403296895 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/enums/Protocols.kt @@ -0,0 +1,6 @@ +package org.fossify.filemanager.enums + +enum class Protocols { + HTTP, + HTTPS +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/extensions/Activity.kt b/app/src/main/kotlin/org/fossify/filemanager/extensions/Activity.kt index 82603c18f..e81fc1337 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/extensions/Activity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import androidx.core.content.FileProvider import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.enums.ConnectionTypes import org.fossify.commons.extensions.getFilenameFromPath import org.fossify.commons.extensions.getMimeTypeFromUri import org.fossify.commons.extensions.getParentPath @@ -11,6 +12,8 @@ import org.fossify.commons.extensions.launchActivityIntent import org.fossify.commons.extensions.openPathIntent import org.fossify.commons.extensions.renameFile import org.fossify.commons.extensions.setAsIntent +import org.fossify.commons.extensions.setAsNetworkIntent +import org.fossify.commons.extensions.shareNetworkPathsIntent import org.fossify.commons.extensions.sharePathsIntent import org.fossify.filemanager.BuildConfig import org.fossify.filemanager.helpers.OPEN_AS_AUDIO @@ -24,6 +27,9 @@ fun Activity.sharePaths(paths: ArrayList) { sharePathsIntent(paths, BuildConfig.APPLICATION_ID) } +fun Activity.networkSharePaths(paths: ArrayList,file: File) { + shareNetworkPathsIntent(paths, BuildConfig.APPLICATION_ID,file) +} fun Activity.tryOpenPathIntent(path: String, forceChooser: Boolean, openAsType: Int = OPEN_AS_DEFAULT, finishActivity: Boolean = false) { if (!forceChooser && path.endsWith(".apk", true)) { val uri = FileProvider.getUriForFile( @@ -61,6 +67,9 @@ private fun getMimeType(type: Int) = when (type) { fun Activity.setAs(path: String) { setAsIntent(path, BuildConfig.APPLICATION_ID) } +fun Activity.setAsNetworkPath(path: String,file: File) { + setAsNetworkIntent(path, BuildConfig.APPLICATION_ID,file) +} fun BaseSimpleActivity.toggleItemVisibility(oldPath: String, hide: Boolean, callback: ((newPath: String) -> Unit)? = null) { val path = oldPath.getParentPath() diff --git a/app/src/main/kotlin/org/fossify/filemanager/factory/NetworkBrowserViewModelFactory.kt b/app/src/main/kotlin/org/fossify/filemanager/factory/NetworkBrowserViewModelFactory.kt new file mode 100644 index 000000000..3969dfdd5 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/factory/NetworkBrowserViewModelFactory.kt @@ -0,0 +1,16 @@ +package org.fossify.filemanager.factory + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.fossify.filemanager.interfaces.FTPApi +import org.fossify.filemanager.interfaces.NetworkConnectionRepositoryDb +import org.fossify.filemanager.interfaces.SFTPApi +import org.fossify.filemanager.interfaces.SMBApi +import org.fossify.filemanager.interfaces.WebDavApi +import org.fossify.filemanager.viewmodels.NetworkBrowserViewModel + +class NetworkBrowserViewModelFactory(private val networkConnectionRepositoryDb: NetworkConnectionRepositoryDb, private val webDavApi: WebDavApi,private val sftpApi: SFTPApi, private val ftpApi: FTPApi,private val smbApi: SMBApi): ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return NetworkBrowserViewModel(networkConnectionRepositoryDb,webDavApi,ftpApi,sftpApi,smbApi) as T + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/fileSystems/FileHelpers.kt b/app/src/main/kotlin/org/fossify/filemanager/fileSystems/FileHelpers.kt new file mode 100644 index 000000000..641e6382f --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/fileSystems/FileHelpers.kt @@ -0,0 +1,119 @@ +package org.fossify.filemanager.fileSystems + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.net.toUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.filemanager.helpers.Helpers + +object FileHelpers { + fun launchSMB(mPath: String, context: Context, mimType: String? = null) { + try { + CoroutineScope(Dispatchers.IO).launch { + val extractedPath = Helpers.retrievePath(mPath) + val uri = Helpers.createNanoHttpdUrl(ConnectionTypes.SMB,extractedPath).toUri() + var i: Intent + if (mimType != null) { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, mimType) + } else { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, MimeTypes.getMimeTypes(mPath)) + } + val packageManager: PackageManager = context.packageManager + val resInfos = packageManager.queryIntentActivities(i, 0) + if (resInfos.size > 0) { + context.startActivity(i) + } + } + } catch (exp: Exception) { + Log.e("Activity Launch Failed", exp.toString()) + } + } + + fun launchWebDav(mPath: String, context: Context, mimType: String? = null) { + try { + CoroutineScope(Dispatchers.IO).launch { + + val extractedPath = Helpers.retrievePath(mPath) + val uri = Helpers.createNanoHttpdUrl(ConnectionTypes.WebDav,extractedPath).toUri() + var i: Intent + if (mimType != null) { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, mimType) + } else { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, MimeTypes.getMimeTypes(mPath)) + } + val packageManager: PackageManager = context.packageManager + val resInfos = packageManager.queryIntentActivities(i, 0) + if (resInfos.size > 0) { + context.startActivity(i) + } + } + } catch (exp: Exception) { + Log.e("Activity Launch Failed", exp.toString()) + } + } + + fun launchSFTP(mPath: String, context: Context,mimType: String? = null) { + try { + CoroutineScope(Dispatchers.IO).launch { + val uri = Helpers.createNanoHttpdUrl(ConnectionTypes.SMB).toUri() + var i: Intent + if (mimType != null) { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, mimType) + } else { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, MimeTypes.getMimeTypes(mPath)) + } + + val packageManager: PackageManager = context.packageManager + val resInfos = packageManager.queryIntentActivities(i, 0) + if (resInfos.size > 0) { + context.startActivity(i) + } + } + } catch (exp: Exception) { + Log.e("Activity Launch Failed", exp.toString()) + } + } + + fun launchFTP(mPath: String, context: Context,mimType: String? = null) { + try { + CoroutineScope(Dispatchers.IO).launch { + val uri = Helpers.createNanoHttpdUrl(ConnectionTypes.SMB).toUri() + var i: Intent + if (mimType != null) { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri,mimType) + } else { + i = + Intent(Intent.ACTION_VIEW) + i.setDataAndType(uri, MimeTypes.getMimeTypes(mPath)) + } + + val packageManager: PackageManager = context.packageManager + val resInfos = packageManager.queryIntentActivities(i, 0) + if (resInfos.size > 0) { + context.startActivity(i) + } + } + } catch (exp: Exception) { + Log.e("Activity Launch Failed", exp.toString()) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/fileSystems/HttpServer.kt b/app/src/main/kotlin/org/fossify/filemanager/fileSystems/HttpServer.kt new file mode 100644 index 000000000..b9cc16fef --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/fileSystems/HttpServer.kt @@ -0,0 +1,195 @@ +package org.fossify.filemanager.fileSystems + +import fi.iki.elonen.NanoHTTPD + +import jcifs.smb.SmbFileInputStream +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.filemanager.dependencies.AppComposition +import org.fossify.filemanager.enums.Protocols +import org.fossify.filemanager.helpers.Helpers +import org.fossify.filemanager.models.ApiResponse +import java.io.BufferedInputStream +import java.io.InputStream + +class HttpServer( + private val port: Int, + private val serverIp: String, + private val connectionType: ConnectionTypes, + private val composition: AppComposition, + private val machinePort: Int, + private val protocol: Protocols = Protocols.HTTP, + private val dispatchException: (Exception) -> Unit +) : NanoHTTPD(port) { + + override fun serve(session: IHTTPSession): Response { + val uri = session.uri + val rangeHeader = session.headers["range"] + + return when (connectionType) { + ConnectionTypes.SMB -> handleSmb(uri, rangeHeader) + ConnectionTypes.WebDav -> handleWebDav(uri, rangeHeader) + ConnectionTypes.SFTP -> handleSftp(uri, rangeHeader) + else -> handleFtp(uri, rangeHeader) + } + } + + private fun handleSmb(uri: String, rangeHeader: String?): Response { + val url = Helpers. createProtocolUrl(connectionType, uri, server = serverIp, port = machinePort, protocols = protocol) + val file = composition.smbApiRepository.getSmbFile(url).response + if (file == null) return notFound() + if (!file.exists()) return notFound() + + val fileLength = file.length() + val (start, end) = parseRange(rangeHeader, fileLength) + ?: return rangeNotSatisfiable() + + val stream = SmbFileInputStream(file).also { it.skipFully(start) } + return try { + buildResponse(stream, start, end, fileLength, MimeTypes.getMimeTypes(file.path)) + } + catch (exp: Exception){ + dispatchException.invoke(exp) + notFound() + } + } + + private fun handleWebDav(uri: String, rangeHeader: String?): Response { + + val url = Helpers. createProtocolUrl(connectionType, uri, server = serverIp, port = machinePort, protocols = protocol) + + val apiResponse = composition.webDavApiRepository.listWebDavFileDetail(url) + return handleResponse(apiResponse) { + val file = apiResponse.response ?: return@handleResponse notFound() + val (start, end) = parseRange(rangeHeader, file.contentLength) + ?: return@handleResponse rangeNotSatisfiable() + + val streamResponse = composition.webDavApiRepository.getWebDavFileInputStream(url, start, end) + val stream = handleStream(streamResponse) { + BufferedInputStream( + streamResponse.response, + BUFFER_SIZE + ) + } + buildResponse(stream, start, end, file.contentLength, MimeTypes.getMimeTypes(uri)) + } + } + + private fun handleSftp(uri: String, rangeHeader: String?): Response { + val apiResponse = composition.sftpApiRepository.listSFTPFileDetails(uri) + + return handleResponse(apiResponse) { + val file = apiResponse.response ?: return@handleResponse notFound() + val (start, end) = parseRange(rangeHeader, file.size) + ?: return@handleResponse rangeNotSatisfiable() + + val streamResponse = composition.sftpApiRepository.getSFTPFileInputStream(uri, start) + val stream = handleStream(streamResponse) { + BufferedInputStream( + streamResponse.response, + BUFFER_SIZE + ) + } + buildResponse(stream, start, end, file.size, MimeTypes.getMimeTypes(uri)) + } + } + + private fun handleFtp(uri: String, rangeHeader: String?): Response { + val apiResponse = composition.ftpApiRepository.getFTPFileDetail(uri) + + return handleResponse(apiResponse) { + val file = apiResponse.response ?: return@handleResponse notFound() + + val (start, end) = parseRange(rangeHeader, file.size) + ?: return@handleResponse rangeNotSatisfiable() + + val streamResponse = composition.ftpApiRepository.getFTPFileInputStream(uri, start) + val stream = handleStream(streamResponse) { + BufferedInputStream( + streamResponse.response, + BUFFER_SIZE + ) + } + buildResponse(stream, start, end, file.size, MimeTypes.getMimeTypes(uri)) + } + } + + private fun parseRange(header: String?, fileLength: Long): Pair? { + var start = 0L + var end = fileLength - 1 + + if (header != null && header.startsWith("bytes=")) { + val parts = header.removePrefix("bytes=").split("-") + try { + if (parts[0].isNotEmpty()) start = parts[0].toLong() + if (parts.size > 1 && parts[1].isNotEmpty()) end = parts[1].toLong() + } catch (_: NumberFormatException) { + } + } + + return if (start >= fileLength) null else start to end + } + + private fun buildResponse( + stream: InputStream?, + start: Long, + end: Long, + fileLength: Long, + mimeType: String? + ): Response { + val contentLength = end - start + 1 + return newFixedLengthResponse(Response.Status.PARTIAL_CONTENT, mimeType, stream, contentLength).apply { + addHeader("Accept-Ranges", "bytes") + addHeader("Content-Length", contentLength.toString()) + addHeader("Content-Range", "bytes $start-$end/$fileLength") + addHeader("Connection", "keep-alive") + } + } + + private fun buildUrl(path: String) = + Helpers.createProtocolUrl(connectionType, server = serverIp, path = path, port = machinePort, protocols = protocol) + + + private fun notFound() = + newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "File not found") + + private fun rangeNotSatisfiable() = + newFixedLengthResponse(Response.Status.RANGE_NOT_SATISFIABLE, MIME_PLAINTEXT, "") + + private fun serverError(message: String?) = + newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, message ?: "Server error") + + + private fun InputStream.skipFully(n: Long) { + var remaining = n + while (remaining > 0) { + val skipped = skip(remaining) + if (skipped <= 0) break + remaining -= skipped + } + } + + private fun handleResponse( + apiResponse: ApiResponse, + callBack: () -> Response + ): Response { + return if (apiResponse.exception != null) { + dispatchException.invoke(apiResponse.exception) + serverError(apiResponse.exception.message) + } else { + callBack.invoke() + } + } + + private fun handleStream(apiResponse: ApiResponse, callBack: () -> InputStream): InputStream? { + return if (apiResponse.exception != null) { + dispatchException.invoke(apiResponse.exception) + return null + } else { + callBack.invoke() + } + } + + companion object { + private const val BUFFER_SIZE = 1024 * 1024 + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/fileSystems/MimeTypes.kt b/app/src/main/kotlin/org/fossify/filemanager/fileSystems/MimeTypes.kt new file mode 100644 index 000000000..7b92277f7 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/fileSystems/MimeTypes.kt @@ -0,0 +1,31 @@ +package org.fossify.filemanager.fileSystems + +import android.webkit.MimeTypeMap +import org.fossify.filemanager.helpers.OPEN_AS_AUDIO +import org.fossify.filemanager.helpers.OPEN_AS_DEFAULT +import org.fossify.filemanager.helpers.OPEN_AS_IMAGE +import org.fossify.filemanager.helpers.OPEN_AS_TEXT +import org.fossify.filemanager.helpers.OPEN_AS_VIDEO +import java.util.Locale.getDefault + +object MimeTypes { + fun getMimeTypes(path: String?): String? { + return getFileExtension(path) + } + + private fun getFileExtension(path: String?): String? { + var extension: String? = path?.substring(path.lastIndexOf(".") + 1)?.lowercase(getDefault()) + val mime = MimeTypeMap.getSingleton() + extension = mime.getMimeTypeFromExtension(extension) + return extension + } + + public fun getMimeType(type: Int) = when (type) { + OPEN_AS_DEFAULT -> "" + OPEN_AS_TEXT -> "text/*" + OPEN_AS_IMAGE -> "image/*" + OPEN_AS_AUDIO -> "audio/*" + OPEN_AS_VIDEO -> "video/*" + else -> "*/*" + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/fragments/ItemsFragment.kt b/app/src/main/kotlin/org/fossify/filemanager/fragments/ItemsFragment.kt index d7589648c..7531e7963 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/fragments/ItemsFragment.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/fragments/ItemsFragment.kt @@ -2,17 +2,31 @@ package org.fossify.filemanager.fragments import android.annotation.SuppressLint import android.content.Context +import android.net.Uri import android.os.Parcelable import android.util.AttributeSet +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager +import com.thegrizzlylabs.sardineandroid.DavResource +import jcifs.smb.SmbFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import net.schmizz.sshj.sftp.RemoteResourceInfo +import org.apache.commons.net.ftp.FTPFile import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.dialogs.StoragePickerDialog +import org.fossify.commons.enums.ConnectionTypes import org.fossify.commons.extensions.* import org.fossify.commons.helpers.* import org.fossify.commons.models.FileDirItem import org.fossify.commons.views.Breadcrumbs import org.fossify.commons.views.MyGridLayoutManager import org.fossify.commons.views.MyRecyclerView +import org.fossify.filemanager.App import org.fossify.filemanager.R import org.fossify.filemanager.activities.MainActivity import org.fossify.filemanager.activities.SimpleActivity @@ -21,15 +35,22 @@ import org.fossify.filemanager.databinding.ItemsFragmentBinding import org.fossify.filemanager.dialogs.CreateNewItemDialog import org.fossify.filemanager.extensions.config import org.fossify.filemanager.extensions.isPathOnRoot +import org.fossify.filemanager.extensions.networkSharePaths +import org.fossify.filemanager.extensions.setAsNetworkPath +import org.fossify.filemanager.fileSystems.FileHelpers import org.fossify.filemanager.helpers.MAX_COLUMN_COUNT import org.fossify.filemanager.helpers.RootHelpers import org.fossify.filemanager.interfaces.ItemOperationsListener +import org.fossify.filemanager.mapper.toFileItem +import org.fossify.filemanager.models.ApiResponse import org.fossify.filemanager.models.ListItem +import org.fossify.filemanager.viewmodels.NetworkBrowserViewModel import java.io.File class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerFragment(context, attributeSet), ItemOperationsListener, Breadcrumbs.BreadcrumbsListener { + private lateinit var viewModel: NetworkBrowserViewModel private var showHidden = false private var lastSearchedText = "" private var scrollStates = HashMap() @@ -38,6 +59,8 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF private var storedItems = ArrayList() private var itemsIgnoringSearch = ArrayList() private lateinit var binding: ItemsFragmentBinding + private var connectionType: ConnectionTypes = ConnectionTypes.Default + override fun onFinishInflate() { super.onFinishInflate() @@ -59,6 +82,16 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF } } } + setUpViewModel() + } + } + + private fun setUpViewModel() { + val composition = (activity?.application as App).appComposition + val factory = composition.provideNetworkBrowserViewModelFactory() + activity?.let { + viewModel = ViewModelProvider(it, factory) + .get(NetworkBrowserViewModel::class.java) } } @@ -77,7 +110,7 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF progressBar.trackColor = properPrimaryColor.adjustAlpha(LOWER_ALPHA) if (currentPath != "") { - breadcrumbs.updateColor(textColor) + breadcrumbs.updateColor(textColor, connectionType) } itemsSwipeRefresh.isEnabled = lastSearchedText.isEmpty() && activity?.config?.enablePullToRefresh != false @@ -99,7 +132,8 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF getRecyclerAdapter()?.finishActMode() } - fun openPath(path: String, forceRefresh: Boolean = false) { + + fun openPath(path: String, forceRefresh: Boolean = false, pathName: String = "", connectionType: ConnectionTypes = ConnectionTypes.Default) { if ((activity as? BaseSimpleActivity)?.isAskingPermissions == true) { return } @@ -108,24 +142,29 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF if (realPath.isEmpty()) { realPath = "/" } - + this.connectionType = connectionType scrollStates[currentPath] = getScrollState()!! currentPath = realPath showHidden = context!!.config.shouldShowHidden() showProgressBar() - getItems(currentPath) { originalPath, listItems -> + getItems(currentPath, connectionType = connectionType) { originalPath, listItems -> if (currentPath != originalPath) { return@getItems } FileDirItem.sorting = context!!.config.getFolderSorting(currentPath) + if (connectionType != ConnectionTypes.Default) { + listItems.forEach { + it.parent = originalPath.substringAfter('/') + } + } listItems.sort() if (context!!.config.getFolderViewType(currentPath) == VIEW_TYPE_GRID && listItems.none { it.isSectionTitle }) { if (listItems.any { it.mIsDirectory } && listItems.any { !it.mIsDirectory }) { val firstFileIndex = listItems.indexOfFirst { !it.mIsDirectory } if (firstFileIndex != -1) { - val sectionTitle = ListItem("", "", false, 0, 0, 0, false, true) + val sectionTitle = ListItem("", "", false, 0, 0, 0, false, true, mConnectionType = connectionType) listItems.add(firstFileIndex, sectionTitle) } } @@ -134,40 +173,71 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF itemsIgnoringSearch = listItems activity?.runOnUiThread { (activity as? MainActivity)?.refreshMenuItems() - addItems(listItems, forceRefresh) + addItems(listItems, forceRefresh,pathName = pathName, connectionType) if (context != null && currentViewType != context!!.config.getFolderViewType(currentPath)) { - setupLayoutManager() + setupLayoutManager(connectionType) } hideProgressBar() } } } - private fun addItems(items: ArrayList, forceRefresh: Boolean = false) { + private fun addItems(items: ArrayList, forceRefresh: Boolean = false,pathName: String = "", connectionType: ConnectionTypes) { activity?.runOnUiThread { binding.itemsSwipeRefresh.isRefreshing = false - binding.breadcrumbs.setBreadcrumb(currentPath) + if (connectionType != ConnectionTypes.DAVx5){ + binding.breadcrumbs.setBreadcrumb(currentPath, connectionType) + } if (!forceRefresh && items.hashCode() == storedItems.hashCode()) { return@runOnUiThread } storedItems = items if (binding.itemsList.adapter == null) { - binding.breadcrumbs.updateFontSize(context!!.getTextSize(), true) + binding.breadcrumbs.updateFontSize(context!!.getTextSize(), true, connectionType) } - + var lastClickedItem: ListItem? = null ItemsAdapter(activity as SimpleActivity, storedItems, this, binding.itemsList, isPickMultipleIntent, binding.itemsSwipeRefresh) { - if ((it as? ListItem)?.isSectionTitle == true) { + lastClickedItem = it as? ListItem + if ((it as? ListItem)?.mIsDirectory == false) { + if (connectionType == ConnectionTypes.SMB) { + it?.let { item -> + FileHelpers.launchSMB(item.mPath, this@ItemsFragment.context) + } + } else if (connectionType == ConnectionTypes.WebDav) { + it?.let { item -> + FileHelpers.launchWebDav(item.mPath, context = this@ItemsFragment.context) + } + } else if (connectionType == ConnectionTypes.SFTP) { + it?.let { item -> + FileHelpers.launchSFTP(item.mPath, context = this@ItemsFragment.context) + } + } else if (connectionType == ConnectionTypes.FTP) { + it?.let { item -> + FileHelpers.launchFTP( item.mPath, context = this@ItemsFragment.context) + } + } else { + itemClicked(it as FileDirItem, connectionType) + } + } else if ((it as? ListItem)?.isSectionTitle == true) { openDirectory(it.mPath) searchClosed() } else { - itemClicked(it as FileDirItem) + itemClicked(it as FileDirItem, connectionType) + } + if (connectionType == ConnectionTypes.DAVx5){ + if (lastClickedItem != null){ + binding.breadcrumbs.setBreadcrumbWithName(lastClickedItem!!.mPath,lastClickedItem?.mName,connectionType) + } } }.apply { setupZoomListener(zoomListener) binding.itemsList.adapter = this } + if (lastClickedItem == null && connectionType == ConnectionTypes.DAVx5){ + binding.breadcrumbs.setBreadcrumbWithName(currentPath,pathName, connectionType) + } if (context.areSystemAnimationsEnabled) { binding.itemsList.scheduleLayoutAnimation() } @@ -181,11 +251,44 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF private fun getRecyclerLayoutManager() = (binding.itemsList.layoutManager as MyGridLayoutManager) @SuppressLint("NewApi") - private fun getItems(path: String, callback: (originalPath: String, items: ArrayList) -> Unit) { + private fun getItems( + path: String, + connectionType: ConnectionTypes, + callback: (originalPath: String, items: ArrayList) -> Unit + ) { ensureBackgroundThread { if (activity?.isDestroyed == false && activity?.isFinishing == false) { val config = context!!.config - if (context.isRestrictedSAFOnlyRoot(path)) { + if (connectionType.equals(ConnectionTypes.SMB)) { + val fileItems = viewModel.getFilesFromNetworkPath(path) + handleApiResponse(fileItems, path, connectionType, callback) + } else if (connectionType.equals(ConnectionTypes.WebDav)) { + CoroutineScope(Dispatchers.IO).launch { + viewModel.listWebDavFiles(path) + viewModel.webDavFiles.collectLatest { + handleApiResponse(it, path, connectionType, callback) + } + } + + } else if (connectionType.equals(ConnectionTypes.SFTP)) { + CoroutineScope(Dispatchers.IO).launch { + viewModel.listAllFilesSFTPRoot(path) + viewModel.sftpFiles.collectLatest { + handleApiResponse(it, path, connectionType, callback) + } + } + } else if (connectionType.equals(ConnectionTypes.FTP)) { + CoroutineScope(Dispatchers.IO).launch { + viewModel.listAllFTPFiles(path) + viewModel.ftpFiles.collectLatest { + handleApiResponse(it, path, connectionType, callback) + } + } + } + else if (connectionType.equals(ConnectionTypes.DAVx5)){ + handleApiResponse(null, path, connectionType, callback) + } + else if (context.isRestrictedSAFOnlyRoot(path)) { activity?.runOnUiThread { hideProgressBar() } activity?.handleAndroidSAFDialog(path, openInSystemAppAllowed = true) { if (!it) { @@ -202,8 +305,11 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF context!!.getOTGItems(path, config.shouldShowHidden(), getProperFileSize) { callback(path, getListItemsFromFileDirItems(it)) } - } else if (!config.enableRootAccess || !context!!.isPathOnRoot(path)) { - getRegularItemsOf(path, callback) + } else if (!config.enableRootAccess || !context!!.isPathOnRoot(path) && (connectionType.equals(ConnectionTypes.DAVx5) || connectionType.equals( + ConnectionTypes.Default + )) + ) { + getRegularItemsOf(path, callback, connectionType) } else { RootHelpers(activity!!).getFiles(path, callback) } @@ -211,23 +317,46 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF } } - private fun getRegularItemsOf(path: String, callback: (originalPath: String, items: ArrayList) -> Unit) { + private fun getRegularItemsOf(path: String, callback: (originalPath: String, items: ArrayList) -> Unit, connectionType: ConnectionTypes) { val items = ArrayList() - val files = File(path).listFiles()?.filterNotNull() - if (context == null || files == null) { - callback(path, items) - return - } - - val isSortingBySize = context!!.config.getFolderSorting(currentPath) and SORT_BY_SIZE != 0 val getProperChildCount = context!!.config.getFolderViewType(currentPath) == VIEW_TYPE_LIST - val lastModifieds = context!!.getFolderLastModifieds(path) - for (file in files) { - val listItem = getListItemFromFile(file, isSortingBySize, lastModifieds, false) - if (listItem != null) { - if (wantedMimeTypes.any { isProperMimeType(it, file.absolutePath, file.isDirectory) }) { - items.add(listItem) + if (connectionType == ConnectionTypes.DAVx5) { + val uri = Uri.parse(path) + val docFile = DocumentFile.fromTreeUri(context, uri) + val files = docFile?.listFiles() + + files?.forEach { file -> + items.add( + ListItem( + mPath = file.uri.toString(), + mName = file.name ?: "", + mIsDirectory = file.isDirectory, + mChildren = if (file.isDirectory) 1 else 0, + mSize = if (file.isFile) file.length() else 0L, + mModified = file.lastModified(), + isSectionTitle = false, + isGridTypeDivider = false, + parent = path + ) + ) + } + } else { + val files = File(path).listFiles()?.filterNotNull() + if (context == null || files == null) { + callback(path, items) + return + } + + val isSortingBySize = context!!.config.getFolderSorting(currentPath) and SORT_BY_SIZE != 0 + val lastModifieds = context!!.getFolderLastModifieds(path) + + for (file in files) { + val listItem = getListItemFromFile(file, isSortingBySize, lastModifieds, false) + if (listItem != null) { + if (wantedMimeTypes.any { isProperMimeType(it, file.absolutePath, file.isDirectory) }) { + items.add(listItem) + } } } } @@ -279,7 +408,7 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF private fun getListItemsFromFileDirItems(fileDirItems: ArrayList): ArrayList { val listItems = ArrayList() fileDirItems.forEach { - val listItem = ListItem(it.path, it.name, it.isDirectory, it.children, it.size, it.modified, false, false) + val listItem = ListItem(it.path, it.name, it.isDirectory, it.children, it.size, it.modified, false, false, mConnectionType = it.connectionType) if (wantedMimeTypes.any { mimeType -> isProperMimeType(mimeType, it.path, it.isDirectory) }) { listItems.add(listItem) } @@ -287,19 +416,19 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF return listItems } - private fun itemClicked(item: FileDirItem) { + private fun itemClicked(item: FileDirItem, connectionType: ConnectionTypes) { if (item.isDirectory) { - openDirectory(item.path) + openDirectory(item.path, connectionType) } else { clickedPath(item.path) } } - private fun openDirectory(path: String) { + private fun openDirectory(path: String, connectionType: ConnectionTypes = ConnectionTypes.Default) { (activity as? MainActivity)?.apply { openedDirectory() } - openPath(path) + openPath(path, connectionType = connectionType) } override fun searchQueryChanged(text: String) { @@ -419,7 +548,7 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF } private fun createNewItem() { - CreateNewItemDialog(activity as SimpleActivity, currentPath) { + CreateNewItemDialog(activity as SimpleActivity, currentPath, connectionType, viewModel) { if (it) { refreshFragment() } else { @@ -430,7 +559,7 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF private fun getRecyclerAdapter() = binding.itemsList.adapter as? ItemsAdapter - private fun setupLayoutManager() { + private fun setupLayoutManager(connectionType: ConnectionTypes) { if (context!!.config.getFolderViewType(currentPath) == VIEW_TYPE_GRID) { currentViewType = VIEW_TYPE_GRID setupGridLayoutManager() @@ -441,7 +570,7 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF binding.itemsList.adapter = null initZoomListener() - addItems(storedItems, true) + addItems(storedItems, true, connectionType = connectionType) } private fun setupGridLayoutManager() { @@ -502,50 +631,352 @@ class ItemsFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerF } } - override fun columnCountChanged() { - (binding.itemsList.layoutManager as MyGridLayoutManager).spanCount = context!!.config.fileColumnCnt - (activity as? MainActivity)?.refreshMenuItems() - getRecyclerAdapter()?.apply { - notifyItemRangeChanged(0, listItems.size) + private fun handleApiResponse( + apiResponse: ApiResponse?, + path: String, + connectionType: ConnectionTypes, + callback: (originalPath: String, items: ArrayList) -> Unit + ) { + if (apiResponse?.exception != null) { + apiResponse.exception.message?.let { exp -> + activity?.toast(exp) + } + } else { + if (connectionType == ConnectionTypes.SMB) { + apiResponse?.response?.let { item -> + val fileItems = item as Array + val items = fileItems.map { it -> it.toFileItem(connectionType) } + callback(path, getListItemsFromFileDirItems(ArrayList(items?.toList()))) + } + } else if (connectionType == ConnectionTypes.WebDav) { + apiResponse?.response?.let { item -> + val fileItems = item as List + val items = fileItems.map { it -> it.toFileItem(connectionType) } + callback(path, getListItemsFromFileDirItems(ArrayList(items?.toList()))) + } + } else if (connectionType == ConnectionTypes.SFTP) { + apiResponse?.response?.let { item -> + val fileItems = item as List + val items = fileItems?.map { it -> it.toFileItem(path, connectionType) } + callback(path, getListItemsFromFileDirItems(ArrayList(items?.toList()))) + } + + } else if (connectionType == ConnectionTypes.FTP) { + apiResponse?.response?.let { item -> + val fileItems = item as List + val items = fileItems?.map { it -> it.toFileItem(path, connectionType) } + callback(path, getListItemsFromFileDirItems(ArrayList(items?.toList()))) + } + } + else if (connectionType == ConnectionTypes.DAVx5) { + val items = ArrayList() + val uri = Uri.parse(path) + val docFile = DocumentFile.fromTreeUri(context, uri) + val files = docFile?.listFiles() + + files?.forEach { file -> + items.add( + ListItem( + mConnectionType = connectionType, + mPath = file.uri.toString(), + mName = file.name ?: "", + mIsDirectory = file.isDirectory, + mChildren = if (file.isDirectory) 1 else 0, + mSize = if (file.isFile) file.length() else 0L, + mModified = file.lastModified(), + isSectionTitle = false, + isGridTypeDivider = false, + parent = path + ) + ) + } + callback(path,items) + } } } - fun showProgressBar() { - binding.progressBar.show() - } + override fun columnCountChanged() { + (binding.itemsList.layoutManager as MyGridLayoutManager).spanCount = context!!.config.fileColumnCnt + (activity as? MainActivity)?.refreshMenuItems() + getRecyclerAdapter()?.apply { + notifyItemRangeChanged(0, listItems.size) + } + } - private fun hideProgressBar() { - binding.progressBar.hide() - } + fun showProgressBar() { + binding.progressBar.show() + } - fun getBreadcrumbs() = binding.breadcrumbs + private fun hideProgressBar() { + binding.progressBar.hide() + } - override fun toggleFilenameVisibility() { - getRecyclerAdapter()?.updateDisplayFilenamesInGrid() - } + fun getBreadcrumbs() = binding.breadcrumbs - override fun breadcrumbClicked(id: Int) { - if (id == 0) { - StoragePickerDialog(activity as SimpleActivity, currentPath, context!!.config.enableRootAccess, true) { - getRecyclerAdapter()?.finishActMode() - openPath(it) - } - } else { + override fun toggleFilenameVisibility() { + getRecyclerAdapter()?.updateDisplayFilenamesInGrid() + } + + override fun breadcrumbClicked(id: Int) { val item = binding.breadcrumbs.getItem(id) - openPath(item.path) + if (id == 0) { + StoragePickerDialog(activity as SimpleActivity, currentPath, context!!.config.enableRootAccess, true) { + getRecyclerAdapter()?.finishActMode() + if (item.connectionType != ConnectionTypes.Default) { + openPath(item.path, connectionType = item.connectionType) + } else { + openPath(item.path) + } + } + } else { + var path = "" + if (item.connectionType == ConnectionTypes.WebDav || item.connectionType == ConnectionTypes.SMB) { + val items = binding.breadcrumbs.getItemsTillIndex(id) + items.forEach { item -> + path += "${item.path}/" + } + } + else + path = item.path + + openPath(path, connectionType = item.connectionType) + } } - } - override fun refreshFragment() { - openPath(currentPath) - } + override fun refreshFragment() { + openPath(currentPath, connectionType = connectionType) + } - override fun deleteFiles(files: ArrayList) { - val hasFolder = files.any { it.isDirectory } - handleFileDeleting(files, hasFolder) - } + override fun deleteFiles(files: ArrayList) { + if (connectionType != ConnectionTypes.Default) { + collectLatest() + } + val hasFolder = files.any { it.isDirectory } + deleteFileOrFolder(files, hasFolder) + } + + + private fun deleteFileOrFolder(files: ArrayList, hasFolder: Boolean) { + when (connectionType) { + ConnectionTypes.SMB -> { + files.forEach { + viewModel.deleteItemSMB(it.path) + } + } + + ConnectionTypes.WebDav -> { + files.forEach { + viewModel.deleteItemWebDav(it.path) + } + } + + ConnectionTypes.SFTP -> { + files.forEach { + viewModel.deleteItemSFTP(it.path, it.isDirectory) + } + } + + ConnectionTypes.FTP -> { + files.forEach { + viewModel.deleteItemFTP(it.path, it.isDirectory) + } + } + + ConnectionTypes.Default -> { + handleFileDeleting(files, hasFolder) + } + + else -> Unit + } + } + + private fun collectLatest() { + CoroutineScope(Dispatchers.IO).launch { + when (connectionType) { + ConnectionTypes.SMB -> { + viewModel.smbDelete.collectLatest { + it.exception?.message?.let { msg -> + activity?.toast(msg) + } + } + } + + ConnectionTypes.WebDav -> { + viewModel.webDavDelete.collectLatest { + it.exception?.message?.let { msg -> + activity?.toast(msg) + } + } + } + + ConnectionTypes.SFTP -> { + viewModel.sftpDelete.collectLatest { + it.exception?.message?.let { msg -> + activity?.toast(msg) + } + } + } + + ConnectionTypes.FTP -> { + viewModel.ftpDelete.collectLatest { + it.exception?.message?.let { msg -> + activity?.toast(msg) + } + } + } + + else -> Unit + } + } + } + + override fun selectedPaths(paths: ArrayList) { + (activity as MainActivity).pickedPaths(paths) + } + + override fun shareFile(paths: ArrayList) { + collectFileShared(paths) + if (connectionType == ConnectionTypes.SMB) { + paths.forEach { + viewModel.writeSmbFileToCache(it, context) + } + } else if (connectionType == ConnectionTypes.WebDav) { + paths.forEach { + viewModel.writeWebDavFileToCache(it, context) + } + } else if (connectionType == ConnectionTypes.SFTP) { + paths.forEach { + viewModel.writeSftpFileToCache(it, context) + } + } else if (connectionType == ConnectionTypes.FTP) { + paths.forEach { + viewModel.writeFtpFileToCache(it, context) + } + } + } + + override fun openWith(path: String, mimType: String?) { + when (connectionType) { + ConnectionTypes.SMB -> { + FileHelpers.launchSMB(path, context, mimType) + } + + ConnectionTypes.WebDav -> { + FileHelpers.launchWebDav(path, context, mimType) + } + + ConnectionTypes.SFTP -> { + FileHelpers.launchSFTP( path, context, mimType) + } + + ConnectionTypes.FTP -> { + FileHelpers.launchFTP( path, context, mimType) + } + + else -> Unit + } + } + + + private fun collectFileShared(paths: ArrayList) { + CoroutineScope(Dispatchers.IO).launch { + when (connectionType) { + ConnectionTypes.SMB -> { + viewModel.smbFileShare.collectLatest { + handleFileSharedResponse(it, paths) + } + } + + ConnectionTypes.WebDav -> { + viewModel.webDavFileShare.collectLatest { + handleFileSharedResponse(it, paths) + } + } + + ConnectionTypes.SFTP -> { + viewModel.sftpFileShare.collectLatest { + handleFileSharedResponse(it, paths) + } + } + + ConnectionTypes.FTP -> { + viewModel.ftpFileShare.collectLatest { + handleFileSharedResponse(it, paths) + } + } + + else -> Unit + } + } + } + + private fun handleFileSharedResponse(response: ApiResponse, paths: ArrayList) { + if (response.exception != null) { + response.exception?.message?.let { msg -> + activity?.toast(msg) + } + } else { + activity?.networkSharePaths(paths, response.response as File) + } + } + + private fun handleFileWriteResponse(response: ApiResponse, paths: String) { + if (response.exception != null) { + response.exception?.message?.let { msg -> + activity?.toast(msg) + } + } else { + activity?.setAsNetworkPath(paths, response.response as File) + } + } + + + override fun setAs(path: String) { + collectFileCopied(path) + if (connectionType == ConnectionTypes.SMB) { + viewModel.writeSmbFileToCache(path, context) + + } else if (connectionType == ConnectionTypes.WebDav) { + viewModel.writeWebDavFileToCache(path, context) + + } else if (connectionType == ConnectionTypes.SFTP) { + viewModel.writeSftpFileToCache(path, context) + + } else if (connectionType == ConnectionTypes.FTP) { + viewModel.writeFtpFileToCache(path, context) + } + } + + private fun collectFileCopied(path: String) { + CoroutineScope(Dispatchers.IO).launch { + when (connectionType) { + ConnectionTypes.SMB -> { + viewModel.smbFileShare.collectLatest { + handleFileWriteResponse(it, path) + } + } + + ConnectionTypes.WebDav -> { + viewModel.webDavFileShare.collectLatest { + handleFileWriteResponse(it, path) + } + } + + ConnectionTypes.SFTP -> { + viewModel.sftpFileShare.collectLatest { + handleFileWriteResponse(it, path) + } + } + + ConnectionTypes.FTP -> { + viewModel.ftpFileShare.collectLatest { + handleFileWriteResponse(it, path) + } + } + + else -> Unit + } + } + } - override fun selectedPaths(paths: ArrayList) { - (activity as MainActivity).pickedPaths(paths) } -} diff --git a/app/src/main/kotlin/org/fossify/filemanager/fragments/RecentsFragment.kt b/app/src/main/kotlin/org/fossify/filemanager/fragments/RecentsFragment.kt index 910877a95..db53f25c5 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/fragments/RecentsFragment.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/fragments/RecentsFragment.kt @@ -261,4 +261,16 @@ class RecentsFragment(context: Context, attributeSet: AttributeSet) : MyViewPage override fun finishActMode() { getRecyclerAdapter()?.finishActMode() } + + override fun shareFile(paths: ArrayList) { + TODO("Not yet implemented") + } + + override fun openWith(path: String,mimType: String?) { + TODO("Not yet implemented") + } + + override fun setAs(path: String) { + TODO("Not yet implemented") + } } diff --git a/app/src/main/kotlin/org/fossify/filemanager/fragments/StorageFragment.kt b/app/src/main/kotlin/org/fossify/filemanager/fragments/StorageFragment.kt index f8a8bf934..9d2451202 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/fragments/StorageFragment.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/fragments/StorageFragment.kt @@ -519,4 +519,16 @@ class StorageFragment( override fun finishActMode() { getRecyclerAdapter()?.finishActMode() } + + override fun shareFile(paths: ArrayList) { + TODO("Not yet implemented") + } + + override fun openWith(path: String,mimType: String?) { + TODO("Not yet implemented") + } + + override fun setAs(path: String) { + TODO("Not yet implemented") + } } diff --git a/app/src/main/kotlin/org/fossify/filemanager/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/filemanager/helpers/Constants.kt index 6df79baf1..fde6c2c63 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/helpers/Constants.kt @@ -8,6 +8,25 @@ import org.fossify.filemanager.models.ListItem const val MAX_COLUMN_COUNT = 15 +//Ports + +const val PORT_SMB = 7871 +const val PORT_WEBDAV = 7890 + +const val PORT_SFTP = 7860 + +const val PORT_FTP = 7850 + + +const val DEFAULT_SMB_PORT = 445 +const val DEFAULT_FTP_PORT = 21 +const val DEFAULT_SFTP_PORT = 22 +const val DEFAULT_WEBDAV_HTTP_PORT = 80 +const val DEFAULT_WEBDAV_HTTPS_PORT = 443 + +const val DB_NAME = "app-db" + + // shared preferences const val SHOW_HIDDEN = "show_hidden" const val PRESS_BACK_TWICE = "press_back_twice" @@ -44,6 +63,14 @@ const val SHOW_MIMETYPE = "show_mimetype" const val VOLUME_NAME = "volume_name" const val PRIMARY_VOLUME_NAME = "external_primary" +const val PATH = "path" + +const val NETWORK_PATH = "network_path" + +const val CONNECTION_TYPE = "connection_type" + +const val DAVX5_PATH_NAME = "davx5_path_name" + // what else should we count as an audio except "audio/*" mimetype val extraAudioMimeTypes = arrayListOf("application/ogg") val extraDocumentMimeTypes = arrayListOf( diff --git a/app/src/main/kotlin/org/fossify/filemanager/helpers/Helpers.kt b/app/src/main/kotlin/org/fossify/filemanager/helpers/Helpers.kt new file mode 100644 index 000000000..c8968528f --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/helpers/Helpers.kt @@ -0,0 +1,57 @@ +package org.fossify.filemanager.helpers + +import android.net.Uri +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.filemanager.enums.Protocols +import java.util.Locale.getDefault + +object Helpers { + val URL: String = "http://127.0.0.1" + fun createProtocolUrl(connectionTypes: ConnectionTypes, path: String? = "", server: String = "", port: Int, protocols: Protocols = Protocols.HTTP): String{ + var protocol = Protocols.HTTP.toString().lowercase() + if(connectionTypes.equals(ConnectionTypes.WebDav)){ + protocol = protocols.name.lowercase() + } + else if(connectionTypes.equals(ConnectionTypes.SMB)){ + protocol = ConnectionTypes.SMB.toString().lowercase() + } + val url = "${protocol}://${if (server.isEmpty()) URL else server }:${port}${path}" + return url + } + + fun createNanoHttpdUrl(connectionTypes: ConnectionTypes,path: String? = ""): String{ + val port = getPortForEachService(connectionTypes) + return "${URL}:${port}${path}" + } + + fun createUrl(connectionTypes: ConnectionTypes,path: String,server: String,port: Int = 0): String{ + if(connectionTypes == ConnectionTypes.SMB){ + return "${connectionTypes.toString().lowercase()}://${server}:${port}/${path}/" + } + return "" + } + + fun getPortForEachService(connectionTypes: ConnectionTypes): Int{ + if (connectionTypes.equals(ConnectionTypes.WebDav)){ + return PORT_WEBDAV + } + else if(connectionTypes.equals(ConnectionTypes.SMB)){ + return PORT_SMB + } + else if(connectionTypes.equals(ConnectionTypes.SFTP)){ + return PORT_SFTP + } + else if(connectionTypes.equals(ConnectionTypes.FTP)){ + return PORT_FTP + } + return PORT_SMB + } + fun createProtocolPath(protocol: Protocols?, server: String, port:Int, path:String): String{ + return "${protocol?.name?.lowercase(getDefault())}://${server}:${port}/${path}" + } + + fun retrievePath(url: String): String?{ + val uri = Uri.parse(url) + return uri.path + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/CertificateRepository.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/CertificateRepository.kt new file mode 100644 index 000000000..b0f85a523 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/CertificateRepository.kt @@ -0,0 +1,10 @@ +package org.fossify.filemanager.interfaces + +import android.content.Context +import android.net.Uri +import java.security.cert.X509Certificate + +interface CertificateRepository { + fun loadCertificate(uri: Uri, context: Context): X509Certificate + fun saveCertificate(host: String, cert: X509Certificate,context: Context) +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/FTPApi.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/FTPApi.kt new file mode 100644 index 000000000..196a05ce4 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/FTPApi.kt @@ -0,0 +1,23 @@ +package org.fossify.filemanager.interfaces + +import android.content.Context +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPFile +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.File +import java.io.InputStream + +interface FTPApi { + suspend fun connectToFTP(connection: NetworkConnection): Pair + + suspend fun listAllFTPFiles(path: String): ApiResponse> + + fun getFTPFileDetail(path: String): ApiResponse + + fun getFTPFileInputStream(path: String,start: Long): ApiResponse + fun deleteItem(path: String,isFolder: Boolean): ApiResponse + fun createItem(path: String, isFolder: Boolean, name: String): ApiResponse + fun writeFileToCache(path: String,context: Context): ApiResponse + fun getFTPConn(): FTPClient +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/ItemOperationsListener.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/ItemOperationsListener.kt index 2812527df..6fd3567d3 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/interfaces/ItemOperationsListener.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/ItemOperationsListener.kt @@ -18,4 +18,10 @@ interface ItemOperationsListener { fun columnCountChanged() fun finishActMode() + + fun shareFile(paths: ArrayList) + + fun openWith(path: String,mimType:String? = null) + + fun setAs(path: String) } diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/NetworkConnectionRepositoryDb.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/NetworkConnectionRepositoryDb.kt new file mode 100644 index 000000000..de2772a84 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/NetworkConnectionRepositoryDb.kt @@ -0,0 +1,13 @@ +package org.fossify.filemanager.interfaces + +import kotlinx.coroutines.flow.Flow +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection + +interface NetworkConnectionRepositoryDb { + suspend fun updateConnection(connection: NetworkConnection): ApiResponse; + fun getAllSavedConnections(): Flow> + suspend fun deleteConnection(connection: NetworkConnection): ApiResponse + + suspend fun addConnection(connection: NetworkConnection) : ApiResponse +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/SFTPApi.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/SFTPApi.kt new file mode 100644 index 000000000..6cba92ef3 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/SFTPApi.kt @@ -0,0 +1,26 @@ +package org.fossify.filemanager.interfaces + +import android.content.Context +import net.schmizz.sshj.sftp.FileAttributes +import net.schmizz.sshj.sftp.RemoteResourceInfo +import net.schmizz.sshj.sftp.SFTPClient +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.File +import java.io.InputStream + +interface SFTPApi { + suspend fun connectToSftp(connection: NetworkConnection): Pair + + suspend fun listAllFilesSFTPRoot(path: String): ApiResponse> + + fun listSFTPFileDetails(path: String): ApiResponse + + fun getSFTPFileInputStream(url: String, startByte: Long): ApiResponse + + fun createItem(path: String, isFolder: Boolean, name: String): ApiResponse + fun deleteItem(path: String,isFolder: Boolean): ApiResponse + + fun writeFileToCache(path: String,context: Context): ApiResponse + fun getSFTPConn(): SFTPClient +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/SMBApi.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/SMBApi.kt new file mode 100644 index 000000000..a827f64b8 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/SMBApi.kt @@ -0,0 +1,23 @@ +package org.fossify.filemanager.interfaces + +import android.content.Context +import jcifs.smb.SmbFile +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.File + +interface SMBApi { + suspend fun verifyConnection(connection: NetworkConnection): Pair + + fun getFilesFromNetworkPath(path: String): ApiResponse> + + fun createFolderOrFile(path: String, isFolder: Boolean, name: String): ApiResponse + + fun deleteItem(path: String): ApiResponse + + fun writeFileToCache(path: String,context: Context): ApiResponse + + fun getSmbFile(path: String): ApiResponse + + fun getMainSmbFile(): SmbFile +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/interfaces/WebDavApi.kt b/app/src/main/kotlin/org/fossify/filemanager/interfaces/WebDavApi.kt new file mode 100644 index 000000000..ea7482997 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/interfaces/WebDavApi.kt @@ -0,0 +1,23 @@ +package org.fossify.filemanager.interfaces + +import android.content.Context +import com.thegrizzlylabs.sardineandroid.DavResource +import org.fossify.filemanager.enums.Protocols +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.File +import java.io.InputStream + +interface WebDavApi { + suspend fun connectAndVerifyWebDav(connection: NetworkConnection, context: Context): Pair + + suspend fun listAllFilesOnWebDav(url: String): ApiResponse> + + fun getWebDavFileInputStream(url: String, start: Long, end: Long): ApiResponse + + fun createItem(path: String, isFolder: Boolean, name: String): ApiResponse + fun deleteItem(path: String): ApiResponse + fun writeFileToCache(url: String, context: Context): ApiResponse + + fun listWebDavFileDetail(url: String): ApiResponse +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/keyStores/CertificateStore.kt b/app/src/main/kotlin/org/fossify/filemanager/keyStores/CertificateStore.kt new file mode 100644 index 000000000..ff19df764 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/keyStores/CertificateStore.kt @@ -0,0 +1,46 @@ +package org.fossify.filemanager.keyStores + +import android.content.Context +import java.io.FileNotFoundException +import java.security.KeyStore +import java.security.cert.X509Certificate + +object CertificateStore { + private const val KEYSTORE_FILE = "webdav_server_certs.keystore" + private const val KEYSTORE_PASSWORD = "webdav_ks_pass" + fun loadOrCreateKeyStore(context: Context): KeyStore { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + return try { + val fis = context.openFileInput(KEYSTORE_FILE) + keyStore.load(fis, KEYSTORE_PASSWORD.toCharArray()) + keyStore + } catch (e: FileNotFoundException) { + keyStore.load(null, null) + keyStore + } + } + + fun loadCert(context: Context, host: String): X509Certificate { + val keyStore = loadOrCreateKeyStore(context) + return keyStore.getCertificate(host) as X509Certificate + } + + fun saveCert(context: Context, host: String, cert: X509Certificate) { + val keyStore = loadOrCreateKeyStore(context) + keyStore.setCertificateEntry(host, cert) + + val fos = context.openFileOutput(KEYSTORE_FILE, Context.MODE_PRIVATE) + keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray()) + fos.close() + } + + fun removeCert(context: Context, host: String) { + val keyStore = loadOrCreateKeyStore(context) + if (keyStore.containsAlias(host)) { + keyStore.deleteEntry(host) + val fos = context.openFileOutput(KEYSTORE_FILE, Context.MODE_PRIVATE) + keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray()) + fos.close() + } + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/mapper/NetworkConnectionMapper.kt b/app/src/main/kotlin/org/fossify/filemanager/mapper/NetworkConnectionMapper.kt new file mode 100644 index 000000000..14818252e --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/mapper/NetworkConnectionMapper.kt @@ -0,0 +1,122 @@ +package org.fossify.filemanager.mapper + +import android.util.Log +import com.thegrizzlylabs.sardineandroid.DavResource +import jcifs.smb.SmbFile +import net.schmizz.sshj.sftp.RemoteResourceInfo +import org.apache.commons.net.ftp.FTPFile +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.commons.models.FileDirItem +import org.fossify.filemanager.entity.NetworkConnectionEntity +import org.fossify.filemanager.enums.Authentication +import org.fossify.filemanager.enums.Protocols +import org.fossify.filemanager.models.NetworkConnection + +fun NetworkConnectionEntity.toDomain(): NetworkConnection { + return NetworkConnection( + host = host, + port = port, + username = username, + password = password, + displayName = displayName, + connectionType = ConnectionTypes.valueOf(connectionType), + sharedPath = sharedPath, + url = url, + authentication = Authentication.valueOf(authentication), + privateKeyText = privateKey, + privateKeyPass = privateKeyPass, + id = id, + protocols = Protocols.valueOf(protocols ?: "") + ) +} + +fun NetworkConnection.toEntity(): NetworkConnectionEntity { + return NetworkConnectionEntity( + host = host, + port = port, + username = username, + password = password, + displayName = displayName, + connectionType = connectionType.toString(), + sharedPath = sharedPath, + url = url, + authentication = authentication.toString(), + privateKey = privateKeyText, + privateKeyPass = privateKeyPass, + id = id, + protocols = protocols?.toString() + ) +} + +fun SmbFile.toFileItem(connectionTypes: ConnectionTypes = ConnectionTypes.Default): FileDirItem { + + val normalizedPath = this.path.trimEnd('/') + val fileName = normalizedPath.substringAfterLast('/') + + val isDir = this.isDirectory + + val childrenCount = if (isDir) { + try { + this.listFiles()?.size ?: 0 + } catch (e: Exception) { + 0 + } + } else { + 0 + } + + return FileDirItem( + path = this.canonicalPath.trimEnd('/'), + name = fileName.ifEmpty { "/" }, + isDirectory = isDir, + size = if (!this.isDirectory) this.length() else 0L, + modified = this.lastModified(), + children = childrenCount, + mediaStoreId = 0L, + connectionType = connectionTypes + ) +} + +fun DavResource.toFileItem(connectionTypes: ConnectionTypes = ConnectionTypes.Default): FileDirItem { + return FileDirItem( + path = this.href.toString().trimEnd('/'), + name = this.name.trimEnd('/'), + isDirectory = this.isDirectory, + size = if (!this.isDirectory) (this.contentLength ?: 0L) else 0L, + modified = this.modified?.time ?: 0L, + children = 0, + mediaStoreId = 0L, + connectionType = connectionTypes + ) +} + + +fun RemoteResourceInfo.toFileItem(parentPath: String,connectionType: ConnectionTypes = ConnectionTypes.Default): FileDirItem { + val attrs = this.attributes + val cleanParent = parentPath.trimEnd('/') + + return FileDirItem( + path = "$cleanParent/${this.name}", + name = this.name, + isDirectory = this.isDirectory, + size = if (this.isRegularFile) attrs.size else 0L, + modified = attrs.mtime * 1000L, + children = 0, + mediaStoreId = 0L, + connectionType = connectionType + ) +} + +fun FTPFile.toFileItem(parentPath: String,connectionType: ConnectionTypes = ConnectionTypes.Default): FileDirItem { + val cleanParent = parentPath.trimEnd('/') + return FileDirItem( + path = "$cleanParent/${this.name}", + name = this.name, + isDirectory = this.isDirectory, + size = if (this.isFile) this.size else 0L, + modified = this.timestamp?.timeInMillis ?: 0L, + children = 0, + mediaStoreId = 0L, + connectionType = connectionType + ) +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/models/ApiResponse.kt b/app/src/main/kotlin/org/fossify/filemanager/models/ApiResponse.kt new file mode 100644 index 000000000..f221963cc --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/models/ApiResponse.kt @@ -0,0 +1,3 @@ +package org.fossify.filemanager.models + +data class ApiResponse(val response:T?,val exception: Exception?) diff --git a/app/src/main/kotlin/org/fossify/filemanager/models/ConnectionResult.kt b/app/src/main/kotlin/org/fossify/filemanager/models/ConnectionResult.kt new file mode 100644 index 000000000..e486c8f1e --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/models/ConnectionResult.kt @@ -0,0 +1,3 @@ +package org.fossify.filemanager.models + +data class ConnectionResult(val item: NetworkConnection, val success: Boolean, val saveInfo: Boolean = true, val isAddCallOperation: Boolean = false, val exception: Exception? = null) diff --git a/app/src/main/kotlin/org/fossify/filemanager/models/ListItem.kt b/app/src/main/kotlin/org/fossify/filemanager/models/ListItem.kt index 8bf14583a..6b0a21672 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/models/ListItem.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/models/ListItem.kt @@ -1,9 +1,10 @@ package org.fossify.filemanager.models +import org.fossify.commons.enums.ConnectionTypes import org.fossify.commons.models.FileDirItem // isSectionTitle is used only at search results for showing the current folders path data class ListItem( val mPath: String, val mName: String = "", var mIsDirectory: Boolean = false, var mChildren: Int = 0, var mSize: Long = 0L, var mModified: Long = 0L, - var isSectionTitle: Boolean, val isGridTypeDivider: Boolean -) : FileDirItem(mPath, mName, mIsDirectory, mChildren, mSize, mModified) + var isSectionTitle: Boolean, val isGridTypeDivider: Boolean, var parent: String = "", val mConnectionType: ConnectionTypes = ConnectionTypes.Default +) : FileDirItem(mPath, mName, mIsDirectory, mChildren, mSize, mModified, connectionType = mConnectionType) diff --git a/app/src/main/kotlin/org/fossify/filemanager/models/NetworkConnection.kt b/app/src/main/kotlin/org/fossify/filemanager/models/NetworkConnection.kt new file mode 100644 index 000000000..e741183e2 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/models/NetworkConnection.kt @@ -0,0 +1,23 @@ +package org.fossify.filemanager.models + +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.filemanager.enums.Authentication +import org.fossify.filemanager.enums.Protocols + +data class NetworkConnection( + var id: Long = 0, + var host: String = "", + var port: Int = 445, + var username: String? = "", + var password: String? = "", + var displayName: String = "", + var connectionType: ConnectionTypes, + var sharedPath: String = "", + var url: String = "", + var privateKeyText: String = "", + var privateKeyPass: String = "", + var authentication: Authentication = Authentication.Password, + var protocols: Protocols? = Protocols.HTTP +) + + diff --git a/app/src/main/kotlin/org/fossify/filemanager/repository/CertificateRepositoryImpl.kt b/app/src/main/kotlin/org/fossify/filemanager/repository/CertificateRepositoryImpl.kt new file mode 100644 index 000000000..95282a144 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/repository/CertificateRepositoryImpl.kt @@ -0,0 +1,22 @@ +package org.fossify.filemanager.repository + +import android.content.Context +import android.net.Uri +import org.fossify.filemanager.interfaces.CertificateRepository +import org.fossify.filemanager.keyStores.CertificateStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +class CertificateRepositoryImpl: CertificateRepository { + override fun loadCertificate(uri: Uri, context: Context): X509Certificate { + val inputStream = context.contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("Cannot open URI") + + val cf = CertificateFactory.getInstance("X.509") + return cf.generateCertificate(inputStream) as X509Certificate + } + + override fun saveCertificate(host: String, cert: X509Certificate, context: Context) { + CertificateStore.saveCert(context, host, cert) + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/repository/FTPApiImpl.kt b/app/src/main/kotlin/org/fossify/filemanager/repository/FTPApiImpl.kt new file mode 100644 index 000000000..a16d06d49 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/repository/FTPApiImpl.kt @@ -0,0 +1,138 @@ +package org.fossify.filemanager.repository + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.apache.commons.net.ftp.FTP +import org.apache.commons.net.ftp.FTPClient +import org.apache.commons.net.ftp.FTPCmd +import org.apache.commons.net.ftp.FTPFile +import org.fossify.filemanager.enums.Authentication +import org.fossify.filemanager.interfaces.FTPApi +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream + +class FTPApiImpl : FTPApi { + private lateinit var currentStream: InputStream + private lateinit var ftp: FTPClient + private lateinit var ftpStream: FTPClient + + val scope = CoroutineScope(Dispatchers.IO) + + override suspend fun connectToFTP(connection: NetworkConnection): Pair { + return try { + ftp = FTPClient() + ftpStream = FTPClient() + ftp.connect(connection.host, connection.port) + ftpStream.connect(connection.host, connection.port) + + val (username, password) = if (connection.authentication == Authentication.Anonymous) { + "anonymous" to "anonymous" + } else { + connection.username to connection.password + } + val loginSuccess = ftp.login(username, password) + ftpStream.login(username, password) + + if (!loginSuccess) return Pair(false, Exception("Login failed")) + + ftp.enterLocalPassiveMode() + ftpStream.enterLocalPassiveMode() + Pair(true, null) + } catch (exp: Exception) { + Pair(false, exp) + } + } + + override suspend fun listAllFTPFiles(path: String): ApiResponse> { + return try { + ftp.changeWorkingDirectory(path) + val files: Array = ftp.listFiles() + val theFiles = files.toList() + ApiResponse(theFiles, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun getFTPFileDetail(path: String): ApiResponse { + return try { + val myPath = path.replace("//", "/") + if (ftp.hasFeature(FTPCmd.MLST)) { + val file = ftp.mlistFile(myPath) + ApiResponse(file, null) + } + val mP = File(myPath) + val files = ftp.listFiles(mP.parent).firstOrNull { it != null && it.name == mP.name } + ApiResponse(files, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun getFTPFileInputStream(path: String, start: Long): ApiResponse { + return try { + if (::currentStream.isInitialized) + currentStream.close() + ftpStream.completePendingCommand() + ftpStream.setFileType(FTP.BINARY_FILE_TYPE) + ftpStream.restartOffset = start + currentStream = ftpStream.retrieveFileStream(path) + ApiResponse(currentStream, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun deleteItem(path: String, isFolder: Boolean): ApiResponse { + return try { + if (isFolder) ftp.removeDirectory(path) else ftp.deleteFile(path) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + } + + override fun createItem(path: String, isFolder: Boolean, name: String): ApiResponse { + return try { + val uri = "$path/$name" + if (isFolder) ftp.makeDirectory(uri) else ftp.storeFile(uri, ByteArrayInputStream(ByteArray(0))) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + } + + override fun writeFileToCache(path: String, context: Context): ApiResponse { + return try { + + val fileName = path.substringAfterLast('/') + val localFile = File(context.cacheDir, fileName) + + scope.launch(Dispatchers.IO) { + try { + ftp.retrieveFileStream(path).use { input -> + localFile.outputStream().use { output -> + input.copyTo(output) + } + } + ftp.completePendingCommand() + } catch (e: Exception) { + e.printStackTrace() + } + } + + ApiResponse(localFile, null) + + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun getFTPConn(): FTPClient = ftp + +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/repository/NetworkConnectionRepositoryDbImpl.kt b/app/src/main/kotlin/org/fossify/filemanager/repository/NetworkConnectionRepositoryDbImpl.kt new file mode 100644 index 000000000..d88a75bb5 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/repository/NetworkConnectionRepositoryDbImpl.kt @@ -0,0 +1,44 @@ +package org.fossify.filemanager.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.fossify.filemanager.dao.NetworkConnectionDao +import org.fossify.filemanager.interfaces.NetworkConnectionRepositoryDb +import org.fossify.filemanager.mapper.toDomain +import org.fossify.filemanager.mapper.toEntity +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection + +class NetworkConnectionRepositoryDbImpl(private val dao: NetworkConnectionDao) : NetworkConnectionRepositoryDb { + override suspend fun updateConnection(connection: NetworkConnection): ApiResponse { + return try { + dao.updateConnection(connection.toEntity()) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + } + + override fun getAllSavedConnections(): Flow> { + return dao.getAll().map { value -> value.map { entity -> entity.toDomain() } } + } + + override suspend fun deleteConnection(connection: NetworkConnection): ApiResponse { + return try { + dao.delete(connection.toEntity()) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + + } + + override suspend fun addConnection(connection: NetworkConnection): ApiResponse { + return try { + dao.addConnection(connection.toEntity()) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/repository/SFTPApiImpl.kt b/app/src/main/kotlin/org/fossify/filemanager/repository/SFTPApiImpl.kt new file mode 100644 index 000000000..9f04d0b2c --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/repository/SFTPApiImpl.kt @@ -0,0 +1,144 @@ +package org.fossify.filemanager.repository + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.schmizz.sshj.DefaultConfig +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.sftp.FileAttributes +import net.schmizz.sshj.sftp.OpenMode +import net.schmizz.sshj.sftp.RemoteResourceInfo +import net.schmizz.sshj.sftp.SFTPClient +import net.schmizz.sshj.transport.verification.PromiscuousVerifier +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil +import net.schmizz.sshj.userauth.method.AuthPublickey +import net.schmizz.sshj.userauth.password.PasswordUtils +import org.fossify.filemanager.enums.Authentication +import org.fossify.filemanager.interfaces.SFTPApi +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.File +import java.io.IOException +import java.io.InputStream + +class SFTPApiImpl : SFTPApi { + private lateinit var ssh: SSHClient + private lateinit var sftp: SFTPClient + val scope = CoroutineScope(Dispatchers.IO) + + override suspend fun connectToSftp(connection: NetworkConnection): Pair { + return try { + if (!::ssh.isInitialized || !ssh.isConnected || !ssh.isAuthenticated) { + ssh = SSHClient() + ssh.addHostKeyVerifier(PromiscuousVerifier()) + ssh.connect(connection.host, connection.port) + if (connection.authentication == Authentication.PrivateKey) { + ssh.auth( + connection.username, + AuthPublickey(createKeyProvider(connection.privateKeyText, connection.privateKeyPass.takeIf { it.isNotBlank() })) + ) + } else { + ssh.authPassword(connection.username, connection.password) + } + sftp = ssh.newSFTPClient() + } + Pair(true, null) + } catch (e: Exception) { + e.printStackTrace() + Pair(false, e) + } + } + + override suspend fun listAllFilesSFTPRoot(path: String): ApiResponse> { + return try { + val files = sftp.ls(path) + ApiResponse(files, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun listSFTPFileDetails(path: String): ApiResponse { + + return try { + val myPath = path.replace("//", "/") + val attributes = sftp.stat(myPath) + ApiResponse(attributes, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun getSFTPFileInputStream(url: String, startByte: Long): ApiResponse { + return try { + val myPath = url.replace("//", "/") + val remoteFile = sftp.open(myPath) + val inputStream = remoteFile.RemoteFileInputStream(startByte) + ApiResponse(inputStream, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + + } + + override fun createItem(path: String, isFolder: Boolean, name: String): ApiResponse { + return try { + val uri = "$path/$name" + if (isFolder) sftp.mkdir(uri) else sftp.open(uri, setOf(OpenMode.CREAT)) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + } + + override fun deleteItem(path: String,isFolder: Boolean): ApiResponse { + return try { + if (isFolder) sftp.rmdir(path) else sftp.rm(path) + ApiResponse(true, null) + } catch (exp: Exception) { + ApiResponse(false, exp) + } + } + + override fun writeFileToCache(path: String,context: Context): ApiResponse{ + return try { + val fileName = path.substringAfterLast('/') + val localFile = File(context.cacheDir, fileName) + scope.launch(Dispatchers.IO) { + sftp.open(path).use { remoteFile -> + remoteFile.RemoteFileInputStream().use { input -> + localFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } + ApiResponse(localFile, null) + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun getSFTPConn() = sftp + + private val KEY_PROVIDER_FACTORIES = DefaultConfig().fileKeyProviderFactories + + + private fun createKeyProvider( + privateKey: String, + privateKeyPassword: String? + ): KeyProvider { + val format = KeyProviderUtil.detectKeyFileFormat(privateKey, false) + val keyProvider = Factory.Named.Util.create(KEY_PROVIDER_FACTORIES, format.toString()) + ?: throw IOException("No key provider factory found for $format") + keyProvider.init( + privateKey, null, + privateKeyPassword?.let { PasswordUtils.createOneOff(it.toCharArray()) } + ) + return keyProvider + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/repository/SMBApiImpl.kt b/app/src/main/kotlin/org/fossify/filemanager/repository/SMBApiImpl.kt new file mode 100644 index 000000000..4e0ff660e --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/repository/SMBApiImpl.kt @@ -0,0 +1,120 @@ +package org.fossify.filemanager.repository + +import android.content.Context +import jcifs.CIFSContext +import jcifs.config.PropertyConfiguration +import jcifs.context.BaseContext +import jcifs.smb.NtlmPasswordAuthenticator +import jcifs.smb.SmbFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.fossify.commons.enums.ConnectionTypes +import org.fossify.filemanager.enums.Authentication +import org.fossify.filemanager.helpers.Helpers +import org.fossify.filemanager.interfaces.SMBApi +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.File +import java.util.Properties + +class SMBApiImpl : SMBApi { + lateinit var smbClient: SmbFile + val scope = CoroutineScope(Dispatchers.IO) + + private val defaultProperties: Properties = + Properties().apply { + setProperty("jcifs.resolveOrder", "BCAST") + setProperty("jcifs.smb.client.responseTimeout", "30000") + setProperty("jcifs.netbios.retryTimeout", "5000") + setProperty("jcifs.netbios.cachePolicy", "-1") + } + override suspend fun verifyConnection(connection: NetworkConnection): Pair { + return try { + val p = Properties(defaultProperties) + val context: CIFSContext = BaseContext(PropertyConfiguration(p)) + var authContext: CIFSContext? = null + if (connection.authentication == Authentication.Password) { + val auth = NtlmPasswordAuthenticator( + "", + connection.username, + connection.password + ) + authContext = context.withCredentials(auth) + } else if (connection.authentication == Authentication.Anonymous) { + authContext = context.withGuestCrendentials() + } + val smbUrl = Helpers.createUrl(ConnectionTypes.SMB, connection.sharedPath, connection.host, connection.port) + smbClient = SmbFile(smbUrl, authContext) + Pair(smbClient.exists(),null) + } catch (exp: Exception) { + Pair(false,exp) + } + } + + override fun getFilesFromNetworkPath(path: String): ApiResponse> { + return try { + if ("$path/" == smbClient.canonicalPath){ + val files = smbClient.listFiles() + return ApiResponse(files,null) + } + val subDir = SmbFile("$path/", smbClient.context) + ApiResponse(subDir.listFiles(),null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun createFolderOrFile(path: String, isFolder: Boolean, name: String): ApiResponse { + return try { + val file = SmbFile("$path/$name", smbClient.context) + if (isFolder) file.mkdir() else file.createNewFile() + ApiResponse(true,null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun deleteItem(path: String): ApiResponse { + return try { + val file = SmbFile(path, smbClient.context) + file.delete() + ApiResponse(true,null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun writeFileToCache(path: String,context: Context): ApiResponse { + return try { + val smbFile = SmbFile(path, smbClient.context) + val localFile = File(context.cacheDir, smbFile.name) + scope.launch { + smbFile.inputStream.use { input -> + localFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + ApiResponse(localFile,null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun getSmbFile(path: String): ApiResponse { + return try { + val smbFile = SmbFile(path, smbClient.context) + ApiResponse(smbFile,null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun getMainSmbFile(): SmbFile = smbClient +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/repository/WebDavApiImpl.kt b/app/src/main/kotlin/org/fossify/filemanager/repository/WebDavApiImpl.kt new file mode 100644 index 000000000..11033c53d --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/repository/WebDavApiImpl.kt @@ -0,0 +1,169 @@ +package org.fossify.filemanager.repository + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.util.Log +import com.thegrizzlylabs.sardineandroid.DavResource +import com.thegrizzlylabs.sardineandroid.Sardine +import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine +import okhttp3.OkHttpClient +import org.fossify.filemanager.enums.Protocols +import org.fossify.filemanager.interfaces.WebDavApi +import org.fossify.filemanager.keyStores.CertificateStore +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.NetworkConnection +import java.io.InputStream +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager +import androidx.core.net.toUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +class WebDavApiImpl: WebDavApi { + lateinit var sardine: Sardine + val scope = CoroutineScope(Dispatchers.IO) + override suspend fun connectAndVerifyWebDav( + connection: NetworkConnection, + context: Context + ): Pair { + return try { + sardine = if (connection.protocols == Protocols.HTTP) { + OkHttpSardine() + } else { + createHTTPSSardine(context,connection.host) + } + sardine.setCredentials(connection.username, connection.password, true) + Pair(sardine.exists(connection.url),null) + } catch (exp: Exception) { + Pair(false,exp) + } + } + + override suspend fun listAllFilesOnWebDav(url: String): ApiResponse> { + return try { + val resources = sardine.list(url) + val b = Uri.decode(url.toUri().encodedPath?.trimEnd('/')) + val filteredItems = resources.filter { resource -> + val a = Uri.decode(resource.href.toString().toUri().encodedPath?.trimEnd('/')) + a != b + } + ApiResponse(filteredItems,null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun getWebDavFileInputStream(url: String, start: Long, end: Long): ApiResponse { + return try { + val rangeHeader = "bytes=$start-$end" + val headers = mapOf("Range" to rangeHeader) + val stream = sardine.get(url, headers) + ApiResponse(stream,null) + } + catch (exp: Exception){ + ApiResponse(null,exp) + } + } + + override fun createItem(path: String, isFolder: Boolean, name: String): ApiResponse { + return try { + val uri = "$path/$name" + if (isFolder) sardine.createDirectory(uri) else sardine.put(uri, ByteArray(0)) + ApiResponse(true,null) + } + catch (exp: Exception){ + ApiResponse(false,exp) + } + } + + override fun deleteItem(path: String): ApiResponse { + return try { + sardine.delete(path) + ApiResponse(true,null) + } + catch (exp: Exception){ + ApiResponse(false,exp) + } + } + + override fun writeFileToCache(url: String, context: Context): ApiResponse { + return try { + val localFile = File(context.cacheDir, Uri.parse(url).lastPathSegment) + scope.launch(Dispatchers.IO) { + try { + sardine.get(url).use { input -> + localFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + ApiResponse(localFile, null) + + } catch (exp: Exception) { + ApiResponse(null, exp) + } + } + + override fun listWebDavFileDetail(url: String): ApiResponse { + + return try { + val resources = sardine.list(url) + var resource:DavResource? = null + if (resources.isNotEmpty()) { + resource = resources[0] + } + ApiResponse(resource,null) + } + catch (exp: Exception){ + ApiResponse(null,null) + } + } + + private fun createHTTPSSardine(context: Context, host: String): Sardine { + return buildSardineWithUserCert(context, host) + } + + private fun buildSardineWithUserCert( + context: Context, + host: String + ): Sardine { + val cert = CertificateStore.loadCert(context, host) + + val trustManager = @SuppressLint("CustomX509TrustManager") + object : X509TrustManager { + override fun getAcceptedIssuers(): Array = arrayOf(cert) + + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array, authType: String) { + } + + override fun checkServerTrusted(chain: Array, authType: String) { + if (!chain[0].encoded.contentEquals(cert.encoded)) { + throw CertificateException("Untrusted certificate") + } + } + } + + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustManager), null) + } + + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .hostnameVerifier { hostname, _ -> + hostname == host + } + .build() + + return OkHttpSardine(okHttpClient) + } +} diff --git a/app/src/main/kotlin/org/fossify/filemanager/viewmodels/NetworkBrowserViewModel.kt b/app/src/main/kotlin/org/fossify/filemanager/viewmodels/NetworkBrowserViewModel.kt new file mode 100644 index 000000000..e7ca0f933 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/filemanager/viewmodels/NetworkBrowserViewModel.kt @@ -0,0 +1,254 @@ +package org.fossify.filemanager.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.thegrizzlylabs.sardineandroid.DavResource +import jcifs.smb.SmbFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import net.schmizz.sshj.sftp.RemoteResourceInfo +import net.schmizz.sshj.sftp.SFTPClient +import org.apache.commons.net.ftp.FTPFile +import org.fossify.filemanager.interfaces.FTPApi +import org.fossify.filemanager.interfaces.NetworkConnectionRepositoryDb +import org.fossify.filemanager.interfaces.SFTPApi +import org.fossify.filemanager.interfaces.SMBApi +import org.fossify.filemanager.interfaces.WebDavApi +import org.fossify.filemanager.models.ApiResponse +import org.fossify.filemanager.models.ConnectionResult +import org.fossify.filemanager.models.NetworkConnection +import java.io.File + +class NetworkBrowserViewModel( + private val networkConnectionRepository: NetworkConnectionRepositoryDb, + private val webDavApi: WebDavApi, + private val ftpApi: FTPApi, + private val sftpApi: SFTPApi, + private val smbApi: SMBApi +) : ViewModel() { + + + + val addConnection = MutableSharedFlow>() + val updateConnection = MutableSharedFlow>() + val deleteConnection = MutableSharedFlow>() + val savedNetworks = MutableStateFlow>(emptyList()) + + val verifyNetwork = MutableSharedFlow() + val smbFolderOrFile = MutableSharedFlow>() + val smbDelete = MutableSharedFlow>() + val smbFileShare = MutableSharedFlow>() + + val verifyWebDav = MutableSharedFlow() + + val verifySFTP = MutableSharedFlow() + val verifyFTP = MutableSharedFlow() + + + val sftpFiles = MutableStateFlow>?>(null) + val sftpFolderOrFile = MutableSharedFlow>() + val sftpDelete = MutableSharedFlow>() + val sftpFileShare = MutableSharedFlow>() + + val webDavFiles = MutableStateFlow< ApiResponse>?>(null) + val webDavFolderOrFile = MutableSharedFlow>() + val webDavDelete = MutableSharedFlow>() + val webDavFileShare = MutableSharedFlow>() + + val ftpFiles = MutableStateFlow>?>(null) + val ftpFolderOrFile = MutableSharedFlow>() + val ftpDelete = MutableSharedFlow>() + val ftpFileShare = MutableSharedFlow>() + + + + fun updateConnection(networkConnection: NetworkConnection) { + viewModelScope.launch(Dispatchers.IO) { + updateConnection.emit(networkConnectionRepository.updateConnection(networkConnection)) + } + } + + fun addConnection(networkConnection: NetworkConnection){ + viewModelScope.launch(Dispatchers.IO) { + addConnection.emit(networkConnectionRepository.addConnection(networkConnection)) + } + } + + fun getAllSavedNetworks() { + viewModelScope.launch(Dispatchers.IO) { + val connections = networkConnectionRepository.getAllSavedConnections().collectLatest { value -> + savedNetworks.emit(value) + } + } + } + + fun deleteConnection(connection: NetworkConnection){ + viewModelScope.launch(Dispatchers.IO) { + deleteConnection.emit(networkConnectionRepository.deleteConnection(connection)) + } + } + + fun verifySMBNetwork(connection: NetworkConnection, saveInfo: Boolean, isAddOperation: Boolean = true) { + viewModelScope.launch(Dispatchers.IO) { + val value = smbApi.verifyConnection(connection) + verifyNetwork.emit(ConnectionResult(connection, value.first, saveInfo = saveInfo,isAddOperation,value.second)) + } + } + + fun getFilesFromNetworkPath(path: String): ApiResponse> { + return smbApi.getFilesFromNetworkPath(path) + } + + fun createFolderOrFileSMB(path: String, isFolder: Boolean, name: String) { + viewModelScope.launch(Dispatchers.IO) { + smbFolderOrFile.emit(smbApi.createFolderOrFile(path, isFolder,name)) + } + } + + fun deleteItemSMB(path: String) { + viewModelScope.launch(Dispatchers.IO) { + smbDelete.emit(smbApi.deleteItem(path)) + } + } + + fun writeSmbFileToCache(path: String,context: Context) { + viewModelScope.launch(Dispatchers.IO) { + smbFileShare.emit(smbApi.writeFileToCache(path,context)) + } + } + + + fun getMainSmb(): SmbFile = smbApi.getMainSmbFile() + + fun getSFTPConn(): SFTPClient = sftpApi.getSFTPConn() + + fun connectAndAuthenticateWebDav(connection: NetworkConnection, saveInfo: Boolean, context: Context,isAddOperation: Boolean = true) { + viewModelScope.launch(Dispatchers.IO) { + val result = webDavApi.connectAndVerifyWebDav(connection, context) + verifyWebDav.emit(ConnectionResult(connection, result.first, saveInfo,isAddOperation,result.second)) + } + } + + fun listWebDavFiles(url: String) { + viewModelScope.launch(Dispatchers.IO) { + webDavFiles.emit(webDavApi.listAllFilesOnWebDav(url)) + } + } + + fun createItem(path: String, isFolder: Boolean, name: String) { + viewModelScope.launch(Dispatchers.IO) { + webDavFolderOrFile.emit(webDavApi.createItem(path, isFolder,name)) + } + } + + fun deleteItemWebDav(path: String) { + viewModelScope.launch(Dispatchers.IO) { + webDavDelete.emit(webDavApi.deleteItem(path)) + } + } + + fun writeWebDavFileToCache(url: String,context: Context) { + viewModelScope.launch(Dispatchers.IO) { + webDavFileShare.emit(webDavApi.writeFileToCache(url,context)) + } + } + +// fun listWebDavFileStream(url: String, start: Long, end: Long): InputStream { +// return webDavApi.getWebDavFileInputStream(url, start, end) +// } + +// fun listWebDavFileDetail(url: String): DavResource? { +// return webDavApi.listWebDavFileDetail(url) +// } + + fun connectSFTP(connection: NetworkConnection, saveInfo: Boolean,isAddOperation: Boolean = true) { + viewModelScope.launch(Dispatchers.IO) { + val res = sftpApi.connectToSftp(connection) + verifySFTP.emit(ConnectionResult(connection, res.first, saveInfo,isAddOperation,res.second)) + } + } + + fun listAllFilesSFTPRoot(path: String) { + viewModelScope.launch(Dispatchers.IO) { + val res = sftpApi.listAllFilesSFTPRoot(path) + sftpFiles.emit(res) + } + } + + fun createItemSFTP(path: String, isFolder: Boolean, name: String) { + viewModelScope.launch(Dispatchers.IO) { + sftpFolderOrFile.emit(sftpApi.createItem(path, isFolder,name)) + } + } + + fun deleteItemSFTP(path: String,isFolder: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + sftpDelete.emit(sftpApi.deleteItem(path,isFolder)) + } + } + + fun writeSftpFileToCache(path: String,context: Context){ + viewModelScope.launch(Dispatchers.IO) { + sftpFileShare.emit(sftpApi.writeFileToCache(path,context)) + } + } + +// fun listAllFilesSFTPPath(path: String) { +// viewModelScope.launch(Dispatchers.IO) { +// val res = sftpApi.listAllFilesSFTPRoot(path) +// sftpFiles.emit(res) +// } +// } +// +// fun listSFTPFileDetails(path: String): FileAttributes? { +// return sftpApi.listSFTPFileDetails(path) +// } +// +// fun getSFTPFileStream(path: String, startByte: Long): InputStream { +// return sftpApi.getSFTPFileInputStream(url = path, startByte) +// } + + fun connectFTP(connection: NetworkConnection, saveInfo: Boolean,isAddOperation: Boolean = true) { + viewModelScope.launch(Dispatchers.IO) { + val res = ftpApi.connectToFTP(connection) + verifyFTP.emit(ConnectionResult(connection, res.first, saveInfo,isAddOperation,res.second)) + } + } + + fun getFTP() = ftpApi.getFTPConn() + + fun listAllFTPFiles(path: String) { + viewModelScope.launch(Dispatchers.IO) { + val res = ftpApi.listAllFTPFiles(path) + ftpFiles.emit(res) + } + } + + fun createItemFTP(path: String, isFolder: Boolean, name: String) { + viewModelScope.launch(Dispatchers.IO) { + ftpFolderOrFile.emit(sftpApi.createItem(path, isFolder,name)) + } + } + + fun deleteItemFTP(path: String,isFolder: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + ftpDelete.emit(sftpApi.deleteItem(path,isFolder)) + } + } + + fun writeFtpFileToCache(path: String,context: Context){ + viewModelScope.launch(Dispatchers.IO) { + ftpFileShare.emit(sftpApi.writeFileToCache(path,context)) + } + } + + + + fun getFTPFileDetail(path: String) = ftpApi.getFTPFileDetail(path) + + fun getFTPFileStream(path: String, start: Long) = ftpApi.getFTPFileInputStream(path, start) +} diff --git a/app/src/main/res/drawable/baseline_attach_file_24.xml b/app/src/main/res/drawable/baseline_attach_file_24.xml new file mode 100644 index 000000000..7b56c632d --- /dev/null +++ b/app/src/main/res/drawable/baseline_attach_file_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_edit_24.xml b/app/src/main/res/drawable/baseline_edit_24.xml new file mode 100644 index 000000000..3c53db7ec --- /dev/null +++ b/app/src/main/res/drawable/baseline_edit_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_insert_drive_file_24.xml b/app/src/main/res/drawable/baseline_insert_drive_file_24.xml new file mode 100644 index 000000000..9cc3bdb23 --- /dev/null +++ b/app/src/main/res/drawable/baseline_insert_drive_file_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/cloud_activity.xml b/app/src/main/res/layout/cloud_activity.xml new file mode 100644 index 000000000..e4b86ec6d --- /dev/null +++ b/app/src/main/res/layout/cloud_activity.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_add_connection.xml b/app/src/main/res/layout/dialog_add_connection.xml new file mode 100644 index 000000000..ddac4d936 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_connection.xml @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_network_connection.xml b/app/src/main/res/layout/item_network_connection.xml new file mode 100644 index 000000000..0621fd844 --- /dev/null +++ b/app/src/main/res/layout/item_network_connection.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu.xml b/app/src/main/res/menu/menu.xml index fbe716715..9e2098336 100644 --- a/app/src/main/res/menu/menu.xml +++ b/app/src/main/res/menu/menu.xml @@ -23,6 +23,11 @@ android:icon="@drawable/ic_star_outline_vector" android:title="@string/add_to_favorites" app:showAsAction="ifRoom" /> + + Others free Total storage: %s + Cloud Connection Enable root access @@ -60,4 +61,42 @@ Haven't found some strings? There's more at https://github.com/FossifyOrg/Commons/tree/master/commons/src/main/res --> + + + Cloud Connection + Add Connection + No connections found + + Select Option + Select Protocol + Authentication + Username + Password + Private Key + Password Private Key + Display Name + Shared path + No certificate attached + Attach certificate + + Host: 192.168.1.1 + SMB + + + 445 + 21 + 22 + 80 + 443 + + Host name cannot be empty + User name cannot be empty + Password cannot be empty + Port cannot be empty + Display name cannot be empty + Shared path cannot be empty + Private key cannot be empty + Private key password cannot be empty + + diff --git a/build.gradle.kts b/build.gradle.kts index 360f5f410..67ae1e505 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ plugins { alias(libs.plugins.android).apply(false) alias(libs.plugins.detekt).apply(false) + alias(libs.plugins.ksp) } diff --git a/gradle.properties b/gradle.properties index ebf6c7249..f42dee71c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,6 +13,7 @@ android.enableJetifier=true android.nonTransitiveRClass=false android.useAndroidX=true org.gradle.jvmargs=-Xmx4g +android.disallowKotlinSourceSets=false # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ce31c12c..e8173832d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ kotlin = "2.3.10" #Detekt detekt = "1.23.8" detektCompose = "0.4.28" -#AndroidX androidx-swiperefreshlayout = "1.2.0" androidx-documentfile = "1.1.0" #Fossify @@ -23,20 +22,38 @@ app-build-targetSDK = "36" app-build-minimumSDK = "26" app-build-javaVersion = "VERSION_17" app-build-kotlinJVMTarget = "17" +ksp = "2.2.0-2.0.2" +room = "2.8.4" +jcifs = "2.1.10" +gson = "2.13.2" +nanohttpd = "2.3.1" +sardine-android = "0.9" +sshj = "0.40.0" +bouncycastle = "1.81" +commons-net = "3.10.0" + [libraries] -#AndroidX androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" } androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidx-documentfile" } -#Compose +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } compose-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } -#Fossify fossify-commons = { module = "org.fossify:commons", version.ref = "commons" } -#Other +jcifs-ng = { module = "eu.agno3.jcifs:jcifs-ng", version.ref = "jcifs" } autofittextview = { module = "me.grantland:autofittextview", version.ref = "autofittextview" } gestureviews = { module = "com.alexvasilkov:gesture-views", version.ref = "gestureviews" } rootshell = { module = "com.github.naveensingh:RootShell", version.ref = "rootshell" } roottools = { module = "com.github.naveensingh:RootTools", version.ref = "roottools" } zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" } +nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } +sardine-android = { module = "com.github.thegrizzlylabs:sardine-android", version.ref = "sardine-android" } +sshj = { module = "com.hierynomus:sshj", version.ref = "sshj" } +bouncycastle-provider = { module = "org.bouncycastle:bcprov-jdk15to18", version.ref = "bouncycastle" } +bouncycastle-pkix = { module = "org.bouncycastle:bcpkix-jdk15to18", version.ref = "bouncycastle" } +commons-net = { module = "commons-net:commons-net", version.ref = "commons-net" } + [plugins] android = { id = "com.android.application", version.ref = "gradlePlugins-agp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4567e61fe..45478cbec 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,8 +10,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { setUrl("https://www.jitpack.io") } - mavenLocal() + maven { setUrl("https://jitpack.io") } } } include(":app")