diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000000..d66fe1703c
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000000..8c1575c2be
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 1d3e3ba73f..01dec3f195 100755
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -8,6 +8,7 @@
+
@@ -54,7 +55,7 @@
-
+
diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml
new file mode 100644
index 0000000000..affd64cc89
--- /dev/null
+++ b/.idea/planningMode.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle
index f41ed12c8f..89affab264 100644
--- a/buildsystem/dependencies.gradle
+++ b/buildsystem/dependencies.gradle
@@ -79,6 +79,8 @@ ext {
scaleImageViewVersion = '3.10.1-dev.0002'
+ slf4jAndroidVersion = '1.7.36'
+
lruFileCacheVersion = '1.2'
// KEEP IN SYNC WITH GENERATOR VERSION IN root build.gradle
@@ -100,6 +102,7 @@ ext {
minIoVersion = '9.0.3'
pcloudVersion = '1.10.0-dev.0001'
+ smbjVersion = '0.13.0'
staxVersion = '1.2.0' // needed for minIO
commonsCodecVersion = '1.22.0'
@@ -193,6 +196,8 @@ ext {
rxJava : "io.reactivex.rxjava2:rxjava:${rxJavaVersion}",
rxAndroid : "io.reactivex.rxjava2:rxandroid:${rxAndroidVersion}",
rxBinding : "com.jakewharton.rxbinding2:rxbinding:${rxBindingVersion}",
+ slf4jAndroid : "org.slf4j:slf4j-android:${slf4jAndroidVersion}",
+ smbj : "com.hierynomus:smbj:${smbjVersion}",
stax : "stax:stax:${staxVersion}",
testingSupportLib : "com.android.support.test:testing-support-lib:${testingSupportLibVersion}",
timber : "com.jakewharton.timber:timber:${timberVersion}",
diff --git a/data/build.gradle b/data/build.gradle
index 69ca59c8f4..ff7bf522ac 100644
--- a/data/build.gradle
+++ b/data/build.gradle
@@ -157,6 +157,7 @@ dependencies {
addToFlavors(cloudFlavors, dependencies.pcloud)
+ implementation dependencies.smbj
implementation dependencies.stax
api dependencies.minIo
diff --git a/data/src/apkStorePlaystore/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java b/data/src/apkStorePlaystore/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
index 918d4dfb20..51fb48a863 100644
--- a/data/src/apkStorePlaystore/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
+++ b/data/src/apkStorePlaystore/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
@@ -7,6 +7,7 @@
import org.cryptomator.data.cloud.onedrive.OnedriveCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.pcloud.PCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.s3.S3CloudContentRepositoryFactory;
+import org.cryptomator.data.cloud.smb.SmbCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.webdav.WebDavCloudContentRepositoryFactory;
import org.cryptomator.data.repository.CloudContentRepositoryFactory;
import org.jetbrains.annotations.NotNull;
@@ -29,6 +30,7 @@ public CloudContentRepositoryFactories(DropboxCloudContentRepositoryFactory drop
OnedriveCloudContentRepositoryFactory oneDriveFactory, //
PCloudContentRepositoryFactory pCloudFactory, //
S3CloudContentRepositoryFactory s3Factory, //
+ SmbCloudContentRepositoryFactory smbFactory, //
CryptoCloudContentRepositoryFactory cryptoFactory, //
LocalStorageContentRepositoryFactory localStorageFactory, //
WebDavCloudContentRepositoryFactory webDavFactory) {
@@ -38,6 +40,7 @@ public CloudContentRepositoryFactories(DropboxCloudContentRepositoryFactory drop
oneDriveFactory, //
pCloudFactory, //
s3Factory, //
+ smbFactory, //
cryptoFactory, //
localStorageFactory, //
webDavFactory);
diff --git a/data/src/fdroidAccrescent/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java b/data/src/fdroidAccrescent/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
index 4511d33771..1d3e577f3d 100644
--- a/data/src/fdroidAccrescent/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
+++ b/data/src/fdroidAccrescent/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
@@ -6,6 +6,7 @@
import org.cryptomator.data.cloud.onedrive.OnedriveCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.pcloud.PCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.s3.S3CloudContentRepositoryFactory;
+import org.cryptomator.data.cloud.smb.SmbCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.webdav.WebDavCloudContentRepositoryFactory;
import org.cryptomator.data.repository.CloudContentRepositoryFactory;
import org.jetbrains.annotations.NotNull;
@@ -27,6 +28,7 @@ public CloudContentRepositoryFactories(DropboxCloudContentRepositoryFactory drop
OnedriveCloudContentRepositoryFactory oneDriveFactory, //
PCloudContentRepositoryFactory pCloudFactory, //
S3CloudContentRepositoryFactory s3Factory, //
+ SmbCloudContentRepositoryFactory smbFactory, //
CryptoCloudContentRepositoryFactory cryptoFactory, //
LocalStorageContentRepositoryFactory localStorageFactory, //
WebDavCloudContentRepositoryFactory webDavFactory) {
@@ -35,6 +37,7 @@ public CloudContentRepositoryFactories(DropboxCloudContentRepositoryFactory drop
oneDriveFactory, //
pCloudFactory, //
s3Factory, //
+ smbFactory, //
cryptoFactory, //
localStorageFactory, //
webDavFactory);
diff --git a/data/src/lite/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java b/data/src/lite/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
index b058c305b4..91805dae92 100644
--- a/data/src/lite/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
+++ b/data/src/lite/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java
@@ -5,6 +5,7 @@
import org.cryptomator.data.cloud.crypto.CryptoCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.local.LocalStorageContentRepositoryFactory;
import org.cryptomator.data.cloud.s3.S3CloudContentRepositoryFactory;
+import org.cryptomator.data.cloud.smb.SmbCloudContentRepositoryFactory;
import org.cryptomator.data.cloud.webdav.WebDavCloudContentRepositoryFactory;
import org.cryptomator.data.repository.CloudContentRepositoryFactory;
import org.jetbrains.annotations.NotNull;
@@ -23,12 +24,14 @@ public class CloudContentRepositoryFactories implements Iterable(Intercepted(cloud, context)) {
+
+ @Throws(BackendException::class)
+ override fun throwWrappedIfRequired(e: Exception) {
+ if (e is SMBApiException) {
+ val status = e.status.value
+ if (status == 0xc000003aL || status == 0xc0000034L) { // STATUS_OBJECT_PATH_NOT_FOUND or STATUS_OBJECT_NAME_NOT_FOUND
+ throw NoSuchCloudFileException(e.message)
+ }
+ }
+ if (e is com.hierynomus.smbj.common.SMBException) {
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ private class Intercepted(
+ private val cloud: SmbCloud,
+ private val context: Context
+ ) : CloudContentRepository {
+
+ private fun getDecryptedPassword(): String {
+ return CredentialCryptor.getInstance(context).decrypt(cloud.password())
+ }
+
+ private fun parseSmbUrl(url: String): Triple {
+ // Expected format: smb://host/share/optional/path
+ val uri = try {
+ URI(url)
+ } catch (e: Exception) {
+ throw IllegalArgumentException("Invalid SMB URL format", e)
+ }
+ val host = uri.host ?: throw IllegalArgumentException("Invalid host in SMB URL")
+ val path = uri.path?.removePrefix("/") ?: ""
+ val parts = path.split("/").filter { it.isNotEmpty() }
+ val share = parts.firstOrNull() ?: throw IllegalArgumentException("Missing share name in SMB URL. Format: smb://hostname/sharename/")
+ val basePath = parts.drop(1).joinToString("/")
+ return Triple(host, share, basePath)
+ }
+
+ override fun checkAuthenticationAndRetrieveCurrentAccount(cloud: SmbCloud): String {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ return cloud.username()
+ } else {
+ throw NetworkConnectionException(RuntimeException("Specified share is not a disk share"))
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("SmbContentRepo").e(e, "SMB Authentication failed")
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ override fun root(cloud: SmbCloud): SmbFolder {
+ val (_, _, basePath) = parseSmbUrl(cloud.url())
+ return SmbFolder(null, basePath, cloud)
+ }
+
+ override fun resolve(cloud: SmbCloud, path: String): SmbFolder {
+ return SmbFolder(null, path, cloud)
+ }
+
+ override fun file(parent: SmbFolder, name: String): SmbFile {
+ return SmbFile(parent, name, cloud)
+ }
+
+ override fun file(parent: SmbFolder, name: String, size: Long?): SmbFile {
+ return SmbFile(parent, name, cloud, size)
+ }
+
+ override fun folder(parent: SmbFolder, name: String): SmbFolder {
+ return SmbFolder(parent, name, cloud)
+ }
+
+ override fun exists(node: SmbNode): Boolean {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ return ds.fileExists(node.path) || ds.folderExists(node.path)
+ }
+ return false
+ }
+ }
+ }
+ } catch (e: Exception) {
+ return false
+ }
+ }
+
+ override fun list(folder: SmbFolder): List {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ return ds.list(folder.path).map { fileInfo ->
+ if (fileInfo.fileName == "." || fileInfo.fileName == "..") {
+ null
+ } else {
+ val isDirectory = (fileInfo.fileAttributes and 0x10L) != 0L
+ if (isDirectory) {
+ SmbFolder(folder, fileInfo.fileName, cloud)
+ } else {
+ SmbFile(folder, fileInfo.fileName, cloud, fileInfo.endOfFile, Date(fileInfo.lastWriteTime.toEpochMillis()))
+ }
+ }
+ }.filterNotNull()
+ } else {
+ throw NetworkConnectionException(RuntimeException("Specified share is not a disk share"))
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("SmbContentRepo").e(e, "SMB Listing failed for path: ${folder.path}")
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ override fun create(folder: SmbFolder): SmbFolder {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ if (!ds.folderExists(folder.path)) {
+ ds.mkdir(folder.path)
+ }
+ return folder
+ } else {
+ throw NetworkConnectionException(RuntimeException("Specified share is not a disk share"))
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("SmbContentRepo").e(e, "SMB Create folder failed for path: ${folder.path}")
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ override fun move(source: SmbFolder, target: SmbFolder): SmbFolder {
+ throw UnsupportedOperationException("SMB move not yet implemented")
+ }
+
+ override fun move(source: SmbFile, target: SmbFile): SmbFile {
+ throw UnsupportedOperationException("SMB move not yet implemented")
+ }
+
+ override fun write(file: SmbFile, data: DataSource, progressAware: ProgressAware, replace: Boolean, size: Long): SmbFile {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ if (!replace && ds.fileExists(file.path)) {
+ throw CloudNodeAlreadyExistsException(file.name)
+ }
+ progressAware.onProgress(Progress.started(UploadState.upload(file)))
+ val disposition = if (replace) SMB2CreateDisposition.FILE_OVERWRITE_IF else SMB2CreateDisposition.FILE_CREATE
+ ds.openFile(file.path, EnumSet.of(AccessMask.GENERIC_WRITE), null, SMB2ShareAccess.ALL, disposition, null).use { f ->
+ f.outputStream.use { outputStream ->
+ data.open(context)?.use { inputStream ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalTransferred = 0L
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
+ outputStream.write(buffer, 0, bytesRead)
+ totalTransferred += bytesRead
+ progressAware.onProgress(
+ Progress.progress(UploadState.upload(file))
+ .between(0)
+ .and(size)
+ .withValue(totalTransferred)
+ )
+ }
+ }
+ }
+ }
+ // Retrieve file info after upload
+ val fileInfo = ds.getFileInformation(file.path)
+ return SmbFile(file.parent, file.name, cloud, fileInfo.standardInformation.endOfFile, Date(fileInfo.basicInformation.lastWriteTime.toEpochMillis()))
+ } else {
+ throw NetworkConnectionException(RuntimeException("Specified share is not a disk share"))
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("SmbContentRepo").e(e, "SMB Write failed for path: ${file.path}")
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ override fun read(file: SmbFile, encryptedTmpFile: File?, data: OutputStream, progressAware: ProgressAware) {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ ds.openFile(file.path, EnumSet.of(AccessMask.FILE_READ_DATA), null, SMB2ShareAccess.ALL, SMB2CreateDisposition.FILE_OPEN, null).use { f ->
+ f.inputStream.use { it.copyTo(data) }
+ }
+ } else {
+ throw NetworkConnectionException(RuntimeException("Specified share is not a disk share"))
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("SmbContentRepo").e(e, "SMB Read failed for path: ${file.path}")
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ override fun delete(node: SmbNode) {
+ val (host, share, _) = parseSmbUrl(cloud.url())
+ val client = SMBClient()
+ try {
+ client.connect(host).use { connection ->
+ val authContext = AuthenticationContext(cloud.username(), getDecryptedPassword().toCharArray(), cloud.domain() ?: "")
+ val session = connection.authenticate(authContext)
+ session.use { s ->
+ s.connectShare(share).use { ds ->
+ if (ds is DiskShare) {
+ if (ds.folderExists(node.path)) {
+ ds.rmdir(node.path, true)
+ } else if (ds.fileExists(node.path)) {
+ ds.rm(node.path)
+ }
+ } else {
+ throw NetworkConnectionException(RuntimeException("Specified share is not a disk share"))
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Timber.tag("SmbContentRepo").e(e, "SMB Delete failed for path: ${node.path}")
+ throw NetworkConnectionException(e)
+ }
+ }
+
+ override fun logout(cloud: SmbCloud) {
+ // No-op
+ }
+ }
+}
diff --git a/data/src/main/java/org/cryptomator/data/cloud/smb/SmbCloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbCloudContentRepositoryFactory.java
new file mode 100644
index 0000000000..6beffa8916
--- /dev/null
+++ b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbCloudContentRepositoryFactory.java
@@ -0,0 +1,41 @@
+package org.cryptomator.data.cloud.smb;
+
+import android.content.Context;
+
+import org.cryptomator.data.repository.CloudContentRepositoryFactory;
+import org.cryptomator.domain.Cloud;
+import org.cryptomator.domain.SmbCloud;
+import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException;
+import org.cryptomator.domain.repository.CloudContentRepository;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import static org.cryptomator.domain.CloudType.SMB;
+
+/**
+ * SMB Cloud content repository factory.
+ * Skeleton for the first step of SMB support.
+ */
+@Singleton
+public class SmbCloudContentRepositoryFactory implements CloudContentRepositoryFactory {
+
+ private final Context context;
+
+ @Inject
+ public SmbCloudContentRepositoryFactory(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public boolean supports(Cloud cloud) {
+ return cloud.type() == SMB;
+ }
+
+ @Override
+ public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) {
+ SmbCloud smbCloud = (SmbCloud) cloud;
+ // Authentication check will be added when SMB implementation is ready
+ return new SmbCloudContentRepository(smbCloud, context);
+ }
+}
diff --git a/data/src/main/java/org/cryptomator/data/cloud/smb/SmbFile.kt b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbFile.kt
new file mode 100644
index 0000000000..1f5aec6467
--- /dev/null
+++ b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbFile.kt
@@ -0,0 +1,17 @@
+package org.cryptomator.data.cloud.smb
+
+import org.cryptomator.domain.Cloud
+import org.cryptomator.domain.CloudFile
+import java.util.Date
+
+/**
+ * SMB Cloud file implementation.
+ * Skeleton for the first step of SMB support.
+ */
+class SmbFile(
+ override val parent: SmbFolder,
+ override val name: String,
+ override val cloud: Cloud,
+ override val size: Long? = null,
+ override val modified: Date? = null
+) : SmbNode, CloudFile
diff --git a/data/src/main/java/org/cryptomator/data/cloud/smb/SmbFolder.kt b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbFolder.kt
new file mode 100644
index 0000000000..09a4fd78a4
--- /dev/null
+++ b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbFolder.kt
@@ -0,0 +1,19 @@
+package org.cryptomator.data.cloud.smb
+
+import org.cryptomator.domain.Cloud
+import org.cryptomator.domain.CloudFolder
+
+/**
+ * SMB Cloud folder implementation.
+ * Skeleton for the first step of SMB support.
+ */
+open class SmbFolder(
+ override val parent: SmbFolder?,
+ override val name: String,
+ override val cloud: Cloud
+) : SmbNode, CloudFolder {
+
+ override fun withCloud(cloud: Cloud?): CloudFolder? {
+ return cloud?.let { SmbFolder(parent, name, it) }
+ }
+}
diff --git a/data/src/main/java/org/cryptomator/data/cloud/smb/SmbNode.kt b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbNode.kt
new file mode 100644
index 0000000000..9fc63926ec
--- /dev/null
+++ b/data/src/main/java/org/cryptomator/data/cloud/smb/SmbNode.kt
@@ -0,0 +1,22 @@
+package org.cryptomator.data.cloud.smb
+
+import org.cryptomator.domain.Cloud
+import org.cryptomator.domain.CloudNode
+
+/**
+ * SMB Cloud node interface.
+ * Skeleton for the first step of SMB support.
+ */
+interface SmbNode : CloudNode {
+ override val parent: SmbFolder?
+
+ override val path: String
+ get() {
+ val parentPath = parent?.path ?: ""
+ return if (parentPath.endsWith("/") || parentPath.isEmpty()) {
+ parentPath + name
+ } else {
+ "$parentPath/$name"
+ }
+ }
+}
diff --git a/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java b/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java
index 5dd41c4d61..71e759f4ed 100644
--- a/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java
+++ b/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java
@@ -9,6 +9,7 @@
import org.cryptomator.domain.OnedriveCloud;
import org.cryptomator.domain.PCloud;
import org.cryptomator.domain.S3Cloud;
+import org.cryptomator.domain.SmbCloud;
import org.cryptomator.domain.WebDavCloud;
import javax.inject.Inject;
@@ -20,6 +21,7 @@
import static org.cryptomator.domain.OnedriveCloud.aOnedriveCloud;
import static org.cryptomator.domain.PCloud.aPCloud;
import static org.cryptomator.domain.S3Cloud.aS3Cloud;
+import static org.cryptomator.domain.SmbCloud.aSmbCloud;
import static org.cryptomator.domain.WebDavCloud.aWebDavCloudCloud;
@Singleton
@@ -79,6 +81,14 @@ public Cloud fromEntity(CloudEntity entity) {
.withPassword(entity.getAccessToken()) //
.withCertificate(entity.getWebdavCertificate()) //
.build();
+ case SMB:
+ return aSmbCloud() //
+ .withId(entity.getId()) //
+ .withUrl(entity.getUrl()) //
+ .withUsername(entity.getUsername()) //
+ .withPassword(entity.getAccessToken()) //
+ .withDomain(entity.getWebdavCertificate()) //
+ .build();
default:
throw new IllegalStateException("Unhandled enum constant " + type);
}
@@ -123,6 +133,12 @@ public CloudEntity toEntity(Cloud domainObject) {
result.setUsername(((WebDavCloud) domainObject).username());
result.setWebdavCertificate(((WebDavCloud) domainObject).certificate());
break;
+ case SMB:
+ result.setAccessToken(((SmbCloud) domainObject).password());
+ result.setUrl(((SmbCloud) domainObject).url());
+ result.setUsername(((SmbCloud) domainObject).username());
+ result.setWebdavCertificate(((SmbCloud) domainObject).domain());
+ break;
default:
throw new IllegalStateException("Unhandled enum constant " + domainObject.type());
}
diff --git a/domain/src/main/java/org/cryptomator/domain/CloudType.kt b/domain/src/main/java/org/cryptomator/domain/CloudType.kt
index a003c02565..558f159ff1 100644
--- a/domain/src/main/java/org/cryptomator/domain/CloudType.kt
+++ b/domain/src/main/java/org/cryptomator/domain/CloudType.kt
@@ -1,5 +1,5 @@
package org.cryptomator.domain
enum class CloudType {
- DROPBOX, GOOGLE_DRIVE, ONEDRIVE, PCLOUD, WEBDAV, LOCAL, S3, CRYPTO
+ DROPBOX, GOOGLE_DRIVE, ONEDRIVE, PCLOUD, WEBDAV, LOCAL, S3, CRYPTO, SMB
}
diff --git a/domain/src/main/java/org/cryptomator/domain/SmbCloud.java b/domain/src/main/java/org/cryptomator/domain/SmbCloud.java
new file mode 100644
index 0000000000..40302ac771
--- /dev/null
+++ b/domain/src/main/java/org/cryptomator/domain/SmbCloud.java
@@ -0,0 +1,158 @@
+package org.cryptomator.domain;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Objects;
+
+/**
+ * SMB Cloud implementation.
+ * Currently just a skeleton for the first step of SMB support.
+ */
+public class SmbCloud implements Cloud {
+
+ private final Long id;
+ private final String url;
+ private final String username;
+ private final String password;
+ private final String domain;
+
+ private SmbCloud(Builder builder) {
+ this.id = builder.id;
+ this.url = builder.url;
+ this.username = builder.username;
+ this.password = builder.password;
+ this.domain = builder.domain;
+ }
+
+ public static Builder aSmbCloud() {
+ return new Builder();
+ }
+
+ public static Builder aCopyOf(SmbCloud smbCloud) {
+ return new Builder() //
+ .withId(smbCloud.id()) //
+ .withUrl(smbCloud.url()) //
+ .withUsername(smbCloud.username()) //
+ .withPassword(smbCloud.password()) //
+ .withDomain(smbCloud.domain());
+ }
+
+ @Override
+ public Long id() {
+ return id;
+ }
+
+ @Override
+ public boolean configurationMatches(Cloud cloud) {
+ return cloud instanceof SmbCloud && configurationMatches((SmbCloud) cloud);
+ }
+
+ private boolean configurationMatches(SmbCloud cloud) {
+ return Objects.equals(url, cloud.url) && Objects.equals(username, cloud.username);
+ }
+
+ @Override
+ public CloudType type() {
+ return CloudType.SMB;
+ }
+
+ public String password() {
+ return password;
+ }
+
+ public String url() {
+ return url;
+ }
+
+ public String username() {
+ return username;
+ }
+
+ public String domain() {
+ return domain;
+ }
+
+ @Override
+ public boolean persistent() {
+ return true;
+ }
+
+ @Override
+ public boolean requiresNetwork() {
+ return true;
+ }
+
+ @Override
+ public boolean isReadOnly() {
+ return false;
+ }
+
+ @NotNull
+ @Override
+ public String toString() {
+ return "SMB";
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null || getClass() != obj.getClass()) {
+ return false;
+ }
+ if (obj == this) {
+ return true;
+ }
+ return internalEquals((SmbCloud) obj);
+ }
+
+ @Override
+ public int hashCode() {
+ return id == null ? 0 : id.hashCode();
+ }
+
+ private boolean internalEquals(SmbCloud obj) {
+ return Objects.equals(id, obj.id);
+ }
+
+ public static class Builder {
+
+ private Long id;
+ private String password;
+ private String url;
+ private String username;
+ private String domain;
+
+ private Builder() {
+ }
+
+ public Builder withId(Long id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder withUsername(String username) {
+ this.username = username;
+ return this;
+ }
+
+ public Builder withPassword(String password) {
+ this.password = password;
+ return this;
+ }
+
+ public Builder withUrl(String url) {
+ this.url = url;
+ return this;
+ }
+
+ public Builder withDomain(String domain) {
+ this.domain = domain;
+ return this;
+ }
+
+ public SmbCloud build() {
+ return new SmbCloud(this);
+ }
+
+ }
+
+}
diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToSmb.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToSmb.java
new file mode 100644
index 0000000000..28bd38d49c
--- /dev/null
+++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToSmb.java
@@ -0,0 +1,23 @@
+package org.cryptomator.domain.usecases.cloud;
+
+import org.cryptomator.domain.SmbCloud;
+import org.cryptomator.domain.exception.BackendException;
+import org.cryptomator.domain.repository.CloudContentRepository;
+import org.cryptomator.generator.Parameter;
+import org.cryptomator.generator.UseCase;
+
+@UseCase
+public class ConnectToSmb {
+
+ private final CloudContentRepository cloudContentRepository;
+ private final SmbCloud cloud;
+
+ public ConnectToSmb(CloudContentRepository cloudContentRepository, @Parameter SmbCloud cloud) {
+ this.cloudContentRepository = cloudContentRepository;
+ this.cloud = cloud;
+ }
+
+ public void execute() throws BackendException {
+ cloudContentRepository.checkAuthenticationAndRetrieveCurrentAccount(cloud);
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 5db8c731d8..d72ea74fc1 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,4 @@
-org.gradle.jvmargs=-Xmx2048m
+org.gradle.jvmargs=-Xmx4096m
android.useAndroidX=true
android.enableJetifier=true
android.jetifier.ignorelist=jackson-core,fastdoubleparser
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index aaaabb3cb9..4f5eb9dcc0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml
index ca2c57f1c2..d8a5692355 100644
--- a/presentation/src/main/AndroidManifest.xml
+++ b/presentation/src/main/AndroidManifest.xml
@@ -126,6 +126,9 @@
+
diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java
index a47963aed3..81e9d7bdf6 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java
+++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java
@@ -22,6 +22,7 @@
import org.cryptomator.presentation.ui.activity.SetPasswordActivity;
import org.cryptomator.presentation.ui.activity.SettingsActivity;
import org.cryptomator.presentation.ui.activity.SharedFilesActivity;
+import org.cryptomator.presentation.ui.activity.SmbAddOrChangeActivity;
import org.cryptomator.presentation.ui.activity.TextEditorActivity;
import org.cryptomator.presentation.ui.activity.UnlockVaultActivity;
import org.cryptomator.presentation.ui.activity.VaultListActivity;
@@ -36,6 +37,7 @@
import org.cryptomator.presentation.ui.fragment.ImagePreviewFragment;
import org.cryptomator.presentation.ui.fragment.S3AddOrChangeFragment;
import org.cryptomator.presentation.ui.fragment.SetPasswordFragment;
+import org.cryptomator.presentation.ui.fragment.SmbAddOrChangeFragment;
import org.cryptomator.presentation.ui.fragment.SharedFilesFragment;
import org.cryptomator.presentation.ui.fragment.TextEditorFragment;
import org.cryptomator.presentation.ui.fragment.UnlockVaultFragment;
@@ -130,6 +132,10 @@ public interface ActivityComponent {
void inject(S3AddOrChangeFragment s3AddOrChangeFragment);
+ void inject(SmbAddOrChangeActivity smbAddOrChangeActivity);
+
+ void inject(SmbAddOrChangeFragment smbAddOrChangeFragment);
+
void inject(CryptomatorVariantsActivity cryptomatorVariantsActivity);
void inject(WelcomeActivity welcomeActivity);
diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/SmbAddOrChangeIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/SmbAddOrChangeIntent.java
new file mode 100644
index 0000000000..e24d567be6
--- /dev/null
+++ b/presentation/src/main/java/org/cryptomator/presentation/intent/SmbAddOrChangeIntent.java
@@ -0,0 +1,21 @@
+package org.cryptomator.presentation.intent;
+
+import org.cryptomator.generator.Intent;
+import org.cryptomator.generator.Optional;
+import org.cryptomator.presentation.model.SmbCloudModel;
+import org.cryptomator.presentation.ui.activity.SmbAddOrChangeActivity;
+
+/**
+ * Intent interface for navigating to the SMB connection setup or edit screen.
+ * Uses the generator to create an 'IntentBuilder'.
+ */
+@Intent(SmbAddOrChangeActivity.class)
+public interface SmbAddOrChangeIntent {
+
+ /**
+ * Optional parameter to pass an existing SMB connection model for editing.
+ */
+ @Optional
+ SmbCloudModel smbCloud();
+
+}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt
index 60d1d97989..d051b49f25 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt
@@ -5,63 +5,73 @@ import org.cryptomator.presentation.R
enum class CloudTypeModel(builder: Builder) {
- CRYPTO(Builder("CRYPTO", R.string.cloud_names_crypto)), //
+ CRYPTO(Builder(R.string.cloud_names_crypto)), //
DROPBOX(
- Builder("DROPBOX", R.string.cloud_names_dropbox) //
+ Builder(R.string.cloud_names_dropbox) //
.withCloudImageResource(R.drawable.dropbox) //
.withVaultImageResource(R.drawable.dropbox_vault) //
- .withVaultSelectedImageResource(R.drawable.dropbox_vault_selected)
+ .withVaultSelectedImageResource(R.drawable.dropbox_vault_selected),
), //
GOOGLE_DRIVE(
- Builder("GOOGLE_DRIVE", R.string.cloud_names_google_drive) //
+ Builder(R.string.cloud_names_google_drive) //
.withCloudImageResource(R.drawable.google_drive) //
.withVaultImageResource(R.drawable.google_drive_vault) //
- .withVaultSelectedImageResource(R.drawable.google_drive_vault_selected)
+ .withVaultSelectedImageResource(R.drawable.google_drive_vault_selected),
), //
ONEDRIVE(
- Builder("ONEDRIVE", R.string.cloud_names_onedrive) //
+ Builder(R.string.cloud_names_onedrive) //
.withCloudImageResource(R.drawable.onedrive) //
.withVaultImageResource(R.drawable.onedrive_vault) //
- .withVaultSelectedImageResource(R.drawable.onedrive_vault_selected)
- .withMultiInstances()
+ .withVaultSelectedImageResource(R.drawable.onedrive_vault_selected) //
+ .withMultiInstances(),
), //
PCLOUD(
- Builder("PCLOUD", R.string.cloud_names_pcloud) //
+ Builder(R.string.cloud_names_pcloud) //
.withCloudImageResource(R.drawable.pcloud) //
.withVaultImageResource(R.drawable.pcloud_vault) //
.withVaultSelectedImageResource(R.drawable.pcloud_vault_selected) //
- .withMultiInstances()
+ .withMultiInstances(),
), //
WEBDAV(
- Builder("WEBDAV", R.string.cloud_names_webdav) //
+ Builder(R.string.cloud_names_webdav) //
.withCloudImageResource(R.drawable.webdav) //
.withVaultImageResource(R.drawable.webdav_vault) //
.withVaultSelectedImageResource(R.drawable.webdav_vault_selected) //
- .withMultiInstances()
+ .withMultiInstances(),
), //
S3(
- Builder("S3", R.string.cloud_names_s3) //
+ Builder(R.string.cloud_names_s3) //
.withCloudImageResource(R.drawable.s3) //
.withVaultImageResource(R.drawable.s3_vault) //
.withVaultSelectedImageResource(R.drawable.s3_vault_selected) //
- .withMultiInstances()
+ .withMultiInstances(),
+ ), //
+ /**
+ * SMB (Server Message Block) cloud provider.
+ * Configured with its own icon set and supports multiple connection instances.
+ */
+ SMB(
+ Builder(R.string.cloud_names_smb) //
+ .withCloudImageResource(R.drawable.smb) //
+ .withVaultImageResource(R.drawable.smb_vault) //
+ .withVaultSelectedImageResource(R.drawable.smb_vault_selected) //
+ .withMultiInstances(),
), //
LOCAL(
- Builder("LOCAL", R.string.cloud_names_local_storage) //
+ Builder(R.string.cloud_names_local_storage) //
.withCloudImageResource(R.drawable.local_fs) //
.withVaultImageResource(R.drawable.local_fs_vault) //
.withVaultSelectedImageResource(R.drawable.local_fs_vault_selected) //
- .withMultiInstances()
+ .withMultiInstances(),
);
- val cloudName: String = builder.cloudName
val displayNameResource: Int = builder.displayNameResource
val cloudImageResource: Int = builder.cloudImageResource
val vaultImageResource: Int = builder.vaultImageResource
val vaultSelectedImageResource: Int = builder.vaultSelectedImageResource
val isMultiInstance: Boolean = builder.multiInstances
- private class Builder(val cloudName: String, val displayNameResource: Int) {
+ private class Builder(val displayNameResource: Int) {
var cloudImageResource = 0
var vaultImageResource = 0
diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/SmbCloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/SmbCloudModel.kt
new file mode 100644
index 0000000000..ac16a3f1ca
--- /dev/null
+++ b/presentation/src/main/java/org/cryptomator/presentation/model/SmbCloudModel.kt
@@ -0,0 +1,44 @@
+package org.cryptomator.presentation.model
+
+import org.cryptomator.domain.Cloud
+import org.cryptomator.domain.SmbCloud
+import org.cryptomator.presentation.R
+
+/**
+ * SMB Cloud model.
+ * Currently just a skeleton for the first step of SMB support.
+ */
+class SmbCloudModel(cloud: Cloud) : CloudModel(cloud) {
+
+ override fun name(): Int {
+ return R.string.cloud_names_smb
+ }
+
+ override fun username(): String? {
+ return smbCloud().username()
+ }
+
+ fun domain(): String? {
+ return smbCloud().domain()
+ }
+
+ override fun cloudType(): CloudTypeModel {
+ return CloudTypeModel.SMB
+ }
+
+ fun url(): String? {
+ return smbCloud().url()
+ }
+
+ fun password(): String? {
+ return smbCloud().password()
+ }
+
+ fun id(): Long? {
+ return smbCloud().id()
+ }
+
+ private fun smbCloud(): SmbCloud {
+ return toCloud() as SmbCloud
+ }
+}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt
index 0006b4571e..4aa977f097 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt
@@ -11,6 +11,7 @@ import org.cryptomator.presentation.model.LocalStorageModel
import org.cryptomator.presentation.model.OnedriveCloudModel
import org.cryptomator.presentation.model.PCloudModel
import org.cryptomator.presentation.model.S3CloudModel
+import org.cryptomator.presentation.model.SmbCloudModel
import org.cryptomator.presentation.model.WebDavCloudModel
import javax.inject.Inject
@@ -31,6 +32,7 @@ class CloudModelMapper @Inject constructor() : ModelMapper()
CloudTypeModel.S3 -> S3CloudModel(domainObject)
CloudTypeModel.CRYPTO -> CryptoCloudModel(domainObject)
CloudTypeModel.WEBDAV -> WebDavCloudModel(domainObject)
+ CloudTypeModel.SMB -> SmbCloudModel(domainObject)
null -> throw IllegalStateException("The type of the object shouldn't be null")
}
}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt
index 73dbce2a69..92e9c1d95a 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt
@@ -13,6 +13,7 @@ import org.cryptomator.domain.DropboxCloud
import org.cryptomator.domain.GoogleDriveCloud
import org.cryptomator.domain.OnedriveCloud
import org.cryptomator.domain.PCloud
+import org.cryptomator.domain.SmbCloud
import org.cryptomator.domain.WebDavCloud
import org.cryptomator.domain.di.PerView
import org.cryptomator.domain.exception.FatalBackendException
@@ -69,6 +70,7 @@ class AuthenticateCloudPresenter @Inject constructor( //
OnedriveAuthStrategy(), //
PCloudAuthStrategy(), //
WebDAVAuthStrategy(), //
+ SmbAuthStrategy(), //
S3AuthStrategy(), //
LocalStorageAuthStrategy() //
)
@@ -117,6 +119,7 @@ class AuthenticateCloudPresenter @Inject constructor( //
return when (cloud.type()) {
CloudType.DROPBOX -> DropboxCloud.aCopyOf(cloud as DropboxCloud).withUsername(username).build()
CloudType.ONEDRIVE -> OnedriveCloud.aCopyOf(cloud as OnedriveCloud).withUsername(username).build()
+ CloudType.SMB -> SmbCloud.aCopyOf(cloud as SmbCloud).withUsername(username).build()
else -> throw IllegalStateException("Cloud " + cloud.type() + " is not supported")
}
}
@@ -395,6 +398,18 @@ class AuthenticateCloudPresenter @Inject constructor( //
}
}
+ private inner class SmbAuthStrategy : AuthStrategy {
+
+ override fun supports(cloud: CloudModel): Boolean {
+ return cloud.cloudType() == CloudTypeModel.SMB
+ }
+
+ override fun resumed(intent: AuthenticateCloudIntent) {
+ // SMB authentication is not yet implemented
+ failAuthentication(intent.cloud().name())
+ }
+ }
+
fun onAcceptWebDavCertificateClicked(cloud: WebDavCloud, certificate: X509Certificate) {
try {
val webDavCloudWithAcceptedCert = WebDavCloud.aCopyOf(cloud) //
diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt
index 6c3f205b30..9cd574e4bc 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt
@@ -25,6 +25,7 @@ import org.cryptomator.presentation.model.CloudModel
import org.cryptomator.presentation.model.CloudTypeModel
import org.cryptomator.presentation.model.LocalStorageModel
import org.cryptomator.presentation.model.S3CloudModel
+import org.cryptomator.presentation.model.SmbCloudModel
import org.cryptomator.presentation.model.WebDavCloudModel
import org.cryptomator.presentation.model.mappers.CloudModelMapper
import org.cryptomator.presentation.ui.activity.view.CloudConnectionListView
@@ -128,6 +129,8 @@ class CloudConnectionListPresenter @Inject constructor( //
CloudTypeModel.WEBDAV -> requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), Intents.webDavAddOrChangeIntent())
CloudTypeModel.PCLOUD -> requestActivityResult(ActivityResultCallbacks.pCloudAuthenticationFinished(), Intents.authenticatePCloudIntent())
CloudTypeModel.S3 -> requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), Intents.s3AddOrChangeIntent())
+ // Launches the SMB setup/edit screen
+ CloudTypeModel.SMB -> requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), Intents.smbAddOrChangeIntent())
CloudTypeModel.LOCAL -> openDocumentTree()
else -> throw IllegalStateException("Cloud type is not supported")
}
@@ -184,6 +187,14 @@ class CloudConnectionListPresenter @Inject constructor( //
.withWebDavCloud(cloudModel as WebDavCloudModel)
)
}
+ cloudModel.cloudType() == CloudTypeModel.SMB -> {
+ // Re-opens the SMB setup screen with existing configuration for editing
+ requestActivityResult(
+ ActivityResultCallbacks.addChangeMultiCloud(), //
+ Intents.smbAddOrChangeIntent() //
+ .withSmbCloud(cloudModel as SmbCloudModel)
+ )
+ }
cloudModel.cloudType() == CloudTypeModel.S3 -> {
requestActivityResult(
ActivityResultCallbacks.addChangeMultiCloud(), //
@@ -214,7 +225,7 @@ class CloudConnectionListPresenter @Inject constructor( //
if (!code.isNullOrEmpty() && !hostname.isNullOrEmpty()) {
Timber.tag("CloudConnectionListPresenter").i("PCloud OAuth code successfully retrieved")
val accessToken = CredentialCryptor.getInstance(this.context()).encrypt(code)
- val pCloudSkeleton = PCloud.aPCloud().withAccessToken(accessToken).withUrl(hostname).build();
+ val pCloudSkeleton = PCloud.aPCloud().withAccessToken(accessToken).withUrl(hostname).build()
getUsernameUseCase //
.withCloud(pCloudSkeleton) //
.run(object : DefaultResultHandler() {
diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt
index f72c032ead..c5280a8c79 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt
@@ -5,6 +5,7 @@ import org.cryptomator.domain.LocalStorageCloud
import org.cryptomator.domain.OnedriveCloud
import org.cryptomator.domain.PCloud
import org.cryptomator.domain.S3Cloud
+import org.cryptomator.domain.SmbCloud
import org.cryptomator.domain.WebDavCloud
import org.cryptomator.domain.di.PerView
import org.cryptomator.domain.exception.FatalBackendException
@@ -22,6 +23,7 @@ import org.cryptomator.presentation.model.LocalStorageModel
import org.cryptomator.presentation.model.OnedriveCloudModel
import org.cryptomator.presentation.model.PCloudModel
import org.cryptomator.presentation.model.S3CloudModel
+import org.cryptomator.presentation.model.SmbCloudModel
import org.cryptomator.presentation.model.WebDavCloudModel
import org.cryptomator.presentation.model.mappers.CloudModelMapper
import org.cryptomator.presentation.ui.activity.view.CloudSettingsView
@@ -44,7 +46,8 @@ class CloudSettingsPresenter @Inject constructor( //
CloudTypeModel.ONEDRIVE, //
CloudTypeModel.PCLOUD, //
CloudTypeModel.S3, //
- CloudTypeModel.WEBDAV
+ CloudTypeModel.WEBDAV, //
+ CloudTypeModel.SMB
)
fun loadClouds() {
@@ -101,6 +104,7 @@ class CloudSettingsPresenter @Inject constructor( //
CloudTypeModel.ONEDRIVE -> return context().getString(R.string.screen_cloud_settings_onedrive_connections)
CloudTypeModel.PCLOUD -> return context().getString(R.string.screen_cloud_settings_pcloud_connections)
CloudTypeModel.WEBDAV -> return context().getString(R.string.screen_cloud_settings_webdav_connections)
+ CloudTypeModel.SMB -> return context().getString(R.string.screen_cloud_settings_smb_connections)
CloudTypeModel.S3 -> return context().getString(R.string.screen_cloud_settings_s3_connections)
CloudTypeModel.LOCAL -> return context().getString(R.string.screen_cloud_settings_local_storage_locations)
else -> {}
@@ -139,6 +143,7 @@ class CloudSettingsPresenter @Inject constructor( //
it.add(aPCloud())
it.add(aWebdavCloud())
it.add(aS3Cloud())
+ it.add(aSmbCloud())
it.add(aLocalCloud())
}
.filter { cloud -> !(FlavorConfig.isLiteFlavor && excludeApiCloudsInLite(cloud.cloudType())) } //
@@ -175,6 +180,10 @@ class CloudSettingsPresenter @Inject constructor( //
return WebDavCloudModel(WebDavCloud.aWebDavCloudCloud().build())
}
+ private fun aSmbCloud(): SmbCloudModel {
+ return SmbCloudModel(SmbCloud.aSmbCloud().build())
+ }
+
private fun aS3Cloud(): S3CloudModel {
return S3CloudModel(S3Cloud.aS3Cloud().build())
}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SmbAddOrChangePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SmbAddOrChangePresenter.kt
new file mode 100644
index 0000000000..31f686bce2
--- /dev/null
+++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SmbAddOrChangePresenter.kt
@@ -0,0 +1,131 @@
+package org.cryptomator.presentation.presenter
+
+import android.widget.Toast
+import org.cryptomator.domain.Cloud
+import org.cryptomator.domain.SmbCloud
+import org.cryptomator.domain.di.PerView
+import org.cryptomator.domain.usecases.cloud.AddOrChangeCloudConnectionUseCase
+import org.cryptomator.domain.usecases.cloud.ConnectToSmbUseCase
+import org.cryptomator.presentation.R
+import org.cryptomator.presentation.exception.ExceptionHandlers
+import org.cryptomator.presentation.model.ProgressModel
+import org.cryptomator.presentation.model.ProgressStateModel
+import org.cryptomator.presentation.ui.activity.view.SmbAddOrChangeView
+import org.cryptomator.util.crypto.CredentialCryptor
+import javax.inject.Inject
+
+/**
+ * Presenter for the SMB setup/edit screen.
+ * Handles validation of SMB URLs and credentials, and performs the connection test/authentication.
+ */
+@PerView
+class SmbAddOrChangePresenter @Inject internal constructor(
+ private val addOrChangeCloudConnectionUseCase: AddOrChangeCloudConnectionUseCase,
+ private val connectToSmbUseCase: ConnectToSmbUseCase,
+ exceptionMappings: ExceptionHandlers,
+) : Presenter(exceptionMappings) {
+
+ /**
+ * Validates the user input for the SMB connection.
+ * Ensures required fields are present and the URL follows the 'smb://' scheme and includes a share.
+ */
+ fun checkUserInput(urlPort: String, username: String, password: String, domain: String, cloudId: Long?) {
+ var statusMessage: String? = null
+
+ if (password.isEmpty()) {
+ statusMessage = getString(R.string.screen_webdav_settings_msg_password_must_not_be_empty)
+ }
+ if (username.isEmpty()) {
+ statusMessage = getString(R.string.screen_webdav_settings_msg_username_must_not_be_empty)
+ }
+ if (urlPort.isEmpty() || (urlPort == "smb://")) {
+ statusMessage = getString(R.string.screen_webdav_settings_msg_url_must_not_be_empty)
+ } else if (!isValid(urlPort)) {
+ statusMessage = getString(R.string.screen_webdav_settings_msg_url_is_invalid)
+ } else if (!hasShare(urlPort)) {
+ statusMessage = getString(R.string.screen_smb_settings_msg_share_must_not_be_empty)
+ }
+ if (statusMessage != null) {
+ Toast.makeText(context(), statusMessage, Toast.LENGTH_SHORT).show()
+ } else {
+ val urlPortWithoutTrailingSlash = if (urlPort.endsWith("/")) urlPort.substring(0, urlPort.length - 1) else urlPort
+ val encryptedPassword = encryptPassword(password)
+ view?.onCheckUserInputSucceeded(urlPortWithoutTrailingSlash, username, encryptedPassword, domain, cloudId)
+ }
+ }
+
+ private fun encryptPassword(password: String): String {
+ return CredentialCryptor //
+ .getInstance(context()) //
+ .encrypt(password)
+ }
+
+ private fun isValid(urlPort: String): Boolean {
+ return urlPort.startsWith("smb://", ignoreCase = true)
+ }
+
+ /**
+ * Checks if the URL includes at least one path segment (representing the SMB share).
+ */
+ private fun hasShare(urlPort: String): Boolean {
+ return try {
+ val uri = java.net.URI(urlPort)
+ val path = uri.path ?: ""
+ path.split("/").any { it.isNotEmpty() }
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private fun mapToCloud(username: String, password: String, hostPort: String, domain: String, id: Long?): SmbCloud {
+ val builder = SmbCloud //
+ .aSmbCloud() //
+ .withUrl(hostPort) //
+ .withUsername(username) //
+ .withPassword(password) //
+ .withDomain(domain)
+
+ id?.let { builder.withId(it) }
+
+ return builder.build()
+ }
+
+ /**
+ * Attempts to connect to the SMB server using the provided credentials.
+ * If successful, the connection is saved to the local database.
+ */
+ fun authenticate(username: String, password: String, urlPort: String, domain: String, cloudId: Long?) {
+ authenticate(mapToCloud(username, password, urlPort, domain, cloudId))
+ }
+
+ private fun authenticate(cloud: SmbCloud) {
+ view?.showProgress(ProgressModel(ProgressStateModel.AUTHENTICATION))
+ connectToSmbUseCase //
+ .withCloud(cloud) //
+ .run(object : DefaultResultHandler() {
+ override fun onSuccess(void: Void?) {
+ onCloudAuthenticated(cloud)
+ }
+
+ override fun onError(e: Throwable) {
+ view?.showProgress(ProgressModel.COMPLETED)
+ super.onError(e)
+ }
+ })
+ }
+
+ private fun onCloudAuthenticated(cloud: Cloud) {
+ save(cloud)
+ finishWithResult(CloudConnectionListPresenter.SELECTED_CLOUD, cloud)
+ }
+
+ private fun save(cloud: Cloud) {
+ addOrChangeCloudConnectionUseCase //
+ .withCloud(cloud) //
+ .run(DefaultResultHandler())
+ }
+
+ init {
+ unsubscribeOnDestroy(addOrChangeCloudConnectionUseCase, connectToSmbUseCase)
+ }
+}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CryptomatorVariantsActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CryptomatorVariantsActivity.kt
index a1033bcd84..9448385b71 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CryptomatorVariantsActivity.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CryptomatorVariantsActivity.kt
@@ -18,13 +18,13 @@ class CryptomatorVariantsActivity : BaseActivity(ActivityLayoutBinding::inflate),
+ SmbAddOrChangeView {
+
+ @Inject
+ lateinit var smbAddOrChangePresenter: SmbAddOrChangePresenter
+
+ @InjectIntent
+ lateinit var smbAddOrChangeIntent: SmbAddOrChangeIntent
+
+ override fun setupView() {
+ binding.mtToolbar.toolbar.setTitle(R.string.cloud_names_smb)
+ setSupportActionBar(binding.mtToolbar.toolbar)
+ }
+
+ override fun createFragment(): Fragment = SmbAddOrChangeFragment.newInstance(smbAddOrChangeIntent.smbCloud())
+
+ override fun onCheckUserInputSucceeded(urlPort: String, username: String, password: String, domain: String, cloudId: Long?) {
+ smbAddOrChangeFragment().hideKeyboard()
+ smbAddOrChangePresenter.authenticate(username, password, urlPort, domain, cloudId)
+ }
+
+ private fun smbAddOrChangeFragment(): SmbAddOrChangeFragment = getCurrentFragment(R.id.fragment_container) as SmbAddOrChangeFragment
+
+}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SmbAddOrChangeView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SmbAddOrChangeView.kt
new file mode 100644
index 0000000000..7c1ca4f8ea
--- /dev/null
+++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SmbAddOrChangeView.kt
@@ -0,0 +1,7 @@
+package org.cryptomator.presentation.ui.activity.view
+
+interface SmbAddOrChangeView : View {
+
+ fun onCheckUserInputSucceeded(urlPort: String, username: String, password: String, domain: String, cloudId: Long?)
+
+}
diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt
index aaa47b3a33..53266d6109 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt
@@ -45,6 +45,7 @@ constructor(private val context: Context) : RecyclerViewBaseAdapter binding.cloudName.text = context.getString(R.string.screen_cloud_settings_onedrive_connections)
CloudTypeModel.PCLOUD -> binding.cloudName.text = context.getString(R.string.screen_cloud_settings_pcloud_connections)
CloudTypeModel.S3 -> binding.cloudName.text = context.getString(R.string.screen_cloud_settings_s3_connections)
+ CloudTypeModel.SMB -> binding.cloudName.text = context.getString(R.string.screen_cloud_settings_smb_connections)
CloudTypeModel.WEBDAV -> binding.cloudName.text = context.getString(R.string.screen_cloud_settings_webdav_connections)
CloudTypeModel.LOCAL -> binding.cloudName.text = context.getString(R.string.screen_cloud_settings_local_storage_locations)
else -> {
diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt
index b13df542a8..6194478531 100644
--- a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt
+++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt
@@ -11,6 +11,7 @@ import org.cryptomator.presentation.model.LocalStorageModel
import org.cryptomator.presentation.model.OnedriveCloudModel
import org.cryptomator.presentation.model.PCloudModel
import org.cryptomator.presentation.model.S3CloudModel
+import org.cryptomator.presentation.model.SmbCloudModel
import org.cryptomator.presentation.model.WebDavCloudModel
@BottomSheet(R.layout.dialog_bottom_sheet_cloud_settings)
@@ -30,6 +31,7 @@ class CloudConnectionSettingsBottomSheet : BaseBottomSheet bindViewForWebDAV(cloudModel as WebDavCloudModel)
CloudTypeModel.PCLOUD -> bindViewForPCloud(cloudModel as PCloudModel)
CloudTypeModel.S3 -> bindViewForS3(cloudModel as S3CloudModel)
+ CloudTypeModel.SMB -> bindViewForSMB(cloudModel as SmbCloudModel)
CloudTypeModel.LOCAL -> bindViewForLocal(cloudModel as LocalStorageModel)
else -> throw IllegalStateException("Cloud model is not binded in the view")
}
@@ -76,6 +78,12 @@ class CloudConnectionSettingsBottomSheet : BaseBottomSheet(FragmentSetupSmbBinding::inflate) {
+
+ @Inject
+ lateinit var smbAddOrChangePresenter: SmbAddOrChangePresenter
+
+ private var cloudId: Long? = null
+
+ private val smbCloudModel: SmbCloudModel?
+ get() = arguments?.let { BundleCompat.getSerializable(it, ARG_SMB_CLOUD, SmbCloudModel::class.java) }
+
+ override fun setupView() {
+ binding.createCloudButton.setOnClickListener { createCloud() }
+ binding.createCloudButton.setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ createCloud()
+ }
+ false
+ }
+
+ binding.urlPortEditText.text?.length?.let { binding.urlPortEditText.setSelection(it) }
+ showEditableCloudContent(smbCloudModel)
+ }
+
+ /**
+ * Populates the UI fields with existing data if we are editing an existing connection.
+ */
+ private fun showEditableCloudContent(smbCloudModel: SmbCloudModel?) {
+ if (smbCloudModel != null) {
+ binding.urlPortEditText.setText(smbCloudModel.url())
+ binding.userNameEditText.setText(smbCloudModel.username())
+ binding.passwordEditText.setText(getPassword(smbCloudModel.password()))
+ binding.domainEditText.setText(smbCloudModel.domain())
+ cloudId = smbCloudModel.id()
+ }
+ }
+
+ /**
+ * Decrypts the stored password for display in the edit field.
+ * If decryption fails, returns an empty string.
+ */
+ private fun getPassword(password: String?): String {
+ return if (password != null) {
+ try {
+ CredentialCryptor //
+ .getInstance(activity?.applicationContext) //
+ .decrypt(password)
+ } catch (e: FatalCryptoException) {
+ Timber.tag("SmbAddOrChangeFragment").e(e, "Unable to decrypt password, clearing it")
+ ""
+ }
+ } else ""
+ }
+
+ /**
+ * Collects user input and passes it to the presenter for validation and saving.
+ */
+ private fun createCloud() {
+ val urlPort = binding.urlPortEditText.text.toString().trim()
+ val username = binding.userNameEditText.text.toString().trim()
+ val password = binding.passwordEditText.text.toString().trim()
+ val domain = binding.domainEditText.text.toString().trim()
+
+ smbAddOrChangePresenter.checkUserInput(urlPort, username, password, domain, cloudId)
+ }
+
+ fun hideKeyboard() {
+ hideKeyboard(binding.passwordEditText)
+ }
+
+ companion object {
+
+ private const val ARG_SMB_CLOUD = "SMB_CLOUD"
+
+ fun newInstance(cloudModel: SmbCloudModel?): SmbAddOrChangeFragment {
+ val result = SmbAddOrChangeFragment()
+ val args = Bundle()
+ args.putSerializable(ARG_SMB_CLOUD, cloudModel)
+ result.arguments = args
+ return result
+ }
+ }
+
+}
diff --git a/presentation/src/main/res/drawable-xxhdpi/smb.png b/presentation/src/main/res/drawable-xxhdpi/smb.png
new file mode 100644
index 0000000000..0643432fae
Binary files /dev/null and b/presentation/src/main/res/drawable-xxhdpi/smb.png differ
diff --git a/presentation/src/main/res/drawable/smb_vault.xml b/presentation/src/main/res/drawable/smb_vault.xml
new file mode 100644
index 0000000000..8f2fe67ccb
--- /dev/null
+++ b/presentation/src/main/res/drawable/smb_vault.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/presentation/src/main/res/drawable/smb_vault_selected.xml b/presentation/src/main/res/drawable/smb_vault_selected.xml
new file mode 100644
index 0000000000..8f2fe67ccb
--- /dev/null
+++ b/presentation/src/main/res/drawable/smb_vault_selected.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/presentation/src/main/res/layout/fragment_setup_smb.xml b/presentation/src/main/res/layout/fragment_setup_smb.xml
new file mode 100644
index 0000000000..e7422ab220
--- /dev/null
+++ b/presentation/src/main/res/layout/fragment_setup_smb.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml
index b5c41279c3..ba8f0ab6a5 100644
--- a/presentation/src/main/res/values/strings.xml
+++ b/presentation/src/main/res/values/strings.xml
@@ -117,6 +117,7 @@
pCloud
WebDAV
S3
+ SMB
Local storage
@@ -250,6 +251,7 @@
URL is invalid.
Username can\'t be empty.
Password can\'t be empty.
+ Share name can\'t be empty.
@string/cloud_names_s3
@@ -360,6 +362,7 @@
@string/screen_settings_cloud_settings_label
OneDrive connections
WebDAV connections
+ SMB connections
pCloud connections
S3 connections
Local storage locations
diff --git a/settings.gradle b/settings.gradle
index 326464e57f..57438dda41 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1,4 @@
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
+}
include ':generator', ':presentation', ':generator-api', ':domain', ':data', ':util'