diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 148d6d81..6bfea57c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,9 +46,25 @@ android { baselineProfile { dexLayoutOptimization = true } + + packaging { + resources { + // Ignorar archivos duplicados META-INF que causan conflictos + excludes += listOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/*.kotlin_module" + ) + } + } } dependencies { + implementation(libs.androidx.material3) "baselineProfile"(project(":baselineprofile")) implementation(libs.androidx.profileinstaller) coreLibraryDesugaring(libs.desugar.jdk.libs) @@ -106,4 +122,12 @@ dependencies { implementation(libs.gson) implementation(libs.storage) implementation(libs.zip4j) + + // SMB 2/3 support + implementation(libs.smbj) + implementation(libs.dcerpc) { + exclude(group = "com.google.code.findbugs", module = "jsr305") + } + // SMB 1 support (JCIFS-NG) + implementation(libs.jcifs.ng) } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 6870b379..849bc536 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -25,5 +25,16 @@ -keep class org.joni.** { *; } -keep class android.content.** { *; } -keep class com.android.apksig.** { *; } +# SMB SUPPORT +-keep class com.hierynomus.** { *; } +-keep class com.rapid7.** { *; } +-keep class sun.security.** { *; } +-dontwarn sun.security.** +-keep class java.rmi.** { *; } +-dontwarn java.rmi.** +-keep class javax.el.** { *; } +-dontwarn javax.el.** +-keep class org.ietf.jgss.** { *; } +-dontwarn org.ietf.jgss.** -keepnames interface * { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f2ecd3d..1ee45547 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,9 @@ + + + + val matchResult = "\\((\\d+\\.\\d+\\.\\d+\\.\\d+):(\\d+)\\)$".toRegex().find(selected) + val ip: String + val port: String + + if (matchResult != null) { + ip = matchResult.groupValues[1] + port = matchResult.groupValues[2] + } else { + ip = selected + port = "445" + } + + mainActivityManager.toggleLanDiscoveryDialog(false) + mainActivityManager.toggleAddSMBDriveDialog(true, defaultHost = ip, defaultPort = port) + } + ) + + AddStorageMenuDialog( + show = mainActivityState.showStorageMenuDialog, + onDismiss = { mainActivityManager.toggleStorageMenuDialog(false) }, + onAddSmb = { + mainActivityManager.toggleStorageMenuDialog(false) + mainActivityManager.toggleAddSMBDriveDialog(true) + }, + onAddLan = { + mainActivityManager.toggleStorageMenuDialog(false) + mainActivityManager.toggleLanDiscoveryDialog(true) + } + ) + AppInfoDialog( show = mainActivityState.showAppInfoDialog, hasNewUpdate = mainActivityState.hasNewUpdate, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt index 3d46fb8a..2eb98b06 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt @@ -1,6 +1,8 @@ package com.raival.compose.file.explorer.screen.main +import android.annotation.SuppressLint import android.content.Context +import android.net.wifi.WifiManager import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.raival.compose.file.explorer.App.Companion.globalClass @@ -21,23 +23,37 @@ import com.raival.compose.file.explorer.screen.main.tab.Tab import com.raival.compose.file.explorer.screen.main.tab.apps.AppsTab import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.SMB1FileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.SMBFileHolder import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab +import jcifs.netbios.NbtAddress +import jcifs.netbios.UniAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import java.io.File import java.io.InputStreamReader import java.net.ConnectException import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.Socket import java.net.URL import java.net.UnknownHostException +import java.nio.ByteOrder import kotlin.math.max import kotlin.math.min + class MainActivityManager { val managerScope = CoroutineScope(Dispatchers.IO) private val _state = MutableStateFlow(MainActivityState()) @@ -223,12 +239,61 @@ class MainActivityManager { openFile(file, context) } + + fun addSmbDrive( + host: String, + port: Int, + username: String, + password: String, + anonymous: Boolean, + domain: String, + context: Context + ): Boolean { + return try { + openSMBFile(SMBFileHolder(host, port, username, password, anonymous, domain, ""), context) + } catch (e: Exception) { + false + } + } + + fun addSmb1Drive( + host: String, + port: Int, + username: String, + password: String, + anonymous: Boolean, + domain: String, + context: Context + ): Boolean { + return try { + openSMB1File(SMB1FileHolder(host, port, username, password, anonymous, domain, ""), context) + } catch (e: Exception) { + false + } + } + private fun openFile(file: LocalFileHolder, context: Context) { if (file.exists()) { addTabAndSelect(FilesTab(file, context)) } } + private fun openSMBFile(file: SMBFileHolder, context: Context) : Boolean { + return if (file.exists()) { + addTabAndSelect(FilesTab(file, context)) + true + }else + false + } + + private fun openSMB1File(file: SMB1FileHolder, context: Context) : Boolean { + return if (file.exists()) { + addTabAndSelect(FilesTab(file, context)) + true + }else + false + } + fun resumeActiveTab() { getActiveTab()?.onTabResumed() } @@ -513,6 +578,132 @@ class MainActivityManager { } } + fun toggleLanDiscoveryDialog(show: Boolean) { + _state.update { + it.copy( + showLanDiscoveryDialog = show, + isLanScanningRunning = show, + lanDevices = if (show) emptyList() else it.lanDevices + ) + } + + if (show) { + startLanScan(globalClass) + } + } + + @SuppressLint("ServiceCast") + fun getLocalIpAddress(context: Context): Pair? { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + for (iface in interfaces) { + val addrs = iface.inetAddresses + for (addr in addrs) { + if (!addr.isLoopbackAddress && addr is InetAddress && addr.address.size == 4) { + val ip = addr.hostAddress + val prefixLength = iface.interfaceAddresses.find { it.address.hostAddress == ip }?.networkPrefixLength + if (prefixLength != null) { + return Pair(ip, prefixLength.toString()) + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return null + } + + fun calculateIpRange(ip: String, prefixLength: Int): List { + val ipParts = ip.split(".").map { it.toInt() } + val ipInt = (ipParts[0] shl 24) or (ipParts[1] shl 16) or (ipParts[2] shl 8) or ipParts[3] + + val mask = (-1 shl (32 - prefixLength)) + val network = ipInt and mask + val broadcast = network or mask.inv() + + val ips = mutableListOf() + for (current in network + 1 until broadcast) { + val octets = listOf( + (current shr 24) and 0xFF, + (current shr 16) and 0xFF, + (current shr 8) and 0xFF, + current and 0xFF + ) + ips.add(octets.joinToString(".")) + } + + return ips + } + + + + fun startLanScan(context: Context) { + managerScope.launch { + _state.update { it.copy(isLanScanningRunning = true, lanDevices = emptyList()) } + + val local = getLocalIpAddress(context) + if (local == null) { + _state.update { it.copy(isLanScanningRunning = false) } + return@launch + } + + val (localIp, prefixStr) = local + val prefixLength = prefixStr.toInt() + val ipsToScan = calculateIpRange(localIp, prefixLength) + + val foundDevices = mutableSetOf() + val ports = listOf(445, 139, 4450) + val semaphore = Semaphore(50) + + val jobs = ipsToScan.map { ip -> + async(Dispatchers.IO) { + semaphore.withPermit { + for (port in ports) { + try { + Socket().use { socket -> + socket.connect(java.net.InetSocketAddress(ip, port), 200) + val inetAddress = InetAddress.getByName(ip) + val hostName = inetAddress.hostName + + val display = "$hostName ($ip:$port)" + if (foundDevices.add(display)) { + _state.update { + it.copy(lanDevices = it.lanDevices + display) + } + } + + break + } + } catch (_: Exception) {} + } + } + } + } + + jobs.awaitAll() + _state.update { it.copy(isLanScanningRunning = false) } + } + } + + fun toggleAddSMBDriveDialog(show: Boolean, defaultHost: String = "", defaultPort: String = "") { + _state.update { + it.copy( + showAddSMBDriveDialog = show, + smbDefaultHost = defaultHost, + smbDefaultPort = defaultPort + ) + } + } + + fun toggleStorageMenuDialog(show: Boolean) { + _state.update { + it.copy( + showStorageMenuDialog = show + ) + } + } + fun toggleAppInfoDialog(show: Boolean) { _state.update { it.copy( diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityState.kt index 89a3f5bc..1ea184dc 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityState.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityState.kt @@ -12,6 +12,13 @@ data class MainActivityState( val subtitle: String = emptyString, val showAppInfoDialog: Boolean = false, val showJumpToPathDialog: Boolean = false, + val showAddSMBDriveDialog: Boolean = false, + val showStorageMenuDialog: Boolean = false, + val showLanDiscoveryDialog: Boolean = false, + val isLanScanningRunning: Boolean = true, + val lanDevices: List = emptyList(), + val smbDefaultHost: String = "", + val smbDefaultPort: String = "", val showSaveEditorFilesDialog: Boolean = false, val showStartupTabsDialog: Boolean = false, val isSavingFiles: Boolean = false, @@ -19,5 +26,5 @@ data class MainActivityState( val storageDevices: List = emptyList(), val tabs: List = emptyList(), val tabLayoutState: LazyListState = LazyListState(), - val hasNewUpdate: Boolean = false + val hasNewUpdate: Boolean = false, ) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt index 91dc49d0..c2976f50 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt @@ -28,6 +28,7 @@ import com.raival.compose.file.explorer.screen.main.MainActivity import com.raival.compose.file.explorer.screen.main.tab.Tab import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.SMBFileHolder import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileListCategory diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/SMB1FileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/SMB1FileHolder.kt new file mode 100644 index 00000000..3ce143f0 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/SMB1FileHolder.kt @@ -0,0 +1,267 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.holder + +import android.content.Context +import android.webkit.MimeTypeMap +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import jcifs.smb.SmbFile +import com.raival.compose.file.explorer.screen.main.tab.files.smb.SMB1ConnectionManager +import kotlinx.coroutines.runBlocking + +class SMB1FileHolder( + val host: String, + val port: Int = 139, + val username: String? = null, + val password: String? = null, + val anonymous: Boolean = false, + val domain: String? = null, + val shareName: String = "", + val pathInsideShare: String = "", + private val _isFolder: Boolean = true +) : ContentHolder() { + + private var folderCount = 0 + private var fileCount = 0 + var details = "" + + override val displayName: String + get() = when { + shareName.isEmpty() -> host // raíz del host + pathInsideShare.isEmpty() -> shareName // share + else -> { + val file = getSmbFile() + file.name.trimEnd('/').substringAfterLast('/') // nombre real del archivo/carpeta + } + } + + override val isFolder: Boolean + get() = _isFolder + + override val lastModified: Long + get() = System.currentTimeMillis() // jcifs-ng no expone lastModified fácilmente + + override val size: Long + get() = if (isFolder) 0L else try { getSmbFile().length() } catch (e: Exception) { 0L } + + override val uniquePath: String + get() = if (pathInsideShare.isEmpty()) "smb://$host/$shareName" else "smb://$host/$shareName/$pathInsideShare" + + override val extension: String by lazy { + if (isFolder) "" + else displayName.substringAfterLast('.', "").lowercase() + } + + override val canAddNewContent: Boolean = true + override val canRead: Boolean get() = true + override val canWrite: Boolean get() = true + + val mimeType: String by lazy { + if (isFolder) anyFileType + else MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: anyFileType + } + + private fun getSmbFile(): SmbFile = SMB1ConnectionManager.getFile( + host = host, + port = port, + share = shareName, + path = pathInsideShare, + username = username, + password = password, + domain = domain, + anonymous = anonymous + ) + + override suspend fun getDetails(): String { + if (details.isNotEmpty()) return details + details = buildString { + append("SMB1 Host: $host") + if (!anonymous) append(" | User: $username") + } + return details + } + + override suspend fun isValid(): Boolean = withContext(Dispatchers.IO) { + try { + val file = getSmbFile() + file.connect() // fuerza la conexión al servidor + file.list() // opcional: lista el directorio para asegurarte que existe + true + } catch (e: Exception) { + false + } + } + + override suspend fun listContent(): ArrayList = withContext(Dispatchers.IO) { + folderCount = 0 + fileCount = 0 + val result = arrayListOf() + try { + val folder = getSmbFile() + + if (shareName.isEmpty()) { + folder.listFiles()?.forEach { s -> + val shareNameClean = s.name.trimEnd('/') + if (!shareNameClean.endsWith("$")) { + result.add( + SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareNameClean, + _isFolder = true + ) + ) + folderCount++ + } + } + } else if (folder.isDirectory) { + folder.listFiles()?.forEach { f -> + if (f.name == "." || f.name == "..") return@forEach + val isDir = f.isDirectory + var rawName = f.name.trimEnd('/') + + if (rawName.startsWith(shareName)) { + rawName = rawName.removePrefix(shareName) + } + + val childPath = if (pathInsideShare.isEmpty()) rawName else "$pathInsideShare/$rawName" + + result.add( + SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = childPath, + _isFolder = isDir + ) + ) + + if (isDir) folderCount++ else fileCount++ + } + } + } catch (_: Exception) {} + result + } + + override suspend fun getParent(): SMB1FileHolder? { + return when { + shareName.isEmpty() -> null + pathInsideShare.isBlank() -> SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = "", + _isFolder = true + ) + else -> { + val parentPath = pathInsideShare.substringBeforeLast("/", "") + SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = parentPath, + _isFolder = true + ) + } + } + } + + override fun open(context: Context, anonymous: Boolean, skipSupportedExtensions: Boolean, customMimeType: String?) { + // abrir archivos no implementado todavía + } + + override suspend fun getContentCount(): ContentCount = ContentCount(fileCount, folderCount) + + override suspend fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) = withContext(Dispatchers.IO) { + try { + val parent = getSmbFile() + if (parent.isDirectory) { + val newFile = SmbFile("${parent.url}$name", SMB1ConnectionManager.getContext(host, port, shareName, username, password, domain, anonymous)) + newFile.createNewFile() + onCreated( + SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = if (pathInsideShare.isEmpty()) name else "$pathInsideShare/$name", + _isFolder = false + ) + ) + } else onCreated(null) + } catch (_: Exception) { + onCreated(null) + } + } + + override suspend fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) = withContext(Dispatchers.IO) { + try { + val parent = getSmbFile() + if (parent.isDirectory) { + val newFolder = SmbFile("${parent.url}$name/", SMB1ConnectionManager.getContext(host, port,shareName, username, password, domain, anonymous)) + newFolder.mkdir() + onCreated( + SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = if (pathInsideShare.isEmpty()) name else "$pathInsideShare/$name", + _isFolder = true + ) + ) + } else onCreated(null) + } catch (_: Exception) { + onCreated(null) + } + } + + override suspend fun findFile(name: String): SMB1FileHolder? = withContext(Dispatchers.IO) { + try { + val folder = getSmbFile() + if (folder.isDirectory) { + folder.listFiles()?.forEach { f -> + if (f.name == name) { + return@withContext SMB1FileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = if (pathInsideShare.isEmpty()) name else "$pathInsideShare/$name", + _isFolder = f.isDirectory + ) + } + } + } + } catch (_: Exception) {} + null + } + + fun exists() = runBlocking { isValid() } +} + diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/SMBFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/SMBFileHolder.kt new file mode 100644 index 00000000..5e662a13 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/SMBFileHolder.kt @@ -0,0 +1,339 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.holder + +import android.content.Context +import android.webkit.MimeTypeMap +import com.hierynomus.msdtyp.AccessMask +import com.hierynomus.msfscc.FileAttributes +import com.hierynomus.msfscc.fileinformation.FileIdBothDirectoryInformation +import com.hierynomus.mssmb2.SMB2CreateDisposition +import com.hierynomus.mssmb2.SMB2ShareAccess +import com.hierynomus.protocol.commons.EnumWithValue +import com.hierynomus.smbj.SMBClient +import com.hierynomus.smbj.connection.Connection +import com.hierynomus.smbj.share.DiskShare +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType +import com.raival.compose.file.explorer.screen.main.tab.files.smb.SMBConnectionManager +import kotlinx.coroutines.runBlocking +import com.rapid7.client.dcerpc.transport.SMBTransportFactories +import com.rapid7.client.dcerpc.mssrvs.ServerService +import com.rapid7.client.dcerpc.mssrvs.dto.NetShareInfo0 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SMBFileHolder( + val host: String, + val port: Int = 445, + val username: String?, + val password: String?, + val anonymous: Boolean, + val domain: String, + val shareName: String = "", + val pathInsideShare: String = "", + private val _isFolder: Boolean = true +) : ContentHolder() { + + private var entry: FileIdBothDirectoryInformation? = null + private var folderCount = 0 + private var fileCount = 0 + private var timestamp = -1L + var details = "" + + override val displayName: String + get() = when { + shareName.isEmpty() -> host + pathInsideShare.isEmpty() -> shareName + else -> pathInsideShare.substringAfterLast("/") + } + + override val isFolder: Boolean + get() = _isFolder + + override val lastModified: Long + get() = System.currentTimeMillis().also { if (timestamp == -1L) timestamp = it } + + override val size: Long + get() = if (isFolder) 0L else entry?.endOfFile ?: 0L + + override val uniquePath: String + get() = if (pathInsideShare.isEmpty()) "smb://$host/$shareName" else "smb://$host/$shareName/$pathInsideShare" + + override val extension: String by lazy { + if (isFolder) "" + else displayName.substringAfterLast('.', "").lowercase() + } + + override val canAddNewContent: Boolean = true + + override val canRead: Boolean by lazy { + if (isFolder) true + else entry?.let { EnumWithValue.EnumUtils.isSet(it.fileAttributes, FileAttributes.FILE_ATTRIBUTE_READONLY).not() } ?: false + } + + override val canWrite: Boolean by lazy { + if (isFolder) false + else entry?.let { EnumWithValue.EnumUtils.isSet(it.fileAttributes, FileAttributes.FILE_ATTRIBUTE_READONLY).not() } ?: false + } + + val mimeType: String by lazy { + if (isFolder) anyFileType + else MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: anyFileType + } + + override suspend fun getDetails(): String { + if (details.isNotEmpty()) return details + details = buildString { + append("SMB Host: $host") + if (!anonymous) append(" | User: $username") + } + return details + } + + override suspend fun isValid(): Boolean = withContext(Dispatchers.IO) { + if (host.isBlank()) return@withContext false + + val client = SMBClient() + try { + client.connect(host).use { connection -> + val session = SMBConnectionManager.getSession( + host = host, + port = port, + username = username, + password = password, + domain = domain, + anonymous = anonymous + ) + + if (shareName.isNotBlank()) { + val share = session.connectShare(shareName) as DiskShare + share.list("").isNotEmpty() + } else { + val transport = SMBTransportFactories.SRVSVC.getTransport(session) + val serverService = ServerService(transport) + val shares: List = serverService.shares0 + shares.any { !it.netName.endsWith("$") } + } + } + } catch (e: Exception) { + false + } + } + + override suspend fun listContent(): ArrayList { + folderCount = 0 + fileCount = 0 + + val result = arrayListOf() + val client = SMBClient() + + try { + client.connect(host).use { connection: Connection -> + val session = SMBConnectionManager.getSession( + host = host, + port = port, + username = username, + password = password, + domain = domain, + anonymous = anonymous + ) + + if (shareName.isNullOrBlank() || shareName == "/") { + val transport = SMBTransportFactories.SRVSVC.getTransport(session) + val serverService = ServerService(transport) + val shares: List = serverService.shares0 + + for (share in shares) { + val name = share.netName + // filter administrative shares (C$, ADMIN$, IPC$, etc.) + if (!name.endsWith("$")) { + result.add( + SMBFileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = name + ) + ) + folderCount++ + } + } + } else { + val share: DiskShare = session.connectShare(shareName) as DiskShare + val listPath = pathInsideShare + for (entry in share.list(listPath)) { + // ignore virtual folders "." and ".." + if (entry.fileName == "." || entry.fileName == "..") continue + + val isDir = EnumWithValue.EnumUtils.isSet( + entry.fileAttributes, FileAttributes.FILE_ATTRIBUTE_DIRECTORY + ) + val childPath = + if (listPath.isEmpty()) entry.fileName else "$listPath/${entry.fileName}" + + result.add( + SMBFileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = childPath, + _isFolder = isDir + ).apply { this.entry = entry }) + + if (isDir) folderCount++ else fileCount++ + } + } + } + } catch (e: Exception) { + globalClass.logger.logError(e) + } + + return result + } + + override suspend fun getParent(): SMBFileHolder? { + if (pathInsideShare.isBlank()) return null // + val parentPath = pathInsideShare.substringBeforeLast("/", "") + return SMBFileHolder(host, port, username, password, anonymous, domain, shareName, parentPath) + } + + override fun open(context: Context, anonymous: Boolean, skipSupportedExtensions: Boolean, customMimeType: String?) { + + } + + override suspend fun getContentCount(): ContentCount = ContentCount(fileCount, folderCount) + + override suspend fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) { + val client = SMBClient() + try { + client.connect(host).use { connection -> + val session = SMBConnectionManager.getSession( + host = host, + port = port, + username = username, + password = password, + domain = domain, + anonymous = anonymous + ) + + val share = session.connectShare(shareName) as DiskShare + val newFilePath = if (pathInsideShare.isBlank()) name else "$pathInsideShare/$name" + + val file = share.openFile( + newFilePath, + setOf(AccessMask.GENERIC_WRITE), + null, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_CREATE, + null + ) + file.close() + + onCreated( + SMBFileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = newFilePath, + _isFolder = false + ) + ) + } + } catch (e: Exception) { + globalClass.logger.logError(e) + onCreated(null) + } + } + + override suspend fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) { + val client = SMBClient() + try { + client.connect(host).use { connection -> + val session = SMBConnectionManager.getSession( + host = host, + port = port, + username = username, + password = password, + domain = domain, + anonymous = anonymous + ) + + val share = session.connectShare(shareName) as DiskShare + + val newFolderPath = if (pathInsideShare.isBlank()) name else "$pathInsideShare/$name" + share.mkdir(newFolderPath) + + onCreated( + SMBFileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = newFolderPath, + _isFolder = true + ) + ) + } + } catch (e: Exception) { + globalClass.logger.logError(e) + onCreated(null) + } + } + + override suspend fun findFile(name: String): SMBFileHolder? { + val client = SMBClient() + try { + client.connect(host).use { connection -> + val session = SMBConnectionManager.getSession( + host = host, + port = port, + username = username, + password = password, + domain = domain, + anonymous = anonymous + ) + + val share = session.connectShare(shareName) as DiskShare + for (entry in share.list(pathInsideShare)) { + if (entry.fileName == name) { + val isDir = EnumWithValue.EnumUtils.isSet( + entry.fileAttributes, FileAttributes.FILE_ATTRIBUTE_DIRECTORY + ) + val fullPath = if (pathInsideShare.isBlank()) name else "$pathInsideShare/$name" + + return SMBFileHolder( + host = host, + port = port, + username = username, + password = password, + anonymous = anonymous, + domain = domain, + shareName = shareName, + pathInsideShare = fullPath, + _isFolder = isDir + ) + } + } + } + } catch (e: Exception) { + globalClass.logger.logError(e) + } + return null + } + + fun exists() = runBlocking { isValid() } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/smb/SMB1ConnectionManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/smb/SMB1ConnectionManager.kt new file mode 100644 index 00000000..cc3f7da4 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/smb/SMB1ConnectionManager.kt @@ -0,0 +1,101 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.smb + +import jcifs.CIFSContext +import jcifs.context.BaseContext +import jcifs.context.SingletonContext +import jcifs.smb.NtlmPasswordAuthenticator +import jcifs.smb.SmbFile +import java.net.MalformedURLException + +object SMB1ConnectionManager { + + private val contexts = mutableMapOf() + + init { + System.setProperty("jcifs.smb.client.minVersion", "SMB1") + System.setProperty("jcifs.smb.client.maxVersion", "SMB1") + System.setProperty("jcifs.smb.client.responseTimeout", "5000") + System.setProperty("jcifs.smb.client.soTimeout", "5000") + } + + fun getOrCreateContext( + host: String, + port: Int, + share: String, + username: String?, + password: String?, + domain: String?, + anonymous: Boolean + ): CIFSContext { + val key = listOf(host, port, share, username ?: "anon", domain ?: "", anonymous, password?.hashCode() ?: 0) + .joinToString("|") + + contexts[key]?.let { return it } + + val baseContext = SingletonContext.getInstance() as BaseContext + val context = if (anonymous || username.isNullOrBlank()) { + baseContext.withAnonymousCredentials() + } else { + val auth = NtlmPasswordAuthenticator(domain ?: "", username, password) + baseContext.withCredentials(auth) + } + + val testUrl = "smb://$host:$port/$share/" + try { + val file = SmbFile(testUrl, context) + file.connect() + } catch (e: Exception) { + throw RuntimeException("Unable to connect to SMB1 share: $testUrl", e) + } + + contexts[key] = context + return context + } + + fun getFile( + host: String, + port: Int, + share: String, + path: String = "", + username: String? = null, + password: String? = null, + domain: String? = null, + anonymous: Boolean = false + ): SmbFile { + val context = getOrCreateContext(host, port, share, username, password, domain, anonymous) + + val url = buildString { + append("smb://") + append(host) + append(":") + append(port) + append("/") + append(share) + if (path.isNotBlank()) { + append("/") + append(path.trimStart('/')) + } + if(share.isNotBlank()) + append("/") + } + + return try { + SmbFile(url, context) + } catch (e: MalformedURLException) { + throw RuntimeException("Invalid SMB URL: $url", e) + } + } + + + fun getContext( + host: String, + port: Int, + share: String, + username: String? = null, + password: String? = null, + domain: String? = null, + anonymous: Boolean = false + ): CIFSContext { + return getOrCreateContext(host, port, share, username, password, domain, anonymous) + } +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/smb/SMBConnectionManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/smb/SMBConnectionManager.kt new file mode 100644 index 00000000..37ef051f --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/smb/SMBConnectionManager.kt @@ -0,0 +1,49 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.smb + +import com.hierynomus.smbj.SMBClient +import com.hierynomus.smbj.session.Session +import com.hierynomus.smbj.auth.AuthenticationContext + +object SMBConnectionManager { + private val clients = mutableMapOf() + + fun getSession( + host: String, + port: Int = 445, + username: String?, + password: String?, + domain: String?, + anonymous: Boolean + ): Session { + val key = listOf(host, port, username ?: "anon", domain ?: "", anonymous, password?.hashCode() ?: 0) + .joinToString("|") + clients[key]?.let { return it } + + val client = SMBClient() + val connection = client.connect(host, port) + val session = try { + if (anonymous || username.isNullOrBlank()) { + connection.authenticate(null) + } else { + connection.authenticate( + AuthenticationContext(username, password?.toCharArray(), domain) + ) + } + } catch (e: Exception) { + connection.close() + throw RuntimeException("Authentication failed for $username@$host:$port", e) + } + + try { + val share = session.connectShare("IPC$") + share.close() + } catch (e: Exception) { + session.logoff() + throw RuntimeException("Session could not access share. Bad credentials?", e) + } + + clients[key] = session + return session + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt index 2e6e8769..e53b47c1 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt @@ -12,6 +12,8 @@ object HomeSectionIds { const val PINNED_FILES = "pinned_files" const val RECYCLE_BIN = "recycle_bin" const val JUMP_TO_PATH = "jump_to_path" + + const val SMB_STORAGE = "smb_storage" } fun getDefaultHomeLayout(minimalLayout: Boolean = false) = HomeLayout( @@ -64,6 +66,13 @@ fun getDefaultHomeLayout(minimalLayout: Boolean = false) = HomeLayout( title = globalClass.getString(R.string.jump_to_path), isEnabled = !minimalLayout, order = 6 + ), + HomeSectionConfig( + id = HomeSectionIds.SMB_STORAGE, + type = HomeSectionType.SMB_STORAGE, + title = globalClass.getString(R.string.smb_storage), + isEnabled = true, + order = 7 ) ) ) @@ -74,19 +83,33 @@ data class HomeLayout( ) { // Adds missing sections for backward compatibility with older saved layouts fun getSections(): List { - // Add Pinned Files if missing (for layouts saved before v1.3.2) - if (sections.find { it.id == HomeSectionIds.PINNED_FILES } == null) { - return sections.plus( + var updatedSections = sections + var nextOrder = updatedSections.maxOfOrNull { it.order }?.plus(1) ?: 0 + // Add Pinned Files if missing + if (updatedSections.none { it.id == HomeSectionIds.PINNED_FILES }) { + updatedSections = updatedSections.plus( HomeSectionConfig( id = HomeSectionIds.PINNED_FILES, type = HomeSectionType.PINNED_FILES, title = globalClass.getString(R.string.pinned_files), isEnabled = true, - order = sections.maxOfOrNull { it.order }?.plus(1) ?: 0 + order = nextOrder++ + ) + ) + } + // Add SMB Storage if missing + if (updatedSections.none { it.id == HomeSectionIds.SMB_STORAGE }) { + updatedSections = updatedSections.plus( + HomeSectionConfig( + id = HomeSectionIds.SMB_STORAGE, + type = HomeSectionType.SMB_STORAGE, + title = globalClass.getString(R.string.smb_storage), + isEnabled = true, + order = nextOrder++ ) ) } - return sections + return updatedSections } } @@ -107,5 +130,6 @@ enum class HomeSectionType { BOOKMARKS, RECYCLE_BIN, JUMP_TO_PATH, - PINNED_FILES + PINNED_FILES, + SMB_STORAGE, } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt index 6983f40d..08567702 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.rounded.Bookmarks import androidx.compose.material.icons.rounded.Category import androidx.compose.material.icons.rounded.DeleteSweep import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.Lan import androidx.compose.material.icons.rounded.Storage import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -284,6 +285,7 @@ fun HomeSectionType.getIcon(): ImageVector { HomeSectionType.RECYCLE_BIN -> Icons.Rounded.DeleteSweep HomeSectionType.JUMP_TO_PATH -> Icons.Rounded.ArrowOutward HomeSectionType.PINNED_FILES -> PrismIcons.Pin + HomeSectionType.SMB_STORAGE -> Icons.Rounded.Lan } } @@ -296,5 +298,6 @@ fun HomeSectionType.getDescription(): String { HomeSectionType.RECYCLE_BIN -> globalClass.getString(R.string.deleted_files) HomeSectionType.JUMP_TO_PATH -> globalClass.getString(R.string.quick_path_navigation) HomeSectionType.PINNED_FILES -> globalClass.getString(R.string.pinned_files_desc) + HomeSectionType.SMB_STORAGE -> globalClass.getString(R.string.smb_storage_desc) } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt index d52e8909..b50a68e9 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.ArrowOutward import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material.icons.rounded.Lan import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -190,6 +191,10 @@ fun ColumnScope.HomeTabContentView(tab: HomeTab) { HomeSectionType.PINNED_FILES -> { PinnedFilesSection(tab = tab, mainActivityManager = mainActivityManager) } + + HomeSectionType.SMB_STORAGE -> { + SMBStorageSection(mainActivityManager = mainActivityManager) + } } } } @@ -639,4 +644,32 @@ private fun JumpToPathSection( mainActivityManager.toggleJumpToPathDialog(true) } } +} + +@Composable +private fun SMBStorageSection( + mainActivityManager: MainActivityManager +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp) + ) + .clip(RoundedCornerShape(12.dp)) + ) { + SimpleNewTabViewItem( + title = stringResource(R.string.smb_storage), + imageVector = Icons.Rounded.Lan + ) { + mainActivityManager.toggleStorageMenuDialog(true) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AddSMBDriveDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AddSMBDriveDialog.kt new file mode 100644 index 00000000..ba918486 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AddSMBDriveDialog.kt @@ -0,0 +1,308 @@ +package com.raival.compose.file.explorer.screen.main.ui + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.Space +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddSMBDriveDialog( + show: Boolean, + onDismiss: () -> Unit = {} +) { + if (!show) return + + val context = LocalContext.current + val mainActivityManager = globalClass.mainActivityManager + val mainActivityState by mainActivityManager.state.collectAsState() + var host by remember { mutableStateOf(mainActivityState.smbDefaultHost) } + var portText by remember { mutableStateOf(mainActivityState.smbDefaultPort) } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var anonymous by remember { mutableStateOf(false) } + var showMore by remember { mutableStateOf(false) } + var domain by remember { mutableStateOf("") } + + val smbAutoText = stringResource(R.string.smb_auto) + val smb1Text = stringResource(R.string.smb_1) + val smb2Text = stringResource(R.string.smb_2) + + var smbVersion by remember { mutableStateOf(smbAutoText) } + val smbVersions = listOf(smbAutoText, smb1Text, smb2Text) + + var expanded by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.smb_storage), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(stringResource(R.string.host)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth() + ) + + if (showMore) { + OutlinedTextField( + value = portText, + onValueChange = { newValue -> + if (newValue.all { it.isDigit() }) { + portText = newValue + } + }, + label = { Text(stringResource(R.string.port)) }, + placeholder = { Text("445") }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ), + modifier = Modifier.fillMaxWidth() + ) + } + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.username)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth(), + enabled = !anonymous + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth(), + enabled = !anonymous + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Checkbox( + checked = anonymous, + onCheckedChange = { + anonymous = it + if (it) { username = ""; password = "" } + } + ) + Text(text = stringResource(R.string.anonymous)) + } + + TextButton(onClick = { showMore = !showMore }) { + Text(if (showMore) stringResource(R.string.see_less) else stringResource(R.string.see_more)) + } + + if (showMore) { + OutlinedTextField( + value = domain, + onValueChange = { domain = it }, + label = { Text(stringResource(R.string.domain)) }, + placeholder = { Text(stringResource(R.string.optional)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth() + ) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = smbVersion, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.version)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + smbVersions.forEach { version -> + DropdownMenuItem( + text = { Text(version) }, + onClick = { + smbVersion = version + expanded = false + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onDismiss, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } + Button( + modifier = Modifier.weight(1f), + onClick = { + CoroutineScope(Dispatchers.IO).launch { + val smb1Port = portText.toIntOrNull() ?: 139 + val smb2Port = portText.toIntOrNull() ?: 445 + + val success = when (smbVersion) { + smb1Text -> mainActivityManager.addSmb1Drive(host, smb1Port, username, password, anonymous, domain, context) + smb2Text -> mainActivityManager.addSmbDrive(host, smb2Port, username, password, anonymous, domain, context) + else -> mainActivityManager.addSmbDrive(host, smb2Port, username, password, anonymous, domain, context) + || mainActivityManager.addSmb1Drive(host, smb1Port, username, password, anonymous, domain, context) + } + + withContext(Dispatchers.Main) { + if (success) { + onDismiss() + } else { + Toast.makeText( + context, + context.getString(R.string.cant_connect_smb), + Toast.LENGTH_LONG + ).show() + } + } + } + }, + enabled = host.isNotBlank() && (anonymous || (username.isNotBlank() && password.isNotBlank())), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.connect), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AddStorageMenuDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AddStorageMenuDialog.kt new file mode 100644 index 00000000..767d56d3 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AddStorageMenuDialog.kt @@ -0,0 +1,87 @@ +package com.raival.compose.file.explorer.screen.main.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddStorageMenuDialog( + show: Boolean, + onDismiss: () -> Unit = {}, + onAddLan: () -> Unit = {}, + onAddSmb: () -> Unit = {} +) { + if (!show) return + + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .widthIn(min = 280.dp, max = 400.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Add Storage", + style = MaterialTheme.typography.headlineSmall, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = { + onAddLan() + onDismiss() + }, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Storage, contentDescription = "LAN") + Spacer(modifier = Modifier.width(8.dp)) + Text("LAN", style = MaterialTheme.typography.labelLarge) + } + + OutlinedButton( + onClick = { + onAddSmb() + onDismiss() + }, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Storage, contentDescription = "SMB") + Spacer(modifier = Modifier.width(8.dp)) + Text("SMB", style = MaterialTheme.typography.labelLarge) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Cancel", style = MaterialTheme.typography.labelLarge) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/LanDiscoveryDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/LanDiscoveryDialog.kt new file mode 100644 index 00000000..ff08a024 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/LanDiscoveryDialog.kt @@ -0,0 +1,88 @@ +package com.raival.compose.file.explorer.screen.main.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanDiscoveryDialog( + show: Boolean, + isScanning: Boolean, + devices: List, + onDismiss: () -> Unit, + onDeviceSelected: (String) -> Unit +) { + if (!show) return + + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .widthIn(min = 280.dp, max = 400.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = "LAN", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) + } + + if (isScanning) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.heightIn(min = 100.dp, max = 300.dp) + ) { + items(devices) { device -> + OutlinedButton( + onClick = { + onDeviceSelected(device) + onDismiss() + }, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text(device, style = MaterialTheme.typography.labelLarge) + } + } + } + + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Cancelar", style = MaterialTheme.typography.labelLarge) + } + } + } + } +} diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 27f227c2..98fdc424 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -453,4 +453,20 @@ Navegación hacia atrás para cerrar pestañas Recordar última sesión Confirmar antes de salir de la aplicación + Nuevo almacenamiento SMB + Conectar a unidades de red + Host + Usuario + Contraseña + Anónimo + Ver menos + Ver más + Dominio + Opcional + No se pudo conectar. Por favor, verifica tus datos. + Conectar + Puerto + Automático + JCIFS-NG + SMBJ (SMB 2.0+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 174d1fb9..d5d57877 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -453,4 +453,20 @@ Back navigation to close tabs Remember last session Confirm before exit the app + New SMB Storage + Connect to network drives + Host + Username + Password + Anonymous + See less + See more + Domain + Optional + Could not connect. Please check your details. + Connect + Port + Automatic + JCIFS-NG + SMBJ (SMB 2.0+) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09a75f43..fdb88846 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,10 @@ accompanistSystemuicontroller = "0.36.0" agp = "8.6.1" apksig = "8.12.0" commonsNet = "3.12.0" +dcerpc = "0.12.13" desugar_jdk_libs = "2.1.5" grid = "2.4.0" +jcifsNg = "2.1.10" kotlin = "2.2.0" coreKtx = "1.16.0" appcompat = "1.7.1" @@ -14,6 +16,7 @@ androidxComposeBom = "2025.07.00" media3Exoplayer = "1.8.0" okio = "3.16.0" paletteKtx = "1.0.0" +smbj = "0.12.1" soraEditor = "0.23.6" gson = "2.13.1" dataStore = "1.1.7" @@ -35,6 +38,9 @@ profileinstaller = "1.4.1" reorderable = "2.5.1" uiToolingPreviewAndroid = "1.9.0" zoomableImageCoil3 = "0.16.0" +material3 = "1.3.2" +foundation = "1.9.0" +uiGraphics = "1.9.0" [libraries] accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } @@ -49,8 +55,10 @@ coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilCompose" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" } coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coilCompose" } commons-net = { module = "commons-net:commons-net", version.ref = "commonsNet" } +dcerpc = { module = "com.rapid7.client:dcerpc", version.ref = "dcerpc" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } grid = { module = "com.cheonjaeung.compose.grid:grid", version.ref = "grid" } +jcifs-ng = { module = "eu.agno3.jcifs:jcifs-ng", version.ref = "jcifsNg" } lazycolumnscrollbar = { module = "com.github.nanihadesuka:LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } @@ -60,6 +68,7 @@ androidx-compose-icons-extended = { group = "androidx.compose.material", name = androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycle_runtime_compose_android" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity_compose" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +smbj = { module = "com.hierynomus:smbj", version.ref = "smbj" } sora-editor = { group = "io.github.Rosemoe.sora-editor", name = "editor", version.ref = "soraEditor" } sora-editor-language-java = { group = "io.github.Rosemoe.sora-editor", name = "language-java", version.ref = "soraEditor" } sora-editor-language-textmate = { group = "io.github.Rosemoe.sora-editor", name = "language-textmate", version.ref = "soraEditor" } @@ -80,6 +89,9 @@ androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profi reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } zoomable-image-coil3 = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "zoomableImageCoil3" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }