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" }