diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md index 53d94eee..b0d1c1e5 100644 --- a/.github/ISSUE_TEMPLATE/bug.md +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -20,7 +20,7 @@ Please make sure to: * Android version: [Shown in the settings of Android] * Cryptomator version: [Shown in the settings of Cryptomator] -* Cloud type: [Dropbox/Google Drive/OneDrive/WebDAV/Local storage] +* Cloud type: [Dropbox/Google Drive/OneDrive/pCloud/WebDAV/S3/Local storage] ### Steps to Reproduce diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle index 240824c4..ae934029 100644 --- a/buildsystem/dependencies.gradle +++ b/buildsystem/dependencies.gradle @@ -26,7 +26,7 @@ ext { rxAndroidVersion = '2.1.1' rxBindingVersion = '2.2.0' - daggerVersion = '2.34.1' + daggerVersion = '2.35' gsonVersion = '2.8.6' @@ -37,7 +37,7 @@ ext { timberVersion = '4.7.1' - zxcvbnVersion = '1.4.1' + zxcvbnVersion = '1.5.0' scaleImageViewVersion = '3.10.0' @@ -51,6 +51,8 @@ ext { // do not update to 1.4.0 until minsdk is 7.x (or desugaring works better) otherwise it will crash on 6.x cryptolibVersion = '2.0.0-beta6' + awsAndroidSdkS3 = '2.23.0' + dropboxVersion = '4.0.0' googleApiServicesVersion = 'v3-rev197-1.25.0' @@ -101,6 +103,7 @@ ext { androidxViewpager : "androidx.viewpager:viewpager:${androidxViewpagerVersion}", androidxSwiperefresh : "androidx.swiperefreshlayout:swiperefreshlayout:${androidxSwiperefreshVersion}", androidxPreference : "androidx.preference:preference:${androidxPreferenceVersion}", + awsAndroidS3 : "com.amazonaws:aws-android-sdk-s3:${awsAndroidSdkS3}", documentFile : "androidx.documentfile:documentfile:${androidxDocumentfileVersion}", recyclerView : "androidx.recyclerview:recyclerview:${androidxRecyclerViewVersion}", androidxTestCore : "androidx.test:core:${androidxTestCoreVersion}", diff --git a/data/build.gradle b/data/build.gradle index 28005cbf..6dc5b0df 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -76,7 +76,7 @@ android { } greendao { - schemaVersion 5 + schemaVersion 7 } configurations.all { @@ -106,6 +106,7 @@ dependencies { implementation dependencies.jsonWebTokenJson // cloud + implementation dependencies.awsAndroidS3 implementation dependencies.dropbox implementation dependencies.msgraph diff --git a/data/src/foss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java b/data/src/foss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java index 277858d2..4511d337 100644 --- a/data/src/foss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java +++ b/data/src/foss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java @@ -4,6 +4,8 @@ import org.cryptomator.data.cloud.crypto.CryptoCloudContentRepositoryFactory; import org.cryptomator.data.cloud.dropbox.DropboxCloudContentRepositoryFactory; import org.cryptomator.data.cloud.local.LocalStorageContentRepositoryFactory; 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.webdav.WebDavCloudContentRepositoryFactory; import org.cryptomator.data.repository.CloudContentRepositoryFactory; import org.jetbrains.annotations.NotNull; @@ -23,12 +25,16 @@ public class CloudContentRepositoryFactories implements Iterable list(PCloudFolder folder) throws IOException, BackendException { List result = new ArrayList<>(); + String path = folder.getPath(); + if (folder instanceof RootPCloudFolder) { + path = "/"; + } + try { - RemoteFolder listFolderResult = client().listFolder(folder.getPath()).execute(); + RemoteFolder listFolderResult = client().listFolder(path).execute(); List entryMetadata = listFolderResult.children(); for (RemoteEntry metadata : entryMetadata) { result.add(PCloudNodeFactory.from(folder, metadata)); diff --git a/data/src/main/java/org/cryptomator/data/cloud/pcloud/PCloudNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/pcloud/PCloudNodeFactory.java index 55e72da9..2b91ca70 100644 --- a/data/src/main/java/org/cryptomator/data/cloud/pcloud/PCloudNodeFactory.java +++ b/data/src/main/java/org/cryptomator/data/cloud/pcloud/PCloudNodeFactory.java @@ -16,8 +16,8 @@ class PCloudNodeFactory { return new PCloudFile(parent, name, getNodePath(parent, name), size, Optional.empty()); } - public static PCloudFile file(PCloudFolder folder, String name, Optional size, String path) { - return new PCloudFile(folder, name, path, size, Optional.empty()); + public static PCloudFile file(PCloudFolder parent, String name, Optional size, String path) { + return new PCloudFile(parent, name, path, size, Optional.empty()); } public static PCloudFolder folder(PCloudFolder parent, RemoteFolder folder) { diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/RootS3Folder.java b/data/src/main/java/org/cryptomator/data/cloud/s3/RootS3Folder.java new file mode 100644 index 00000000..25116de8 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/RootS3Folder.java @@ -0,0 +1,29 @@ +package org.cryptomator.data.cloud.s3; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.S3Cloud; + +class RootS3Folder extends S3Folder { + + private final S3Cloud cloud; + + public RootS3Folder(S3Cloud cloud) { + super(null, "", ""); + this.cloud = cloud; + } + + @Override + public S3Cloud getCloud() { + return cloud; + } + + @Override + public String getKey() { + return ""; + } + + @Override + public S3Folder withCloud(Cloud cloud) { + return new RootS3Folder((S3Cloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3ClientFactory.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3ClientFactory.java new file mode 100644 index 00000000..ed0c1838 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3ClientFactory.java @@ -0,0 +1,87 @@ +package org.cryptomator.data.cloud.s3; + +import android.content.Context; + +import com.amazonaws.Request; +import com.amazonaws.Response; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.handlers.RequestHandler2; +import com.amazonaws.regions.Region; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3Client; + +import org.cryptomator.domain.S3Cloud; +import org.cryptomator.util.crypto.CredentialCryptor; + +import timber.log.Timber; + +class S3ClientFactory { + + private AmazonS3 apiClient; + + public AmazonS3 getClient(S3Cloud cloud, Context context) { + if (apiClient == null) { + apiClient = createApiClient(cloud, context); + } + return apiClient; + } + + private AmazonS3 createApiClient(S3Cloud cloud, Context context) { + Region region = Region.getRegion(Regions.DEFAULT_REGION); + String endpoint = null; + + if (cloud.s3Region() != null) { + region = Region.getRegion(cloud.s3Region()); + } else if (cloud.s3Endpoint() != null) { + endpoint = cloud.s3Endpoint(); + } + + AmazonS3Client client = new AmazonS3Client(new BasicAWSCredentials(decrypt(cloud.accessKey(), context), decrypt(cloud.secretKey(), context)), region); + + if (endpoint != null) { + client.setEndpoint(cloud.s3Endpoint()); + } + + client.addRequestHandler(new LoggingAwareRequestHandler()); + + return client; + } + + private String decrypt(String password, Context context) { + return CredentialCryptor // + .getInstance(context) // + .decrypt(password); + } + + private static class LoggingAwareRequestHandler extends RequestHandler2 { + + @Override + public void beforeRequest(Request request) { + Timber.tag("S3Client").d("Sending request (%s) %s", request.getAWSRequestMetrics().getTimingInfo().getStartTimeNano(), request.toString()); + } + + @Override + public void afterResponse(Request request, Response response) { + Timber.tag("S3Client").d( // + "Response received (%s) with status %s (%s)", // + request.getAWSRequestMetrics().getTimingInfo().getStartTimeNano(), // + response.getHttpResponse().getStatusText(), // + response.getHttpResponse().getStatusCode()); + } + + @Override + public void afterError(Request request, Response response, Exception e) { + if (response != null) { + Timber.tag("S3Client").e( // + e, // + "Error occurred (%s) with status %s (%s)", // + request.getAWSRequestMetrics().getTimingInfo().getStartTimeNano(), // + response.getHttpResponse().getStatusText(), // + response.getHttpResponse().getStatusCode()); + } else { + Timber.tag("S3Client").e(e, "Error occurred (%s)", request.getAWSRequestMetrics().getTimingInfo().getStartTimeNano()); + } + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudApiErrorCodes.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudApiErrorCodes.java new file mode 100644 index 00000000..52c37b52 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudApiErrorCodes.java @@ -0,0 +1,22 @@ +package org.cryptomator.data.cloud.s3; + +public enum S3CloudApiErrorCodes { + ACCESS_DENIED("AccessDenied"), + ACCOUNT_PROBLEM("AccountProblem"), + INTERNAL_ERROR("InternalError"), + INVALID_ACCESS_KEY_ID("InvalidAccessKeyId"), + INVALID_BUCKET_NAME("InvalidBucketName"), + INVALID_OBJECT_STATE("InvalidObjectState"), + NO_SUCH_BUCKET("NoSuchBucket"), + NO_SUCH_KEY("NoSuchKey"); + + private final String value; + + S3CloudApiErrorCodes(final String newValue) { + value = newValue; + } + + public String getValue() { + return value; + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudApiExceptions.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudApiExceptions.java new file mode 100644 index 00000000..6abe3337 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudApiExceptions.java @@ -0,0 +1,14 @@ +package org.cryptomator.data.cloud.s3; + +public class S3CloudApiExceptions { + + public static boolean isAccessProblem(String errorCode) { + return errorCode.equals(S3CloudApiErrorCodes.ACCESS_DENIED.getValue()) + || errorCode.equals(S3CloudApiErrorCodes.ACCOUNT_PROBLEM.getValue()) + || errorCode.equals(S3CloudApiErrorCodes.INVALID_ACCESS_KEY_ID.getValue()); + } + + public static boolean isNoSuchBucketException(String errorCode) { + return errorCode.equals(S3CloudApiErrorCodes.NO_SUCH_BUCKET.getValue()); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudContentRepository.java new file mode 100644 index 00000000..a6cfa94c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudContentRepository.java @@ -0,0 +1,187 @@ +package org.cryptomator.data.cloud.s3; + +import android.content.Context; + +import com.amazonaws.services.s3.model.AmazonS3Exception; + +import org.cryptomator.data.cloud.InterceptingCloudContentRepository; +import org.cryptomator.domain.S3Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NetworkConnectionException; +import org.cryptomator.domain.exception.NoSuchBucketException; +import org.cryptomator.domain.exception.authentication.WrongCredentialsException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.domain.usecases.cloud.DownloadState; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.util.Optional; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +import static org.cryptomator.util.ExceptionUtil.contains; + +class S3CloudContentRepository extends InterceptingCloudContentRepository { + + private final S3Cloud cloud; + + public S3CloudContentRepository(S3Cloud cloud, Context context) { + super(new Intercepted(cloud, context)); + this.cloud = cloud; + } + + @Override + protected void throwWrappedIfRequired(Exception e) throws BackendException { + throwNoSuchBucketExceptionIfRequired(e); + throwConnectionErrorIfRequired(e); + throwWrongCredentialsExceptionIfRequired(e); + } + + private void throwNoSuchBucketExceptionIfRequired(Exception e) throws NoSuchBucketException { + if (e instanceof AmazonS3Exception) { + String errorCode = ((AmazonS3Exception)e).getErrorCode(); + if(S3CloudApiExceptions.isNoSuchBucketException(errorCode)) { + throw new NoSuchBucketException(cloud.s3Bucket()); + } + } + } + + private void throwConnectionErrorIfRequired(Exception e) throws NetworkConnectionException { + if (contains(e, IOException.class)) { + throw new NetworkConnectionException(e); + } + } + + private void throwWrongCredentialsExceptionIfRequired(Exception e) { + if (e instanceof AmazonS3Exception) { + String errorCode = ((AmazonS3Exception) e).getErrorCode(); + if (S3CloudApiExceptions.isAccessProblem(errorCode)) { + throw new WrongCredentialsException(cloud); + } + } + } + + private static class Intercepted implements CloudContentRepository { + + private final S3Impl cloud; + + public Intercepted(S3Cloud cloud, Context context) { + this.cloud = new S3Impl(context, cloud); + } + + public S3Folder root(S3Cloud cloud) { + return this.cloud.root(); + } + + @Override + public S3Folder resolve(S3Cloud cloud, String path) throws BackendException { + return this.cloud.resolve(path); + } + + @Override + public S3File file(S3Folder parent, String name) throws BackendException { + try { + return cloud.file(parent, name); + } catch (IOException ex) { + throw new FatalBackendException(ex); + } + } + + @Override + public S3File file(S3Folder parent, String name, Optional size) throws BackendException { + try { + return cloud.file(parent, name, size); + } catch (IOException ex) { + throw new FatalBackendException(ex); + } + } + + @Override + public S3Folder folder(S3Folder parent, String name) throws BackendException { + return cloud.folder(parent, name); + } + + @Override + public boolean exists(S3Node node) throws BackendException { + return cloud.exists(node); + } + + @Override + public List list(S3Folder folder) throws BackendException { + try { + return cloud.list(folder); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public S3Folder create(S3Folder folder) throws BackendException { + try { + return cloud.create(folder); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public S3Folder move(S3Folder source, S3Folder target) throws BackendException { + try { + return (S3Folder) cloud.move(source, target); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public S3File move(S3File source, S3File target) throws BackendException { + try { + return (S3File) cloud.move(source, target); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public S3File write(S3File uploadFile, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return cloud.write(uploadFile, data, progressAware, replace, size); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void read(S3File file, Optional encryptedTmpFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + cloud.read(file, encryptedTmpFile, data, progressAware); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void delete(S3Node node) throws BackendException { + try { + cloud.delete(node); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(S3Cloud cloud) throws BackendException { + return this.cloud.checkAuthenticationAndRetrieveCurrentAccount(); + } + + @Override + public void logout(S3Cloud cloud) throws BackendException { + // empty + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudContentRepositoryFactory.java new file mode 100644 index 00000000..da7177e4 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudContentRepositoryFactory.java @@ -0,0 +1,35 @@ +package org.cryptomator.data.cloud.s3; + +import android.content.Context; + +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.S3Cloud; +import org.cryptomator.domain.repository.CloudContentRepository; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.domain.CloudType.S3; + +@Singleton +public class S3CloudContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final Context context; + + @Inject + public S3CloudContentRepositoryFactory(Context context) { + this.context = context; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == S3; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + return new S3CloudContentRepository((S3Cloud) cloud, context); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudNodeFactory.java new file mode 100644 index 00000000..e3baf233 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3CloudNodeFactory.java @@ -0,0 +1,69 @@ +package org.cryptomator.data.cloud.s3; + +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3ObjectSummary; + +import org.cryptomator.util.Optional; + +import java.util.Date; + +class S3CloudNodeFactory { + + private static final String DELIMITER = "/"; + + public static S3File file(S3Folder parent, S3ObjectSummary file) { + String name = getNameFromKey(file.getKey()); + return new S3File(parent, name, getNodePath(parent, name), Optional.ofNullable(file.getSize()), Optional.ofNullable(file.getLastModified())); + } + + public static S3File file(S3Folder parent, String name, ObjectMetadata file) { + return new S3File(parent, name, getNodePath(parent, name), Optional.ofNullable(file.getContentLength()), Optional.ofNullable(file.getLastModified())); + } + + + public static S3File file(S3Folder parent, String name, Optional size) { + return new S3File(parent, name, getNodePath(parent, name), size, Optional.empty()); + } + + public static S3File file(S3Folder parent, String name, Optional size, String path) { + return new S3File(parent, name, path, size, Optional.empty()); + } + + public static S3File file(S3Folder parent, String name, Optional size, Optional lastModified) { + return new S3File(parent, name, getNodePath(parent, name), size, lastModified); + } + + public static S3Folder folder(S3Folder parent, S3ObjectSummary folder) { + String name = getNameFromKey(folder.getKey()); + return new S3Folder(parent, name, getNodePath(parent, name)); + } + + public static S3Folder folder(S3Folder parent, String name) { + return new S3Folder(parent, name, getNodePath(parent, name)); + } + + public static S3Folder folder(S3Folder parent, String name, String path) { + return new S3Folder(parent, name, path); + } + + private static String getNodePath(S3Folder parent, String name) { + return parent.getKey() + name; + } + + public static String getNameFromKey(String key) { + String name = key; + if (key.endsWith(DELIMITER)) { + name = key.substring(0, key.length() -1); + } + return name.contains(DELIMITER) ? name.substring(name.lastIndexOf(DELIMITER) + 1) : name; + } + + public static S3Node from(S3Folder parent, S3ObjectSummary objectSummary) { + if (objectSummary.getKey().endsWith(DELIMITER)) { + return folder(parent, objectSummary); + } else { + return file(parent, objectSummary); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3File.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3File.java new file mode 100644 index 00000000..ce3520f8 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3File.java @@ -0,0 +1,60 @@ +package org.cryptomator.data.cloud.s3; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class S3File implements CloudFile, S3Node { + + private final S3Folder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + + public S3File(S3Folder parent, String name, String path, Optional size, Optional modified) { + this.parent = parent; + this.name = name; + this.path = path; + this.size = size; + this.modified = modified; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public String getKey() { + return path; + } + + @Override + public S3Folder getParent() { + return parent; + } + + @Override + public Optional getSize() { + return size; + } + + @Override + public Optional getModified() { + return modified; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3Folder.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3Folder.java new file mode 100644 index 00000000..591c6dea --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3Folder.java @@ -0,0 +1,49 @@ +package org.cryptomator.data.cloud.s3; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +class S3Folder implements CloudFolder, S3Node { + + private static final String DELIMITER = "/"; + + private final S3Folder parent; + private final String name; + private final String path; + + public S3Folder(S3Folder parent, String name, String path) { + this.parent = parent; + this.name = name; + this.path = path; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public String getKey() { + return path + DELIMITER; + } + + @Override + public S3Folder getParent() { + return parent; + } + + @Override + public S3Folder withCloud(Cloud cloud) { + return new S3Folder(parent.withCloud(cloud), name, path); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3Impl.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3Impl.java new file mode 100644 index 00000000..05a72fea --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3Impl.java @@ -0,0 +1,417 @@ +package org.cryptomator.data.cloud.s3; + +import android.content.Context; + +import com.amazonaws.event.ProgressListener; +import com.amazonaws.mobileconnectors.s3.transferutility.TransferListener; +import com.amazonaws.mobileconnectors.s3.transferutility.TransferState; +import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility; +import com.amazonaws.mobileconnectors.s3.transferutility.UploadOptions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.CopyObjectResult; +import com.amazonaws.services.s3.model.DeleteObjectsRequest; +import com.amazonaws.services.s3.model.DeleteObjectsRequest.KeyVersion; +import com.amazonaws.services.s3.model.GetObjectRequest; +import com.amazonaws.services.s3.model.ListObjectsV2Request; +import com.amazonaws.services.s3.model.ListObjectsV2Result; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.Owner; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.S3Object; +import com.amazonaws.services.s3.model.S3ObjectSummary; +import com.tomclaw.cache.DiskLruCache; + +import org.cryptomator.data.util.CopyStream; +import org.cryptomator.domain.S3Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.ForbiddenException; +import org.cryptomator.domain.exception.NoSuchBucketException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.UnauthorizedException; +import org.cryptomator.domain.exception.authentication.WrongCredentialsException; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.domain.usecases.cloud.DownloadState; +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.util.Optional; +import org.cryptomator.util.SharedPreferencesHandler; +import org.cryptomator.util.concurrent.CompletableFuture; +import org.cryptomator.util.file.LruFileCacheUtil; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicLong; + +import timber.log.Timber; + +import static org.cryptomator.domain.usecases.cloud.Progress.progress; +import static org.cryptomator.util.file.LruFileCacheUtil.Cache.S3; +import static org.cryptomator.util.file.LruFileCacheUtil.retrieveFromLruCache; +import static org.cryptomator.util.file.LruFileCacheUtil.storeToLruCache; + +class S3Impl { + + private static final long CHUNKED_UPLOAD_MAX_SIZE = 100L << 20; + private static final String DELIMITER = "/"; + + private final S3ClientFactory clientFactory = new S3ClientFactory(); + private final S3Cloud cloud; + private final RootS3Folder root; + private final Context context; + + private final SharedPreferencesHandler sharedPreferencesHandler; + private DiskLruCache diskLruCache; + + S3Impl(Context context, S3Cloud cloud) { + if (cloud.accessKey() == null || cloud.secretKey() == null) { + throw new WrongCredentialsException(cloud); + } + + this.context = context; + this.cloud = cloud; + this.root = new RootS3Folder(cloud); + this.sharedPreferencesHandler = new SharedPreferencesHandler(context); + } + + private AmazonS3 client() { + return clientFactory.getClient(cloud, context); + } + + public S3Folder root() { + return root; + } + + public S3Folder resolve(String path) { + if (path.startsWith(DELIMITER)) { + path = path.substring(1); + } + String[] names = path.split(DELIMITER); + S3Folder folder = root; + for (String name : names) { + if (!name.isEmpty()) { + folder = folder(folder, name); + } + } + return folder; + } + + public S3File file(S3Folder parent, String name) throws BackendException, IOException { + return file(parent, name, Optional.empty()); + } + + public S3File file(S3Folder parent, String name, Optional size) throws BackendException, IOException { + return S3CloudNodeFactory.file(parent, name, size, parent.getKey() + name); + } + + public S3Folder folder(S3Folder parent, String name) { + return S3CloudNodeFactory.folder(parent, name, parent.getKey() + name); + } + + public boolean exists(S3Node node) { + String key = node.getKey(); + + ListObjectsV2Result result = client().listObjectsV2(cloud.s3Bucket(), key); + + return result.getObjectSummaries().size() > 0; + } + + public List list(S3Folder folder) throws IOException, BackendException { + List result = new ArrayList<>(); + + ListObjectsV2Request request = new ListObjectsV2Request().withBucketName(cloud.s3Bucket()).withPrefix(folder.getKey()).withDelimiter(DELIMITER); + + ListObjectsV2Result listObjects = client().listObjectsV2(request); + for (String prefix : listObjects.getCommonPrefixes()) { + // add folders + result.add(S3CloudNodeFactory.folder(folder, S3CloudNodeFactory.getNameFromKey(prefix))); + } + + for (S3ObjectSummary objectSummary : listObjects.getObjectSummaries()) { + // add files but skip parent folder + if (!objectSummary.getKey().equals(listObjects.getPrefix())) { + result.add(S3CloudNodeFactory.file(folder, objectSummary)); + } + } + return result; + } + + public S3Folder create(S3Folder folder) throws IOException, BackendException { + if (!exists(folder.getParent())) { + folder = new S3Folder( // + create(folder.getParent()), // + folder.getName(), folder.getPath() // + ); + } + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(0); + + InputStream emptyContent = new ByteArrayInputStream(new byte[0]); + + try { + PutObjectRequest putObjectRequest = new PutObjectRequest(cloud.s3Bucket(), folder.getKey(), emptyContent, metadata); + client().putObject(putObjectRequest); + } catch(AmazonS3Exception ex) { + handleApiError(ex, folder.getName()); + } + + return S3CloudNodeFactory.folder(folder.getParent(), folder.getName()); + } + + public S3Node move(S3Node source, S3Node target) throws IOException, BackendException { + if (exists(target)) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + + if (source instanceof S3Folder) { + ListObjectsV2Result listObjects = client().listObjectsV2(cloud.s3Bucket(), source.getPath()); + + if (listObjects.getObjectSummaries().size() > 0) { + + List objectsToDelete = new ArrayList<>(); + + for (S3ObjectSummary summary : listObjects.getObjectSummaries()) { + objectsToDelete.add(new DeleteObjectsRequest.KeyVersion(summary.getKey())); + String destinationKey = summary.getKey().replace(source.getPath(), target.getPath()); + + client().copyObject(cloud.s3Bucket(), summary.getKey(), cloud.s3Bucket(), destinationKey); + } + client().deleteObjects(new DeleteObjectsRequest(cloud.s3Bucket()).withKeys(objectsToDelete)); + } else { + throw new NoSuchCloudFileException(source.getPath()); + } + return S3CloudNodeFactory.folder(target.getParent(), target.getName()); + } else { + CopyObjectResult result = client().copyObject(cloud.s3Bucket(), source.getPath(), cloud.s3Bucket(), target.getPath()); + client().deleteObject(cloud.s3Bucket(), source.getPath()); + return S3CloudNodeFactory.file(target.getParent(), target.getName(), ((S3File) source).getSize(), Optional.of(result.getLastModifiedDate())); + } + } + + public S3File write(S3File file, DataSource data, final ProgressAware progressAware, boolean replace, long size) throws IOException, BackendException { + if (!replace && exists(file)) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + progressAware.onProgress(Progress.started(UploadState.upload(file))); + + final CompletableFuture> result = new CompletableFuture<>(); + + try { + if (size <= CHUNKED_UPLOAD_MAX_SIZE) { + uploadFile(file, data, progressAware, result, size); + } else { + uploadChunkedFile(file, data, progressAware, result, size); + } + } catch(AmazonS3Exception ex) { + handleApiError(ex, file.getName()); + } + + try { + Optional objectMetadataOptional = result.get(); + ObjectMetadata objectMetadata = objectMetadataOptional.orElseGet(() -> client().getObjectMetadata(cloud.s3Bucket(), file.getPath())); + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + return S3CloudNodeFactory.file(file.getParent(), file.getName(), objectMetadata); + } catch (ExecutionException | InterruptedException e) { + throw new FatalBackendException(e); + } + + } + + private void uploadFile(final S3File file, DataSource data, final ProgressAware progressAware, CompletableFuture> result, final long size) // + throws IOException { + AtomicLong bytesTransferred = new AtomicLong(0); + ProgressListener listener = progressEvent -> { + bytesTransferred.set(bytesTransferred.get() + progressEvent.getBytesTransferred()); + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(bytesTransferred.get())); + }; + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(data.size(context).get()); + + PutObjectRequest request = new PutObjectRequest(cloud.s3Bucket(), file.getPath(), data.open(context), metadata); + request.setGeneralProgressListener(listener); + + result.complete(Optional.of(client().putObject(request).getMetadata())); + } + + private void uploadChunkedFile(final S3File file, DataSource data, final ProgressAware progressAware, CompletableFuture> result, final long size) // + throws IOException { + + TransferUtility tu = TransferUtility // + .builder() // + .s3Client(client()) // + .context(context) // + .defaultBucket(cloud.s3Bucket()) // + .build(); + + TransferListener transferListener = new TransferListener() { + @Override + public void onStateChanged(int id, TransferState state) { + if (state.equals(TransferState.COMPLETED)) { + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + result.complete(Optional.empty()); + } + } + + @Override + public void onProgressChanged(int id, long bytesCurrent, long bytesTotal) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(bytesCurrent)); + } + + @Override + public void onError(int id, Exception ex) { + result.fail(ex); + } + }; + + UploadOptions uploadOptions = UploadOptions.builder().transferListener(transferListener).build(); + + tu.upload(file.getPath(), data.open(context), uploadOptions); + } + + public void read(S3File file, Optional encryptedTmpFile, OutputStream data, final ProgressAware progressAware) throws IOException, BackendException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + + Optional cacheKey = Optional.empty(); + Optional cacheFile = Optional.empty(); + + ListObjectsV2Result listObjects; + + if (sharedPreferencesHandler.useLruCache() && createLruCache(sharedPreferencesHandler.lruCacheSize())) { + listObjects = client().listObjectsV2(cloud.s3Bucket(), file.getKey()); + if (listObjects.getObjectSummaries().size() != 1) { + throw new NoSuchCloudFileException(file.getKey()); + } + S3ObjectSummary summary = listObjects.getObjectSummaries().get(0); + cacheKey = Optional.of(summary.getKey() + summary.getETag()); + + File cachedFile = diskLruCache.get(cacheKey.get()); + cacheFile = cachedFile != null ? Optional.of(cachedFile) : Optional.empty(); + } + + if (sharedPreferencesHandler.useLruCache() && cacheFile.isPresent()) { + try { + retrieveFromLruCache(cacheFile.get(), data); + } catch (IOException e) { + Timber.tag("S3Impl").w(e, "Error while retrieving content from Cache, get from web request"); + writeToData(file, data, encryptedTmpFile, cacheKey, progressAware); + } + } else { + writeToData(file, data, encryptedTmpFile, cacheKey, progressAware); + } + + progressAware.onProgress(Progress.completed(DownloadState.download(file))); + } + + private void writeToData(final S3File file, // + final OutputStream data, // + final Optional encryptedTmpFile, // + final Optional cacheKey, // + final ProgressAware progressAware) throws IOException, BackendException { + AtomicLong bytesTransferred = new AtomicLong(0); + ProgressListener listener = progressEvent -> { + bytesTransferred.set(bytesTransferred.get() + progressEvent.getBytesTransferred()); + + progressAware.onProgress( // + progress(DownloadState.download(file)) // + .between(0) // + .and(file.getSize().orElse(Long.MAX_VALUE)) // + .withValue(bytesTransferred.get())); + }; + + GetObjectRequest request = new GetObjectRequest(cloud.s3Bucket(), file.getPath()); + request.setGeneralProgressListener(listener); + + try { + S3Object s3Object = client().getObject(request); + + CopyStream.copyStreamToStream(s3Object.getObjectContent(), data); + + if (sharedPreferencesHandler.useLruCache() && encryptedTmpFile.isPresent() && cacheKey.isPresent()) { + try { + storeToLruCache(diskLruCache, cacheKey.get(), encryptedTmpFile.get()); + } catch (IOException e) { + Timber.tag("S3Impl").e(e, "Failed to write downloaded file in LRU cache"); + } + } + } catch(AmazonS3Exception ex) { + handleApiError(ex, file.getName()); + } + + } + + public void delete(S3Node node) throws IOException, BackendException { + if (node instanceof S3Folder) { + List summaries = client().listObjectsV2(cloud.s3Bucket(), node.getPath()).getObjectSummaries(); + + List keys = new ArrayList<>(); + for (S3ObjectSummary summary : summaries) { + keys.add(new KeyVersion(summary.getKey())); + } + + DeleteObjectsRequest request = new DeleteObjectsRequest(cloud.s3Bucket()); + request.withKeys(keys); + + client().deleteObjects(request); + } else { + client().deleteObject(cloud.s3Bucket(), node.getPath()); + } + } + + public String checkAuthenticationAndRetrieveCurrentAccount() throws NoSuchBucketException { + if (!client().doesBucketExist(cloud.s3Bucket())) { + throw new NoSuchBucketException(cloud.s3Bucket()); + } + + Owner currentAccount = client() // + .getS3AccountOwner(); + + return currentAccount.getDisplayName(); + } + + private boolean createLruCache(int cacheSize) { + if (diskLruCache == null) { + try { + diskLruCache = DiskLruCache.create(new LruFileCacheUtil(context).resolve(S3), cacheSize); + } catch (IOException e) { + Timber.tag("S3Impl").e(e, "Failed to setup LRU cache"); + return false; + } + } + + return true; + } + + private void handleApiError(AmazonS3Exception ex, String name) throws BackendException { + String errorCode = ex.getErrorCode(); + if (S3CloudApiExceptions.isAccessProblem(errorCode)) { + throw new ForbiddenException(); + } else if (S3CloudApiErrorCodes.NO_SUCH_BUCKET.getValue().equals(errorCode)) { + throw new NoSuchBucketException(name); + } else if (S3CloudApiErrorCodes.NO_SUCH_KEY.getValue().equals(errorCode)) { + throw new NoSuchCloudFileException(name); + } else { + throw new FatalBackendException(ex); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/s3/S3Node.java b/data/src/main/java/org/cryptomator/data/cloud/s3/S3Node.java new file mode 100644 index 00000000..570d338b --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/s3/S3Node.java @@ -0,0 +1,12 @@ +package org.cryptomator.data.cloud.s3; + +import org.cryptomator.domain.CloudNode; + +interface S3Node extends CloudNode { + + @Override + S3Folder getParent(); + + String getKey(); + +} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java index b116cb04..59fef0f2 100644 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java @@ -23,14 +23,18 @@ class DatabaseUpgrades { Upgrade1To2 upgrade1To2, // Upgrade2To3 upgrade2To3, // Upgrade3To4 upgrade3To4, // - Upgrade4To5 upgrade4To5) { + Upgrade4To5 upgrade4To5, // + Upgrade5To6 upgrade5To6, // + Upgrade6To7 upgrade6To7) { availableUpgrades = defineUpgrades( // upgrade0To1, // upgrade1To2, // upgrade2To3, // upgrade3To4, // - upgrade4To5); + upgrade4To5, // + upgrade5To6, // + upgrade6To7); } private static Comparator reverseOrder() { diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade5To6.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade5To6.kt new file mode 100644 index 00000000..0caa34ce --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade5To6.kt @@ -0,0 +1,76 @@ +package org.cryptomator.data.db + +import org.greenrobot.greendao.database.Database +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Upgrade5To6 @Inject constructor() : DatabaseUpgrade(5, 6) { + + override fun internalApplyTo(db: Database, origin: Int) { + db.beginTransaction() + try { + changeCloudEntityToSupportS3(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun changeCloudEntityToSupportS3(db: Database) { + Sql.alterTable("CLOUD_ENTITY").renameTo("CLOUD_ENTITY_OLD").executeOn(db) + + Sql.createTable("CLOUD_ENTITY") // + .id() // + .requiredText("TYPE") // + .optionalText("ACCESS_TOKEN") // + .optionalText("URL") // + .optionalText("USERNAME") // + .optionalText("WEBDAV_CERTIFICATE") // + .optionalText("S3_BUCKET") // + .optionalText("S3_REGION") // + .optionalText("S3_SECRET_KEY") // + .executeOn(db); + + Sql.insertInto("CLOUD_ENTITY") // + .select("_id", "TYPE", "ACCESS_TOKEN", "URL", "USERNAME", "WEBDAV_CERTIFICATE") // + .columns("_id", "TYPE", "ACCESS_TOKEN", "URL", "USERNAME", "WEBDAV_CERTIFICATE") // + .from("CLOUD_ENTITY_OLD") // + .executeOn(db) + + recreateVaultEntity(db) + + Sql.dropTable("CLOUD_ENTITY_OLD").executeOn(db) + } + + private fun recreateVaultEntity(db: Database) { + Sql.alterTable("VAULT_ENTITY").renameTo("VAULT_ENTITY_OLD").executeOn(db) + Sql.createTable("VAULT_ENTITY") // + .id() // + .optionalInt("FOLDER_CLOUD_ID") // + .optionalText("FOLDER_PATH") // + .optionalText("FOLDER_NAME") // + .requiredText("CLOUD_TYPE") // + .optionalText("PASSWORD") // + .optionalInt("POSITION") // + .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .executeOn(db) + + Sql.insertInto("VAULT_ENTITY") // + .select("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_ENTITY.TYPE") // + .columns("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_TYPE") // + .from("VAULT_ENTITY_OLD") // + .join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // + .executeOn(db) + + Sql.dropIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID").executeOn(db) + + Sql.createUniqueIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID") // + .on("VAULT_ENTITY") // + .asc("FOLDER_PATH") // + .asc("FOLDER_CLOUD_ID") // + .executeOn(db) + + Sql.dropTable("VAULT_ENTITY_OLD").executeOn(db) + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade6To7.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade6To7.kt new file mode 100644 index 00000000..63c2e4a7 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade6To7.kt @@ -0,0 +1,41 @@ +package org.cryptomator.data.db + +import org.greenrobot.greendao.database.Database +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Upgrade6To7 @Inject constructor() : DatabaseUpgrade(6, 7) { + + override fun internalApplyTo(db: Database, origin: Int) { + db.beginTransaction() + try { + changeUpdateEntityToSupportSha256Verification(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun changeUpdateEntityToSupportSha256Verification(db: Database) { + Sql.alterTable("UPDATE_CHECK_ENTITY").renameTo("UPDATE_CHECK_ENTITY_OLD").executeOn(db) + + Sql.createTable("UPDATE_CHECK_ENTITY") // + .id() // + .optionalText("LICENSE_TOKEN") // + .optionalText("RELEASE_NOTE") // + .optionalText("VERSION") // + .optionalText("URL_TO_APK") // + .optionalText("APK_SHA256") // + .optionalText("URL_TO_RELEASE_NOTE") // + .executeOn(db) + + Sql.insertInto("UPDATE_CHECK_ENTITY") // + .select("_id", "LICENSE_TOKEN", "RELEASE_NOTE", "VERSION", "URL_TO_APK", "URL_TO_RELEASE_NOTE") // + .columns("_id", "LICENSE_TOKEN", "RELEASE_NOTE", "VERSION", "URL_TO_APK", "URL_TO_RELEASE_NOTE") // + .from("UPDATE_CHECK_ENTITY_OLD") // + .executeOn(db) + + Sql.dropTable("UPDATE_CHECK_ENTITY_OLD").executeOn(db) + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java index 0ce2c8a1..b939e925 100644 --- a/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java +++ b/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java @@ -22,14 +22,23 @@ public class CloudEntity extends DatabaseEntity { private String webdavCertificate; - @Generated(hash = 361171073) - public CloudEntity(Long id, @NotNull String type, String accessToken, String url, String username, String webdavCertificate) { + private String s3Bucket; + + private String s3Region; + + private String s3SecretKey; + + @Generated(hash = 1685351705) + public CloudEntity(Long id, @NotNull String type, String accessToken, String url, String username, String webdavCertificate, String s3Bucket, String s3Region, String s3SecretKey) { this.id = id; this.type = type; this.accessToken = accessToken; this.url = url; this.username = username; this.webdavCertificate = webdavCertificate; + this.s3Bucket = s3Bucket; + this.s3Region = s3Region; + this.s3SecretKey = s3SecretKey; } @Generated(hash = 1354152224) @@ -83,4 +92,28 @@ public class CloudEntity extends DatabaseEntity { public void setWebdavCertificate(String webdavCertificate) { this.webdavCertificate = webdavCertificate; } + + public String getS3Bucket() { + return this.s3Bucket; + } + + public void setS3Bucket(String s3Bucket) { + this.s3Bucket = s3Bucket; + } + + public String getS3Region() { + return this.s3Region; + } + + public void setS3Region(String s3Region) { + this.s3Region = s3Region; + } + + public String getS3SecretKey() { + return this.s3SecretKey; + } + + public void setS3SecretKey(String s3SecretKey) { + this.s3SecretKey = s3SecretKey; + } } diff --git a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java index 10323409..bb27dfdc 100644 --- a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java +++ b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java @@ -18,18 +18,22 @@ public class UpdateCheckEntity extends DatabaseEntity { private String urlToApk; + private String apkSha256; + private String urlToReleaseNote; public UpdateCheckEntity() { } - @Generated(hash = 38676936) - public UpdateCheckEntity(Long id, String licenseToken, String releaseNote, String version, String urlToApk, String urlToReleaseNote) { + @Generated(hash = 67239496) + public UpdateCheckEntity(Long id, String licenseToken, String releaseNote, String version, String urlToApk, String apkSha256, + String urlToReleaseNote) { this.id = id; this.licenseToken = licenseToken; this.releaseNote = releaseNote; this.version = version; this.urlToApk = urlToApk; + this.apkSha256 = apkSha256; this.urlToReleaseNote = urlToReleaseNote; } @@ -81,4 +85,12 @@ public class UpdateCheckEntity extends DatabaseEntity { public void setUrlToReleaseNote(String urlToReleaseNote) { this.urlToReleaseNote = urlToReleaseNote; } + + public String getApkSha256() { + return this.apkSha256; + } + + public void setApkSha256(String apkSha256) { + this.apkSha256 = apkSha256; + } } 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 4b637b25..70e049f7 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 @@ -8,6 +8,7 @@ import org.cryptomator.domain.GoogleDriveCloud; import org.cryptomator.domain.LocalStorageCloud; import org.cryptomator.domain.OnedriveCloud; import org.cryptomator.domain.PCloud; +import org.cryptomator.domain.S3Cloud; import org.cryptomator.domain.WebDavCloud; import javax.inject.Inject; @@ -18,6 +19,7 @@ import static org.cryptomator.domain.GoogleDriveCloud.aGoogleDriveCloud; import static org.cryptomator.domain.LocalStorageCloud.aLocalStorage; 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.WebDavCloud.aWebDavCloudCloud; @Singleton @@ -43,6 +45,10 @@ public class CloudEntityMapper extends EntityMapper { .withAccessToken(entity.getAccessToken()) // .withUsername(entity.getUsername()) // .build(); + case LOCAL: + return aLocalStorage() // + .withId(entity.getId()) // + .withRootUri(entity.getAccessToken()).build(); case ONEDRIVE: return aOnedriveCloud() // .withId(entity.getId()) // @@ -56,10 +62,16 @@ public class CloudEntityMapper extends EntityMapper { .withAccessToken(entity.getAccessToken()) // .withUsername(entity.getUsername()) // .build(); - case LOCAL: - return aLocalStorage() // + case S3: + return aS3Cloud() // .withId(entity.getId()) // - .withRootUri(entity.getAccessToken()).build(); + .withS3Endpoint(entity.getUrl()) // + .withS3Region(entity.getS3Region()) // + .withAccessKey(entity.getAccessToken()) // + .withSecretKey(entity.getS3SecretKey()) // + .withS3Bucket(entity.getS3Bucket()) // + .withDisplayName(entity.getUsername()) // + .build(); case WEBDAV: return aWebDavCloudCloud() // .withId(entity.getId()) // @@ -87,6 +99,9 @@ public class CloudEntityMapper extends EntityMapper { result.setAccessToken(((GoogleDriveCloud) domainObject).accessToken()); result.setUsername(((GoogleDriveCloud) domainObject).username()); break; + case LOCAL: + result.setAccessToken(((LocalStorageCloud) domainObject).rootUri()); + break; case ONEDRIVE: result.setAccessToken(((OnedriveCloud) domainObject).accessToken()); result.setUsername(((OnedriveCloud) domainObject).username()); @@ -96,8 +111,13 @@ public class CloudEntityMapper extends EntityMapper { result.setUrl(((PCloud) domainObject).url()); result.setUsername(((PCloud) domainObject).username()); break; - case LOCAL: - result.setAccessToken(((LocalStorageCloud) domainObject).rootUri()); + case S3: + result.setUrl(((S3Cloud) domainObject).s3Endpoint()); + result.setS3Region(((S3Cloud) domainObject).s3Region()); + result.setAccessToken(((S3Cloud) domainObject).accessKey()); + result.setS3SecretKey(((S3Cloud) domainObject).secretKey()); + result.setS3Bucket(((S3Cloud) domainObject).s3Bucket()); + result.setUsername(((S3Cloud) domainObject).displayName()); break; case WEBDAV: result.setAccessToken(((WebDavCloud) domainObject).password()); diff --git a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java index 2c053cfb..41229dfe 100644 --- a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java @@ -1,22 +1,28 @@ package org.cryptomator.data.repository; +import android.content.Context; +import android.net.Uri; + import com.google.common.io.BaseEncoding; +import org.apache.commons.codec.binary.Hex; import org.cryptomator.data.db.Database; import org.cryptomator.data.db.entities.UpdateCheckEntity; import org.cryptomator.data.util.UserAgentInterceptor; import org.cryptomator.domain.exception.BackendException; import org.cryptomator.domain.exception.FatalBackendException; import org.cryptomator.domain.exception.update.GeneralUpdateErrorException; -import org.cryptomator.domain.exception.update.SSLHandshakePreAndroid5UpdateCheckException; +import org.cryptomator.domain.exception.update.HashMismatchUpdateCheckException; import org.cryptomator.domain.repository.UpdateCheckRepository; import org.cryptomator.domain.usecases.UpdateCheck; import org.cryptomator.util.Optional; import java.io.File; import java.io.IOException; +import java.security.DigestInputStream; import java.security.Key; import java.security.KeyFactory; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.interfaces.ECPublicKey; import java.security.spec.InvalidKeySpecException; @@ -25,7 +31,6 @@ import java.security.spec.X509EncodedKeySpec; import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Singleton; -import javax.net.ssl.SSLHandshakeException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; @@ -42,11 +47,13 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { private final Database database; private final OkHttpClient httpClient; + private final Context context; @Inject - UpdateCheckRepositoryImpl(Database database) { + UpdateCheckRepositoryImpl(Database database, Context context) { this.httpClient = httpClient(); this.database = database; + this.context = context; } private OkHttpClient httpClient() { @@ -65,13 +72,14 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); - if (entity.getVersion() != null && entity.getVersion().equals(latestVersion.version)) { + if (entity.getVersion() != null && entity.getVersion().equals(latestVersion.version) && entity.getApkSha256() != null) { return Optional.of(new UpdateCheckImpl("", entity)); } UpdateCheck updateCheck = loadUpdateStatus(latestVersion); entity.setUrlToApk(updateCheck.getUrlApk()); entity.setVersion(updateCheck.getVersion()); + entity.setApkSha256(updateCheck.getApkSha256()); database.store(entity); @@ -107,7 +115,18 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { if (response.isSuccessful()) { final BufferedSink sink = Okio.buffer(Okio.sink(file)); sink.writeAll(response.body().source()); + sink.flush(); sink.close(); + + String apkSha256 = calculateSha256(file); + + if(!apkSha256.equals(entity.getApkSha256())) { + file.delete(); + throw new HashMismatchUpdateCheckException(String.format( // + "Sha of calculated hash (%s) doesn't match the specified one (%s)", // + apkSha256, // + entity.getApkSha256())); + } } else { throw new GeneralUpdateErrorException("Failed to load update file, status code is not correct: " + response.code()); } @@ -116,6 +135,20 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { } } + private String calculateSha256(File file) throws GeneralUpdateErrorException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try(DigestInputStream digestInputStream = new DigestInputStream(context.getContentResolver().openInputStream(Uri.fromFile(file)), digest)) { + byte[] buffer = new byte[8192]; + while(digestInputStream.read(buffer) > -1) { + } + } + return new String(Hex.encodeHex(digest.digest())); + } catch (Exception e) { + throw new GeneralUpdateErrorException(e); + } + } + private LatestVersion loadLatestVersion() throws BackendException { try { final Request request = new Request // @@ -123,12 +156,6 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { .url(HOSTNAME_LATEST_VERSION) // .build(); return toLatestVersion(httpClient.newCall(request).execute()); - } catch (SSLHandshakeException e) { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) { - throw new SSLHandshakePreAndroid5UpdateCheckException("Failed to update.", e); - } else { - throw new GeneralUpdateErrorException("Failed to update. General error occurred.", e); - } } catch (IOException e) { throw new GeneralUpdateErrorException("Failed to update. General error occurred.", e); } @@ -181,12 +208,14 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { private final String releaseNote; private final String version; private final String urlApk; + private final String apkSha256; private final String urlReleaseNote; private UpdateCheckImpl(String releaseNote, LatestVersion latestVersion) { this.releaseNote = releaseNote; this.version = latestVersion.version; this.urlApk = latestVersion.urlApk; + this.apkSha256 = latestVersion.apkSha256; this.urlReleaseNote = latestVersion.urlReleaseNote; } @@ -194,6 +223,7 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { this.releaseNote = releaseNote; this.version = updateCheckEntity.getVersion(); this.urlApk = updateCheckEntity.getUrlToApk(); + this.apkSha256 = updateCheckEntity.getApkSha256(); this.urlReleaseNote = updateCheckEntity.getUrlToReleaseNote(); } @@ -212,6 +242,11 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { return urlApk; } + @Override + public String getApkSha256() { + return apkSha256; + } + @Override public String getUrlReleaseNote() { return urlReleaseNote; @@ -222,6 +257,7 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { private final String version; private final String urlApk; + private final String apkSha256; private final String urlReleaseNote; LatestVersion(String json) throws GeneralUpdateErrorException { @@ -234,6 +270,7 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { version = jws.get("version", String.class); urlApk = jws.get("url", String.class); + apkSha256 = jws.get("apk_sha_256", String.class); urlReleaseNote = jws.get("release_notes", String.class); } catch (Exception e) { throw new GeneralUpdateErrorException("Failed to parse latest version", e); diff --git a/data/src/notFoss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java b/data/src/notFoss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java index ce4f9b41..918d4dfb 100644 --- a/data/src/notFoss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java +++ b/data/src/notFoss/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java @@ -6,6 +6,7 @@ import org.cryptomator.data.cloud.googledrive.GoogleDriveCloudContentRepositoryF import org.cryptomator.data.cloud.local.LocalStorageContentRepositoryFactory; 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.webdav.WebDavCloudContentRepositoryFactory; import org.cryptomator.data.repository.CloudContentRepositoryFactory; import org.jetbrains.annotations.NotNull; @@ -27,6 +28,7 @@ public class CloudContentRepositoryFactories implements Iterable -
  • Added pCloud native support (thanks to Manu for this huge contribution)
  • -
  • Fixed app crash when restoring Cryptomator from a backup
  • -
  • Enhanced display of long settings
  • -
  • Enhanced deletion of the last image via the preview. Now jumps back to the vault contents list
  • +
  • Fixed pCloud login using F-Droid
  • \ No newline at end of file diff --git a/pcloud-sdk-java b/pcloud-sdk-java index d12c6e6c..c99ebf65 160000 --- a/pcloud-sdk-java +++ b/pcloud-sdk-java @@ -1 +1 @@ -Subproject commit d12c6e6c4af8d0360812900663d5298ca093377b +Subproject commit c99ebf651c18dd5a667dc4ecb106c3e43665cc6c diff --git a/presentation/src/foss/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt b/presentation/src/foss/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt index 5bdc211c..cab9818b 100644 --- a/presentation/src/foss/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt +++ b/presentation/src/foss/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt @@ -2,17 +2,34 @@ package org.cryptomator.presentation.presenter import android.Manifest import android.accounts.AccountManager +import android.content.Intent +import android.widget.Toast import com.dropbox.core.android.Auth +import com.pcloud.sdk.AuthorizationActivity +import com.pcloud.sdk.AuthorizationData +import com.pcloud.sdk.AuthorizationRequest +import com.pcloud.sdk.AuthorizationResult import org.cryptomator.data.cloud.onedrive.OnedriveClientFactory import org.cryptomator.data.cloud.onedrive.graph.ClientException import org.cryptomator.data.cloud.onedrive.graph.ICallback import org.cryptomator.data.util.X509CertificateHelper -import org.cryptomator.domain.* +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.CloudType +import org.cryptomator.domain.DropboxCloud +import org.cryptomator.domain.GoogleDriveCloud +import org.cryptomator.domain.OnedriveCloud +import org.cryptomator.domain.PCloud +import org.cryptomator.domain.WebDavCloud import org.cryptomator.domain.di.PerView import org.cryptomator.domain.exception.FatalBackendException import org.cryptomator.domain.exception.NetworkConnectionException -import org.cryptomator.domain.exception.authentication.* +import org.cryptomator.domain.exception.authentication.AuthenticationException +import org.cryptomator.domain.exception.authentication.WebDavCertificateUntrustedAuthenticationException +import org.cryptomator.domain.exception.authentication.WebDavNotSupportedException +import org.cryptomator.domain.exception.authentication.WebDavServerNotFoundException +import org.cryptomator.domain.exception.authentication.WrongCredentialsException import org.cryptomator.domain.usecases.cloud.AddOrChangeCloudConnectionUseCase +import org.cryptomator.domain.usecases.cloud.GetCloudsUseCase import org.cryptomator.domain.usecases.cloud.GetUsernameUseCase import org.cryptomator.generator.Callback import org.cryptomator.presentation.BuildConfig @@ -20,23 +37,34 @@ import org.cryptomator.presentation.R import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.exception.PermissionNotGrantedException import org.cryptomator.presentation.intent.AuthenticateCloudIntent -import org.cryptomator.presentation.model.* +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.CloudTypeModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.ProgressStateModel +import org.cryptomator.presentation.model.S3CloudModel +import org.cryptomator.presentation.model.WebDavCloudModel import org.cryptomator.presentation.model.mappers.CloudModelMapper import org.cryptomator.presentation.ui.activity.view.AuthenticateCloudView -import org.cryptomator.presentation.workflow.* +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow +import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow +import org.cryptomator.presentation.workflow.PermissionsResult +import org.cryptomator.presentation.workflow.Workflow import org.cryptomator.util.ExceptionUtil import org.cryptomator.util.crypto.CredentialCryptor -import timber.log.Timber import java.security.cert.CertificateEncodingException import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.inject.Inject +import timber.log.Timber @PerView class AuthenticateCloudPresenter @Inject constructor( // exceptionHandlers: ExceptionHandlers, // private val cloudModelMapper: CloudModelMapper, // private val addOrChangeCloudConnectionUseCase: AddOrChangeCloudConnectionUseCase, // + private val getCloudsUseCase: GetCloudsUseCase, // private val getUsernameUseCase: GetUsernameUseCase, // private val addExistingVaultWorkflow: AddExistingVaultWorkflow, // private val createNewVaultWorkflow: CreateNewVaultWorkflow) : Presenter(exceptionHandlers) { @@ -44,7 +72,9 @@ class AuthenticateCloudPresenter @Inject constructor( // private val strategies = arrayOf( // DropboxAuthStrategy(), // OnedriveAuthStrategy(), // + PCloudAuthStrategy(), // WebDAVAuthStrategy(), // + S3AuthStrategy(), // LocalStorageAuthStrategy() // ) @@ -221,6 +251,102 @@ class AuthenticateCloudPresenter @Inject constructor( // } } + private inner class PCloudAuthStrategy : AuthStrategy { + + private var authenticationStarted = false + + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.PCLOUD + } + + override fun resumed(intent: AuthenticateCloudIntent) { + when { + ExceptionUtil.contains(intent.error(), WrongCredentialsException::class.java) -> { + if (!authenticationStarted) { + startAuthentication() + Toast.makeText( + context(), + String.format(getString(R.string.error_authentication_failed_re_authenticate), intent.cloud().username()), + Toast.LENGTH_LONG).show() + } + } + else -> { + Timber.tag("AuthicateCloudPrester").e(intent.error()) + failAuthentication(intent.cloud().name()) + } + } + } + + private fun startAuthentication() { + authenticationStarted = true + val authIntent: Intent = AuthorizationActivity.createIntent( + context(), + AuthorizationRequest.create() + .setType(AuthorizationRequest.Type.TOKEN) + .setClientId(BuildConfig.PCLOUD_CLIENT_ID) + .setForceAccessApproval(true) + .addPermission("manageshares") + .build()) + requestActivityResult(ActivityResultCallbacks.pCloudReAuthenticationFinished(), // + authIntent) + } + } + + @Callback + fun pCloudReAuthenticationFinished(activityResult: ActivityResult) { + val authData: AuthorizationData = AuthorizationActivity.getResult(activityResult.intent()) + val result: AuthorizationResult = authData.result + + when (result) { + AuthorizationResult.ACCESS_GRANTED -> { + val accessToken: String = CredentialCryptor // + .getInstance(context()) // + .encrypt(authData.token) + val pCloudSkeleton: PCloud = PCloud.aPCloud() // + .withAccessToken(accessToken) + .withUrl(authData.apiHost) + .build(); + getUsernameUseCase // + .withCloud(pCloudSkeleton) // + .run(object : DefaultResultHandler() { + override fun onSuccess(username: String?) { + prepareForSavingPCloud(PCloud.aCopyOf(pCloudSkeleton).withUsername(username).build()) + } + }) + } + AuthorizationResult.ACCESS_DENIED -> { + Timber.tag("CloudConnListPresenter").e("Account access denied") + view?.showMessage(String.format(getString(R.string.screen_authenticate_auth_authentication_failed), getString(R.string.cloud_names_pcloud))) + } + AuthorizationResult.AUTH_ERROR -> { + Timber.tag("CloudConnListPresenter").e("""Account access grant error: ${authData.errorMessage}""".trimIndent()) + view?.showMessage(String.format(getString(R.string.screen_authenticate_auth_authentication_failed), getString(R.string.cloud_names_pcloud))) + } + AuthorizationResult.CANCELLED -> { + Timber.tag("CloudConnListPresenter").i("Account access grant cancelled") + view?.showMessage(String.format(getString(R.string.screen_authenticate_auth_authentication_failed), getString(R.string.cloud_names_pcloud))) + } + } + } + + fun prepareForSavingPCloud(cloud: PCloud) { + getCloudsUseCase // + .withCloudType(cloud.type()) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(clouds: List) { + clouds.firstOrNull { + (it as PCloud).username() == cloud.username() + }?.let { + it as PCloud + succeedAuthenticationWith(PCloud.aCopyOf(it) // + .withUrl(cloud.url()) + .withAccessToken(cloud.accessToken()) + .build()) + } ?: succeedAuthenticationWith(cloud) + } + }) + } + private inner class WebDAVAuthStrategy : AuthStrategy { override fun supports(cloud: CloudModel): Boolean { @@ -281,6 +407,38 @@ class AuthenticateCloudPresenter @Inject constructor( // finish() } + private inner class S3AuthStrategy : AuthStrategy { + + private var authenticationStarted = false + + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.S3 + } + + override fun resumed(intent: AuthenticateCloudIntent) { + when { + ExceptionUtil.contains(intent.error(), WrongCredentialsException::class.java) -> { + if (!authenticationStarted) { + startAuthentication(intent.cloud()) + Toast.makeText( + context(), + String.format(getString(R.string.error_authentication_failed), intent.cloud().username()), + Toast.LENGTH_LONG).show() + } + } + else -> { + Timber.tag("AuthicateCloudPrester").e(intent.error()) + failAuthentication(intent.cloud().name()) + } + } + } + + private fun startAuthentication(cloud: CloudModel) { + authenticationStarted = true + startIntent(Intents.s3AddOrChangeIntent().withS3Cloud(cloud as S3CloudModel)) + } + } + private inner class LocalStorageAuthStrategy : AuthStrategy { private var authenticationStarted = false @@ -342,6 +500,6 @@ class AuthenticateCloudPresenter @Inject constructor( // } init { - unsubscribeOnDestroy(addOrChangeCloudConnectionUseCase, getUsernameUseCase) + unsubscribeOnDestroy(addOrChangeCloudConnectionUseCase, getCloudsUseCase, getUsernameUseCase) } } diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index c360e244..f811b796 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -109,6 +109,7 @@ + () return when (CloudTypeModel.valueOf(domainObject.type())) { CloudTypeModel.DROPBOX -> DropboxCloudModel(domainObject) CloudTypeModel.GOOGLE_DRIVE -> GoogleDriveCloudModel(domainObject) + CloudTypeModel.LOCAL -> LocalStorageModel(domainObject) CloudTypeModel.ONEDRIVE -> OnedriveCloudModel(domainObject) CloudTypeModel.PCLOUD -> PCloudModel(domainObject) + CloudTypeModel.S3 -> S3CloudModel(domainObject) CloudTypeModel.CRYPTO -> CryptoCloudModel(domainObject) - CloudTypeModel.LOCAL -> LocalStorageModel(domainObject) CloudTypeModel.WEBDAV -> WebDavCloudModel(domainObject) } } 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 e5cab69e..00ee88aa 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt @@ -29,6 +29,7 @@ import org.cryptomator.presentation.intent.Intents 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.WebDavCloudModel import org.cryptomator.presentation.model.mappers.CloudModelMapper import org.cryptomator.presentation.ui.activity.view.CloudConnectionListView @@ -129,7 +130,7 @@ class CloudConnectionListPresenter @Inject constructor( // fun onAddConnectionClicked() { when (selectedCloudType.get()) { - CloudTypeModel.WEBDAV -> requestActivityResult(ActivityResultCallbacks.addChangeWebDavCloud(), // + CloudTypeModel.WEBDAV -> requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), // Intents.webDavAddOrChangeIntent()) CloudTypeModel.PCLOUD -> { val authIntent: Intent = AuthorizationActivity.createIntent( @@ -143,6 +144,8 @@ class CloudConnectionListPresenter @Inject constructor( // requestActivityResult(ActivityResultCallbacks.pCloudAuthenticationFinished(), // authIntent) } + CloudTypeModel.S3 -> requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), // + Intents.s3AddOrChangeIntent()) CloudTypeModel.LOCAL -> openDocumentTree() } } @@ -165,12 +168,20 @@ class CloudConnectionListPresenter @Inject constructor( // } fun onChangeCloudClicked(cloudModel: CloudModel) { - if (cloudModel.cloudType() == CloudTypeModel.WEBDAV) { - requestActivityResult(ActivityResultCallbacks.addChangeWebDavCloud(), // - Intents.webDavAddOrChangeIntent() // - .withWebDavCloud(cloudModel as WebDavCloudModel)) - } else { - throw IllegalStateException("Change cloud with type " + cloudModel.cloudType() + " is not supported") + when { + cloudModel.cloudType() == CloudTypeModel.WEBDAV -> { + requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), // + Intents.webDavAddOrChangeIntent() // + .withWebDavCloud(cloudModel as WebDavCloudModel)) + } + cloudModel.cloudType() == CloudTypeModel.S3 -> { + requestActivityResult(ActivityResultCallbacks.addChangeMultiCloud(), // + Intents.s3AddOrChangeIntent() // + .withS3Cloud(cloudModel as S3CloudModel)) + } + else -> { + throw IllegalStateException("Change cloud with type " + cloudModel.cloudType() + " is not supported") + } } } @@ -179,7 +190,7 @@ class CloudConnectionListPresenter @Inject constructor( // } @Callback - fun addChangeWebDavCloud(result: ActivityResult?) { + fun addChangeMultiCloud(result: ActivityResult?) { loadCloudList() } 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 4c00f108..4d29c9b4 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt @@ -3,6 +3,7 @@ package org.cryptomator.presentation.presenter import org.cryptomator.domain.Cloud import org.cryptomator.domain.LocalStorageCloud import org.cryptomator.domain.PCloud +import org.cryptomator.domain.S3Cloud import org.cryptomator.domain.WebDavCloud import org.cryptomator.domain.di.PerView import org.cryptomator.domain.exception.FatalBackendException @@ -18,6 +19,7 @@ import org.cryptomator.presentation.model.CloudModel import org.cryptomator.presentation.model.CloudTypeModel import org.cryptomator.presentation.model.LocalStorageModel import org.cryptomator.presentation.model.PCloudModel +import org.cryptomator.presentation.model.S3CloudModel import org.cryptomator.presentation.model.WebDavCloudModel import org.cryptomator.presentation.model.mappers.CloudModelMapper import org.cryptomator.presentation.ui.activity.view.CloudSettingsView @@ -37,6 +39,7 @@ class CloudSettingsPresenter @Inject constructor( // CloudTypeModel.CRYPTO, // CloudTypeModel.LOCAL, // CloudTypeModel.PCLOUD, // + CloudTypeModel.S3, // CloudTypeModel.WEBDAV) fun loadClouds() { @@ -44,7 +47,7 @@ class CloudSettingsPresenter @Inject constructor( // } fun onCloudClicked(cloudModel: CloudModel) { - if (isWebdavOrPCloudOrLocal(cloudModel)) { + if (cloudModel.cloudType().isMultiInstance) { startConnectionListActivity(cloudModel.cloudType()) } else { if (isLoggedIn(cloudModel)) { @@ -61,10 +64,6 @@ class CloudSettingsPresenter @Inject constructor( // } } - private fun isWebdavOrPCloudOrLocal(cloudModel: CloudModel): Boolean { - return cloudModel is WebDavCloudModel || cloudModel is LocalStorageModel || cloudModel is PCloudModel - } - private fun loginCloud(cloudModel: CloudModel) { getCloudsUseCase // .withCloudType(CloudTypeModel.valueOf(cloudModel.cloudType())) // @@ -93,8 +92,9 @@ class CloudSettingsPresenter @Inject constructor( // private fun effectiveTitle(cloudTypeModel: CloudTypeModel): String { when (cloudTypeModel) { - CloudTypeModel.WEBDAV -> return context().getString(R.string.screen_cloud_settings_webdav_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.S3 -> return context().getString(R.string.screen_cloud_settings_s3_connections) CloudTypeModel.LOCAL -> return context().getString(R.string.screen_cloud_settings_local_storage_locations) } return context().getString(R.string.screen_cloud_settings_title) @@ -126,19 +126,24 @@ class CloudSettingsPresenter @Inject constructor( // .filter { cloud -> !(BuildConfig.FLAVOR == "fdroid" && cloud.cloudType() == CloudTypeModel.GOOGLE_DRIVE) } // .toMutableList() // .also { - it.add(aWebdavCloud()) it.add(aPCloud()) + it.add(aWebdavCloud()) + it.add(aS3Cloud()) it.add(aLocalCloud()) } view?.render(cloudModel) } + private fun aPCloud(): PCloudModel { + return PCloudModel(PCloud.aPCloud().build()) + } + private fun aWebdavCloud(): WebDavCloudModel { return WebDavCloudModel(WebDavCloud.aWebDavCloudCloud().build()) } - private fun aPCloud(): PCloudModel { - return PCloudModel(PCloud.aPCloud().build()) + private fun aS3Cloud(): S3CloudModel { + return S3CloudModel(S3Cloud.aS3Cloud().build()) } private fun aLocalCloud(): CloudModel { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/S3AddOrChangePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/S3AddOrChangePresenter.kt new file mode 100644 index 00000000..20fe2282 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/S3AddOrChangePresenter.kt @@ -0,0 +1,104 @@ +package org.cryptomator.presentation.presenter + +import android.widget.Toast +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.S3Cloud +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.cloud.AddOrChangeCloudConnectionUseCase +import org.cryptomator.domain.usecases.cloud.ConnectToS3UseCase +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.S3AddOrChangeView +import org.cryptomator.util.crypto.CredentialCryptor +import javax.inject.Inject + +@PerView +class S3AddOrChangePresenter @Inject internal constructor( // + private val addOrChangeCloudConnectionUseCase: AddOrChangeCloudConnectionUseCase, // + private val connectToS3UseCase: ConnectToS3UseCase, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + fun checkUserInput(accessKey: String, secretKey: String, bucket: String, endpoint: String?, region: String?, cloudId: Long?, displayName: String) { + var statusMessage: String? = null + + if (accessKey.isEmpty()) { + statusMessage = getString(R.string.screen_s3_settings_msg_access_key_not_empty) + } + if (secretKey.isEmpty()) { + statusMessage = getString(R.string.screen_s3_settings_msg_secret_key_not_empty) + } + if (bucket.isEmpty()) { + statusMessage = getString(R.string.screen_s3_settings_msg_bucket_not_empty) + } + if (displayName.isEmpty()) { + statusMessage = getString(R.string.screen_s3_settings_msg_display_name_not_empty) + } + if (endpoint.isNullOrEmpty() && region.isNullOrEmpty()) { + statusMessage = getString(R.string.screen_s3_settings_msg_endpoint_and_region_not_empty) + } + + if (statusMessage != null) { + Toast.makeText(context(), statusMessage, Toast.LENGTH_SHORT).show() + } else { + view?.onCheckUserInputSucceeded(encrypt(accessKey), encrypt(secretKey), bucket, endpoint, region, cloudId, displayName) + } + } + + private fun encrypt(text: String): String { + return CredentialCryptor // + .getInstance(context()) // + .encrypt(text) + } + + private fun mapToCloud(accessKey: String, secretKey: String, bucket: String, endpoint: String?, region: String?, cloudId: Long?, displayName: String): S3Cloud { + var builder = S3Cloud // + .aS3Cloud() // + .withAccessKey(accessKey) // + .withSecretKey(secretKey) // + .withS3Bucket(bucket) // + .withS3Endpoint(endpoint) // + .withS3Region(region) // + .withDisplayName(displayName) + + cloudId?.let { builder = builder.withId(cloudId) } + + return builder.build() + } + + fun authenticate(accessKey: String, secretKey: String, bucket: String, endpoint: String?, region: String?, cloudId: Long?, displayName: String) { + authenticate(mapToCloud(accessKey, secretKey, bucket, endpoint, region, cloudId, displayName)) + } + + private fun authenticate(cloud: S3Cloud) { + view?.showProgress(ProgressModel(ProgressStateModel.AUTHENTICATION)) + connectToS3UseCase // + .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, connectToS3UseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/UnlockVaultPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/UnlockVaultPresenter.kt index 974acdb2..c9375dba 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/UnlockVaultPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/UnlockVaultPresenter.kt @@ -133,7 +133,9 @@ class UnlockVaultPresenter @Inject constructor( } private fun canUseBiometricOn(vault: VaultModel): Boolean { - return vault.password != null && BiometricManager.from(context()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS + return vault.password != null && BiometricManager // + .from(context()) // + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS } fun onUnlockCanceled() { diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BiometricAuthSettingsActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BiometricAuthSettingsActivity.kt index 0cf91bd2..46193de5 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BiometricAuthSettingsActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BiometricAuthSettingsActivity.kt @@ -28,7 +28,9 @@ class BiometricAuthSettingsActivity : BaseActivity(), // } override fun showSetupBiometricAuthDialog() { - val biometricAuthenticationAvailable = BiometricManager.from(context()).canAuthenticate() + val biometricAuthenticationAvailable = BiometricManager // + .from(context()) // + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) if (biometricAuthenticationAvailable == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) { showDialog(EnrollSystemBiometricDialog.newInstance()) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index e828c74c..ad7bcb93 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -45,7 +45,6 @@ import org.cryptomator.presentation.ui.dialog.SymLinkDialog import org.cryptomator.presentation.ui.dialog.UploadCloudFileDialog import org.cryptomator.presentation.ui.fragment.BrowseFilesFragment import java.util.ArrayList -import java.util.Locale import java.util.regex.Pattern import javax.inject.Inject import kotlinx.android.synthetic.main.toolbar_layout.toolbar @@ -541,7 +540,7 @@ class BrowseFilesActivity : BaseActivity(), // } override fun onQueryTextSubmit(query: String?): Boolean { - updateFilter(query?.toLowerCase(Locale.getDefault())) + updateFilter(query) return false } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudConnectionListActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudConnectionListActivity.kt index a4281b4d..4b5c95d8 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudConnectionListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudConnectionListActivity.kt @@ -51,7 +51,7 @@ class CloudConnectionListActivity : BaseActivity(), private fun connectionListFragment(): CloudConnectionListFragment = getCurrentFragment(R.id.fragmentContainer) as CloudConnectionListFragment - override fun createFragment(): Fragment? = CloudConnectionListFragment() + override fun createFragment(): Fragment = CloudConnectionListFragment() override fun showNodeSettings(cloudModel: CloudModel) { val cloudNodeSettingDialog = // diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/S3AddOrChangeActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/S3AddOrChangeActivity.kt new file mode 100644 index 00000000..39d00be3 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/S3AddOrChangeActivity.kt @@ -0,0 +1,37 @@ +package org.cryptomator.presentation.ui.activity + +import androidx.fragment.app.Fragment +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.S3AddOrChangeIntent +import org.cryptomator.presentation.presenter.S3AddOrChangePresenter +import org.cryptomator.presentation.ui.activity.view.S3AddOrChangeView +import org.cryptomator.presentation.ui.fragment.S3AddOrChangeFragment +import javax.inject.Inject +import kotlinx.android.synthetic.main.toolbar_layout.toolbar + +@Activity +class S3AddOrChangeActivity : BaseActivity(), S3AddOrChangeView { + + @Inject + lateinit var s3AddOrChangePresenter: S3AddOrChangePresenter + + @InjectIntent + lateinit var s3AddOrChangeIntent: S3AddOrChangeIntent + + override fun setupView() { + toolbar.setTitle(R.string.screen_s3_settings_title) + setSupportActionBar(toolbar) + } + + override fun createFragment(): Fragment = S3AddOrChangeFragment.newInstance(s3AddOrChangeIntent.s3Cloud()) + + override fun onCheckUserInputSucceeded(accessKey: String, secretKey: String, bucket: String, endpoint: String?, region: String?, cloudId: Long?, displayName: String) { + s3AddOrChangeFragment().hideKeyboard() + s3AddOrChangePresenter.authenticate(accessKey, secretKey, bucket, endpoint, region, cloudId, displayName) + } + + private fun s3AddOrChangeFragment(): S3AddOrChangeFragment = getCurrentFragment(R.id.fragmentContainer) as S3AddOrChangeFragment + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/S3AddOrChangeView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/S3AddOrChangeView.kt new file mode 100644 index 00000000..bb41fd6d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/S3AddOrChangeView.kt @@ -0,0 +1,7 @@ +package org.cryptomator.presentation.ui.activity.view + +interface S3AddOrChangeView : View { + + fun onCheckUserInputSucceeded(accessKey: String, secretKey: String, bucket: String, endpoint: String?, region: String?, cloudId: Long?, displayName: String) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudConnectionListAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudConnectionListAdapter.kt index a632a3ac..f2cedc79 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudConnectionListAdapter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudConnectionListAdapter.kt @@ -8,6 +8,7 @@ import org.cryptomator.presentation.R import org.cryptomator.presentation.model.CloudModel import org.cryptomator.presentation.model.LocalStorageModel import org.cryptomator.presentation.model.PCloudModel +import org.cryptomator.presentation.model.S3CloudModel import org.cryptomator.presentation.model.WebDavCloudModel import org.cryptomator.presentation.model.comparator.CloudModelComparator import org.cryptomator.presentation.ui.adapter.CloudConnectionListAdapter.CloudConnectionHolder @@ -53,12 +54,19 @@ internal constructor(context: Context) : RecyclerViewBaseAdapter { + bindWebDavCloudModel(cloudModel) + } + is PCloudModel -> { + bindPCloudModel(cloudModel) + } + is S3CloudModel -> { + bindS3loudModel(cloudModel) + } + is LocalStorageModel -> { + bindLocalStorageCloudModel(cloudModel) + } } } @@ -70,7 +78,6 @@ internal constructor(context: Context) : RecyclerViewBaseAdapter itemView.cloudName.text = context.getString(R.string.screen_cloud_settings_pcloud_connections) + CloudTypeModel.S3 -> itemView.cloudName.text = context.getString(R.string.screen_cloud_settings_s3_connections) + CloudTypeModel.WEBDAV -> itemView.cloudName.text = context.getString(R.string.screen_cloud_settings_webdav_connections) + CloudTypeModel.LOCAL -> itemView.cloudName.text = context.getString(R.string.screen_cloud_settings_local_storage_locations) + else -> { + itemView.cloudName.text = getCloudNameText(isAlreadyLoggedIn(cloudModel), cloudModel) + if (isAlreadyLoggedIn(cloudModel)) { + itemView.cloudUsername.text = cloudModel.username() + itemView.cloudUsername.visibility = View.VISIBLE + } else { + itemView.cloudUsername.visibility = View.GONE + } } } @@ -73,16 +73,4 @@ constructor(private val context: Context) : RecyclerViewBaseAdapter bindViewForWebDAV(cloudModel as WebDavCloudModel) CloudTypeModel.PCLOUD -> bindViewForPCloud(cloudModel as PCloudModel) + CloudTypeModel.S3 -> bindViewForS3(cloudModel as S3CloudModel) CloudTypeModel.LOCAL -> bindViewForLocal(cloudModel as LocalStorageModel) else -> throw IllegalStateException("Cloud model is not binded in the view") } @@ -66,6 +68,11 @@ class CloudConnectionSettingsBottomSheet : BaseBottomSheet et_new_retype_password.nextFocusForwardId = button.id } + + registerOnEditorDoneActionAndPerformButtonClick(et_new_retype_password) { changePasswordButton } + + PasswordStrengthUtil() // + .startUpdatingPasswordStrengthMeter(et_new_password, // + progressBarPwStrengthIndicator, // + textViewPwStrengthIndicator, // + changePasswordButton) } } @@ -86,11 +94,6 @@ class ChangePasswordDialog : BaseProgressErrorDialog + if (actionId == EditorInfo.IME_ACTION_DONE) { + createCloud() + } + false + } + + showEditableCloudContent(s3CloudModel) + + toggleCustomS3.setOnClickListener { switch -> + regionOrEndpointEditText.text?.clear() + toggleUseAmazonS3((switch as SwitchMaterial).isChecked) + } + } + + private fun toggleUseAmazonS3(checked: Boolean) = if (checked) { + regionOrEndpointEditTextLayout.setHint(R.string.screen_s3_settings_region_label) + } else { + regionOrEndpointEditTextLayout.setHint(R.string.screen_s3_settings_endpoint_label) + } + + private fun showEditableCloudContent(s3CloudModel: S3CloudModel?) { + s3CloudModel?.let { + cloudId = s3CloudModel.id() + displayNameEditText.setText(s3CloudModel.username()) + accessKeyEditText.setText(decrypt(s3CloudModel.accessKey())) + secretKeyEditText.setText(decrypt(s3CloudModel.secretKey())) + bucketEditText.setText(s3CloudModel.s3Bucket()) + + if (it.s3Endpoint().isNotEmpty()) { + toggleCustomS3.isChecked = false + regionOrEndpointEditText.setText(s3CloudModel.s3Endpoint()) + regionOrEndpointEditTextLayout.setHint(R.string.screen_s3_settings_endpoint_label) + } else { + regionOrEndpointEditText.setText(s3CloudModel.s3Region()) + regionOrEndpointEditTextLayout.setHint(R.string.screen_s3_settings_region_label) + } + } ?: regionOrEndpointEditTextLayout.setHint(R.string.screen_s3_settings_region_label) + } + + private fun decrypt(text: String?): String { + return if (text != null) { + try { + CredentialCryptor // + .getInstance(activity?.applicationContext) // + .decrypt(text) + } catch (e: RuntimeException) { + Timber.tag("S3AddOrChangeFragment").e(e, "Unable to decrypt password, clearing it") + "" + } + } else "" + } + + private fun createCloud() { + val accessKey = accessKeyEditText.text.toString().trim() + val secretKey = secretKeyEditText.text.toString().trim() + val bucket = bucketEditText.text.toString().trim() + val displayName = displayNameEditText.text.toString().trim() + + if (toggleCustomS3.isChecked) { + s3AddOrChangePresenter.checkUserInput(accessKey, secretKey, bucket, null, regionOrEndpointEditText.text.toString().trim(), cloudId, displayName) + } else { + s3AddOrChangePresenter.checkUserInput(accessKey, secretKey, bucket, regionOrEndpointEditText.text.toString().trim(), null, cloudId, displayName) + } + } + + fun hideKeyboard() { + hideKeyboard(bucketEditText) + } + + companion object { + + private const val ARG_S3_CLOUD = "S3_CLOUD" + + fun newInstance(cloudModel: S3CloudModel?): S3AddOrChangeFragment { + val result = S3AddOrChangeFragment() + val args = Bundle() + args.putSerializable(ARG_S3_CLOUD, cloudModel) + result.arguments = args + return result + } + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SetPasswordFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SetPasswordFragment.kt index a2479259..607aa8b8 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SetPasswordFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SetPasswordFragment.kt @@ -29,9 +29,10 @@ class SetPasswordFragment : BaseFragment() { } false } - passwordStrengthUtil.startUpdatingPasswortStrengthMeter(passwordEditText, // + passwordStrengthUtil.startUpdatingPasswordStrengthMeter(passwordEditText, // progressBarPwStrengthIndicator, // - textViewPwStrengthIndicator) + textViewPwStrengthIndicator, // + createVaultButton) passwordEditText.requestFocus() } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt index a8cd7886..074155b0 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt @@ -99,7 +99,9 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun activity(): SettingsActivity = this.activity as SettingsActivity private fun isBiometricAuthenticationNotAvailableRemovePreference() { - val biometricAuthenticationAvailable = BiometricManager.from(requireContext()).canAuthenticate() + val biometricAuthenticationAvailable = BiometricManager // + .from(requireContext()) // + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) if (biometricAuthenticationAvailable != BiometricManager.BIOMETRIC_SUCCESS && biometricAuthenticationAvailable != BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) { diff --git a/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrength.kt b/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrength.kt index d1bb6227..de0de585 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrength.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrength.kt @@ -14,13 +14,21 @@ enum class PasswordStrength(val score: Int, val description: Int, val color: Int companion object { + private const val MIN_PASSWORD_LENGTH = 8 + private val zxcvbn = Zxcvbn() fun forPassword(password: String, sanitizedInputs: List): PasswordStrength { - return if (password.isEmpty()) { - EMPTY - } else { - forScore(zxcvbn.measure(password, sanitizedInputs).score).orElse(EMPTY) + return when { + password.isEmpty() -> { + EMPTY + } + password.length < MIN_PASSWORD_LENGTH -> { + EXTREMELY_WEAK + } + else -> { + forScore(zxcvbn.measure(password, sanitizedInputs).score).orElse(EMPTY) + } } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrengthUtil.java b/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrengthUtil.java index 7b6a9589..7ecaa252 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrengthUtil.java +++ b/presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrengthUtil.java @@ -1,6 +1,7 @@ package org.cryptomator.presentation.util; import android.graphics.PorterDuff; +import android.widget.Button; import android.widget.EditText; import android.widget.ProgressBar; import android.widget.TextView; @@ -40,9 +41,10 @@ public class PasswordStrengthUtil { public PasswordStrengthUtil() { } - public void startUpdatingPasswortStrengthMeter(EditText passwordInput, // + public void startUpdatingPasswordStrengthMeter(EditText passwordInput, // final ProgressBar strengthMeter, // - final TextView strengthLabel) { + final TextView strengthLabel, // + final Button button) { RxTextView.textChanges(passwordInput) // .observeOn(Schedulers.computation()) // .map(password -> PasswordStrength.Companion.forPassword(password.toString(), SANITIZED_INPUTS)) // @@ -51,6 +53,7 @@ public class PasswordStrengthUtil { strengthMeter.getProgressDrawable().setColorFilter(ResourceHelper.Companion.getColor(strength.getColor()), PorterDuff.Mode.SRC_IN); strengthLabel.setText(strength.getDescription()); strengthMeter.setProgress(strength.getScore() + 1); + button.setEnabled(strength.getScore() > PasswordStrength.EXTREMELY_WEAK.getScore()); }); } } diff --git a/presentation/src/main/res/drawable-mdpi/s3.png b/presentation/src/main/res/drawable-mdpi/s3.png new file mode 100644 index 00000000..9b00cd54 Binary files /dev/null and b/presentation/src/main/res/drawable-mdpi/s3.png differ diff --git a/presentation/src/main/res/drawable-mdpi/s3_vault.png b/presentation/src/main/res/drawable-mdpi/s3_vault.png new file mode 100644 index 00000000..ddfe67e4 Binary files /dev/null and b/presentation/src/main/res/drawable-mdpi/s3_vault.png differ diff --git a/presentation/src/main/res/drawable-mdpi/s3_vault_selected.png b/presentation/src/main/res/drawable-mdpi/s3_vault_selected.png new file mode 100644 index 00000000..1a1a7e9e Binary files /dev/null and b/presentation/src/main/res/drawable-mdpi/s3_vault_selected.png differ diff --git a/presentation/src/main/res/drawable-xhdpi/s3.png b/presentation/src/main/res/drawable-xhdpi/s3.png new file mode 100644 index 00000000..774346d1 Binary files /dev/null and b/presentation/src/main/res/drawable-xhdpi/s3.png differ diff --git a/presentation/src/main/res/drawable-xhdpi/s3_vault.png b/presentation/src/main/res/drawable-xhdpi/s3_vault.png new file mode 100644 index 00000000..edc4d47f Binary files /dev/null and b/presentation/src/main/res/drawable-xhdpi/s3_vault.png differ diff --git a/presentation/src/main/res/drawable-xhdpi/s3_vault_selected.png b/presentation/src/main/res/drawable-xhdpi/s3_vault_selected.png new file mode 100644 index 00000000..852ef2ce Binary files /dev/null and b/presentation/src/main/res/drawable-xhdpi/s3_vault_selected.png differ diff --git a/presentation/src/main/res/drawable-xxhdpi/s3.png b/presentation/src/main/res/drawable-xxhdpi/s3.png new file mode 100644 index 00000000..9daae841 Binary files /dev/null and b/presentation/src/main/res/drawable-xxhdpi/s3.png differ diff --git a/presentation/src/main/res/drawable-xxhdpi/s3_vault.png b/presentation/src/main/res/drawable-xxhdpi/s3_vault.png new file mode 100644 index 00000000..006f36de Binary files /dev/null and b/presentation/src/main/res/drawable-xxhdpi/s3_vault.png differ diff --git a/presentation/src/main/res/drawable-xxhdpi/s3_vault_selected.png b/presentation/src/main/res/drawable-xxhdpi/s3_vault_selected.png new file mode 100644 index 00000000..a42452ab Binary files /dev/null and b/presentation/src/main/res/drawable-xxhdpi/s3_vault_selected.png differ diff --git a/presentation/src/main/res/layout/fragment_setup_s3.xml b/presentation/src/main/res/layout/fragment_setup_s3.xml new file mode 100644 index 00000000..deeb56e9 --- /dev/null +++ b/presentation/src/main/res/layout/fragment_setup_s3.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +