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 @@ + - + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +