From 80444758cc1649d1ef6907822980bae7662e1aa6 Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Mon, 21 Dec 2020 15:52:03 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20open=20source=20commit=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 20 + .gitignore | 43 + .gitmodules | 6 + LICENSE.txt | 674 +++++++++ README.md | 29 +- build.gradle | 61 + buildsystem/ci.gradle | 13 + buildsystem/dependencies.gradle | 154 ++ data/.gitignore | 1 + data/build.gradle | 122 ++ data/src/main/AndroidManifest.xml | 8 + .../CloudContentRepositoryFactories.java | 45 + .../InterceptingCloudContentRepository.java | 224 +++ .../crypto/BackupFileIdSuffixGenerator.java | 28 + .../data/cloud/crypto/CryptoCloud.java | 71 + .../crypto/CryptoCloudContentRepository.java | 135 ++ .../CryptoCloudContentRepositoryFactory.java | 80 ++ .../data/cloud/crypto/CryptoCloudFactory.java | 216 +++ .../data/cloud/crypto/CryptoConstants.java | 14 + .../data/cloud/crypto/CryptoFile.java | 80 ++ .../data/cloud/crypto/CryptoFolder.java | 70 + .../cloud/crypto/CryptoImplDecorator.java | 424 ++++++ .../cloud/crypto/CryptoImplVaultFormat7.java | 546 ++++++++ .../crypto/CryptoImplVaultFormatPre7.java | 270 ++++ .../data/cloud/crypto/CryptoNode.java | 6 + .../data/cloud/crypto/CryptoSymlink.java | 80 ++ .../data/cloud/crypto/Cryptors.java | 144 ++ .../data/cloud/crypto/CryptorsModule.java | 23 + .../data/cloud/crypto/DirIdCache.java | 38 + .../data/cloud/crypto/DirIdCacheFormat7.java | 78 ++ .../cloud/crypto/DirIdCacheFormatPre7.java | 86 ++ .../data/cloud/crypto/RootCryptoFolder.java | 27 + .../cloud/dropbox/DropboxClientFactory.java | 56 + .../DropboxCloudContentRepository.java | 213 +++ .../DropboxCloudContentRepositoryFactory.java | 35 + .../dropbox/DropboxCloudNodeFactory.java | 39 + .../data/cloud/dropbox/DropboxFile.java | 55 + .../data/cloud/dropbox/DropboxFolder.java | 42 + .../data/cloud/dropbox/DropboxImpl.java | 468 +++++++ .../data/cloud/dropbox/DropboxNode.java | 6 + .../data/cloud/dropbox/RootDropboxFolder.java | 24 + .../FixedGoogleAccountCredential.java | 116 ++ .../googledrive/GoogleDriveClientFactory.java | 34 + .../GoogleDriveCloudContentRepository.java | 213 +++ ...gleDriveCloudContentRepositoryFactory.java | 35 + .../GoogleDriveCloudNodeFactory.java | 59 + .../cloud/googledrive/GoogleDriveFile.java | 61 + .../cloud/googledrive/GoogleDriveFolder.java | 49 + .../cloud/googledrive/GoogleDriveIdCache.java | 77 + .../googledrive/GoogleDriveIdCloudNode.java | 9 + .../cloud/googledrive/GoogleDriveImpl.java | 448 ++++++ .../cloud/googledrive/GoogleDriveNode.java | 10 + .../googledrive/RootGoogleDriveFolder.java | 24 + .../LocalStorageContentRepositoryFactory.java | 62 + .../data/cloud/local/file/LocalFile.java | 54 + .../data/cloud/local/file/LocalFolder.java | 42 + .../data/cloud/local/file/LocalNode.java | 6 + .../file/LocalStorageContentRepository.java | 121 ++ .../cloud/local/file/LocalStorageImpl.java | 193 +++ .../local/file/LocalStorageNodeFactory.java | 34 + .../cloud/local/file/RootLocalFolder.java | 26 + .../DocumentIdCache.java | 74 + .../LocalStorageAccessFile.java | 94 ++ .../LocalStorageAccessFolder.java | 85 ++ ...orageAccessFrameworkContentRepository.java | 123 ++ .../LocalStorageAccessFrameworkImpl.java | 536 +++++++ ...ocalStorageAccessFrameworkNodeFactory.java | 124 ++ .../LocalStorageAccessNode.java | 15 + .../RootLocalStorageAccessFolder.java | 41 + .../data/cloud/okhttplogging/HeaderNames.java | 20 + .../okhttplogging/HttpLoggingInterceptor.java | 147 ++ .../onedrive/MSAAuthAndroidAdapterImpl.java | 25 + .../cloud/onedrive/OnedriveClientFactory.java | 78 ++ .../OnedriveCloudContentRepository.java | 165 +++ ...OnedriveCloudContentRepositoryFactory.java | 34 + .../onedrive/OnedriveCloudNodeFactory.java | 77 + .../data/cloud/onedrive/OnedriveFile.java | 59 + .../data/cloud/onedrive/OnedriveFolder.java | 47 + .../cloud/onedrive/OnedriveHttpProvider.java | 577 ++++++++ .../data/cloud/onedrive/OnedriveIdCache.java | 89 ++ .../cloud/onedrive/OnedriveIdCloudNode.java | 11 + .../data/cloud/onedrive/OnedriveImpl.java | 549 ++++++++ .../data/cloud/onedrive/OnedriveNode.java | 15 + .../cloud/onedrive/RootOnedriveFolder.java | 24 + .../cloud/onedrive/graph/ClientException.java | 29 + .../graph/IAuthenticationAdapter.java | 40 + .../data/cloud/onedrive/graph/ICallback.java | 44 + .../onedrive/graph/IProgressCallback.java | 39 + .../onedrive/graph/MSAAuthAndroidAdapter.java | 275 ++++ .../graph/MicrosoftOAuth2Endpoint.java | 41 + .../cloud/onedrive/graph/SimpleWaiter.java | 65 + .../data/cloud/webdav/RootWebDavFolder.java | 24 + .../webdav/WebDavCloudContentRepository.java | 236 ++++ .../WebDavCloudContentRepositoryFactory.java | 36 + .../data/cloud/webdav/WebDavFile.java | 58 + .../data/cloud/webdav/WebDavFolder.java | 55 + .../data/cloud/webdav/WebDavImpl.java | 254 ++++ .../data/cloud/webdav/WebDavNode.java | 9 + .../network/ConnectionHandlerFactory.java | 22 + .../network/ConnectionHandlerHandlerImpl.java | 57 + .../network/DataSourceBasedRequestBody.java | 42 + .../webdav/network/DefaultTrustManager.java | 66 + .../webdav/network/PinningTrustManager.java | 91 ++ .../webdav/network/PropfindEntryData.java | 88 ++ .../network/PropfindResponseParser.java | 188 +++ .../webdav/network/SSLSocketFactories.java | 24 + .../cloud/webdav/network/WebDavClient.java | 318 +++++ .../network/WebDavCompatibleHttpClient.java | 165 +++ .../webdav/network/WebDavRedirectHandler.java | 96 ++ .../data/db/CompoundDatabaseUpgrade.java | 21 + .../org/cryptomator/data/db/Database.java | 53 + .../cryptomator/data/db/DatabaseFactory.java | 57 + .../cryptomator/data/db/DatabaseUpgrade.java | 41 + .../cryptomator/data/db/DatabaseUpgrades.java | 79 ++ .../java/org/cryptomator/data/db/Sql.java | 472 +++++++ .../org/cryptomator/data/db/Upgrade0To1.java | 105 ++ .../org/cryptomator/data/db/Upgrade1To2.java | 52 + .../org/cryptomator/data/db/Upgrade2To3.kt | 45 + .../data/db/entities/CloudEntity.java | 86 ++ .../data/db/entities/DatabaseEntity.java | 10 + .../data/db/entities/UpdateCheckEntity.java | 84 ++ .../data/db/entities/VaultEntity.java | 176 +++ .../data/db/mappers/CloudEntityMapper.java | 100 ++ .../data/db/mappers/EntityMapper.java | 34 + .../data/db/mappers/VaultEntityMapper.java | 56 + .../data/exception/CloudError.java | 34 + .../data/exception/DatabaseError.java | 15 + .../data/executor/JobExecutor.java | 55 + .../CloudContentRepositoryFactory.java | 23 + .../data/repository/CloudRepositoryImpl.java | 127 ++ .../DispatchingCloudContentRepository.java | 252 ++++ .../data/repository/RepositoryModule.java | 50 + .../repository/UpdateCheckRepositoryImpl.java | 242 ++++ .../data/repository/VaultRepositoryImpl.java | 93 ++ .../org/cryptomator/data/util/CopyStream.java | 65 + .../data/util/NetworkConnectionCheck.java | 52 + .../cryptomator/data/util/NetworkTimeout.java | 32 + ...redBytesAwareGoogleContentInputStream.java | 49 + .../TransferredBytesAwareInputStream.java | 68 + .../TransferredBytesAwareOutputStream.java | 50 + .../data/util/UserAgentInterceptor.java | 21 + .../data/util/X509CertificateHelper.java | 41 + .../org/cryptomator/data/ApplicationStub.java | 6 + .../data/cloud/CloudFileMatcher.java | 46 + .../data/cloud/CloudFolderMatcher.java | 44 + .../crypto/CryptoImplVaultFormat7Test.java | 1091 +++++++++++++++ .../crypto/CryptoImplVaultFormatPre7Test.java | 937 +++++++++++++ .../data/cloud/crypto/RootTestFolder.java | 46 + .../data/cloud/crypto/TestFile.java | 85 ++ .../data/cloud/crypto/TestFolder.java | 68 + .../network/PropfindResponseParserTest.java | 149 ++ .../TransferredBytesAwareInputStreamTest.java | 241 ++++ ...TransferredBytesAwareOutputStreamTest.java | 150 ++ .../directory-and-file.xml | 40 + .../directory-one-file-no-server.xml | 35 + .../directory-one-file.xml | 29 + .../directory-one-folder.xml | 37 + .../propfind-test-request/empty-directory.xml | 16 + .../malformatted-response-illegalstate.xml | 66 + .../malformatted-response-xmlpullparser.xml | 21 + domain/.gitignore | 1 + domain/build.gradle | 73 + .../domain/executor/BackgroundTasks.java | 93 ++ .../domain/executor/ObjectCounts.java | 54 + domain/src/main/AndroidManifest.xml | 5 + .../java/org/cryptomator/domain/Cloud.java | 18 + .../org/cryptomator/domain/CloudFile.java | 13 + .../org/cryptomator/domain/CloudFolder.java | 7 + .../org/cryptomator/domain/CloudNode.java | 14 + .../org/cryptomator/domain/CloudType.java | 7 + .../org/cryptomator/domain/DropboxCloud.java | 120 ++ .../cryptomator/domain/GoogleDriveCloud.java | 120 ++ .../cryptomator/domain/LocalStorageCloud.java | 118 ++ .../org/cryptomator/domain/OnedriveCloud.java | 120 ++ .../java/org/cryptomator/domain/Vault.java | 195 +++ .../org/cryptomator/domain/WebDavCloud.java | 150 ++ .../org/cryptomator/domain/di/PerView.java | 7 + .../exception/AlreadyExistException.java | 4 + .../domain/exception/BackendException.java | 21 + .../exception/CancellationException.java | 16 + .../CloudAlreadyExistsException.java | 7 + .../CloudNodeAlreadyExistsException.java | 8 + .../exception/EmptyDirFileException.java | 21 + .../exception/FatalBackendException.java | 17 + .../domain/exception/ForbiddenException.java | 4 + .../exception/MissingCryptorException.java | 5 + .../exception/NetworkConnectionException.java | 13 + .../domain/exception/NoDirFileException.java | 20 + .../exception/NoSuchCloudFileException.java | 11 + .../exception/NoSuchVaultException.java | 17 + .../domain/exception/NotFoundException.java | 11 + .../exception/NotImplementedException.java | 4 + .../NotTrustableCertificateException.java | 14 + .../ParentFolderDoesNotExistException.java | 4 + .../ServerNotWebdavCompatibleException.java | 4 + .../domain/exception/SymLinkException.java | 11 + .../exception/TypeMismatchException.java | 4 + ...nableToDecryptWebdavPasswordException.java | 8 + .../exception/UnauthorizedException.java | 4 + .../exception/VaultAlreadyExistException.java | 5 + .../AuthenticationException.java | 30 + .../NoAuthenticationProvidedException.java | 11 + ...serRecoverableAuthenticationException.java | 21 + ...icateUntrustedAuthenticationException.java | 17 + .../WebDavNotSupportedException.java | 9 + .../WebDavServerNotFoundException.java | 9 + .../WrongCredentialsException.java | 11 + .../license/LicenseNotValidException.java | 16 + .../license/NoLicenseAvailableException.java | 11 + .../update/GeneralUpdateErrorException.java | 14 + ...dshakePreAndroid5UpdateCheckException.java | 11 + .../domain/executor/PostExecutionThread.java | 15 + .../domain/executor/ThreadExecutor.java | 11 + .../CloudAuthenticationService.java | 13 + .../repository/CloudContentRepository.java | 80 ++ .../domain/repository/CloudRepository.java | 38 + .../repository/UpdateCheckRepository.java | 19 + .../domain/repository/VaultRepository.java | 21 + .../usecases/CloudFolderRecursiveListing.java | 40 + .../usecases/CloudNodeRecursiveListing.java | 21 + .../cryptomator/domain/usecases/CopyData.java | 39 + .../domain/usecases/DoLicenseCheck.java | 81 ++ .../cryptomator/domain/usecases/DoUpdate.java | 24 + .../domain/usecases/DoUpdateCheck.java | 23 + .../domain/usecases/DownloadFile.java | 45 + .../DownloadFileReplacingProgressAware.java | 25 + .../usecases/GetDecryptedCloudForVault.java | 25 + .../domain/usecases/LicenseCheck.java | 7 + .../domain/usecases/NoOpResultHandler.java | 18 + .../domain/usecases/ProgressAware.java | 13 + .../usecases/ProgressAwareResultHandler.java | 81 ++ .../domain/usecases/ResultHandler.java | 32 + .../domain/usecases/ResultRenamed.java | 22 + .../domain/usecases/ResultWithProgress.java | 35 + .../usecases/ThrottlingProgressAware.java | 37 + .../domain/usecases/UpdateCheck.java | 12 + .../UploadFileReplacingProgressAware.java | 25 + .../cloud/AddOrChangeCloudConnection.java | 47 + .../usecases/cloud/ByteArrayDataSource.java | 43 + .../usecases/cloud/CancelAwareDataSource.java | 49 + .../cloud/CancelAwareInputStream.java | 95 ++ .../usecases/cloud/ConnectToWebDav.java | 23 + .../domain/usecases/cloud/CreateFolder.java | 26 + .../domain/usecases/cloud/DataSource.java | 20 + .../domain/usecases/cloud/DeleteNodes.java | 37 + .../domain/usecases/cloud/DownloadFiles.java | 50 + .../domain/usecases/cloud/DownloadState.java | 36 + .../usecases/cloud/FileBasedDataSource.java | 43 + .../usecases/cloud/FileTransferState.java | 9 + .../domain/usecases/cloud/Flag.java | 7 + .../domain/usecases/cloud/GetAllClouds.java | 22 + .../domain/usecases/cloud/GetCloudList.java | 27 + .../usecases/cloud/GetCloudListRecursive.java | 49 + .../domain/usecases/cloud/GetClouds.java | 26 + .../domain/usecases/cloud/GetRootFolder.java | 24 + .../domain/usecases/cloud/GetUsername.java | 24 + .../domain/usecases/cloud/LogoutCloud.java | 53 + .../domain/usecases/cloud/MoveFiles.java | 34 + .../domain/usecases/cloud/MoveFolders.java | 35 + .../domain/usecases/cloud/Progress.java | 131 ++ .../domain/usecases/cloud/ProgressState.java | 5 + .../domain/usecases/cloud/RemoveCloud.java | 23 + .../domain/usecases/cloud/RenameFile.java | 30 + .../domain/usecases/cloud/RenameFolder.java | 30 + .../domain/usecases/cloud/UploadFile.java | 68 + .../domain/usecases/cloud/UploadFiles.java | 158 +++ .../domain/usecases/cloud/UploadState.java | 39 + .../domain/usecases/vault/AssertUnlocked.java | 24 + .../domain/usecases/vault/ChangePassword.java | 46 + .../usecases/vault/CheckVaultPassword.java | 26 + .../domain/usecases/vault/CreateVault.java | 41 + .../domain/usecases/vault/DeleteVault.java | 24 + .../domain/usecases/vault/GetVaultList.java | 23 + .../domain/usecases/vault/LockVault.java | 25 + .../domain/usecases/vault/PrepareUnlock.java | 35 + .../domain/usecases/vault/ReloadVault.java | 24 + .../vault/RemoveStoredVaultPasswords.java | 43 + .../domain/usecases/vault/RenameVault.java | 56 + .../domain/usecases/vault/SaveVault.java | 24 + .../domain/usecases/vault/UnlockToken.java | 11 + .../domain/usecases/vault/UnlockVault.java | 30 + .../usecases/vault/VaultOrUnlockToken.java | 34 + .../BackgroundTasks.java | 15 + .../cloud/DataSourceCapturingAnswer.java | 44 + .../domain/usecases/cloud/DeleteNodeTest.java | 68 + .../usecases/cloud/DownloadFileTest.java | 70 + .../domain/usecases/cloud/MoveFileTest.java | 78 ++ .../domain/usecases/cloud/MoveFolderTest.java | 77 + .../domain/usecases/cloud/UploadFileTest.java | 122 ++ .../usecases/vault/UnlockVaultTest.java | 48 + eclipse+formatter.xml | 634 +++++++++ generator-api/.gitignore | 1 + generator-api/build.gradle | 12 + .../org/cryptomator/generator/Activity.java | 17 + .../cryptomator/generator/BottomSheet.java | 15 + .../cryptomator/generator/BoundCallback.java | 38 + .../org/cryptomator/generator/Callback.java | 15 + .../org/cryptomator/generator/Dialog.java | 15 + .../org/cryptomator/generator/Fragment.java | 15 + .../cryptomator/generator/InjectIntent.java | 12 + .../cryptomator/generator/InstanceState.java | 12 + .../org/cryptomator/generator/Intent.java | 17 + .../org/cryptomator/generator/Optional.java | 13 + .../org/cryptomator/generator/Parameter.java | 12 + .../cryptomator/generator/Unsubscribable.java | 7 + .../org/cryptomator/generator/UseCase.java | 12 + generator/.gitignore | 1 + generator/build.gradle | 23 + .../generator/ActivityProcessor.java | 44 + .../cryptomator/generator/BaseProcessor.java | 68 + .../generator/CallbackProcessor.java | 48 + .../generator/FragmentProcessor.java | 44 + .../generator/InstanceStateProcessor.java | 41 + .../generator/IntentProcessor.java | 66 + .../generator/ProcessorException.java | 17 + .../generator/UseCaseProcessor.java | 36 + .../generator/model/ActivitiesModel.java | 48 + .../generator/model/ActivityModel.java | 131 ++ .../generator/model/CallbackModel.java | 96 ++ .../generator/model/CallbacksModel.java | 51 + .../generator/model/FragmentModel.java | 48 + .../generator/model/FragmentsModel.java | 48 + .../generator/model/InstanceStateModel.java | 147 ++ .../generator/model/InstanceStatesModel.java | 25 + .../generator/model/IntentBuilderModel.java | 141 ++ .../generator/model/IntentReaderModel.java | 128 ++ .../generator/model/IntentsModel.java | 60 + .../generator/model/UseCaseModel.java | 263 ++++ .../templates/ActivitiesTemplate.java | 11 + .../templates/CallbacksTemplate.java | 11 + .../templates/FragmentsTemplate.java | 11 + .../templates/InstanceStateTemplate.java | 11 + .../templates/IntentBuilderTemplate.java | 11 + .../templates/IntentReaderTemplate.java | 11 + .../generator/templates/IntentsTemplate.java | 11 + .../generator/templates/Template.java | 56 + .../generator/templates/UseCaseTemplate.java | 10 + .../cryptomator/generator/utils/Field.java | 51 + .../cryptomator/generator/utils/Method.java | 78 ++ .../generator/utils/MethodParameter.java | 54 + .../org/cryptomator/generator/utils/Type.java | 186 +++ .../cryptomator/generator/utils/Utils.java | 29 + .../javax.annotation.processing.Processor | 6 + .../resources/templates/ActivitiesTemplate.vm | 55 + .../resources/templates/CallbacksTemplate.vm | 38 + .../resources/templates/FragmentsTemplate.vm | 39 + .../templates/InstanceStateTemplate.vm | 45 + .../templates/IntentBuilderTemplate.vm | 73 + .../templates/IntentReaderTemplate.vm | 47 + .../resources/templates/IntentsTemplate.vm | 32 + .../resources/templates/UseCaseTemplate.vm | 290 ++++ gradle.properties | 13 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 160 +++ gradlew.bat | 90 ++ intellij+formatter.xml | 109 ++ msa-auth-for-android | 1 + presentation/.gitignore | 2 + presentation/build.gradle | 194 +++ presentation/debug.keystore | Bin 0 -> 1261 bytes presentation/lint.xml | 4 + presentation/proguard-rules.pro | 81 ++ presentation/src/.gitignore | 1 + .../src/androidTest/AndroidManifest.xml | 7 + .../CloudContentRepositoryBlackboxTest.java | 426 ++++++ .../presentation/CloudNodeMatchers.java | 126 ++ .../presentation/logging/LogRotatorTest.java | 57 + .../testCloud/CryptoTestCloud.java | 71 + .../testCloud/DropboxTestCloud.java | 30 + .../testCloud/GoogledriveTestCloud.java | 85 ++ .../testCloud/LocalStorageTestCloud.java | 62 + .../testCloud/LocalTestCloud.java | 27 + .../testCloud/OnedriveTestCloud.java | 45 + .../presentation/testCloud/TestCloud.java | 8 + .../testCloud/WebdavTestCloud.java | 30 + .../presentation/ui/RecyclerViewMatcher.java | 64 + .../cryptomator/presentation/ui/TestUtil.java | 178 +++ .../ui/activity/BasicNodeOperationsUtil.java | 57 + .../ui/activity/CloudsOperationsTest.java | 249 ++++ .../ui/activity/FileOperationsTest.java | 670 +++++++++ .../ui/activity/FolderOperationsTest.java | 314 +++++ .../ui/activity/LoginLocalClouds.java | 80 ++ .../ui/activity/LoginWebdavClouds.java | 167 +++ .../ui/activity/VaultsOperationsTest.java | 412 ++++++ .../ui/activity/suite/FailureListener.java | 21 + .../ui/activity/suite/StopOnFailureSuite.java | 22 + .../ui/activity/suite/UiTestSuite.java | 20 + presentation/src/main/AndroidManifest.xml | 184 +++ .../presentation/AutoPhotoUploadReceiver.kt | 30 + .../presentation/BootAwareReceiver.kt | 28 + .../presentation/CacheCleanupTask.kt | 12 + .../presentation/CryptomatorApp.kt | 186 +++ .../org/cryptomator/presentation/UIThread.kt | 18 + .../presentation/di/HasComponent.java | 8 + .../di/component/ActivityComponent.java | 117 ++ .../di/component/ApplicationComponent.java | 47 + .../di/module/ActivityModule.java | 24 + .../di/module/ApplicationModule.java | 26 + .../presentation/di/module/ThreadModule.java | 27 + .../exception/CancellationExceptionHandler.kt | 21 + .../exception/DefaultExceptionHandler.kt | 27 + .../exception/ExceptionHandler.kt | 24 + .../exception/ExceptionHandlers.kt | 80 ++ .../exception/IllegalFileNameException.kt | 3 + .../exception/MessageExceptionHandler.kt | 17 + .../MissingCryptorExceptionHandler.kt | 21 + .../exception/NoSuchVaultExceptionHandler.kt | 18 + .../PermissionNotGrantedException.kt | 3 + .../PermissionNotGrantedExceptionHandler.kt | 17 + .../intent/AuthenticateCloudIntent.java | 20 + .../intent/BrowseFilesIntent.java | 19 + .../intent/ChooseCloudNodeSettings.java | 199 +++ .../intent/ChooseCloudServiceIntent.java | 13 + .../intent/CloudConnectionListIntent.java | 16 + .../intent/CloudSettingsIntent.java | 8 + .../intent/CreateVaultIntent.java | 9 + .../intent/EmptyDirIdFileInfoIntent.java | 13 + .../intent/ImagePreviewIntent.java | 11 + .../presentation/intent/IntentBuilder.java | 13 + .../intent/SetPasswordIntent.java | 9 + .../presentation/intent/SettingsIntent.java | 8 + .../presentation/intent/TextEditorIntent.java | 12 + .../presentation/intent/VaultListIntent.java | 13 + .../intent/WebDavAddOrChangeIntent.java | 13 + .../presentation/logging/CrashLogging.kt | 17 + .../presentation/logging/DebugLogger.kt | 18 + .../presentation/logging/FormattedTime.kt | 32 + .../logging/GeneratedErrorCode.kt | 51 + .../presentation/logging/LogRotator.kt | 99 ++ .../presentation/logging/Logfiles.kt | 42 + .../presentation/logging/ReleaseLogger.kt | 61 + .../logging/SizeMeasuringOutputStream.kt | 42 + .../model/AutoUploadFilesStore.kt | 11 + .../presentation/model/CloudFileModel.kt | 26 + .../presentation/model/CloudFolderModel.kt | 26 + .../presentation/model/CloudModel.kt | 29 + .../presentation/model/CloudNodeModel.kt | 45 + .../presentation/model/CloudTypeModel.kt | 77 + .../presentation/model/CryptoCloudModel.kt | 30 + .../presentation/model/DropboxCloudModel.kt | 24 + .../model/FileProgressStateModel.kt | 21 + .../model/GoogleDriveCloudModel.kt | 24 + .../presentation/model/ImagePreviewFile.kt | 13 + .../model/ImagePreviewFilesStore.kt | 8 + .../presentation/model/LocalStorageModel.kt | 45 + .../presentation/model/OnedriveCloudModel.kt | 24 + .../presentation/model/ProgressModel.kt | 23 + .../presentation/model/ProgressStateModel.kt | 81 ++ .../presentation/model/SharedFileModel.kt | 32 + .../presentation/model/VaultModel.kt | 33 + .../presentation/model/WebDavCloudModel.kt | 40 + .../model/comparator/CloudModelComparator.kt | 16 + ...CloudNodeModelDateNewestFirstComparator.kt | 31 + ...CloudNodeModelDateOldestFirstComparator.kt | 31 + .../CloudNodeModelNameAZComparator.kt | 18 + .../CloudNodeModelNameZAComparator.kt | 18 + ...loudNodeModelSizeBiggestFirstComparator.kt | 31 + ...oudNodeModelSizeSmallestFirstComparator.kt | 31 + .../model/mappers/CloudFileModelMapper.kt | 23 + .../model/mappers/CloudFolderModelMapper.kt | 20 + .../model/mappers/CloudModelMapper.kt | 24 + .../model/mappers/CloudNodeModelMapper.kt | 30 + .../presentation/model/mappers/ModelMapper.kt | 16 + .../model/mappers/ProgressModelMapper.kt | 19 + .../model/mappers/ProgressStateModelMapper.kt | 50 + .../presentation/presenter/ActivityHolder.kt | 9 + .../presenter/AuthenticateCloudPresenter.kt | 381 +++++ .../AutoUploadChooseVaultPresenter.kt | 199 +++ .../BiometricAuthSettingsPresenter.kt | 203 +++ .../presenter/BrowseFilesPresenter.kt | 1238 +++++++++++++++++ .../presenter/ChooseCloudServicePresenter.kt | 85 ++ .../presenter/CloudConnectionListPresenter.kt | 217 +++ .../presenter/CloudSettingsPresenter.kt | 141 ++ .../presentation/presenter/ContextHolder.kt | 7 + .../presenter/CreateVaultPresenter.kt | 34 + .../presenter/EmptyDirIdFileInfoPresenter.kt | 17 + .../presenter/ImagePreviewPresenter.kt | 201 +++ .../presenter/LicenseCheckPresenter.kt | 52 + .../presentation/presenter/Presenter.kt | 327 +++++ .../presenter/SetPasswordPresenter.kt | 36 + .../presenter/SettingsPresenter.kt | 237 ++++ .../presenter/SharedFilesPresenter.kt | 454 ++++++ .../presentation/presenter/SplashPresenter.kt | 15 + .../presenter/TextEditorPresenter.kt | 121 ++ .../presenter/UriBasedDataSource.kt | 37 + .../presenter/VaultListPresenter.kt | 700 ++++++++++ .../presenter/WebDavAddOrChangePresenter.kt | 127 ++ .../service/AutoUploadNotification.kt | 130 ++ .../service/AutoUploadService.java | 293 ++++ .../presentation/service/AutolockTimeout.java | 59 + .../presentation/service/CryptorsService.java | 220 +++ .../service/OpenWritableFileNotification.kt | 73 + .../presentation/service/PhotoContentJob.kt | 148 ++ .../service/UnlockedNotification.java | 127 ++ .../ui/activity/AuthenticateCloudActivity.kt | 49 + .../activity/AutoUploadChooseVaultActivity.kt | 109 ++ .../presentation/ui/activity/BaseActivity.kt | 390 ++++++ .../activity/BiometricAuthSettingsActivity.kt | 105 ++ .../ui/activity/BrowseFilesActivity.kt | 568 ++++++++ .../ui/activity/ChooseCloudServiceActivity.kt | 46 + .../activity/CloudConnectionListActivity.kt | 77 + .../ui/activity/CloudSettingsActivity.kt | 35 + .../ui/activity/CreateVaultActivity.kt | 38 + .../ui/activity/EmptyDirIdFileInfoActivity.kt | 37 + .../presentation/ui/activity/ErrorDisplay.kt | 9 + .../ui/activity/ImagePreviewActivity.kt | 260 ++++ .../ui/activity/LicenseCheckActivity.kt | 69 + .../ui/activity/LicensesActivity.kt | 19 + .../ui/activity/MessageDisplay.kt | 9 + .../presentation/ui/activity/ProgressAware.kt | 9 + .../ui/activity/SetPasswordActivity.kt | 24 + .../ui/activity/SettingsActivity.kt | 100 ++ .../ui/activity/SharedFilesActivity.kt | 225 +++ .../ui/activity/SplashActivity.kt | 14 + .../ui/activity/TextEditorActivity.kt | 134 ++ .../ui/activity/VaultListActivity.kt | 282 ++++ .../ui/activity/WebDavAddOrChangeActivity.kt | 55 + .../ui/activity/view/AuthenticateCloudView.kt | 13 + .../view/AutoUploadChooseVaultView.kt | 14 + .../view/BiometricAuthSettingsView.kt | 14 + .../ui/activity/view/BrowseFilesView.kt | 39 + .../activity/view/ChooseCloudServiceView.kt | 9 + .../activity/view/CloudConnectionListView.kt | 15 + .../ui/activity/view/CloudSettingsView.kt | 10 + .../ui/activity/view/CreateVaultView.kt | 3 + .../ui/activity/view/EmptyDirFileView.kt | 3 + .../ui/activity/view/ImagePreviewView.kt | 13 + .../ui/activity/view/SetPasswordView.kt | 3 + .../ui/activity/view/SettingsView.kt | 8 + .../ui/activity/view/SharedFilesView.kt | 20 + .../ui/activity/view/SplashView.kt | 3 + .../ui/activity/view/TextEditorView.kt | 11 + .../ui/activity/view/UpdateLicenseView.kt | 8 + .../ui/activity/view/VaultListView.kt | 27 + .../presentation/ui/activity/view/View.kt | 20 + .../ui/activity/view/WebDavAddOrChangeView.kt | 9 + .../adapter/BiometricAuthSettingsAdapter.kt | 57 + .../ui/adapter/BrowseFilesAdapter.kt | 485 +++++++ .../ui/adapter/CloudConnectionListAdapter.kt | 81 ++ .../ui/adapter/CloudSettingsAdapter.kt | 81 ++ .../presentation/ui/adapter/CloudsAdapter.kt | 35 + .../ui/adapter/RecyclerViewBaseAdapter.java | 142 ++ .../ui/adapter/SharedFilesAdapter.kt | 80 ++ .../ui/adapter/SharedLocationsAdapter.kt | 106 ++ .../presentation/ui/adapter/VaultsAdapter.kt | 68 + .../ui/bottomsheet/AddVaultBottomSheet.kt | 20 + .../ui/bottomsheet/BaseBottomSheet.kt | 51 + .../CloudConnectionSettingsBottomSheet.kt | 67 + .../ui/bottomsheet/FileSettingsBottomSheet.kt | 75 + .../bottomsheet/FolderSettingsBottomSheet.kt | 67 + .../bottomsheet/SettingsVaultBottomSheet.kt | 59 + .../VaultContentActionBottomSheet.kt | 56 + .../ui/callback/BrowseFilesCallback.kt | 10 + .../ui/callback/VaultListCallback.kt | 9 + .../ui/dialog/AppIsObscuredInfoDialog.kt | 35 + .../ui/dialog/AskForLockScreenDialog.kt | 33 + .../ui/dialog/AssignSslCertificateDialog.kt | 82 ++ .../presentation/ui/dialog/BaseDialog.kt | 124 ++ .../ui/dialog/BaseProgressErrorDialog.kt | 57 + .../ui/dialog/BetaConfirmationDialog.kt | 32 + .../BiometricAuthKeyInvalidatedDialog.kt | 27 + .../ui/dialog/ChangePasswordDialog.kt | 99 ++ .../ui/dialog/CloudNodeRenameDialog.kt | 106 ++ .../ui/dialog/ConfirmDeleteCloudNodeDialog.kt | 62 + .../ui/dialog/CreateFolderDialog.kt | 67 + .../ui/dialog/DebugModeDisclaimerDialog.kt | 39 + .../DeleteCloudConnectionWithVaultsDialog.kt | 44 + .../DisableAppWhenObscuredDisclaimerDialog.kt | 41 + .../DisableSecureScreenDisclaimerDialog.kt | 41 + .../ui/dialog/EnrollSystemBiometricDialog.kt | 38 + .../ui/dialog/EnterPasswordDialog.kt | 89 ++ .../ui/dialog/ExistingFileDialog.kt | 43 + .../ui/dialog/ExportCloudFilesDialog.kt | 80 ++ .../presentation/ui/dialog/FileNameDialog.kt | 76 + .../ui/dialog/FileTypeNotSupportedDialog.kt | 37 + .../ui/dialog/GenericProgressDialog.kt | 53 + .../ui/dialog/LicenseConfirmationDialog.kt | 39 + .../presentation/ui/dialog/NoDirFileDialog.kt | 50 + .../ui/dialog/NotEnoughVaultsDialog.kt | 45 + .../presentation/ui/dialog/ReplaceDialog.kt | 86 ++ .../presentation/ui/dialog/SymLinkDialog.kt | 37 + .../ui/dialog/UnsavedChangesDialog.kt | 36 + .../ui/dialog/UpdateAppAvailableDialog.kt | 55 + .../presentation/ui/dialog/UpdateAppDialog.kt | 42 + .../ui/dialog/UpdateLicenseDialog.kt | 72 + .../ui/dialog/UploadCloudFileDialog.kt | 101 ++ .../dialog/VaultDeleteConfirmationDialog.kt | 40 + .../ui/dialog/VaultNotFoundDialog.kt | 37 + .../ui/dialog/VaultRenameDialog.kt | 81 ++ .../ui/dialog/WebDavAskForHttpDialog.kt | 84 ++ .../fragment/AutoUploadChooseVaultFragment.kt | 78 ++ .../presentation/ui/fragment/BaseFragment.kt | 129 ++ .../fragment/BiometricAuthSettingsFragment.kt | 73 + .../ui/fragment/BrowseFilesFragment.kt | 315 +++++ .../ui/fragment/ChooseCloudServiceFragment.kt | 44 + .../fragment/CloudConnectionListFragment.kt | 80 ++ .../ui/fragment/CloudSettingsFragment.kt | 52 + .../ui/fragment/EmptyDirIdFileInfoFragment.kt | 22 + .../ui/fragment/ImagePreviewFragment.kt | 103 ++ .../ui/fragment/LicensesFragment.kt | 12 + .../ui/fragment/SetPasswordFragment.kt | 41 + .../ui/fragment/SettingsFragment.kt | 276 ++++ .../ui/fragment/SharedFilesFragment.kt | 91 ++ .../ui/fragment/TextEditorFragment.kt | 109 ++ .../ui/fragment/VaultListFragment.kt | 87 ++ .../ui/fragment/WebDavAddOrChangeFragment.kt | 87 ++ .../layout/ObscuredAwareCoordinatorLayout.kt | 32 + .../ui/layout/SlidingCoordinatorLayout.kt | 13 + .../ui/layout/VaultListCoordinatorLayout.kt | 129 ++ .../ui/snackbar/AppSettingsAction.kt | 25 + .../ui/snackbar/SnackbarAction.kt | 9 + .../util/AfterPermissionGranted.java | 29 + .../util/BiometricAuthentication.kt | 125 ++ .../presentation/util/Blacklist.kt | 13 + .../presentation/util/ContentResolverUtil.kt | 136 ++ .../presentation/util/DateHelper.kt | 78 ++ .../presentation/util/DownloadFileUtil.kt | 30 + .../presentation/util/EmailBuilder.kt | 58 + .../presentation/util/FileIcon.java | 99 ++ .../presentation/util/FileNameBlacklist.kt | 37 + .../presentation/util/FileNameValidator.kt | 12 + .../presentation/util/FileSizeHelper.kt | 40 + .../cryptomator/presentation/util/FileUtil.kt | 259 ++++ .../presentation/util/FolderNameBlacklist.kt | 32 + .../presentation/util/KeyboardHelper.kt | 18 + .../presentation/util/PasswordStrength.kt | 33 + .../util/PasswordStrengthUtil.java | 56 + .../presentation/util/ResourceHelper.kt | 27 + .../presentation/util/ShareFileHelper.kt | 39 + .../presentation/workflow/ActivityResult.java | 38 + .../workflow/AddExistingVaultWorkflow.java | 140 ++ .../presentation/workflow/AsyncResult.java | 17 + .../AuthenticationExceptionHandler.java | 40 + .../workflow/CreateNewVaultWorkflow.java | 143 ++ .../workflow/PermissionsResult.java | 18 + .../workflow/SerializableResult.java | 20 + .../presentation/workflow/Workflow.java | 101 ++ .../src/main/res/anim/slide_in_left.xml | 7 + .../src/main/res/anim/slide_in_right.xml | 7 + .../src/main/res/anim/slide_out_left.xml | 7 + .../src/main/res/anim/slide_out_right.xml | 7 + .../src/main/res/animator/enter_from_left.xml | 9 + .../main/res/animator/enter_from_right.xml | 9 + .../src/main/res/animator/exit_to_left.xml | 9 + .../src/main/res/animator/exit_to_right.xml | 9 + .../background_splash_cryptomator.png | Bin 0 -> 13928 bytes .../res/drawable-mdpi/cloud_type_dropbox.png | Bin 0 -> 765 bytes .../cloud_type_dropbox_large.png | Bin 0 -> 2159 bytes .../drawable-mdpi/cloud_type_google_drive.png | Bin 0 -> 849 bytes .../cloud_type_google_drive_large.png | Bin 0 -> 5619 bytes .../res/drawable-mdpi/cloud_type_onedrive.png | Bin 0 -> 936 bytes .../cloud_type_onedrive_large.png | Bin 0 -> 1851 bytes .../res/drawable-mdpi/cloud_type_webdav.png | Bin 0 -> 1064 bytes .../drawable-mdpi/cloud_type_webdav_large.png | Bin 0 -> 9383 bytes .../res/drawable-mdpi/node_file_archive.png | Bin 0 -> 201 bytes .../res/drawable-mdpi/node_file_audio.png | Bin 0 -> 239 bytes .../main/res/drawable-mdpi/node_file_html.png | Bin 0 -> 292 bytes .../res/drawable-mdpi/node_file_image.png | Bin 0 -> 231 bytes .../res/drawable-mdpi/node_file_movie.png | Bin 0 -> 230 bytes .../main/res/drawable-mdpi/node_file_pdf.png | Bin 0 -> 423 bytes .../drawable-mdpi/node_file_presentation.png | Bin 0 -> 306 bytes .../drawable-mdpi/node_file_sourcecode.png | Bin 0 -> 265 bytes .../drawable-mdpi/node_file_spreadsheet.png | Bin 0 -> 199 bytes .../main/res/drawable-mdpi/node_file_text.png | Bin 0 -> 188 bytes .../res/drawable-mdpi/node_file_unknown.png | Bin 0 -> 148 bytes .../main/res/drawable-mdpi/node_folder.png | Bin 0 -> 239 bytes .../src/main/res/drawable-mdpi/node_vault.png | Bin 0 -> 944 bytes .../res/drawable-mdpi/storage_type_local.png | Bin 0 -> 726 bytes .../storage_type_local_large.png | Bin 0 -> 1972 bytes .../main/res/drawable-mdpi/vault_unlocked.png | Bin 0 -> 357 bytes .../main/res/drawable-night/ic_add_gray.xml | 9 + .../main/res/drawable-night/ic_add_white.xml | 9 + .../src/main/res/drawable-night/ic_clear.xml | 9 + .../src/main/res/drawable-night/ic_cloud.xml | 9 + .../drawable-night/ic_create_new_folder.xml | 9 + .../src/main/res/drawable-night/ic_delete.xml | 9 + .../src/main/res/drawable-night/ic_edit.xml | 9 + .../res/drawable-night/ic_expand_more.xml | 9 + .../src/main/res/drawable-night/ic_export.xml | 9 + .../src/main/res/drawable-night/ic_file.xml | 9 + .../res/drawable-night/ic_file_download.xml | 9 + .../res/drawable-night/ic_file_upload.xml | 9 + .../drawable-night/ic_file_upload_gray.xml | 9 + .../src/main/res/drawable-night/ic_folder.xml | 9 + .../res/drawable-night/ic_license_key.xml | 9 + .../src/main/res/drawable-night/ic_lock.xml | 9 + .../res/drawable-night/ic_lock_closed.xml | 9 + .../main/res/drawable-night/ic_lock_open.xml | 9 + .../main/res/drawable-night/ic_open_with.xml | 9 + .../src/main/res/drawable-night/ic_save.xml | 9 + .../main/res/drawable-night/ic_sd_storage.xml | 9 + .../src/main/res/drawable-night/ic_search.xml | 9 + .../main/res/drawable-night/ic_select_all.xml | 9 + .../src/main/res/drawable-night/ic_share.xml | 9 + .../src/main/res/drawable-night/ic_sort.xml | 9 + .../main/res/drawable-night/ic_sort_az.xml | 10 + .../res/drawable-night/ic_sort_biggest.xml | 10 + .../res/drawable-night/ic_sort_newest.xml | 10 + .../res/drawable-night/ic_sort_oldest.xml | 10 + .../res/drawable-night/ic_sort_smallest.xml | 10 + .../main/res/drawable-night/ic_sort_za.xml | 10 + .../main/res/drawable-night/ic_swap_horiz.xml | 9 + .../background_splash_cryptomator.png | Bin 0 -> 29114 bytes .../res/drawable-xhdpi/cloud_type_dropbox.png | Bin 0 -> 1468 bytes .../cloud_type_dropbox_large.png | Bin 0 -> 3046 bytes .../cloud_type_google_drive.png | Bin 0 -> 1664 bytes .../cloud_type_google_drive_large.png | Bin 0 -> 12882 bytes .../drawable-xhdpi/cloud_type_onedrive.png | Bin 0 -> 1909 bytes .../cloud_type_onedrive_large.png | Bin 0 -> 3532 bytes .../res/drawable-xhdpi/cloud_type_webdav.png | Bin 0 -> 2196 bytes .../cloud_type_webdav_large.png | Bin 0 -> 26743 bytes .../res/drawable-xhdpi/node_file_archive.png | Bin 0 -> 323 bytes .../res/drawable-xhdpi/node_file_audio.png | Bin 0 -> 270 bytes .../res/drawable-xhdpi/node_file_html.png | Bin 0 -> 471 bytes .../res/drawable-xhdpi/node_file_image.png | Bin 0 -> 327 bytes .../res/drawable-xhdpi/node_file_movie.png | Bin 0 -> 279 bytes .../main/res/drawable-xhdpi/node_file_pdf.png | Bin 0 -> 762 bytes .../drawable-xhdpi/node_file_presentation.png | Bin 0 -> 515 bytes .../drawable-xhdpi/node_file_sourcecode.png | Bin 0 -> 387 bytes .../drawable-xhdpi/node_file_spreadsheet.png | Bin 0 -> 244 bytes .../res/drawable-xhdpi/node_file_text.png | Bin 0 -> 245 bytes .../res/drawable-xhdpi/node_file_unknown.png | Bin 0 -> 228 bytes .../main/res/drawable-xhdpi/node_folder.png | Bin 0 -> 370 bytes .../main/res/drawable-xhdpi/node_vault.png | Bin 0 -> 2029 bytes .../res/drawable-xhdpi/storage_type_local.png | Bin 0 -> 1512 bytes .../storage_type_local_large.png | Bin 0 -> 4034 bytes .../res/drawable-xhdpi/vault_unlocked.png | Bin 0 -> 686 bytes .../background_splash_cryptomator.png | Bin 0 -> 44928 bytes .../drawable-xxhdpi/cloud_type_dropbox.png | Bin 0 -> 2228 bytes .../cloud_type_dropbox_large.png | Bin 0 -> 5266 bytes .../cloud_type_google_drive.png | Bin 0 -> 2326 bytes .../cloud_type_google_drive_large.png | Bin 0 -> 20926 bytes .../drawable-xxhdpi/cloud_type_onedrive.png | Bin 0 -> 2761 bytes .../cloud_type_onedrive_large.png | Bin 0 -> 5680 bytes .../res/drawable-xxhdpi/cloud_type_webdav.png | Bin 0 -> 3152 bytes .../cloud_type_webdav_large.png | Bin 0 -> 53102 bytes .../res/drawable-xxhdpi/node_file_archive.png | Bin 0 -> 531 bytes .../res/drawable-xxhdpi/node_file_audio.png | Bin 0 -> 383 bytes .../res/drawable-xxhdpi/node_file_html.png | Bin 0 -> 642 bytes .../res/drawable-xxhdpi/node_file_image.png | Bin 0 -> 433 bytes .../res/drawable-xxhdpi/node_file_movie.png | Bin 0 -> 391 bytes .../res/drawable-xxhdpi/node_file_pdf.png | Bin 0 -> 1176 bytes .../node_file_presentation.png | Bin 0 -> 708 bytes .../drawable-xxhdpi/node_file_sourcecode.png | Bin 0 -> 566 bytes .../drawable-xxhdpi/node_file_spreadsheet.png | Bin 0 -> 324 bytes .../res/drawable-xxhdpi/node_file_text.png | Bin 0 -> 314 bytes .../res/drawable-xxhdpi/node_file_unknown.png | Bin 0 -> 298 bytes .../main/res/drawable-xxhdpi/node_folder.png | Bin 0 -> 531 bytes .../main/res/drawable-xxhdpi/node_vault.png | Bin 0 -> 3028 bytes .../drawable-xxhdpi/storage_type_local.png | Bin 0 -> 2153 bytes .../storage_type_local_large.png | Bin 0 -> 6110 bytes .../res/drawable-xxhdpi/vault_unlocked.png | Bin 0 -> 642 bytes .../background_image_preview_controls.xml | 7 + .../background_image_preview_toolbar.xml | 7 + .../main/res/drawable/background_splash.xml | 9 + .../src/main/res/drawable/ic_add_gray.xml | 9 + .../src/main/res/drawable/ic_add_white.xml | 9 + .../src/main/res/drawable/ic_chevron_left.xml | 9 + .../main/res/drawable/ic_chevron_right.xml | 9 + .../src/main/res/drawable/ic_clear.xml | 9 + .../src/main/res/drawable/ic_cloud.xml | 9 + .../res/drawable/ic_create_new_folder.xml | 9 + .../src/main/res/drawable/ic_delete.xml | 9 + .../src/main/res/drawable/ic_edit.xml | 9 + .../src/main/res/drawable/ic_expand_more.xml | 9 + .../src/main/res/drawable/ic_export.xml | 9 + .../src/main/res/drawable/ic_file.xml | 9 + .../main/res/drawable/ic_file_download.xml | 9 + .../src/main/res/drawable/ic_file_upload.xml | 9 + .../main/res/drawable/ic_file_upload_gray.xml | 9 + .../src/main/res/drawable/ic_folder.xml | 9 + .../src/main/res/drawable/ic_license_key.xml | 9 + .../src/main/res/drawable/ic_lock.xml | 9 + .../src/main/res/drawable/ic_lock_closed.xml | 9 + .../src/main/res/drawable/ic_lock_open.xml | 9 + .../src/main/res/drawable/ic_open_with.xml | 9 + .../src/main/res/drawable/ic_save.xml | 9 + .../src/main/res/drawable/ic_sd_storage.xml | 9 + .../src/main/res/drawable/ic_search.xml | 9 + .../src/main/res/drawable/ic_search_white.xml | 9 + .../src/main/res/drawable/ic_select_all.xml | 9 + .../src/main/res/drawable/ic_share.xml | 9 + .../src/main/res/drawable/ic_sort.xml | 9 + .../src/main/res/drawable/ic_sort_az.xml | 10 + .../src/main/res/drawable/ic_sort_biggest.xml | 10 + .../src/main/res/drawable/ic_sort_newest.xml | 10 + .../src/main/res/drawable/ic_sort_oldest.xml | 10 + .../main/res/drawable/ic_sort_smallest.xml | 10 + .../src/main/res/drawable/ic_sort_za.xml | 10 + .../src/main/res/drawable/ic_swap_horiz.xml | 9 + .../item_browse_files_node_selector.xml | 5 + .../src/main/res/drawable/primary_button.xml | 16 + .../res/drawable/primary_button_disabled.xml | 16 + .../res/drawable/primary_button_pressed.xml | 16 + .../res/drawable/primary_button_selector.xml | 6 + presentation/src/main/res/font/open_sans.ttf | Bin 0 -> 217360 bytes .../src/main/res/font/quicksand_bold.ttf | Bin 0 -> 107584 bytes .../src/main/res/font/quicksand_medium.ttf | Bin 0 -> 106468 bytes .../main/res/layout/activity_create_vault.xml | 20 + .../src/main/res/layout/activity_empty.xml | 8 + .../layout/activity_empty_dir_file_info.xml | 19 + .../res/layout/activity_image_preview.xml | 63 + .../src/main/res/layout/activity_layout.xml | 22 + .../layout/activity_layout_obscure_aware.xml | 22 + .../src/main/res/layout/activity_licenses.xml | 19 + .../src/main/res/layout/activity_settings.xml | 20 + .../main/res/layout/content_create_vault.xml | 41 + .../layout/dialog_app_is_obscured_info.xml | 19 + .../src/main/res/layout/dialog_app_update.xml | 30 + .../main/res/layout/dialog_ask_for_http.xml | 31 + .../res/layout/dialog_beta_confirmation.xml | 19 + .../dialog_biometric_auth_key_invalidated.xml | 17 + .../layout/dialog_bottom_sheet_add_vault.xml | 39 + .../dialog_bottom_sheet_cloud_settings.xml | 82 ++ .../dialog_bottom_sheet_file_settings.xml | 105 ++ .../dialog_bottom_sheet_folder_settings.xml | 99 ++ .../dialog_bottom_sheet_vault_action.xml | 48 + .../dialog_bottom_sheet_vault_settings.xml | 82 ++ .../res/layout/dialog_change_password.xml | 93 ++ .../dialog_confirm_delete_cloud_node.xml | 22 + .../main/res/layout/dialog_create_folder.xml | 45 + .../layout/dialog_debug_mode_disclaimer.xml | 18 + ...og_delete_cloud_connection_with_vaults.xml | 28 + ...dialog_disable_app_obscured_disclaimer.xml | 19 + ...ialog_disable_secure_screen_disclaimer.xml | 19 + .../main/res/layout/dialog_enter_license.xml | 51 + .../main/res/layout/dialog_enter_password.xml | 42 + .../main/res/layout/dialog_existing_file.xml | 22 + .../src/main/res/layout/dialog_file_name.xml | 32 + .../layout/dialog_file_type_not_supported.xml | 18 + .../res/layout/dialog_generic_progress.xml | 17 + .../layout/dialog_handle_ssl_certificate.xml | 57 + .../layout/dialog_license_confirmation.xml | 19 + .../main/res/layout/dialog_no_dir_file.xml | 18 + .../res/layout/dialog_no_screen_lock_set.xml | 32 + .../src/main/res/layout/dialog_rename.xml | 32 + .../dialog_setup_biometric_auth_in_system.xml | 18 + .../src/main/res/layout/dialog_sym_link.xml | 19 + .../main/res/layout/dialog_upload_loading.xml | 33 + .../dialog_vault_delete_confirmation.xml | 28 + .../layout/floating_action_button_layout.xml | 11 + .../fragment_auto_upload_choose_vault.xml | 38 + .../fragment_browse_cloud_connections.xml | 70 + .../main/res/layout/fragment_browse_files.xml | 51 + .../layout/fragment_choose_cloud_service.xml | 8 + .../res/layout/fragment_cloud_settings.xml | 8 + .../layout/fragment_empty_dir_file_info.xml | 33 + .../res/layout/fragment_image_preview.xml | 19 + .../main/res/layout/fragment_set_password.xml | 62 + .../fragment_settings_biometric_auth.xml | 85 ++ .../main/res/layout/fragment_setup_webdav.xml | 74 + .../main/res/layout/fragment_shared_files.xml | 63 + .../main/res/layout/fragment_text_editor.xml | 21 + .../main/res/layout/fragment_vault_list.xml | 17 + .../res/layout/item_biometric_auth_vault.xml | 44 + .../item_browse_cloud_model_connections.xml | 45 + .../res/layout/item_browse_files_node.xml | 71 + .../src/main/res/layout/item_cloud.xml | 23 + .../main/res/layout/item_cloud_setting.xml | 54 + .../res/layout/item_shareable_location.xml | 72 + .../src/main/res/layout/item_shared_files.xml | 37 + .../src/main/res/layout/item_vault.xml | 79 ++ .../main/res/layout/recycler_view_layout.xml | 10 + .../src/main/res/layout/toolbar_layout.xml | 13 + ...ew_browses_files_extra_text_and_button.xml | 37 + .../layout/view_cloud_connection_content.xml | 34 + .../res/layout/view_cloud_file_content.xml | 56 + .../res/layout/view_cloud_file_progress.xml | 15 + .../res/layout/view_cloud_folder_content.xml | 28 + .../res/layout/view_default_local_cloud.xml | 28 + .../src/main/res/layout/view_dialog_error.xml | 20 + .../view_dialog_intermediate_progress.xml | 43 + .../main/res/layout/view_dialog_progress.xml | 24 + .../layout/view_empty_cloud_connections.xml | 11 + .../src/main/res/layout/view_empty_folder.xml | 12 + .../view_password_strength_indicator.xml | 22 + .../res/layout/view_receive_save_button.xml | 24 + .../src/main/res/layout/view_retry.xml | 22 + .../res/layout/view_vault_creation_hint.xml | 13 + .../src/main/res/menu/menu_cloud_services.xml | 9 + .../src/main/res/menu/menu_file_browser.xml | 52 + .../menu/menu_file_browser_select_folder.xml | 17 + .../menu/menu_file_browser_selection_mode.xml | 40 + .../src/main/res/menu/menu_text_editor.xml | 35 + .../src/main/res/menu/menu_vault_list.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 31009 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 17817 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 45686 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 92045 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 156986 bytes .../src/main/res}/values-de/strings.xml | 24 +- .../src/main/res}/values-es/strings.xml | 13 +- .../src/main/res}/values-fr/strings.xml | 23 +- .../src/main/res/values-night/colors.xml | 26 + .../src/main/res/values-night/styles.xml | 121 ++ .../values-sw360dp-v13/values-preference.xml | 10 + .../src/main/res}/values-tr/strings.xml | 25 +- .../src/main/res/values-v21/styles.xml | 8 + .../src/main/res/values-w820dp/dimens.xml | 6 + presentation/src/main/res/values/arrays.xml | 57 + presentation/src/main/res/values/colors.xml | 26 + presentation/src/main/res/values/dimens.xml | 11 + .../src/main/res}/values/strings.xml | 23 +- presentation/src/main/res/values/styles.xml | 119 ++ presentation/src/main/res/xml/file_paths.xml | 8 + presentation/src/main/res/xml/licenses.xml | 168 +++ presentation/src/main/res/xml/preferences.xml | 242 ++++ .../presentation/SvgValidationTest.java | 78 ++ .../presentation/logging/LogfilesTest.java | 23 + .../presenter/VaultListPresenterTest.java | 306 ++++ .../util/FileNameValidatorTest.java | 89 ++ secrets.properties | 3 + settings.gradle | 2 + subsampling-scale-image-view | 1 + util/.gitignore | 1 + util/build.gradle | 75 + .../util/CredentialCryptorTest.java | 41 + .../cryptomator/util/KeyStoreBuilderTest.java | 45 + .../util/SharedPreferencesHandlerTest.java | 47 + util/src/main/AndroidManifest.xml | 5 + .../org/cryptomator/util/ByteArrayUtils.kt | 27 + .../java/org/cryptomator/util/Comparators.kt | 7 + .../java/org/cryptomator/util/Consumer.java | 7 + .../java/org/cryptomator/util/Encodings.kt | 13 + .../org/cryptomator/util/ExceptionUtil.java | 41 + .../java/org/cryptomator/util/Function.java | 7 + .../java/org/cryptomator/util/LockTimeout.kt | 33 + .../util/NoOpActivityLifecycleCallbacks.java | 44 + .../java/org/cryptomator/util/Optional.java | 108 ++ .../java/org/cryptomator/util/Predicate.java | 7 + .../java/org/cryptomator/util/Predicates.java | 13 + .../util/SharedPreferencesHandler.kt | 299 ++++ .../java/org/cryptomator/util/Supplier.java | 7 + .../util/concurrent/CompletableFuture.java | 116 ++ .../util/crypto/BiometricAuthCryptor.java | 58 + .../org/cryptomator/util/crypto/Cipher.java | 16 + .../util/crypto/CipherFromApi23.java | 88 ++ .../util/crypto/CredentialCryptor.java | 45 + .../util/crypto/CryptoOperations.java | 13 + .../util/crypto/CryptoOperationsFactory.java | 22 + .../crypto/CryptoOperationsFromApi23.java | 53 + .../cryptomator/util/crypto/KeyGenerator.java | 9 + .../util/crypto/KeyStoreBuilder.java | 107 ++ .../UnrecoverableStorageKeyException.java | 11 + .../cryptomator/util/file/FileCacheUtils.kt | 76 + .../cryptomator/util/file/LruFileCacheUtil.kt | 134 ++ .../org/cryptomator/util/file/MimeType.kt | 55 + .../org/cryptomator/util/file/MimeTypeMap.kt | 14 + .../org/cryptomator/util/file/MimeTypes.kt | 26 + .../cryptomator/util/ByteArrayUtilsTest.java | 112 ++ .../concurrent/CompletableFutureTest.java | 251 ++++ .../cryptomator/util/file/MimeTypeTest.java | 52 + .../cryptomator/util/file/MimeTypesTest.java | 105 ++ .../util/matchers/ByteArrayMatchers.java | 28 + .../util/matchers/MimeTypeMatchers.java | 28 + .../util/matchers/OptionalMatchers.java | 53 + .../certificate.X509Certificate.pem | 1 + .../keystorehelper-test/certificate.pem | 17 + 959 files changed, 55670 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 LICENSE.txt create mode 100644 build.gradle create mode 100644 buildsystem/ci.gradle create mode 100644 buildsystem/dependencies.gradle create mode 100755 data/.gitignore create mode 100644 data/build.gradle create mode 100644 data/src/main/AndroidManifest.xml create mode 100644 data/src/main/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/InterceptingCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/BackupFileIdSuffixGenerator.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloud.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoConstants.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoSymlink.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/Cryptors.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/CryptorsModule.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCache.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormat7.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormatPre7.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/crypto/RootCryptoFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxClientFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudNodeFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/dropbox/RootDropboxFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/FixedGoogleAccountCredential.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveClientFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudNodeFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCache.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCloudNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/googledrive/RootGoogleDriveFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/LocalStorageContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/LocalNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageNodeFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/file/RootLocalFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/DocumentIdCache.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkNodeFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/RootLocalStorageAccessFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HeaderNames.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HttpLoggingInterceptor.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/MSAAuthAndroidAdapterImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveClientFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudNodeFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveHttpProvider.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCache.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCloudNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/RootOnedriveFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ClientException.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IAuthenticationAdapter.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ICallback.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IProgressCallback.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MSAAuthAndroidAdapter.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MicrosoftOAuth2Endpoint.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/SimpleWaiter.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/RootWebDavFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFile.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFolder.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavNode.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerHandlerImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/DataSourceBasedRequestBody.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/DefaultTrustManager.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/PinningTrustManager.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindEntryData.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParser.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/SSLSocketFactories.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavClient.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavCompatibleHttpClient.java create mode 100644 data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavRedirectHandler.java create mode 100755 data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java create mode 100644 data/src/main/java/org/cryptomator/data/db/Database.java create mode 100644 data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java create mode 100644 data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java create mode 100644 data/src/main/java/org/cryptomator/data/db/Sql.java create mode 100644 data/src/main/java/org/cryptomator/data/db/Upgrade0To1.java create mode 100644 data/src/main/java/org/cryptomator/data/db/Upgrade1To2.java create mode 100644 data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt create mode 100644 data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java create mode 100755 data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java create mode 100644 data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java create mode 100644 data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java create mode 100644 data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java create mode 100644 data/src/main/java/org/cryptomator/data/db/mappers/EntityMapper.java create mode 100644 data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java create mode 100644 data/src/main/java/org/cryptomator/data/exception/CloudError.java create mode 100644 data/src/main/java/org/cryptomator/data/exception/DatabaseError.java create mode 100644 data/src/main/java/org/cryptomator/data/executor/JobExecutor.java create mode 100644 data/src/main/java/org/cryptomator/data/repository/CloudContentRepositoryFactory.java create mode 100644 data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.java create mode 100644 data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java create mode 100644 data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java create mode 100644 data/src/main/java/org/cryptomator/data/util/CopyStream.java create mode 100644 data/src/main/java/org/cryptomator/data/util/NetworkConnectionCheck.java create mode 100644 data/src/main/java/org/cryptomator/data/util/NetworkTimeout.java create mode 100644 data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareGoogleContentInputStream.java create mode 100644 data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareInputStream.java create mode 100644 data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareOutputStream.java create mode 100644 data/src/main/java/org/cryptomator/data/util/UserAgentInterceptor.java create mode 100644 data/src/main/java/org/cryptomator/data/util/X509CertificateHelper.java create mode 100755 data/src/test/java/org/cryptomator/data/ApplicationStub.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/CloudFileMatcher.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/CloudFolderMatcher.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7Test.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7Test.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/crypto/RootTestFolder.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/crypto/TestFile.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/crypto/TestFolder.java create mode 100644 data/src/test/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParserTest.java create mode 100644 data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareInputStreamTest.java create mode 100644 data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareOutputStreamTest.java create mode 100644 data/src/test/resources/propfind-test-request/directory-and-file.xml create mode 100644 data/src/test/resources/propfind-test-request/directory-one-file-no-server.xml create mode 100644 data/src/test/resources/propfind-test-request/directory-one-file.xml create mode 100644 data/src/test/resources/propfind-test-request/directory-one-folder.xml create mode 100644 data/src/test/resources/propfind-test-request/empty-directory.xml create mode 100644 data/src/test/resources/propfind-test-request/malformatted-response-illegalstate.xml create mode 100644 data/src/test/resources/propfind-test-request/malformatted-response-xmlpullparser.xml create mode 100755 domain/.gitignore create mode 100644 domain/build.gradle create mode 100644 domain/src/debug/java/org/cryptomator/domain/executor/BackgroundTasks.java create mode 100644 domain/src/debug/java/org/cryptomator/domain/executor/ObjectCounts.java create mode 100644 domain/src/main/AndroidManifest.xml create mode 100644 domain/src/main/java/org/cryptomator/domain/Cloud.java create mode 100644 domain/src/main/java/org/cryptomator/domain/CloudFile.java create mode 100644 domain/src/main/java/org/cryptomator/domain/CloudFolder.java create mode 100755 domain/src/main/java/org/cryptomator/domain/CloudNode.java create mode 100644 domain/src/main/java/org/cryptomator/domain/CloudType.java create mode 100644 domain/src/main/java/org/cryptomator/domain/DropboxCloud.java create mode 100644 domain/src/main/java/org/cryptomator/domain/GoogleDriveCloud.java create mode 100644 domain/src/main/java/org/cryptomator/domain/LocalStorageCloud.java create mode 100644 domain/src/main/java/org/cryptomator/domain/OnedriveCloud.java create mode 100644 domain/src/main/java/org/cryptomator/domain/Vault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/WebDavCloud.java create mode 100755 domain/src/main/java/org/cryptomator/domain/di/PerView.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/AlreadyExistException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/BackendException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/CancellationException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/CloudAlreadyExistsException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/CloudNodeAlreadyExistsException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/EmptyDirFileException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/FatalBackendException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/ForbiddenException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/MissingCryptorException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NetworkConnectionException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NoDirFileException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NoSuchCloudFileException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NoSuchVaultException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NotFoundException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NotImplementedException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/NotTrustableCertificateException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/ParentFolderDoesNotExistException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/ServerNotWebdavCompatibleException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/SymLinkException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/TypeMismatchException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/UnableToDecryptWebdavPasswordException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/UnauthorizedException.java create mode 100755 domain/src/main/java/org/cryptomator/domain/exception/VaultAlreadyExistException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/AuthenticationException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/NoAuthenticationProvidedException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/UserRecoverableAuthenticationException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavCertificateUntrustedAuthenticationException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavNotSupportedException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavServerNotFoundException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/authentication/WrongCredentialsException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/license/LicenseNotValidException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/license/NoLicenseAvailableException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/update/GeneralUpdateErrorException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/exception/update/SSLHandshakePreAndroid5UpdateCheckException.java create mode 100644 domain/src/main/java/org/cryptomator/domain/executor/PostExecutionThread.java create mode 100644 domain/src/main/java/org/cryptomator/domain/executor/ThreadExecutor.java create mode 100644 domain/src/main/java/org/cryptomator/domain/repository/CloudAuthenticationService.java create mode 100644 domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.java create mode 100644 domain/src/main/java/org/cryptomator/domain/repository/CloudRepository.java create mode 100644 domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java create mode 100644 domain/src/main/java/org/cryptomator/domain/repository/VaultRepository.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/CloudFolderRecursiveListing.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/CloudNodeRecursiveListing.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/CopyData.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/DoUpdate.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/DoUpdateCheck.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/DownloadFile.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/DownloadFileReplacingProgressAware.java create mode 100755 domain/src/main/java/org/cryptomator/domain/usecases/GetDecryptedCloudForVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/LicenseCheck.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/NoOpResultHandler.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/ProgressAware.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/ProgressAwareResultHandler.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/ResultHandler.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/ResultRenamed.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/ResultWithProgress.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/ThrottlingProgressAware.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/UpdateCheck.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/UploadFileReplacingProgressAware.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/AddOrChangeCloudConnection.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/ByteArrayDataSource.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareDataSource.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareInputStream.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToWebDav.java create mode 100755 domain/src/main/java/org/cryptomator/domain/usecases/cloud/CreateFolder.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/DataSource.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/DeleteNodes.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadFiles.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadState.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileBasedDataSource.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileTransferState.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/Flag.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetAllClouds.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudList.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudListRecursive.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetClouds.java create mode 100755 domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetRootFolder.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetUsername.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/LogoutCloud.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFiles.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFolders.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/Progress.java create mode 100755 domain/src/main/java/org/cryptomator/domain/usecases/cloud/ProgressState.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/RemoveCloud.java create mode 100755 domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFile.java create mode 100755 domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFolder.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFile.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadState.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/AssertUnlocked.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/ChangePassword.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/CheckVaultPassword.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/CreateVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/DeleteVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/GetVaultList.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/LockVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/PrepareUnlock.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/ReloadVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/RemoveStoredVaultPasswords.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/RenameVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/SaveVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockToken.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockVault.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/vault/VaultOrUnlockToken.java create mode 100644 domain/src/release/java/org.cryptomator.domain.executor/BackgroundTasks.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/DataSourceCapturingAnswer.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/DeleteNodeTest.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/DownloadFileTest.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFileTest.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFolderTest.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockVaultTest.java create mode 100755 eclipse+formatter.xml create mode 100755 generator-api/.gitignore create mode 100644 generator-api/build.gradle create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Activity.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/BottomSheet.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/BoundCallback.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Callback.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Dialog.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Fragment.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/InjectIntent.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/InstanceState.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Intent.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Optional.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Parameter.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/Unsubscribable.java create mode 100644 generator-api/src/main/java/org/cryptomator/generator/UseCase.java create mode 100755 generator/.gitignore create mode 100644 generator/build.gradle create mode 100644 generator/src/main/java/org/cryptomator/generator/ActivityProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/BaseProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/CallbackProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/FragmentProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/InstanceStateProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/IntentProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/ProcessorException.java create mode 100644 generator/src/main/java/org/cryptomator/generator/UseCaseProcessor.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/ActivitiesModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/ActivityModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/CallbackModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/CallbacksModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/FragmentModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/FragmentsModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/InstanceStateModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/InstanceStatesModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/IntentBuilderModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/IntentReaderModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/IntentsModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/model/UseCaseModel.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/ActivitiesTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/CallbacksTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/FragmentsTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/InstanceStateTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/IntentBuilderTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/IntentReaderTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/IntentsTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/Template.java create mode 100644 generator/src/main/java/org/cryptomator/generator/templates/UseCaseTemplate.java create mode 100644 generator/src/main/java/org/cryptomator/generator/utils/Field.java create mode 100644 generator/src/main/java/org/cryptomator/generator/utils/Method.java create mode 100644 generator/src/main/java/org/cryptomator/generator/utils/MethodParameter.java create mode 100644 generator/src/main/java/org/cryptomator/generator/utils/Type.java create mode 100755 generator/src/main/java/org/cryptomator/generator/utils/Utils.java create mode 100755 generator/src/main/resources/META-INF/services/javax.annotation.processing.Processor create mode 100644 generator/src/main/resources/templates/ActivitiesTemplate.vm create mode 100644 generator/src/main/resources/templates/CallbacksTemplate.vm create mode 100644 generator/src/main/resources/templates/FragmentsTemplate.vm create mode 100644 generator/src/main/resources/templates/InstanceStateTemplate.vm create mode 100644 generator/src/main/resources/templates/IntentBuilderTemplate.vm create mode 100644 generator/src/main/resources/templates/IntentReaderTemplate.vm create mode 100644 generator/src/main/resources/templates/IntentsTemplate.vm create mode 100644 generator/src/main/resources/templates/UseCaseTemplate.vm create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 intellij+formatter.xml create mode 160000 msa-auth-for-android create mode 100644 presentation/.gitignore create mode 100644 presentation/build.gradle create mode 100755 presentation/debug.keystore create mode 100644 presentation/lint.xml create mode 100644 presentation/proguard-rules.pro create mode 100755 presentation/src/.gitignore create mode 100644 presentation/src/androidTest/AndroidManifest.xml create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/CloudContentRepositoryBlackboxTest.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/CloudNodeMatchers.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/logging/LogRotatorTest.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/CryptoTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/DropboxTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/GoogledriveTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalStorageTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/OnedriveTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/TestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/WebdavTestCloud.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/RecyclerViewMatcher.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/TestUtil.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/BasicNodeOperationsUtil.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/CloudsOperationsTest.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FileOperationsTest.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FolderOperationsTest.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginLocalClouds.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginWebdavClouds.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/VaultsOperationsTest.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/FailureListener.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/StopOnFailureSuite.java create mode 100644 presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/UiTestSuite.java create mode 100644 presentation/src/main/AndroidManifest.xml create mode 100644 presentation/src/main/java/org/cryptomator/presentation/AutoPhotoUploadReceiver.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/BootAwareReceiver.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/CacheCleanupTask.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/UIThread.kt create mode 100755 presentation/src/main/java/org/cryptomator/presentation/di/HasComponent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/di/module/ActivityModule.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/di/module/ApplicationModule.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/di/module/ThreadModule.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/CancellationExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/DefaultExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandlers.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/IllegalFileNameException.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/MessageExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/MissingCryptorExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/NoSuchVaultExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedException.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedExceptionHandler.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/AuthenticateCloudIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudNodeSettings.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudServiceIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/CloudConnectionListIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/CloudSettingsIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/CreateVaultIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/EmptyDirIdFileInfoIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/ImagePreviewIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/IntentBuilder.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/SetPasswordIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/SettingsIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/VaultListIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/intent/WebDavAddOrChangeIntent.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/CrashLogging.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/DebugLogger.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/FormattedTime.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/GeneratedErrorCode.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/LogRotator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/Logfiles.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/ReleaseLogger.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/logging/SizeMeasuringOutputStream.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/AutoUploadFilesStore.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/CloudFolderModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/CloudModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/CryptoCloudModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/DropboxCloudModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/FileProgressStateModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/GoogleDriveCloudModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFile.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFilesStore.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/LocalStorageModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/OnedriveCloudModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/ProgressModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/ProgressStateModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/SharedFileModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/WebDavCloudModel.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudModelComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateNewestFirstComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateOldestFirstComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameAZComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameZAComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeBiggestFirstComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeSmallestFirstComparator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFileModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFolderModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudNodeModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/ModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressStateModelMapper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/ActivityHolder.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/BiometricAuthSettingsPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/ContextHolder.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/CreateVaultPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/EmptyDirIdFileInfoPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/ImagePreviewPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/Presenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/SetPasswordPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/SplashPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/WebDavAddOrChangePresenter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadNotification.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadService.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/AutolockTimeout.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/OpenWritableFileNotification.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/PhotoContentJob.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/UnlockedNotification.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/AuthenticateCloudActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/AutoUploadChooseVaultActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/BaseActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/BiometricAuthSettingsActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/ChooseCloudServiceActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudConnectionListActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudSettingsActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/CreateVaultActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/EmptyDirIdFileInfoActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/ErrorDisplay.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicensesActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/MessageDisplay.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/ProgressAware.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/SetPasswordActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/SettingsActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/SplashActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/WebDavAddOrChangeActivity.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AuthenticateCloudView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AutoUploadChooseVaultView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BiometricAuthSettingsView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ChooseCloudServiceView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudConnectionListView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudSettingsView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CreateVaultView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/EmptyDirFileView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ImagePreviewView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SetPasswordView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SettingsView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SplashView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/TextEditorView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/VaultListView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/View.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WebDavAddOrChangeView.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BiometricAuthSettingsAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudConnectionListAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudsAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/RecyclerViewBaseAdapter.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedFilesAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedLocationsAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/adapter/VaultsAdapter.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/AddVaultBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/BaseBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FolderSettingsBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/SettingsVaultBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/VaultContentActionBottomSheet.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/callback/BrowseFilesCallback.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/callback/VaultListCallback.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AppIsObscuredInfoDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AskForLockScreenDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AssignSslCertificateDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BaseDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BaseProgressErrorDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BetaConfirmationDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BiometricAuthKeyInvalidatedDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ChangePasswordDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/CloudNodeRenameDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ConfirmDeleteCloudNodeDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/CreateFolderDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/DebugModeDisclaimerDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/DeleteCloudConnectionWithVaultsDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/DisableAppWhenObscuredDisclaimerDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/DisableSecureScreenDisclaimerDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/EnrollSystemBiometricDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/EnterPasswordDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ExistingFileDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ExportCloudFilesDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/FileNameDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/FileTypeNotSupportedDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/GenericProgressDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/LicenseConfirmationDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NoDirFileDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/NotEnoughVaultsDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ReplaceDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/SymLinkDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UnsavedChangesDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UpdateAppAvailableDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UpdateAppDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UpdateLicenseDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/UploadCloudFileDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/VaultDeleteConfirmationDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/VaultNotFoundDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/VaultRenameDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/WebDavAskForHttpDialog.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/AutoUploadChooseVaultFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/BaseFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/BiometricAuthSettingsFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/BrowseFilesFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/ChooseCloudServiceFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/CloudConnectionListFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/CloudSettingsFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/EmptyDirIdFileInfoFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/ImagePreviewFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/LicensesFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SetPasswordFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SettingsFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/SharedFilesFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/VaultListFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/fragment/WebDavAddOrChangeFragment.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/layout/ObscuredAwareCoordinatorLayout.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/layout/SlidingCoordinatorLayout.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/layout/VaultListCoordinatorLayout.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/snackbar/AppSettingsAction.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/snackbar/SnackbarAction.kt create mode 100755 presentation/src/main/java/org/cryptomator/presentation/util/AfterPermissionGranted.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/BiometricAuthentication.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/Blacklist.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/ContentResolverUtil.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/DateHelper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/DownloadFileUtil.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/EmailBuilder.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/FileIcon.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/FileNameBlacklist.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/FileNameValidator.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/FileSizeHelper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/FileUtil.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/FolderNameBlacklist.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/KeyboardHelper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrength.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/PasswordStrengthUtil.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/ResourceHelper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/util/ShareFileHelper.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/ActivityResult.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/AddExistingVaultWorkflow.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/AsyncResult.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/AuthenticationExceptionHandler.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/CreateNewVaultWorkflow.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/PermissionsResult.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/SerializableResult.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/workflow/Workflow.java create mode 100644 presentation/src/main/res/anim/slide_in_left.xml create mode 100644 presentation/src/main/res/anim/slide_in_right.xml create mode 100644 presentation/src/main/res/anim/slide_out_left.xml create mode 100644 presentation/src/main/res/anim/slide_out_right.xml create mode 100644 presentation/src/main/res/animator/enter_from_left.xml create mode 100644 presentation/src/main/res/animator/enter_from_right.xml create mode 100644 presentation/src/main/res/animator/exit_to_left.xml create mode 100644 presentation/src/main/res/animator/exit_to_right.xml create mode 100644 presentation/src/main/res/drawable-mdpi/background_splash_cryptomator.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_dropbox.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_dropbox_large.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_google_drive.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_google_drive_large.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_onedrive.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_onedrive_large.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_webdav.png create mode 100644 presentation/src/main/res/drawable-mdpi/cloud_type_webdav_large.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_archive.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_audio.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_html.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_image.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_movie.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_pdf.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_presentation.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_sourcecode.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_spreadsheet.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_text.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_file_unknown.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_folder.png create mode 100644 presentation/src/main/res/drawable-mdpi/node_vault.png create mode 100644 presentation/src/main/res/drawable-mdpi/storage_type_local.png create mode 100644 presentation/src/main/res/drawable-mdpi/storage_type_local_large.png create mode 100755 presentation/src/main/res/drawable-mdpi/vault_unlocked.png create mode 100644 presentation/src/main/res/drawable-night/ic_add_gray.xml create mode 100644 presentation/src/main/res/drawable-night/ic_add_white.xml create mode 100644 presentation/src/main/res/drawable-night/ic_clear.xml create mode 100644 presentation/src/main/res/drawable-night/ic_cloud.xml create mode 100644 presentation/src/main/res/drawable-night/ic_create_new_folder.xml create mode 100644 presentation/src/main/res/drawable-night/ic_delete.xml create mode 100644 presentation/src/main/res/drawable-night/ic_edit.xml create mode 100644 presentation/src/main/res/drawable-night/ic_expand_more.xml create mode 100644 presentation/src/main/res/drawable-night/ic_export.xml create mode 100644 presentation/src/main/res/drawable-night/ic_file.xml create mode 100644 presentation/src/main/res/drawable-night/ic_file_download.xml create mode 100644 presentation/src/main/res/drawable-night/ic_file_upload.xml create mode 100644 presentation/src/main/res/drawable-night/ic_file_upload_gray.xml create mode 100644 presentation/src/main/res/drawable-night/ic_folder.xml create mode 100644 presentation/src/main/res/drawable-night/ic_license_key.xml create mode 100644 presentation/src/main/res/drawable-night/ic_lock.xml create mode 100644 presentation/src/main/res/drawable-night/ic_lock_closed.xml create mode 100644 presentation/src/main/res/drawable-night/ic_lock_open.xml create mode 100644 presentation/src/main/res/drawable-night/ic_open_with.xml create mode 100644 presentation/src/main/res/drawable-night/ic_save.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sd_storage.xml create mode 100644 presentation/src/main/res/drawable-night/ic_search.xml create mode 100644 presentation/src/main/res/drawable-night/ic_select_all.xml create mode 100644 presentation/src/main/res/drawable-night/ic_share.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort_az.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort_biggest.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort_newest.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort_oldest.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort_smallest.xml create mode 100644 presentation/src/main/res/drawable-night/ic_sort_za.xml create mode 100644 presentation/src/main/res/drawable-night/ic_swap_horiz.xml create mode 100644 presentation/src/main/res/drawable-xhdpi/background_splash_cryptomator.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_dropbox.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_dropbox_large.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_google_drive.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_google_drive_large.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_onedrive.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_onedrive_large.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_webdav.png create mode 100644 presentation/src/main/res/drawable-xhdpi/cloud_type_webdav_large.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_archive.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_audio.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_html.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_image.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_movie.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_pdf.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_presentation.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_sourcecode.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_spreadsheet.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_text.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_file_unknown.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_folder.png create mode 100644 presentation/src/main/res/drawable-xhdpi/node_vault.png create mode 100644 presentation/src/main/res/drawable-xhdpi/storage_type_local.png create mode 100644 presentation/src/main/res/drawable-xhdpi/storage_type_local_large.png create mode 100755 presentation/src/main/res/drawable-xhdpi/vault_unlocked.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/background_splash_cryptomator.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_dropbox.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_dropbox_large.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_google_drive.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_google_drive_large.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_onedrive.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_onedrive_large.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_webdav.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/cloud_type_webdav_large.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_archive.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_audio.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_html.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_image.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_movie.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_pdf.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_presentation.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_sourcecode.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_spreadsheet.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_text.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_file_unknown.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_folder.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/node_vault.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/storage_type_local.png create mode 100644 presentation/src/main/res/drawable-xxhdpi/storage_type_local_large.png create mode 100755 presentation/src/main/res/drawable-xxhdpi/vault_unlocked.png create mode 100644 presentation/src/main/res/drawable/background_image_preview_controls.xml create mode 100644 presentation/src/main/res/drawable/background_image_preview_toolbar.xml create mode 100644 presentation/src/main/res/drawable/background_splash.xml create mode 100644 presentation/src/main/res/drawable/ic_add_gray.xml create mode 100644 presentation/src/main/res/drawable/ic_add_white.xml create mode 100644 presentation/src/main/res/drawable/ic_chevron_left.xml create mode 100644 presentation/src/main/res/drawable/ic_chevron_right.xml create mode 100644 presentation/src/main/res/drawable/ic_clear.xml create mode 100644 presentation/src/main/res/drawable/ic_cloud.xml create mode 100644 presentation/src/main/res/drawable/ic_create_new_folder.xml create mode 100644 presentation/src/main/res/drawable/ic_delete.xml create mode 100644 presentation/src/main/res/drawable/ic_edit.xml create mode 100644 presentation/src/main/res/drawable/ic_expand_more.xml create mode 100644 presentation/src/main/res/drawable/ic_export.xml create mode 100644 presentation/src/main/res/drawable/ic_file.xml create mode 100644 presentation/src/main/res/drawable/ic_file_download.xml create mode 100644 presentation/src/main/res/drawable/ic_file_upload.xml create mode 100644 presentation/src/main/res/drawable/ic_file_upload_gray.xml create mode 100644 presentation/src/main/res/drawable/ic_folder.xml create mode 100644 presentation/src/main/res/drawable/ic_license_key.xml create mode 100644 presentation/src/main/res/drawable/ic_lock.xml create mode 100644 presentation/src/main/res/drawable/ic_lock_closed.xml create mode 100644 presentation/src/main/res/drawable/ic_lock_open.xml create mode 100644 presentation/src/main/res/drawable/ic_open_with.xml create mode 100644 presentation/src/main/res/drawable/ic_save.xml create mode 100644 presentation/src/main/res/drawable/ic_sd_storage.xml create mode 100644 presentation/src/main/res/drawable/ic_search.xml create mode 100644 presentation/src/main/res/drawable/ic_search_white.xml create mode 100644 presentation/src/main/res/drawable/ic_select_all.xml create mode 100644 presentation/src/main/res/drawable/ic_share.xml create mode 100644 presentation/src/main/res/drawable/ic_sort.xml create mode 100644 presentation/src/main/res/drawable/ic_sort_az.xml create mode 100644 presentation/src/main/res/drawable/ic_sort_biggest.xml create mode 100644 presentation/src/main/res/drawable/ic_sort_newest.xml create mode 100644 presentation/src/main/res/drawable/ic_sort_oldest.xml create mode 100644 presentation/src/main/res/drawable/ic_sort_smallest.xml create mode 100644 presentation/src/main/res/drawable/ic_sort_za.xml create mode 100644 presentation/src/main/res/drawable/ic_swap_horiz.xml create mode 100644 presentation/src/main/res/drawable/item_browse_files_node_selector.xml create mode 100644 presentation/src/main/res/drawable/primary_button.xml create mode 100644 presentation/src/main/res/drawable/primary_button_disabled.xml create mode 100644 presentation/src/main/res/drawable/primary_button_pressed.xml create mode 100644 presentation/src/main/res/drawable/primary_button_selector.xml create mode 100644 presentation/src/main/res/font/open_sans.ttf create mode 100644 presentation/src/main/res/font/quicksand_bold.ttf create mode 100644 presentation/src/main/res/font/quicksand_medium.ttf create mode 100644 presentation/src/main/res/layout/activity_create_vault.xml create mode 100644 presentation/src/main/res/layout/activity_empty.xml create mode 100644 presentation/src/main/res/layout/activity_empty_dir_file_info.xml create mode 100644 presentation/src/main/res/layout/activity_image_preview.xml create mode 100644 presentation/src/main/res/layout/activity_layout.xml create mode 100644 presentation/src/main/res/layout/activity_layout_obscure_aware.xml create mode 100644 presentation/src/main/res/layout/activity_licenses.xml create mode 100644 presentation/src/main/res/layout/activity_settings.xml create mode 100644 presentation/src/main/res/layout/content_create_vault.xml create mode 100644 presentation/src/main/res/layout/dialog_app_is_obscured_info.xml create mode 100644 presentation/src/main/res/layout/dialog_app_update.xml create mode 100644 presentation/src/main/res/layout/dialog_ask_for_http.xml create mode 100644 presentation/src/main/res/layout/dialog_beta_confirmation.xml create mode 100644 presentation/src/main/res/layout/dialog_biometric_auth_key_invalidated.xml create mode 100644 presentation/src/main/res/layout/dialog_bottom_sheet_add_vault.xml create mode 100644 presentation/src/main/res/layout/dialog_bottom_sheet_cloud_settings.xml create mode 100644 presentation/src/main/res/layout/dialog_bottom_sheet_file_settings.xml create mode 100644 presentation/src/main/res/layout/dialog_bottom_sheet_folder_settings.xml create mode 100644 presentation/src/main/res/layout/dialog_bottom_sheet_vault_action.xml create mode 100644 presentation/src/main/res/layout/dialog_bottom_sheet_vault_settings.xml create mode 100644 presentation/src/main/res/layout/dialog_change_password.xml create mode 100644 presentation/src/main/res/layout/dialog_confirm_delete_cloud_node.xml create mode 100644 presentation/src/main/res/layout/dialog_create_folder.xml create mode 100644 presentation/src/main/res/layout/dialog_debug_mode_disclaimer.xml create mode 100644 presentation/src/main/res/layout/dialog_delete_cloud_connection_with_vaults.xml create mode 100644 presentation/src/main/res/layout/dialog_disable_app_obscured_disclaimer.xml create mode 100644 presentation/src/main/res/layout/dialog_disable_secure_screen_disclaimer.xml create mode 100644 presentation/src/main/res/layout/dialog_enter_license.xml create mode 100644 presentation/src/main/res/layout/dialog_enter_password.xml create mode 100644 presentation/src/main/res/layout/dialog_existing_file.xml create mode 100644 presentation/src/main/res/layout/dialog_file_name.xml create mode 100644 presentation/src/main/res/layout/dialog_file_type_not_supported.xml create mode 100644 presentation/src/main/res/layout/dialog_generic_progress.xml create mode 100644 presentation/src/main/res/layout/dialog_handle_ssl_certificate.xml create mode 100644 presentation/src/main/res/layout/dialog_license_confirmation.xml create mode 100644 presentation/src/main/res/layout/dialog_no_dir_file.xml create mode 100644 presentation/src/main/res/layout/dialog_no_screen_lock_set.xml create mode 100644 presentation/src/main/res/layout/dialog_rename.xml create mode 100644 presentation/src/main/res/layout/dialog_setup_biometric_auth_in_system.xml create mode 100644 presentation/src/main/res/layout/dialog_sym_link.xml create mode 100644 presentation/src/main/res/layout/dialog_upload_loading.xml create mode 100644 presentation/src/main/res/layout/dialog_vault_delete_confirmation.xml create mode 100644 presentation/src/main/res/layout/floating_action_button_layout.xml create mode 100644 presentation/src/main/res/layout/fragment_auto_upload_choose_vault.xml create mode 100644 presentation/src/main/res/layout/fragment_browse_cloud_connections.xml create mode 100644 presentation/src/main/res/layout/fragment_browse_files.xml create mode 100644 presentation/src/main/res/layout/fragment_choose_cloud_service.xml create mode 100644 presentation/src/main/res/layout/fragment_cloud_settings.xml create mode 100644 presentation/src/main/res/layout/fragment_empty_dir_file_info.xml create mode 100644 presentation/src/main/res/layout/fragment_image_preview.xml create mode 100644 presentation/src/main/res/layout/fragment_set_password.xml create mode 100644 presentation/src/main/res/layout/fragment_settings_biometric_auth.xml create mode 100644 presentation/src/main/res/layout/fragment_setup_webdav.xml create mode 100644 presentation/src/main/res/layout/fragment_shared_files.xml create mode 100644 presentation/src/main/res/layout/fragment_text_editor.xml create mode 100644 presentation/src/main/res/layout/fragment_vault_list.xml create mode 100644 presentation/src/main/res/layout/item_biometric_auth_vault.xml create mode 100644 presentation/src/main/res/layout/item_browse_cloud_model_connections.xml create mode 100644 presentation/src/main/res/layout/item_browse_files_node.xml create mode 100644 presentation/src/main/res/layout/item_cloud.xml create mode 100644 presentation/src/main/res/layout/item_cloud_setting.xml create mode 100644 presentation/src/main/res/layout/item_shareable_location.xml create mode 100644 presentation/src/main/res/layout/item_shared_files.xml create mode 100644 presentation/src/main/res/layout/item_vault.xml create mode 100644 presentation/src/main/res/layout/recycler_view_layout.xml create mode 100644 presentation/src/main/res/layout/toolbar_layout.xml create mode 100644 presentation/src/main/res/layout/view_browses_files_extra_text_and_button.xml create mode 100644 presentation/src/main/res/layout/view_cloud_connection_content.xml create mode 100644 presentation/src/main/res/layout/view_cloud_file_content.xml create mode 100644 presentation/src/main/res/layout/view_cloud_file_progress.xml create mode 100644 presentation/src/main/res/layout/view_cloud_folder_content.xml create mode 100644 presentation/src/main/res/layout/view_default_local_cloud.xml create mode 100644 presentation/src/main/res/layout/view_dialog_error.xml create mode 100644 presentation/src/main/res/layout/view_dialog_intermediate_progress.xml create mode 100644 presentation/src/main/res/layout/view_dialog_progress.xml create mode 100644 presentation/src/main/res/layout/view_empty_cloud_connections.xml create mode 100644 presentation/src/main/res/layout/view_empty_folder.xml create mode 100644 presentation/src/main/res/layout/view_password_strength_indicator.xml create mode 100644 presentation/src/main/res/layout/view_receive_save_button.xml create mode 100644 presentation/src/main/res/layout/view_retry.xml create mode 100644 presentation/src/main/res/layout/view_vault_creation_hint.xml create mode 100644 presentation/src/main/res/menu/menu_cloud_services.xml create mode 100644 presentation/src/main/res/menu/menu_file_browser.xml create mode 100644 presentation/src/main/res/menu/menu_file_browser_select_folder.xml create mode 100644 presentation/src/main/res/menu/menu_file_browser_selection_mode.xml create mode 100644 presentation/src/main/res/menu/menu_text_editor.xml create mode 100644 presentation/src/main/res/menu/menu_vault_list.xml create mode 100644 presentation/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 presentation/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 presentation/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 presentation/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 presentation/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename {res => presentation/src/main/res}/values-de/strings.xml (94%) rename {res => presentation/src/main/res}/values-es/strings.xml (93%) rename {res => presentation/src/main/res}/values-fr/strings.xml (94%) create mode 100644 presentation/src/main/res/values-night/colors.xml create mode 100644 presentation/src/main/res/values-night/styles.xml create mode 100644 presentation/src/main/res/values-sw360dp-v13/values-preference.xml rename {res => presentation/src/main/res}/values-tr/strings.xml (94%) create mode 100644 presentation/src/main/res/values-v21/styles.xml create mode 100644 presentation/src/main/res/values-w820dp/dimens.xml create mode 100644 presentation/src/main/res/values/arrays.xml create mode 100644 presentation/src/main/res/values/colors.xml create mode 100644 presentation/src/main/res/values/dimens.xml rename {res => presentation/src/main/res}/values/strings.xml (94%) create mode 100644 presentation/src/main/res/values/styles.xml create mode 100644 presentation/src/main/res/xml/file_paths.xml create mode 100644 presentation/src/main/res/xml/licenses.xml create mode 100644 presentation/src/main/res/xml/preferences.xml create mode 100644 presentation/src/test/java/org/cryptomator/presentation/SvgValidationTest.java create mode 100644 presentation/src/test/java/org/cryptomator/presentation/logging/LogfilesTest.java create mode 100644 presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java create mode 100644 presentation/src/test/java/org/cryptomator/presentation/util/FileNameValidatorTest.java create mode 100644 secrets.properties create mode 100644 settings.gradle create mode 160000 subsampling-scale-image-view create mode 100644 util/.gitignore create mode 100644 util/build.gradle create mode 100644 util/src/androidTest/java/org/cryptomator/util/CredentialCryptorTest.java create mode 100644 util/src/androidTest/java/org/cryptomator/util/KeyStoreBuilderTest.java create mode 100644 util/src/androidTest/java/org/cryptomator/util/SharedPreferencesHandlerTest.java create mode 100644 util/src/main/AndroidManifest.xml create mode 100644 util/src/main/java/org/cryptomator/util/ByteArrayUtils.kt create mode 100644 util/src/main/java/org/cryptomator/util/Comparators.kt create mode 100644 util/src/main/java/org/cryptomator/util/Consumer.java create mode 100644 util/src/main/java/org/cryptomator/util/Encodings.kt create mode 100644 util/src/main/java/org/cryptomator/util/ExceptionUtil.java create mode 100644 util/src/main/java/org/cryptomator/util/Function.java create mode 100644 util/src/main/java/org/cryptomator/util/LockTimeout.kt create mode 100644 util/src/main/java/org/cryptomator/util/NoOpActivityLifecycleCallbacks.java create mode 100644 util/src/main/java/org/cryptomator/util/Optional.java create mode 100644 util/src/main/java/org/cryptomator/util/Predicate.java create mode 100644 util/src/main/java/org/cryptomator/util/Predicates.java create mode 100644 util/src/main/java/org/cryptomator/util/SharedPreferencesHandler.kt create mode 100644 util/src/main/java/org/cryptomator/util/Supplier.java create mode 100644 util/src/main/java/org/cryptomator/util/concurrent/CompletableFuture.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/BiometricAuthCryptor.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/Cipher.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/CipherFromApi23.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/CredentialCryptor.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/CryptoOperations.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/CryptoOperationsFactory.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/CryptoOperationsFromApi23.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/KeyGenerator.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/KeyStoreBuilder.java create mode 100644 util/src/main/java/org/cryptomator/util/crypto/UnrecoverableStorageKeyException.java create mode 100644 util/src/main/java/org/cryptomator/util/file/FileCacheUtils.kt create mode 100644 util/src/main/java/org/cryptomator/util/file/LruFileCacheUtil.kt create mode 100644 util/src/main/java/org/cryptomator/util/file/MimeType.kt create mode 100644 util/src/main/java/org/cryptomator/util/file/MimeTypeMap.kt create mode 100644 util/src/main/java/org/cryptomator/util/file/MimeTypes.kt create mode 100644 util/src/test/java/org/cryptomator/util/ByteArrayUtilsTest.java create mode 100644 util/src/test/java/org/cryptomator/util/concurrent/CompletableFutureTest.java create mode 100644 util/src/test/java/org/cryptomator/util/file/MimeTypeTest.java create mode 100644 util/src/test/java/org/cryptomator/util/file/MimeTypesTest.java create mode 100644 util/src/test/java/org/cryptomator/util/matchers/ByteArrayMatchers.java create mode 100644 util/src/test/java/org/cryptomator/util/matchers/MimeTypeMatchers.java create mode 100644 util/src/test/java/org/cryptomator/util/matchers/OptionalMatchers.java create mode 100644 util/src/test/resources/keystorehelper-test/certificate.X509Certificate.pem create mode 100644 util/src/test/resources/keystorehelper-test/certificate.pem diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..f78d664ae --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + [push] + +jobs: + build: + name: Test + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]')" + steps: + - uses: actions/checkout@v2 + with: + submodules: true + fetch-depth: 0 + - uses: actions/setup-java@v1 + with: + java-version: 1.8 + - name: Build and Test + run: bash ./gradlew clean test --stacktrace diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..804275077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +secrets.properties + +###IntelliJ### + +*.iml +*.ipr +*.iws +.idea/ + +###Android### + +# Built application files +*.apk +*.aab +*.ap_ + +# Java class files +*.class + +# Generated files +bin/ +gen/ +**/**/debug/output.json +**/**/release/output.json + +# Gradle files +.gradle/ +build/ +**/release/output-metadata.json +**/debug/output-metadata.json + +# Local configuration file (sdk path, etc) +local.properties + +# fastlane +secret_key_file.json +**/**/fastlane/fastlane/** +**/**/fastlane/metadata/** +**/**/fastlane/report.xml +**/**/fastlane/mappings/** +**/**/fastlane/release_notes/** +**/**/fastlane/latest_versions/** +.env.default diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..32f481675 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "msa-auth-for-android"] + path = msa-auth-for-android + url = https://github.com/SailReal/msa-auth-for-android.git +[submodule "subsampling-scale-image-view"] + path = subsampling-scale-image-view + url = https://github.com/SailReal/subsampling-scale-image-view.git diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..20d40b6bc --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md index 50ef2e072..24d0ac6da 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,31 @@ [![Community](https://img.shields.io/badge/help-Community-orange.svg)](https://community.cryptomator.org) [![Documentation](https://img.shields.io/badge/help-Docs-orange.svg)](https://docs.cryptomator.org) -Cryptomator for Android is available on Google play: [Download Cryptomator for Android](https://play.google.com/store/apps/details?id=org.cryptomator) +Cryptomator offers multi-platform transparent client-side encryption of your files in the cloud. -## Open Core +Cryptomator for Android is currently available in the following distribution channels: -Cryptomator for Android is an _open core_ project. This repository is used for collecting issues regarding the Android app of Cryptomator. +1. [Using Google Play](https://play.google.com/store/apps/details?id=org.cryptomator) +2. [Using Cryptomator's Website](https://cryptomator.org/android/) +3. Building from source using Gradle (instructions below) -You can find the open source Java crypto library to access Cryptomator vaults at this repository: [cryptomator/cryptolib](https://github.com/cryptomator/cryptolib) +## Building -For more information on the security details visit [cryptomator.org](https://cryptomator.org/architecture/). +### Dependencies + +* Git +* JDK 8 +* Gradle + +### Run Git and Gradle + +``` +git submodule init && git submodule update // (not necessary if cloned using --recurse-submodules) +./gradlew assembleLicenseDebug +``` + +Before connecting to Onedrive or Dropbox you have to enter valid API keys in [secrets.properties](https://github.com/cryptomator/android/blob/master/secrets.properties). + +## License + +This project is dual-licensed under the GPLv3 for FOSS projects as well as a commercial license for independent software vendors and resellers. If you want to modify this application under different conditions, feel free to contact our support team. diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..87f09f0ce --- /dev/null +++ b/build.gradle @@ -0,0 +1,61 @@ +apply from: 'buildsystem/ci.gradle' +apply from: 'buildsystem/dependencies.gradle' +apply plugin: "com.vanniktech.android.junit.jacoco" + +buildscript { + ext.kotlin_version = '1.4.21' + repositories { + jcenter() + mavenCentral() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.1.1' + classpath 'org.greenrobot:greendao-gradle-plugin:3.3.0' + classpath 'com.fernandocejas.frodo:frodo-plugin:0.8.3' + classpath 'com.vanniktech:gradle-android-junit-jacoco-plugin:0.16.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "de.mannodermaus.gradle.plugins:android-junit5:1.7.0.0" + } +} + +def getVersionCode = { -> + try { + def branchName = new ByteArrayOutputStream() + exec { + commandLine 'git', 'rev-parse', '--abbrev-ref', 'HEAD' + standardOutput = branchName + } + def appBuild = new ByteArrayOutputStream() + exec { + commandLine 'git', 'rev-list', '--count', branchName.toString().trim() + standardOutput = appBuild + } + return Integer.parseInt(appBuild.toString().trim()) + 1958 // adding 1958 for legacy reasons + } + catch (ignored) { + return -1 + } +} + +allprojects { + ext { + androidApplicationId = 'org.cryptomator' + androidVersionCode = getVersionCode() + androidVersionName = '1.5.11-SNAPSHOT' + } + repositories { + mavenCentral() + maven { + url "https://maven.google.com" + } + flatDir { + dirs '../libs' + } + google() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/buildsystem/ci.gradle b/buildsystem/ci.gradle new file mode 100644 index 000000000..1800ee45c --- /dev/null +++ b/buildsystem/ci.gradle @@ -0,0 +1,13 @@ +def ciServer = 'TRAVIS' +def executingOnCI = "true" == System.getenv(ciServer) + +// Since for CI we always do full clean builds, we don't want to pre-dex +// See http://tools.android.com/tech-docs/new-build-system/tips +subprojects { + project.plugins.whenPluginAdded { plugin -> + if ('com.android.build.gradle.AppPlugin' == plugin.class.name || + 'com.android.build.gradle.LibraryPlugin' == plugin.class.name) { + project.android.dexOptions.preDexLibraries = !executingOnCI + } + } +} diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle new file mode 100644 index 000000000..2d941c031 --- /dev/null +++ b/buildsystem/dependencies.gradle @@ -0,0 +1,154 @@ +allprojects { + repositories { + jcenter() + } +} + +ext { + androidBuildToolsVersion = "29.0.2" + androidMinSdkVersion = 23 + androidTargetSdkVersion = 29 + androidCompileSdkVersion = 29 + + // android and java libs + androidVersion = '4.1.1.4' + multidexVersion = '2.0.1' + javaxAnnotationVersion = '1.0' + + // support lib + androidSupportAnnotationsVersion = '1.1.0' + androidSupportAppcompatVersion = '1.2.0' // check https://stackoverflow.com/questions/41025200/android-view-inflateexception-error-inflating-class-android-webkit-webview/57968071#57968071 !!!!!! + androidSupportDesignVersion = '1.2.1' + + // app frameworks and utilities + + rxJavaVersion = '2.2.20' + rxAndroidVersion = '2.1.1' + rxBindingVersion = '2.2.0' + + daggerVersion = '2.30.1' + + gsonVersion = '2.8.6' + + okHttpVersion = '4.9.0' + okHttpDigestVersion = '2.5' + + velocityVersion = '1.7' + + timberVersion = '4.7.1' + + zxcvbnVersion = '1.3.1' + + scaleImageViewVersion = '3.10.0' + + lruFileCacheVersion = '1.0' + + // KEEP IN SYNC WITH GENERATOR VERSION IN root build.gradle + greenDaoVersion = '3.3.0' + + // cloud provider libs + + // do not update to 1.4.0 until dropping minsdk 4.x + cryptolibVersion = '1.3.0' + + dropboxVersion = '3.1.5' + + googleApiServicesVersion = 'v3-rev197-1.25.0' + googlePlayServicesVersion = '19.0.0' + googleClientVersion = '1.31.1' + + msgraphVersion = '2.5.0' + msaAuthVersion = '0.10.0' + + commonsCodecVersion = '1.15' + + recyclerViewFastScrollVersion = '2.0.1' + + // testing dependencies + + jUnitVersion = '5.7.0' + jUnit4Version = '4.13.1' + assertJVersion = '1.7.1' + mockitoVersion = '3.6.28' + mockitoInlineVersion = '3.6.28' + hamcrestVersion = '1.3' + dexmakerVersion = '1.0' + espressoVersion = '3.3.0' + testingSupportLibVersion = '0.1' + runnerVersion = '1.3.0' + rulesVersion = '1.3.0' + contributionVersion = '3.3.0' + uiautomatorVersion = '2.2.0' + + androidxCoreVersion = '1.3.2' + androidxFragmentVersion = '1.2.5' + androidxViewpagerVersion = '1.0.0' + androidxSwiperefreshVersion = '1.1.0' + androidxPreferenceVersion = '1.0.0' // 1.1.0 and 1.1.2 does have a bug with the text size + androidxRecyclerViewVersion = '1.1.0' + androidxDocumentfileVersion = '1.0.1' + androidxBiometricVersion = '1.0.1' + androidxTestCoreVersion = '1.3.0' + + jsonWebTokenApiVersion = '0.11.2' + + dependencies = [ + android : "com.google.android:android:${androidVersion}", + androidAnnotations : "androidx.annotation:annotation:${androidSupportAnnotationsVersion}", + appcompat : "androidx.appcompat:appcompat:${androidSupportAppcompatVersion}", + androidxBiometric : "androidx.biometric:biometric:${androidxBiometricVersion}", + androidxCore : "androidx.core:core-ktx:${androidxCoreVersion}", + androidxFragment : "androidx.fragment:fragment-ktx:${androidxFragmentVersion}", + androidxViewpager : "androidx.viewpager:viewpager:${androidxViewpagerVersion}", + androidxSwiperefresh : "androidx.swiperefreshlayout:swiperefreshlayout:${androidxSwiperefreshVersion}", + androidxPreference : "androidx.preference:preference:${androidxPreferenceVersion}", + documentFile : "androidx.documentfile:documentfile:${androidxDocumentfileVersion}", + recyclerView : "androidx.recyclerview:recyclerview:${androidxRecyclerViewVersion}", + androidxTestCore : "androidx.test:core:${androidxTestCoreVersion}", + commonsCodec : "commons-codec:commons-codec:${commonsCodecVersion}", + cryptolib : "org.cryptomator:cryptolib:${cryptolibVersion}", + dagger : "com.google.dagger:dagger:${daggerVersion}", + daggerCompiler : "com.google.dagger:dagger-compiler:${daggerVersion}", + design : "com.google.android.material:material:${androidSupportDesignVersion}", + dropbox : "com.dropbox.core:dropbox-core-sdk:${dropboxVersion}", + espresso : "androidx.test.espresso:espresso-core:${espressoVersion}", + googleApiClientAndroid: "com.google.api-client:google-api-client-android:${googleClientVersion}", + googleApiServicesDrive: "com.google.apis:google-api-services-drive:${googleApiServicesVersion}", + googlePlayServicesAuth: "com.google.android.gms:play-services-auth:${googlePlayServicesVersion}", + greenDao : "org.greenrobot:greendao:${greenDaoVersion}", + gson : "com.google.code.gson:gson:${gsonVersion}", + hamcrest : "org.hamcrest:hamcrest-all:${hamcrestVersion}", + javaxAnnotation : "javax.annotation:jsr250-api:${javaxAnnotationVersion}", + junit : "org.junit.jupiter:junit-jupiter:${jUnitVersion}", + junitApi : "org.junit.jupiter:junit-jupiter-api:${jUnitVersion}", + junitEngine : "org.junit.jupiter:junit-jupiter-engine:${jUnitVersion}", + junitParams : "org.junit.jupiter:junit-jupiter-params:${jUnitVersion}", + junit4 : "org.junit.jupiter:junit-jupiter:${jUnit4Version}", + junit4Engine : "org.junit.vintage:junit-vintage-engine:${jUnitVersion}", + msgraph : "com.microsoft.graph:microsoft-graph:${msgraphVersion}", + msaAuth : "com.microsoft.graph:msa-auth-for-android-adapter:${msaAuthVersion}", + mockito : "org.mockito:mockito-core:${mockitoVersion}", + mockitoInline : "org.mockito:mockito-inline:${mockitoInlineVersion}", + multidex : "androidx.multidex:multidex:${multidexVersion}", + okHttp : "com.squareup.okhttp3:okhttp:${okHttpVersion}", + okHttpDigest : "com.burgstaller:okhttp-digest:${okHttpDigestVersion}", + recyclerViewFastScroll: "com.simplecityapps:recyclerview-fastscroll:${recyclerViewFastScrollVersion}", + rxJava : "io.reactivex.rxjava2:rxjava:${rxJavaVersion}", + rxAndroid : "io.reactivex.rxjava2:rxandroid:${rxAndroidVersion}", + rxBinding : "com.jakewharton.rxbinding2:rxbinding:${rxBindingVersion}", + testingSupportLib : "com.android.support.test:testing-support-lib:${testingSupportLibVersion}", + timber : "com.jakewharton.timber:timber:${timberVersion}", + velocity : "org.apache.velocity:velocity:${velocityVersion}", + runner : "androidx.test:runner:${runnerVersion}", + rules : "androidx.test:rules:${rulesVersion}", + contribution : "androidx.test.espresso:espresso-contrib:${contributionVersion}", + uiAutomator : "androidx.test.uiautomator:uiautomator:${uiautomatorVersion}", + zxcvbn : "com.nulab-inc:zxcvbn:${zxcvbnVersion}", + scaleImageView : "com.davemorrissey.labs:subsampling-scale-image-view:${scaleImageViewVersion}", + lruFileCache : "com.tomclaw.cache:cache:${lruFileCacheVersion}", + jsonWebTokenApi : "io.jsonwebtoken:jjwt-api:${jsonWebTokenApiVersion}", + jsonWebTokenImpl : "io.jsonwebtoken:jjwt-impl:${jsonWebTokenApiVersion}", + jsonWebTokenJson : "io.jsonwebtoken:jjwt-orgjson:${jsonWebTokenApiVersion}" + ] + +} diff --git a/data/.gitignore b/data/.gitignore new file mode 100755 index 000000000..796b96d1c --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +/build diff --git a/data/build.gradle b/data/build.gradle new file mode 100644 index 000000000..3608ab82e --- /dev/null +++ b/data/build.gradle @@ -0,0 +1,122 @@ +apply plugin: 'org.greenrobot.greendao' +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'de.mannodermaus.android-junit5' + +android { + defaultPublishConfig "debug" + + def globalConfiguration = rootProject.extensions.getByName("ext") + + compileSdkVersion globalConfiguration["androidCompileSdkVersion"] + buildToolsVersion globalConfiguration["androidBuildToolsVersion"] + + defaultConfig { + minSdkVersion globalConfiguration["androidMinSdkVersion"] + targetSdkVersion globalConfiguration["androidTargetSdkVersion"] + + buildConfigField 'int', 'VERSION_CODE', "${globalConfiguration["androidVersionCode"]}" + buildConfigField "String", "VERSION_NAME", "\"${globalConfiguration["androidVersionName"]}\"" + buildConfigField "String", "ONEDRIVE_API_KEY", "\"" + getApiKey('ONEDRIVE_API_KEY') + "\"" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + quiet true + abortOnError false + ignoreWarnings true + } + + flavorDimensions "version" + + productFlavors { + playstore { + dimension "version" + } + + license { + dimension "version" + } + } +} + +greendao { + schemaVersion 3 +} + +configurations.all { + // Check for updates every build (use for cryptolib snapshot) + //resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +dependencies { + def dependencies = rootProject.ext.dependencies + + implementation project(':domain') + implementation project(':util') + implementation project(':msa-auth-for-android') + + // cryptomator + implementation dependencies.cryptolib + + // greendao + api dependencies.greenDao + // dagger + annotationProcessor dependencies.daggerCompiler + implementation dependencies.dagger + // cloud + implementation dependencies.dropbox + implementation dependencies.googlePlayServicesAuth + implementation(dependencies.googleApiServicesDrive) { + exclude module: 'guava-jdk5' + exclude module: 'httpclient' + } + implementation(dependencies.googleApiClientAndroid) { + exclude module: 'guava-jdk5' + exclude module: 'httpclient' + } + implementation dependencies.msgraph + + // rest + implementation dependencies.rxJava + implementation dependencies.rxAndroid + implementation dependencies.okHttp + implementation dependencies.okHttpDigest + implementation dependencies.androidAnnotations + compileOnly dependencies.javaxAnnotation + implementation dependencies.gson + + implementation dependencies.commonsCodec + + implementation dependencies.documentFile + + implementation dependencies.lruFileCache + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + // test + testImplementation dependencies.junit + testImplementation dependencies.junitApi + testRuntimeOnly dependencies.junitEngine + testImplementation dependencies.junitParams + + testImplementation dependencies.junit4 + testRuntimeOnly dependencies.junit4Engine + + testImplementation dependencies.mockito + testImplementation dependencies.hamcrest +} + +configurations { + all*.exclude group: 'com.google.android', module: 'android' +} + +static def getApiKey(key) { + Properties props = new Properties() + props.load(new FileInputStream(new File('secrets.properties'))) + return props[key] +} diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml new file mode 100644 index 000000000..406049e36 --- /dev/null +++ b/data/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/data/src/main/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java b/data/src/main/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java new file mode 100644 index 000000000..955822abd --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/CloudContentRepositoryFactories.java @@ -0,0 +1,45 @@ +package org.cryptomator.data.cloud; + +import org.cryptomator.data.cloud.crypto.CryptoCloudContentRepositoryFactory; +import org.cryptomator.data.cloud.dropbox.DropboxCloudContentRepositoryFactory; +import org.cryptomator.data.cloud.googledrive.GoogleDriveCloudContentRepositoryFactory; +import org.cryptomator.data.cloud.local.LocalStorageContentRepositoryFactory; +import org.cryptomator.data.cloud.onedrive.OnedriveCloudContentRepositoryFactory; +import org.cryptomator.data.cloud.webdav.WebDavCloudContentRepositoryFactory; +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.jetbrains.annotations.NotNull; + +import java.util.Iterator; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static java.util.Arrays.asList; + +@Singleton +public class CloudContentRepositoryFactories implements Iterable { + + private final Iterable factories; + + @Inject + public CloudContentRepositoryFactories(DropboxCloudContentRepositoryFactory dropboxFactory, // + GoogleDriveCloudContentRepositoryFactory googleDriveFactory, // + OnedriveCloudContentRepositoryFactory oneDriveFactory, // + CryptoCloudContentRepositoryFactory cryptoFactory, // + LocalStorageContentRepositoryFactory localStorageFactory, // + WebDavCloudContentRepositoryFactory webDavFactory) { + + factories = asList(dropboxFactory, // + googleDriveFactory, // + oneDriveFactory, // + cryptoFactory, // + localStorageFactory, // + webDavFactory); + } + + @NotNull + @Override + public Iterator iterator() { + return factories.iterator(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/InterceptingCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/InterceptingCloudContentRepository.java new file mode 100644 index 000000000..6fc502d75 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/InterceptingCloudContentRepository.java @@ -0,0 +1,224 @@ +package org.cryptomator.data.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +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.OutputStream; +import java.util.List; + +public abstract class InterceptingCloudContentRepository + implements CloudContentRepository { + + private final CloudContentRepository delegate; + + protected InterceptingCloudContentRepository(CloudContentRepository delegate) { + this.delegate = delegate; + } + + protected abstract void throwWrappedIfRequired(Exception e) throws BackendException; + + @Override + public DirType root(CloudType cloud) throws BackendException { + try { + return delegate.root(cloud); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public DirType resolve(CloudType cloud, String path) throws BackendException { + try { + return delegate.resolve(cloud, path); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public FileType file(DirType parent, String name) throws BackendException { + try { + return delegate.file(parent, name); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public FileType file(DirType parent, String name, Optional size) throws BackendException { + try { + return delegate.file(parent, name, size); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public DirType folder(DirType parent, String name) throws BackendException { + try { + return delegate.folder(parent, name); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public boolean exists(NodeType node) throws BackendException { + try { + return delegate.exists(node); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public List list(DirType folder) throws BackendException { + try { + return delegate.list(folder); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public DirType create(DirType folder) throws BackendException { + try { + return delegate.create(folder); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public DirType move(DirType source, DirType target) throws BackendException { + try { + return delegate.move(source, target); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public FileType move(FileType source, FileType target) throws BackendException { + try { + return delegate.move(source, target); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public FileType write(FileType file, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return delegate.write(file, data, progressAware, replace, size); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public void read(FileType file, Optional encryptedTmpFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + delegate.read(file, encryptedTmpFile, data, progressAware); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public void delete(NodeType node) throws BackendException { + try { + delegate.delete(node); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(CloudType cloud) throws BackendException { + try { + return delegate.checkAuthenticationAndRetrieveCurrentAccount(cloud); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } + + @Override + public void logout(CloudType cloud) throws BackendException { + try { + delegate.logout(cloud); + } catch (BackendException e) { + throwWrappedIfRequired(e); + throw e; + } catch (RuntimeException e) { + throwWrappedIfRequired(e); + throw e; + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/BackupFileIdSuffixGenerator.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/BackupFileIdSuffixGenerator.java new file mode 100644 index 000000000..7a5017c96 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/BackupFileIdSuffixGenerator.java @@ -0,0 +1,28 @@ +package org.cryptomator.data.cloud.crypto; + +import com.google.common.io.BaseEncoding; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Utility class for generating a suffix for the backup file to make it unique to its original master key file. + */ +class BackupFileIdSuffixGenerator { + + /** + * Computes the SHA-256 digest of the given byte array and returns a file suffix containing the first 4 bytes in hex string format. + * + * @param fileBytes the input byte for which the digest is computed + * @return "." + first 4 bytes of SHA-256 digest in hex string format + */ + static String generate(byte[] fileBytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(fileBytes); + return "." + BaseEncoding.base16().encode(digest, 0, 4); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every Java Platform must support the Message Digest algorithm SHA-256", e); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloud.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloud.java new file mode 100644 index 000000000..102b62659 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloud.java @@ -0,0 +1,71 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.Vault; + +public class CryptoCloud implements Cloud { + + private final Vault vault; + + CryptoCloud(Vault vault) { + this.vault = vault; + } + + @Override + public Long id() { + return null; + } + + @Override + public CloudType type() { + return CloudType.CRYPTO; + } + + @Override + public boolean configurationMatches(Cloud cloud) { + return cloud instanceof CryptoCloud && configurationMatches((CryptoCloud) cloud); + } + + private boolean configurationMatches(CryptoCloud cloud) { + return vault.equals(cloud.vault); + } + + @Override + public boolean predefined() { + return false; + } + + @Override + public boolean persistent() { + return false; + } + + @Override + public boolean requiresNetwork() { + return false; + } + + public Vault getVault() { + return vault; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((CryptoCloud) obj); + } + + @Override + public int hashCode() { + return vault.hashCode(); + } + + private boolean internalEquals(CryptoCloud obj) { + return vault != null && vault.equals(obj.vault); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.java new file mode 100644 index 000000000..597a80b86 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepository.java @@ -0,0 +1,135 @@ +package org.cryptomator.data.cloud.crypto; + +import static java.lang.String.format; + +import java.io.File; +import java.io.OutputStream; +import java.util.List; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +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 org.cryptomator.util.Supplier; + +import android.content.Context; + +class CryptoCloudContentRepository implements CloudContentRepository { + + private final CryptoImplDecorator cryptoImpl; + + CryptoCloudContentRepository(Context context, CloudContentRepository cloudContentRepository, CryptoCloud cloud, Supplier cryptor) { + CloudFolder vaultLocation; + try { + vaultLocation = cloudContentRepository.resolve(cloud.getVault().getCloud(), cloud.getVault().getPath()); + } catch (BackendException e) { + throw new FatalBackendException(e); + } + + switch (cloud.getVault().getVersion()) { + case 7: + this.cryptoImpl = new CryptoImplVaultFormat7(context, cryptor, cloudContentRepository, vaultLocation, new DirIdCacheFormat7()); + break; + case 6: + case 5: + this.cryptoImpl = new CryptoImplVaultFormatPre7(context, cryptor, cloudContentRepository, vaultLocation, new DirIdCacheFormatPre7()); + break; + default: + throw new IllegalStateException(format("No CryptoImpl for vault version %d.", cloud.getVault().getVersion())); + } + } + + @Override + public synchronized CryptoFolder root(CryptoCloud cloud) throws BackendException { + return cryptoImpl.root(cloud); + } + + @Override + public CryptoFolder resolve(CryptoCloud cloud, String path) throws BackendException { + return cryptoImpl.resolve(cloud, path); + } + + @Override + public CryptoFile file(CryptoFolder parent, String name) throws BackendException { + return cryptoImpl.file(parent, name); + } + + @Override + public CryptoFile file(CryptoFolder parent, String name, Optional size) throws BackendException { + return cryptoImpl.file(parent, name, size); + } + + @Override + public CryptoFolder folder(CryptoFolder parent, String name) throws BackendException { + return cryptoImpl.folder(parent, name); + } + + @Override + public boolean exists(CryptoNode node) throws BackendException { + return cryptoImpl.exists(node); + } + + @Override + public List list(CryptoFolder folder) throws BackendException { + return cryptoImpl.list(folder); + } + + @Override + public CryptoFolder create(CryptoFolder folder) throws BackendException { + try { + return cryptoImpl.create(folder); + } catch (CloudNodeAlreadyExistsException e) { + throw new CloudNodeAlreadyExistsException(folder.getName()); + } + } + + @Override + public CryptoFolder move(CryptoFolder source, CryptoFolder target) throws BackendException { + try { + return cryptoImpl.move(source, target); + } catch (CloudNodeAlreadyExistsException e) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + } + + @Override + public CryptoFile move(CryptoFile source, CryptoFile target) throws BackendException { + try { + return cryptoImpl.move(source, target); + } catch (CloudNodeAlreadyExistsException e) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + } + + @Override + public CryptoFile write(CryptoFile file, DataSource data, ProgressAware progressAware, boolean replace, long length) throws BackendException { + return cryptoImpl.write(file, data, progressAware, replace, length); + } + + @Override + public void read(CryptoFile file, Optional tmpEncryptedFile, OutputStream data, ProgressAware progressAware) throws BackendException { + cryptoImpl.read(file, data, progressAware); + } + + @Override + public void delete(CryptoNode node) throws BackendException { + cryptoImpl.delete(node); + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(CryptoCloud cloud) throws BackendException { + return cryptoImpl.currentAccount(cloud); + } + + @Override + public void logout(CryptoCloud cloud) throws BackendException { + // empty + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepositoryFactory.java new file mode 100644 index 000000000..a4f29c31a --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudContentRepositoryFactory.java @@ -0,0 +1,80 @@ +package org.cryptomator.data.cloud.crypto; + +import android.content.Context; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.MissingCryptorException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.util.Optional; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import dagger.Lazy; + +import static java.lang.String.format; +import static org.cryptomator.domain.CloudType.CRYPTO; + +@Singleton +public class CryptoCloudContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final Lazy cloudContentRepository; + private final Cryptors cryptors; + private final Context context; + + @Inject + public CryptoCloudContentRepositoryFactory(Lazy cloudContentRepository, Cryptors cryptors, Context context) { + this.cloudContentRepository = cloudContentRepository; + this.cryptors = cryptors; + this.context = context; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == CRYPTO; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + CryptoCloud cryptoCloud = (CryptoCloud) cloud; + Vault vault = cryptoCloud.getVault(); + return new CryptoCloudContentRepository(context, cloudContentRepository.get(), cryptoCloud, cryptors.get(vault)); + } + + public void deregisterCryptor(Vault vault) { + deregisterCryptor(vault, true); + } + + public void deregisterCryptor(Vault vault, boolean assertPresent) { + Optional cryptor = cryptors.remove(vault); + if (cryptor.isAbsent()) { + if (assertPresent) { + throw new IllegalStateException(format("No cryptor registered for vault %s", vault)); + } + } else { + cryptor.get().destroy(); + } + } + + public boolean cryptorIsRegisteredFor(Vault vault) { + try { + assertCryptorRegisteredFor(vault); + return true; + } catch (MissingCryptorException e) { + return false; + } + } + + public void assertCryptorRegisteredFor(Vault vault) throws MissingCryptorException { + cryptors.get(vault).get(); + } + + void registerCryptor(Vault vault, Cryptor cryptor) { + if (!cryptors.putIfAbsent(vault, cryptor)) { + throw new IllegalStateException(format("Cryptor already registered for vault %s", vault)); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudFactory.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudFactory.java new file mode 100644 index 000000000..9f211a491 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoCloudFactory.java @@ -0,0 +1,216 @@ +package org.cryptomator.data.cloud.crypto; + +import static android.R.attr.version; +import static java.text.Normalizer.normalize; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.DATA_DIR_NAME; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.MASTERKEY_BACKUP_FILE_EXT; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.MASTERKEY_FILE_NAME; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.MAX_VAULT_VERSION; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.MIN_VAULT_VERSION; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.ROOT_DIR_ID; +import static org.cryptomator.data.cloud.crypto.CryptoConstants.VERSION_WITH_NORMALIZED_PASSWORDS; +import static org.cryptomator.domain.Vault.aCopyOf; +import static org.cryptomator.domain.usecases.ProgressAware.NO_OP_PROGRESS_AWARE; + +import java.io.ByteArrayOutputStream; +import java.text.Normalizer; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.cryptomator.cryptolib.Cryptors; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.InvalidPassphraseException; +import org.cryptomator.cryptolib.api.KeyFile; +import org.cryptomator.cryptolib.api.UnsupportedVaultFormatException; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.cloud.ByteArrayDataSource; +import org.cryptomator.domain.usecases.vault.UnlockToken; +import org.cryptomator.util.Optional; + +@Singleton +public class CryptoCloudFactory { + + private final CryptorProvider cryptorProvider; + private final CloudContentRepository cloudContentRepository; + private final CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory; + + @Inject + public CryptoCloudFactory( // + CloudContentRepository cloudContentRepository, // + CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory, // + CryptorProvider cryptorProvider) { + this.cryptorProvider = cryptorProvider; + this.cloudContentRepository = cloudContentRepository; + this.cryptoCloudContentRepositoryFactory = cryptoCloudContentRepositoryFactory; + } + + public void create(CloudFolder location, CharSequence password) throws BackendException { + Cryptor cryptor = cryptorProvider.createNew(); + try { + KeyFile keyFile = cryptor.writeKeysToMasterkeyFile(normalizePassword(password, version), MAX_VAULT_VERSION); + writeKeyFile(location, keyFile); + createRootFolder(location, cryptor); + } finally { + cryptor.destroy(); + } + } + + private void createRootFolder(CloudFolder location, Cryptor cryptor) throws BackendException { + CloudFolder dFolder = cloudContentRepository.folder(location, DATA_DIR_NAME); + dFolder = cloudContentRepository.create(dFolder); + String rootDirHash = cryptor.fileNameCryptor().hashDirectoryId(ROOT_DIR_ID); + CloudFolder lvl1Folder = cloudContentRepository.folder(dFolder, rootDirHash.substring(0, 2)); + lvl1Folder = cloudContentRepository.create(lvl1Folder); + CloudFolder lvl2Folder = cloudContentRepository.folder(lvl1Folder, rootDirHash.substring(2)); + cloudContentRepository.create(lvl2Folder); + } + + public Cloud decryptedViewOf(Vault vault) throws BackendException { + return new CryptoCloud(aCopyOf(vault).build()); + } + + public Vault unlock(Vault vault, CharSequence password) throws BackendException { + return unlock(createUnlockToken(vault), password); + } + + public Vault unlock(UnlockToken token, CharSequence password) throws BackendException { + UnlockTokenImpl impl = (UnlockTokenImpl) token; + Cryptor cryptor = cryptorFor(impl.getKeyFile(), password); + cryptoCloudContentRepositoryFactory.registerCryptor(impl.getVault(), cryptor); + + return aCopyOf(token.getVault()) // + .withVersion(impl.getKeyFile().getVersion()) // + .build(); + } + + public UnlockTokenImpl createUnlockToken(Vault vault) throws BackendException { + CloudFolder vaultLocation = cloudContentRepository.resolve(vault.getCloud(), vault.getPath()); + return createUnlockToken(vault, vaultLocation); + } + + private UnlockTokenImpl createUnlockToken(Vault vault, CloudFolder location) throws BackendException { + byte[] keyFileData = readKeyFileData(location); + UnlockTokenImpl unlockToken = new UnlockTokenImpl(vault, keyFileData); + assertVaultVersionIsSupported(unlockToken.getKeyFile().getVersion()); + return unlockToken; + } + + private Cryptor cryptorFor(KeyFile keyFile, CharSequence password) { + return cryptorProvider.createFromKeyFile(keyFile, normalizePassword(password, keyFile.getVersion()), keyFile.getVersion()); + } + + private CloudFolder vaultLocation(Vault vault) throws BackendException { + return cloudContentRepository.resolve(vault.getCloud(), vault.getPath()); + } + + public boolean isVaultPasswordValid(Vault vault, CharSequence password) throws BackendException { + try { + // create a cryptor, which checks the password, then destroy it immediately + cryptorFor(createUnlockToken(vault).getKeyFile(), password).destroy(); + return true; + } catch (InvalidPassphraseException e) { + return false; + } + } + + public void lock(Vault vault) { + cryptoCloudContentRepositoryFactory.deregisterCryptor(vault); + } + + private void assertVaultVersionIsSupported(int version) { + if (version < MIN_VAULT_VERSION) { + throw new UnsupportedVaultFormatException(version, MIN_VAULT_VERSION); + } else if (version > MAX_VAULT_VERSION) { + throw new UnsupportedVaultFormatException(version, MAX_VAULT_VERSION); + } + } + + private void writeKeyFile(CloudFolder location, KeyFile keyFile) throws BackendException { + byte[] data = keyFile.serialize(); + cloudContentRepository.write(masterkeyFile(location), ByteArrayDataSource.from(data), NO_OP_PROGRESS_AWARE, false, data.length); + } + + private byte[] readKeyFileData(CloudFolder location) throws BackendException { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + cloudContentRepository.read(masterkeyFile(location), Optional.empty(), data, NO_OP_PROGRESS_AWARE); + return data.toByteArray(); + } + + private CloudFile masterkeyFile(CloudFolder location) throws BackendException { + return cloudContentRepository.file(location, MASTERKEY_FILE_NAME); + } + + private CloudFile masterkeyBackupFile(CloudFolder location, byte[] data) throws BackendException { + String fileName = MASTERKEY_FILE_NAME + BackupFileIdSuffixGenerator.generate(data) + MASTERKEY_BACKUP_FILE_EXT; + return cloudContentRepository.file(location, fileName); + } + + public void changePassword(Vault vault, String oldPassword, String newPassword) throws BackendException { + CloudFolder vaultLocation = vaultLocation(vault); + ByteArrayOutputStream dataOutputStream = new ByteArrayOutputStream(); + cloudContentRepository.read(masterkeyFile(vaultLocation), Optional.empty(), dataOutputStream, NO_OP_PROGRESS_AWARE); + + byte[] data = dataOutputStream.toByteArray(); + int vaultVersion = KeyFile.parse(data).getVersion(); + + createBackupMasterKeyFile(data, vaultLocation); + createNewMasterKeyFile(data, vaultVersion, oldPassword, newPassword, vaultLocation); + } + + private static class UnlockTokenImpl implements UnlockToken { + + private final Vault vault; + private final byte[] keyFileData; + + private UnlockTokenImpl(Vault vault, byte[] keyFileData) { + this.vault = vault; + this.keyFileData = keyFileData; + } + + @Override + public Vault getVault() { + return vault; + } + + public KeyFile getKeyFile() { + return KeyFile.parse(keyFileData); + } + } + + private void createBackupMasterKeyFile(byte[] data, CloudFolder vaultLocation) throws BackendException { + cloudContentRepository.write( // + masterkeyBackupFile(vaultLocation, data), // + ByteArrayDataSource.from(data), // + NO_OP_PROGRESS_AWARE, // + true, // + data.length); + } + + private void createNewMasterKeyFile(byte[] data, int vaultVersion, String oldPassword, String newPassword, CloudFolder vaultLocation) throws BackendException { + byte[] newMasterKeyFile = Cryptors.changePassphrase(cryptorProvider, // + data, // + normalizePassword(oldPassword, vaultVersion), // + normalizePassword(newPassword, vaultVersion)); + cloudContentRepository.write(masterkeyFile(vaultLocation), // + ByteArrayDataSource.from(newMasterKeyFile), // + NO_OP_PROGRESS_AWARE, // + true, // + newMasterKeyFile.length); + } + + private CharSequence normalizePassword(CharSequence password, int vaultVersion) { + if (vaultVersion >= VERSION_WITH_NORMALIZED_PASSWORDS) { + return normalize(password, Normalizer.Form.NFC); + } else { + return password; + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoConstants.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoConstants.java new file mode 100644 index 000000000..45bfabedb --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoConstants.java @@ -0,0 +1,14 @@ +package org.cryptomator.data.cloud.crypto; + +class CryptoConstants { + + static final String ROOT_DIR_ID = ""; + static final String DATA_DIR_NAME = "d"; + static final String MASTERKEY_FILE_NAME = "masterkey.cryptomator"; + static final String MASTERKEY_BACKUP_FILE_EXT = ".bkup"; + + static final int MAX_VAULT_VERSION = 7; + static final int VERSION_WITH_NORMALIZED_PASSWORDS = 6; + static final int MIN_VAULT_VERSION = 5; + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.java new file mode 100644 index 000000000..d280b687c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFile.java @@ -0,0 +1,80 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class CryptoFile implements CloudFile, CryptoNode { + + private final String name; + private final String path; + private final Optional size; + private final CloudFile cloudFile; + private final CryptoFolder parent; + + public CryptoFile(CryptoFolder parent, String name, String path, Optional size, CloudFile cloudFile) { + this.parent = parent; + this.name = name; + this.path = path; + this.size = size; + this.cloudFile = cloudFile; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public CryptoFolder getParent() { + return parent; + } + + @Override + public Optional getSize() { + return size; + } + + @Override + public Optional getModified() { + return cloudFile.getModified(); + } + + /** + * @return The actual file in the underlying, i.e. decorated, CloudContentRepository + */ + CloudFile getCloudFile() { + return cloudFile; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((CryptoFile) obj); + } + + private boolean internalEquals(CryptoFile obj) { + return path != null && path.equals(obj.path); + } + + @Override + public int hashCode() { + return path == null ? 0 : path.hashCode(); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFolder.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFolder.java new file mode 100644 index 000000000..9cdb6858f --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoFolder.java @@ -0,0 +1,70 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; + +class CryptoFolder implements CloudFolder, CryptoNode { + + private final String name; + private final String path; + private final CryptoFolder parent; + private final CloudFile dirFile; + + CryptoFolder(CryptoFolder parent, String name, String path, CloudFile dirFile) { + this.parent = parent; + this.name = name; + this.path = path; + this.dirFile = dirFile; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public CryptoFolder getParent() { + return parent; + } + + /** + * @return the file containing the directory id, in the underlying, i.e. decorated, CloudContentRepository + */ + CloudFile getDirFile() { + return dirFile; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((CryptoFolder) obj); + } + + private boolean internalEquals(CryptoFolder obj) { + return path != null && path.equals(obj.path); + } + + @Override + public int hashCode() { + return path == null ? 0 : path.hashCode(); + } + + @Override + public CryptoFolder withCloud(Cloud cloud) { + return new CryptoFolder(parent.withCloud(cloud), name, path, dirFile); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.java new file mode 100644 index 000000000..def3a346d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplDecorator.java @@ -0,0 +1,424 @@ +package org.cryptomator.data.cloud.crypto; + +import android.content.Context; + +import org.cryptomator.cryptolib.Cryptors; +import org.cryptomator.cryptolib.DecryptingReadableByteChannel; +import org.cryptomator.cryptolib.EncryptingWritableByteChannel; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.data.cloud.crypto.DirIdCache.DirIdInfo; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.EmptyDirFileException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoDirFileException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.DownloadFileReplacingProgressAware; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.UploadFileReplacingProgressAware; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.domain.usecases.cloud.DownloadState; +import org.cryptomator.domain.usecases.cloud.FileBasedDataSource; +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.util.Optional; +import org.cryptomator.util.Supplier; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.UUID; + +import static org.cryptomator.data.cloud.crypto.CryptoConstants.DATA_DIR_NAME; +import static org.cryptomator.domain.usecases.ProgressAware.NO_OP_PROGRESS_AWARE; +import static org.cryptomator.domain.usecases.cloud.Progress.progress; + +abstract class CryptoImplDecorator { + + final CloudContentRepository cloudContentRepository; + final Context context; + final DirIdCache dirIdCache; + + private final Supplier cryptor; + private final CloudFolder storageLocation; + + private RootCryptoFolder root; + + CryptoImplDecorator(Context context, Supplier cryptor, CloudContentRepository cloudContentRepository, CloudFolder storageLocation, DirIdCache dirIdCache) { + this.context = context; + this.cryptor = cryptor; + this.cloudContentRepository = cloudContentRepository; + this.storageLocation = storageLocation; + this.dirIdCache = dirIdCache; + } + + abstract CryptoFolder folder(CryptoFolder cryptoParent, String cleartextName) throws BackendException; + + abstract String decryptName(String dirId, String encryptedName); + + abstract String encryptName(CryptoFolder cryptoParent, String name) throws BackendException; + + abstract Optional extractEncryptedName(String ciphertextName); + + abstract List list(CryptoFolder cryptoFolder) throws BackendException; + + abstract String encryptFolderName(CryptoFolder cryptoFolder, String name) throws BackendException; + + abstract CryptoSymlink symlink(CryptoFolder cryptoParent, String cleartextName, String target) throws BackendException; + + abstract CryptoFolder create(CryptoFolder folder) throws BackendException; + + abstract CryptoFolder move(CryptoFolder source, CryptoFolder target) throws BackendException; + + abstract CryptoFile move(CryptoFile source, CryptoFile target) throws BackendException; + + abstract void delete(CloudNode node) throws BackendException; + + abstract CryptoFile write(CryptoFile cryptoFile, DataSource data, ProgressAware progressAware, boolean replace, long length) throws BackendException; + + abstract String loadDirId(CryptoFolder folder) throws BackendException, EmptyDirFileException; + + abstract DirIdInfo createDirIdInfo(CryptoFolder folder) throws BackendException; + + private String dirHash(String directoryId) { + return cryptor().fileNameCryptor().hashDirectoryId(directoryId); + } + + private CloudFolder dataFolder() throws BackendException { + return cloudContentRepository.folder(storageLocation, DATA_DIR_NAME); + } + + String path(CloudFolder base, String name) { + return base.getPath() + "/" + name; + } + + File getInternalCache() { + return context.getCacheDir(); + } + + List deepCollectSubfolders(CryptoFolder source) throws BackendException { + Queue queue = new LinkedList<>(); + queue.add(source); + + List result = new LinkedList<>(); + while (!queue.isEmpty()) { + CryptoFolder folder = queue.remove(); + List subfolders = shallowCollectSubfolders(folder); + queue.addAll(subfolders); + result.addAll(subfolders); + } + + Collections.reverse(result); + + return result; + } + + private List shallowCollectSubfolders(CryptoFolder source) throws BackendException { + List result = new LinkedList<>(); + + try { + List list = list(source); + for (CloudNode node : list) { + if (node instanceof CryptoFolder) { + result.add((CryptoFolder) node); + } + } + } catch (NoDirFileException e) { + // Ignoring because nothing can be done if the dir-file doesn't exists in the cloud + } + + return result; + } + + public RootCryptoFolder root(CryptoCloud cryptoCloud) throws BackendException { + if (root == null) { + root = new RootCryptoFolder(cryptoCloud); + } + return root; + } + + public CryptoFolder resolve(CryptoCloud cloud, String path) throws BackendException { + if (path.startsWith("/")) { + path = path.substring(1); + } + String[] names = path.split("/"); + CryptoFolder folder = root(cloud); + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + public CryptoFile file(CryptoFolder cryptoParent, String cleartextName) throws BackendException { + return file(cryptoParent, cleartextName, Optional.empty()); + } + + public CryptoFile file(CryptoFolder cryptoParent, String cleartextName, Optional cleartextSize) throws BackendException { + String ciphertextName = encryptFileName(cryptoParent, cleartextName); + return file(cryptoParent, cleartextName, ciphertextName, cleartextSize); + } + + private CryptoFile file(CryptoFolder cryptoParent, String cleartextName, String ciphertextName, Optional cleartextSize) throws BackendException { + Optional ciphertextSize; + if (cleartextSize.isPresent()) { + ciphertextSize = Optional.of(Cryptors.ciphertextSize(cleartextSize.get(), cryptor()) + cryptor().fileHeaderCryptor().headerSize()); + } else { + ciphertextSize = Optional.empty(); + } + CloudFile cloudFile = cloudContentRepository.file(dirIdInfo(cryptoParent).getCloudFolder(), ciphertextName, ciphertextSize); + return file(cryptoParent, cleartextName, cloudFile, cleartextSize); + } + + CryptoFile file(CryptoFile cryptoFile, CloudFile cloudFile, Optional cleartextSize) throws BackendException { + return file(cryptoFile.getParent(), cryptoFile.getName(), cloudFile, cleartextSize); + } + + CryptoFile file(CryptoFolder cryptoParent, String cleartextName, CloudFile cloudFile, Optional cleartextSize) throws BackendException { + return new CryptoFile(cryptoParent, cleartextName, path(cryptoParent, cleartextName), cleartextSize, cloudFile); + } + + private String encryptFileName(CryptoFolder cryptoParent, String name) throws BackendException { + return encryptName(cryptoParent, name); + } + + CryptoFolder folder(CryptoFolder cryptoParent, String cleartextName, CloudFile dirFile) throws BackendException { + return new CryptoFolder(cryptoParent, cleartextName, path(cryptoParent, cleartextName), dirFile); + } + + CryptoFolder folder(CryptoFolder cryptoFolder, CloudFile dirFile) throws BackendException { + return new CryptoFolder(cryptoFolder.getParent(), cryptoFolder.getName(), cryptoFolder.getPath(), dirFile); + } + + boolean exists(CloudNode node) throws BackendException { + if (node instanceof CryptoFolder) { + return exists((CryptoFolder) node); + } else if (node instanceof CryptoFile) { + return exists((CryptoFile) node); + } else if (node instanceof CryptoSymlink) { + return exists((CryptoSymlink) node); + } else { + throw new IllegalArgumentException("Unexpected CloudNode type: " + node.getClass()); + } + } + + private boolean exists(CryptoFolder folder) throws BackendException { + return cloudContentRepository.exists(folder.getDirFile()) && cloudContentRepository.exists(dirIdInfo(folder).getCloudFolder()); + } + + private boolean exists(CryptoFile file) throws BackendException { + return cloudContentRepository.exists(file.getCloudFile()); + } + + private boolean exists(CryptoSymlink symlink) throws BackendException { + return cloudContentRepository.exists(symlink.getCloudFile()); + } + + void assertCryptoFolderAlreadyExists(CryptoFolder cryptoFolder) throws BackendException { + if (cloudContentRepository.exists(cryptoFolder.getDirFile()) // + || cloudContentRepository.exists(file(cryptoFolder.getParent(), cryptoFolder.getName()))) { + throw new CloudNodeAlreadyExistsException(cryptoFolder.getName()); + } + } + + void assertCryptoFileAlreadyExists(CryptoFile cryptoFile) throws BackendException { + if (cloudContentRepository.exists(cryptoFile.getCloudFile()) // + || cloudContentRepository.exists(folder(cryptoFile.getParent(), cryptoFile.getName()).getDirFile())) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + } + + private CryptoFile writeFromTmpFile(DataSource originalDataSource, final CryptoFile cryptoFile, File encryptedFile, final ProgressAware progressAware, boolean replace) + throws BackendException, IOException { + CryptoFile targetFile = targetFile(cryptoFile, replace); + return file(targetFile, // + cloudContentRepository.write( // + targetFile.getCloudFile(), // + originalDataSource.decorate(FileBasedDataSource.from(encryptedFile)), // + new UploadFileReplacingProgressAware(cryptoFile, progressAware), // + replace, // + encryptedFile.length()), // + cryptoFile.getSize()); + } + + private CryptoFile targetFile(CryptoFile cryptoFile, boolean replace) throws BackendException { + if (replace || !cloudContentRepository.exists(cryptoFile)) { + return cryptoFile; + } + return firstNonExistingAutoRenamedFile(cryptoFile); + } + + private CryptoFile firstNonExistingAutoRenamedFile(CryptoFile original) throws BackendException { + String name = original.getName(); + String nameWithoutExtension = nameWithoutExtension(name); + String extension = extension(name); + int counter = 1; + CryptoFile result; + do { + String newFileName = nameWithoutExtension + " (" + counter + ")" + extension; + result = file(original.getParent(), newFileName, original.getSize()); + counter++; + } while (cloudContentRepository.exists(result)); + return result; + } + + String nameWithoutExtension(String name) { + int lastDot = name.lastIndexOf("."); + if (lastDot == -1) { + return name; + } + return name.substring(0, lastDot); + } + + String extension(String name) { + int lastDot = name.lastIndexOf("."); + if (lastDot == -1) { + return ""; + } + return name.substring(lastDot + 1); + } + + public void read(CryptoFile cryptoFile, OutputStream data, ProgressAware progressAware) throws BackendException { + CloudFile ciphertextFile = cryptoFile.getCloudFile(); + try { + File encryptedTmpFile = readToTmpFile(cryptoFile, ciphertextFile, progressAware); + progressAware.onProgress(Progress.started(DownloadState.decryption(cryptoFile))); + try (ReadableByteChannel readableByteChannel = Channels.newChannel(new FileInputStream(encryptedTmpFile)); + ReadableByteChannel decryptingReadableByteChannel = new DecryptingReadableByteChannel(readableByteChannel, cryptor(), true)) { + ByteBuffer buff = ByteBuffer.allocate(cryptor().fileContentCryptor().ciphertextChunkSize()); + long cleartextSize = cryptoFile.getSize().orElse(Long.MAX_VALUE); + long decrypted = 0; + int read; + while ((read = decryptingReadableByteChannel.read(buff)) > 0) { + buff.flip(); + data.write(buff.array(), 0, buff.remaining()); + decrypted += read; + progressAware.onProgress(progress(DownloadState.decryption(cryptoFile)).between(0).and(cleartextSize).withValue(decrypted)); + } + } finally { + encryptedTmpFile.delete(); + progressAware.onProgress(Progress.completed(DownloadState.decryption(cryptoFile))); + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private File readToTmpFile(CryptoFile cryptoFile, CloudFile file, ProgressAware progressAware) throws BackendException, IOException { + File encryptedTmpFile = File.createTempFile(UUID.randomUUID().toString(), ".crypto", getInternalCache()); + try (OutputStream encryptedData = new FileOutputStream(encryptedTmpFile)) { + cloudContentRepository.read(file, Optional.of(encryptedTmpFile), encryptedData, new DownloadFileReplacingProgressAware(cryptoFile, progressAware)); + return encryptedTmpFile; + } + } + + public String currentAccount(Cloud cloud) throws BackendException { + return cloudContentRepository.checkAuthenticationAndRetrieveCurrentAccount(cloud); + } + + DirIdInfo dirIdInfo(CryptoFolder folder) throws BackendException { + DirIdInfo dirIdInfo = dirIdCache.get(folder); + if (dirIdInfo == null) { + return createDirIdInfo(folder); + } + return dirIdInfo; + } + + DirIdInfo createDirIdInfoFor(String dirId) throws BackendException { + String dirHash = dirHash(dirId); + CloudFolder lvl2Dir = lvl2Dir(dirHash); + return new DirIdInfo(dirId, lvl2Dir); + } + + byte[] loadContentsOfDirFile(CryptoFolder folder) throws BackendException, EmptyDirFileException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + cloudContentRepository.read(folder.getDirFile(), Optional.empty(), out, NO_OP_PROGRESS_AWARE); + if (dirfileIsEmpty(out)) { + throw new EmptyDirFileException(folder.getName(), folder.getDirFile().getPath()); + } + return out.toByteArray(); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + String newDirId() { + return UUID.randomUUID().toString(); + } + + boolean dirfileIsEmpty(ByteArrayOutputStream out) { + return out.size() == 0; + } + + private CloudFolder lvl2Dir(String dirHash) throws BackendException { + return cloudContentRepository.folder(lvl1Dir(dirHash), dirHash.substring(2)); + } + + private CloudFolder lvl1Dir(String dirHash) throws BackendException { + return cloudContentRepository.folder(dataFolder(), dirHash.substring(0, 2)); + } + + Cryptor cryptor() { + return cryptor.get(); + } + + CloudFolder storageLocation() { + return storageLocation; + } + + void addFolderToCache(CryptoFolder result, DirIdCache.DirIdInfo dirInfo) { + dirIdCache.put(result, dirInfo); + } + + void evictFromCache(CryptoFolder cryptoFolder) { + dirIdCache.evict(cryptoFolder); + } + + CryptoFile writeShortNameFile(CryptoFile cryptoFile, DataSource data, ProgressAware progressAware, boolean replace, long length) throws BackendException { + if (!replace) { + assertCryptoFileAlreadyExists(cryptoFile); + } + try (InputStream stream = data.open(context)) { + File encryptedTmpFile = File.createTempFile(UUID.randomUUID().toString(), ".crypto", getInternalCache()); + try (WritableByteChannel writableByteChannel = Channels.newChannel(new FileOutputStream(encryptedTmpFile)); + WritableByteChannel encryptingWritableByteChannel = new EncryptingWritableByteChannel(writableByteChannel, cryptor())) { + progressAware.onProgress(Progress.started(UploadState.encryption(cryptoFile))); + ByteBuffer buff = ByteBuffer.allocate(cryptor().fileContentCryptor().cleartextChunkSize()); + long ciphertextSize = Cryptors.ciphertextSize(cryptoFile.getSize().get(), cryptor()) + cryptor().fileHeaderCryptor().headerSize(); + int read; + long encrypted = 0; + while ((read = stream.read(buff.array())) > 0) { + buff.limit(read); + int written = encryptingWritableByteChannel.write(buff); + buff.flip(); + encrypted += written; + progressAware.onProgress(progress(UploadState.encryption(cryptoFile)).between(0).and(ciphertextSize).withValue(encrypted)); + } + encryptingWritableByteChannel.close(); + progressAware.onProgress(Progress.completed(UploadState.encryption(cryptoFile))); + return writeFromTmpFile(data, cryptoFile, encryptedTmpFile, progressAware, replace); + } catch (Throwable e) { + throw e; + } finally { + encryptedTmpFile.delete(); + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.java new file mode 100644 index 000000000..7bd375849 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7.java @@ -0,0 +1,546 @@ +package org.cryptomator.data.cloud.crypto; + +import android.content.Context; + +import com.google.common.io.BaseEncoding; + +import org.cryptomator.cryptolib.Cryptors; +import org.cryptomator.cryptolib.EncryptingWritableByteChannel; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.common.MessageDigestSupplier; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.EmptyDirFileException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoDirFileException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.SymLinkException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.UploadFileReplacingProgressAware; +import org.cryptomator.domain.usecases.cloud.ByteArrayDataSource; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.domain.usecases.cloud.FileBasedDataSource; +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.util.Optional; +import org.cryptomator.util.Supplier; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import timber.log.Timber; + +import static org.cryptomator.domain.usecases.ProgressAware.NO_OP_PROGRESS_AWARE; +import static org.cryptomator.domain.usecases.cloud.Progress.progress; +import static org.cryptomator.util.Encodings.UTF_8; + +final class CryptoImplVaultFormat7 extends CryptoImplDecorator { + + private static final int SHORT_NAMES_MAX_LENGTH = 220; + private static final String CLOUD_NODE_EXT = ".c9r"; + private static final String LONG_NODE_FILE_EXT = ".c9s"; + private static final String CLOUD_FOLDER_DIR_FILE_PRE = "dir"; + private static final String LONG_NODE_FILE_CONTENT_CONTENTS = "contents"; + private static final String LONG_NODE_FILE_CONTENT_NAME = "name"; + private static final String CLOUD_NODE_SYMLINK_PRE = "symlink"; + private static final Pattern BASE64_ENCRYPTED_NAME_PATTERN = Pattern.compile("^([A-Za-z0-9+/\\-_]{4})*([A-Za-z0-9+/\\-]{4}|[A-Za-z0-9+/\\-_]{3}=|[A-Za-z0-9+/\\-_]{2}==)?$"); + + private static final BaseEncoding BASE64 = BaseEncoding.base64Url(); + + CryptoImplVaultFormat7(Context context, Supplier cryptor, CloudContentRepository cloudContentRepository, CloudFolder storageLocation, DirIdCache dirIdCache) { + super(context, cryptor, cloudContentRepository, storageLocation, dirIdCache); + } + + @Override + CryptoFolder folder(CryptoFolder cryptoParent, String cleartextName) throws BackendException { + String dirFileName = encryptFolderName(cryptoParent, cleartextName); + CloudFolder dirFolder = cloudContentRepository.folder(dirIdInfo(cryptoParent).getCloudFolder(), dirFileName); + CloudFile dirFile = cloudContentRepository.file(dirFolder, CLOUD_FOLDER_DIR_FILE_PRE + CLOUD_NODE_EXT); + return folder(cryptoParent, cleartextName, dirFile); + } + + @Override + String encryptName(CryptoFolder cryptoFolder, String name) throws BackendException { + String ciphertextName = cryptor() // + .fileNameCryptor() // + .encryptFilename(BASE64, name, dirIdInfo(cryptoFolder).getId().getBytes(UTF_8)) + CLOUD_NODE_EXT; + + if (ciphertextName.length() > SHORT_NAMES_MAX_LENGTH) { + ciphertextName = deflate(cryptoFolder, ciphertextName); + } + return ciphertextName; + } + + private String deflate(CryptoFolder cryptoParent, String longFileName) throws BackendException { + byte[] longFilenameBytes = longFileName.getBytes(UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortFileName = BASE64.encode(hash) + LONG_NODE_FILE_EXT; + + CloudFolder dirFolder = cloudContentRepository.folder(dirIdInfo(cryptoParent).getCloudFolder(), shortFileName); + + // if folder already exists in case of renaming + if (!cloudContentRepository.exists(dirFolder)) { + dirFolder = cloudContentRepository.create(dirFolder); + } + + byte[] data = longFileName.getBytes(UTF_8); + CloudFile cloudFile = cloudContentRepository.file(dirFolder, LONG_NODE_FILE_CONTENT_NAME + LONG_NODE_FILE_EXT, Optional.of((long) data.length)); + cloudContentRepository.write(cloudFile, ByteArrayDataSource.from(data), NO_OP_PROGRESS_AWARE, true, data.length); + return shortFileName; + } + + private CloudFile metadataFile(CloudNode cloudNode) throws BackendException { + CloudFolder cloudFolder; + + if (cloudNode instanceof CloudFile) { + cloudFolder = cloudNode.getParent(); + } else if (cloudNode instanceof CloudFolder) { + cloudFolder = (CloudFolder) cloudNode; + } else { + throw new IllegalStateException("Should be file or folder"); + } + + return cloudContentRepository.file(cloudFolder, LONG_NODE_FILE_CONTENT_NAME + LONG_NODE_FILE_EXT); + } + + private String inflate(CloudNode cloudNode) throws BackendException { + CloudFile metadataFile = metadataFile(cloudNode); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + cloudContentRepository.read(metadataFile, Optional.empty(), out, NO_OP_PROGRESS_AWARE); + return new String(out.toByteArray(), UTF_8); + } + + @Override + String decryptName(String dirId, String encryptedName) { + Optional ciphertextName = extractEncryptedName(encryptedName); + if (ciphertextName.isPresent()) { + return cryptor().fileNameCryptor().decryptFilename(BASE64, ciphertextName.get(), dirId.getBytes(UTF_8)); + } else { + return null; + } + } + + @Override + List list(CryptoFolder cryptoFolder) throws BackendException { + dirIdCache.evictSubFoldersOf(cryptoFolder); + + DirIdCache.DirIdInfo dirIdInfo = dirIdInfo(cryptoFolder); + String dirId = dirIdInfo(cryptoFolder).getId(); + CloudFolder lvl2Dir = dirIdInfo.getCloudFolder(); + + List ciphertextNodes; + + try { + ciphertextNodes = cloudContentRepository.list(lvl2Dir); + } catch (NoSuchCloudFileException e) { + if (cryptoFolder instanceof RootCryptoFolder) { + Timber.tag("CryptoFs").e("No lvl2Dir exists for root folder in %s", lvl2Dir.getPath()); + throw new FatalBackendException(String.format("No lvl2Dir exists for root folder in %s", lvl2Dir.getPath()), e); + } else if (cloudContentRepository.exists(cloudContentRepository.file(cryptoFolder.getDirFile().getParent(), CLOUD_NODE_SYMLINK_PRE + CLOUD_NODE_EXT))) { + throw new SymLinkException(); + } else if (!cloudContentRepository.exists(cryptoFolder.getDirFile())) { + Timber.tag("CryptoFs").e("No dir file exists in %s", cryptoFolder.getDirFile().getPath()); + throw new NoDirFileException(cryptoFolder.getName(), cryptoFolder.getDirFile().getPath()); + } + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + for (CloudNode node : ciphertextNodes) { + ciphertextToCleartextNode(cryptoFolder, dirId, node).ifPresent(result::add); + } + + return result; + } + + private Optional ciphertextToCleartextNode(CryptoFolder cryptoFolder, String dirId, CloudNode cloudNode) throws BackendException { + String ciphertextName = cloudNode.getName(); + Optional longNameFolderDirFile = Optional.empty(); + Optional longNameFile = Optional.empty(); + + if (ciphertextName.endsWith(CLOUD_NODE_EXT)) { + ciphertextName = nameWithoutExtension(ciphertextName); + } else if (ciphertextName.endsWith(LONG_NODE_FILE_EXT)) { + Optional ciphertextNameOption = longNodeCiphertextName(cloudNode); + if (ciphertextNameOption.isPresent()) { + ciphertextName = ciphertextNameOption.get(); + } else { + return Optional.empty(); + } + + List subfiles = cloudContentRepository.list((CloudFolder) cloudNode); + + for (CloudNode cloudNode1 : subfiles) { + switch (cloudNode1.getName()) { + case LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT: + longNameFile = Optional.of((CloudFile) cloudNode1); + break; + case CLOUD_FOLDER_DIR_FILE_PRE + CLOUD_NODE_EXT: + longNameFolderDirFile = Optional.of((CloudFile) cloudNode1); + break; + case CLOUD_NODE_SYMLINK_PRE + CLOUD_NODE_EXT: + return Optional.empty(); + } + } + } + + try { + String cleartextName = decryptName(dirId, ciphertextName); + + if (cleartextName == null) { + Timber.tag("CryptoFs").w("Failed to parse cipher text name of: %s", cloudNode.getPath()); + return Optional.empty(); + } + + return cloudNodeFromName(cloudNode, cryptoFolder, cleartextName, longNameFile, longNameFolderDirFile); + } catch (AuthenticationFailedException e) { + Timber.tag("CryptoFs").w(e, "File/Folder name authentication failed: %s", cloudNode.getPath()); + return Optional.empty(); + } catch (IllegalArgumentException e) { + Timber.tag("CryptoFs").w(e, "Illegal ciphertext filename/folder: %s", cloudNode.getPath()); + return Optional.empty(); + } + } + + private Optional cloudNodeFromName(CloudNode cloudNode, CryptoFolder cryptoFolder, String cleartextName, Optional longNameFile, Optional dirFile) throws BackendException { + if (cloudNode instanceof CloudFile) { + CloudFile cloudFile = (CloudFile) cloudNode; + Optional cleartextSize = Optional.empty(); + if (cloudFile.getSize().isPresent()) { + long ciphertextSizeWithoutHeader = cloudFile.getSize().get() - cryptor().fileHeaderCryptor().headerSize(); + if (ciphertextSizeWithoutHeader >= 0) { + cleartextSize = Optional.of(Cryptors.cleartextSize(ciphertextSizeWithoutHeader, cryptor())); + } + } + return Optional.of(file(cryptoFolder, cleartextName, cloudFile, cleartextSize)); + } else if (cloudNode instanceof CloudFolder) { + if (longNameFile.isPresent()) { + // long file + Optional cleartextSize = Optional.empty(); + if (longNameFile.get().getSize().isPresent()) { + long ciphertextSizeWithoutHeader = longNameFile.get().getSize().get() - cryptor().fileHeaderCryptor().headerSize(); + if (ciphertextSizeWithoutHeader >= 0) { + cleartextSize = Optional.of(Cryptors.cleartextSize(ciphertextSizeWithoutHeader, cryptor())); + } + } + + return Optional.of(file(cryptoFolder, cleartextName, longNameFile.get(), cleartextSize)); + } else { + // folder + if (dirFile.isPresent()) { + return Optional.of(folder(cryptoFolder, cleartextName, dirFile.get())); + } else { + CloudFile constructedDirFile = cloudContentRepository.file((CloudFolder) cloudNode, "dir" + CLOUD_NODE_EXT); + return Optional.of(folder(cryptoFolder, cleartextName, constructedDirFile)); + } + } + } + + return Optional.empty(); + } + + private Optional longNodeCiphertextName(CloudNode cloudNode) { + try { + String ciphertextName = inflate(cloudNode); + ciphertextName = nameWithoutExtension(ciphertextName); + return Optional.of(ciphertextName); + } catch (NoSuchCloudFileException e) { + Timber.tag("CryptoFs").e("Missing %s%s for cloud node: %s", LONG_NODE_FILE_CONTENT_NAME, LONG_NODE_FILE_EXT, cloudNode.getPath()); + return Optional.empty(); + } catch (BackendException e) { + Timber.tag("CryptoFs").e(e, "Failed to read %s%s for cloud node: %s", LONG_NODE_FILE_CONTENT_NAME, LONG_NODE_FILE_EXT, cloudNode.getPath()); + return Optional.empty(); + } + } + + @Override + DirIdCache.DirIdInfo createDirIdInfo(CryptoFolder folder) throws BackendException { + String dirId = loadDirId(folder); + return dirIdCache.put(folder, createDirIdInfoFor(dirId)); + } + + @Override + String encryptFolderName(CryptoFolder cryptoFolder, String name) throws BackendException { + return encryptName(cryptoFolder, name); + } + + @Override + CryptoSymlink symlink(CryptoFolder cryptoParent, String cleartextName, String target) throws BackendException { + return null; + } + + @Override + String loadDirId(CryptoFolder folder) throws BackendException, EmptyDirFileException { + CloudFile dirFile = null; + + if (folder.getDirFile() != null) { + dirFile = folder.getDirFile(); + } + + if (RootCryptoFolder.isRoot(folder)) { + return CryptoConstants.ROOT_DIR_ID; + } else if (dirFile != null && cloudContentRepository.exists(dirFile)) { + return new String(loadContentsOfDirFile(dirFile), UTF_8); + } else { + return newDirId(); + } + } + + private byte[] loadContentsOfDirFile(CloudFile file) throws BackendException, EmptyDirFileException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + cloudContentRepository.read(file, Optional.empty(), out, NO_OP_PROGRESS_AWARE); + if (dirfileIsEmpty(out)) { + throw new EmptyDirFileException(file.getName(), file.getPath()); + } + return out.toByteArray(); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + CryptoFolder create(CryptoFolder folder) throws BackendException { + boolean shortName = false; + if (folder.getDirFile().getParent().getName().endsWith(LONG_NODE_FILE_EXT)) { + assertCryptoLongDirFileAlreadyExists(folder); + } else { + assertCryptoFolderAlreadyExists(folder); + shortName = true; + } + + DirIdCache.DirIdInfo dirIdInfo = dirIdInfo(folder); + CloudFolder createdCloudFolder = cloudContentRepository.create(dirIdInfo.getCloudFolder()); + + CloudFolder dirFolder = folder.getDirFile().getParent(); + CloudFile dirFile = folder.getDirFile(); + if (shortName) { + dirFolder = cloudContentRepository.create(dirFolder); + dirFile = cloudContentRepository.file(dirFolder, folder.getDirFile().getName()); + } + + byte[] dirId = dirIdInfo.getId().getBytes(UTF_8); + CloudFile createdDirFile = cloudContentRepository.write(dirFile, ByteArrayDataSource.from(dirId), NO_OP_PROGRESS_AWARE, false, dirId.length); + CryptoFolder result = folder(folder, createdDirFile); + addFolderToCache(result, dirIdInfo.withCloudFolder(createdCloudFolder)); + return result; + } + + @Override + Optional extractEncryptedName(String ciphertextName) { + final Matcher matcher = BASE64_ENCRYPTED_NAME_PATTERN.matcher(ciphertextName); + if (matcher.find(0)) { + return Optional.of(matcher.group()); + } else { + return Optional.empty(); + } + } + + @Override + CryptoFolder move(CryptoFolder source, CryptoFolder target) throws BackendException { + boolean shortName = false; + if (target.getDirFile().getParent().getName().endsWith(LONG_NODE_FILE_EXT)) { + assertCryptoLongDirFileAlreadyExists(target); + } else { + assertCryptoFolderAlreadyExists(target); + shortName = true; + } + + CloudFile targetDirFile = target.getDirFile(); + if (shortName) { + CloudFolder targetDirFolder = cloudContentRepository.create(target.getDirFile().getParent()); + targetDirFile = cloudContentRepository.file(targetDirFolder, target.getDirFile().getName()); + } + + CryptoFolder result = folder(target.getParent(), target.getName(), cloudContentRepository.move(source.getDirFile(), targetDirFile)); + + cloudContentRepository.delete(source.getDirFile().getParent()); + + evictFromCache(source); + evictFromCache(target); + + return result; + } + + @Override + CryptoFile move(CryptoFile source, CryptoFile target) throws BackendException { + if (source.getCloudFile().getParent().getName().endsWith(LONG_NODE_FILE_EXT)) { + CloudFolder targetDirFolder = cloudContentRepository.folder(target.getCloudFile().getParent(), target.getCloudFile().getName()); + CryptoFile cryptoFile; + if (target.getCloudFile().getName().endsWith(LONG_NODE_FILE_EXT)) { + assertCryptoLongDirFileAlreadyExists(targetDirFolder); + cryptoFile = moveLongFileToLongFile(source, target, targetDirFolder); + } else { + assertCryptoFileAlreadyExists(target); + cryptoFile = moveLongFileToShortFile(source, target); + } + CloudFolder sourceDirFolder = cloudContentRepository.folder(source.getCloudFile().getParent().getParent(), source.getCloudFile().getParent().getName()); + cloudContentRepository.delete(sourceDirFolder); + return cryptoFile; + } else { + CloudFolder targetDirFolder = cloudContentRepository.folder(target.getCloudFile().getParent(), target.getCloudFile().getName()); + if (target.getCloudFile().getName().endsWith(LONG_NODE_FILE_EXT)) { + assertCryptoLongDirFileAlreadyExists(targetDirFolder); + return moveShortFileToLongFile(source, target, targetDirFolder); + } else { + assertCryptoFileAlreadyExists(target); + return file(target, cloudContentRepository.move(source.getCloudFile(), target.getCloudFile()), source.getSize()); + } + } + } + + private CryptoFile moveLongFileToLongFile(CryptoFile source, CryptoFile target, CloudFolder targetDirFolder) throws BackendException { + CloudFile sourceFile = cloudContentRepository.file(source.getCloudFile().getParent(), LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT); + CloudFile movedFile = cloudContentRepository.move(sourceFile, cloudContentRepository.file(targetDirFolder, LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT)); + return file(target, movedFile, movedFile.getSize()); + } + + private CryptoFile moveLongFileToShortFile(CryptoFile source, CryptoFile target) throws BackendException { + CloudFile sourceFile = cloudContentRepository.file(source.getCloudFile().getParent(), LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT); + CloudFile movedFile = cloudContentRepository.move(sourceFile, target.getCloudFile()); + return file(target, movedFile, movedFile.getSize()); + } + + private CryptoFile moveShortFileToLongFile(CryptoFile source, CryptoFile target, CloudFolder targetDirFolder) throws BackendException { + CloudFile movedFile = cloudContentRepository.move(source.getCloudFile(), cloudContentRepository.file(targetDirFolder, LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT)); + return file(target, movedFile, movedFile.getSize()); + } + + @Override + void delete(CloudNode node) throws BackendException { + if (node instanceof CryptoFolder) { + CryptoFolder cryptoFolder = (CryptoFolder) node; + List cryptoSubfolders = deepCollectSubfolders(cryptoFolder); + for (CryptoFolder cryptoSubfolder : cryptoSubfolders) { + try { + cloudContentRepository.delete(dirIdInfo(cryptoSubfolder).getCloudFolder()); + } catch (NoSuchCloudFileException e) { + // Ignoring because nothing can be done if the dir-file doesn't exists in the cloud + } + } + + try { + cloudContentRepository.delete(dirIdInfo(cryptoFolder).getCloudFolder()); + } catch (NoSuchCloudFileException e) { + // Ignoring because nothing can be done if the dir-file doesn't exists in the cloud + } + + cloudContentRepository.delete(cryptoFolder.getDirFile().getParent()); + + evictFromCache(cryptoFolder); + } else if (node instanceof CryptoFile) { + CryptoFile cryptoFile = (CryptoFile) node; + if (cryptoFile.getCloudFile().getParent().getName().endsWith(LONG_NODE_FILE_EXT)) { + cloudContentRepository.delete(cryptoFile.getCloudFile().getParent()); + } else { + cloudContentRepository.delete(cryptoFile.getCloudFile()); + } + } + } + + @Override + public CryptoFile write(CryptoFile cryptoFile, DataSource data, ProgressAware progressAware, boolean replace, long length) throws BackendException { + if (cryptoFile.getCloudFile().getName().endsWith(LONG_NODE_FILE_EXT)) { + return writeLongFile(cryptoFile, data, progressAware, replace, length); + } else { + return writeShortNameFile(cryptoFile, data, progressAware, replace, length); + } + } + + private CryptoFile writeLongFile(CryptoFile cryptoFile, DataSource data, ProgressAware progressAware, boolean replace, long length) throws BackendException { + CloudFolder dirFolder = cloudContentRepository.folder(cryptoFile.getCloudFile().getParent(), cryptoFile.getCloudFile().getName()); + CloudFile cloudFile = cloudContentRepository.file(dirFolder, LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT, data.size(context)); + + assertCryptoLongDirFileAlreadyExists(dirFolder); + + try (InputStream stream = data.open(context)) { + File encryptedTmpFile = File.createTempFile(UUID.randomUUID().toString(), ".crypto", getInternalCache()); + try (WritableByteChannel writableByteChannel = Channels.newChannel(new FileOutputStream(encryptedTmpFile)); // + WritableByteChannel encryptingWritableByteChannel = new EncryptingWritableByteChannel(writableByteChannel, cryptor())) { + progressAware.onProgress(Progress.started(UploadState.encryption(cloudFile))); + ByteBuffer buff = ByteBuffer.allocate(cryptor().fileContentCryptor().cleartextChunkSize()); + long ciphertextSize = Cryptors.ciphertextSize(cloudFile.getSize().get(), cryptor()) + cryptor().fileHeaderCryptor().headerSize(); + int read; + long encrypted = 0; + while ((read = stream.read(buff.array())) > 0) { + buff.limit(read); + int written = encryptingWritableByteChannel.write(buff); + buff.flip(); + encrypted += written; + progressAware.onProgress(progress(UploadState.encryption(cloudFile)).between(0).and(ciphertextSize).withValue(encrypted)); + } + encryptingWritableByteChannel.close(); + progressAware.onProgress(Progress.completed(UploadState.encryption(cloudFile))); + + CloudFile targetFile = targetFile(cryptoFile, cloudFile, replace); + + return file(cryptoFile, // + cloudContentRepository.write( // + targetFile, // + data.decorate(FileBasedDataSource.from(encryptedTmpFile)), // + new UploadFileReplacingProgressAware(cryptoFile, progressAware), // + replace, // + encryptedTmpFile.length()), // + cryptoFile.getSize()); + } catch (Throwable e) { + throw e; + } finally { + encryptedTmpFile.delete(); + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private CloudFile targetFile(CryptoFile cryptoFile, CloudFile cloudFile, boolean replace) throws BackendException { + if (replace || !cloudContentRepository.exists(cloudFile)) { + return cloudFile; + } + return firstNonExistingAutoRenamedFile(cryptoFile); + } + + private CloudFile firstNonExistingAutoRenamedFile(CryptoFile original) throws BackendException { + String name = original.getName(); + String nameWithoutExtension = nameWithoutExtension(name); + String extension = extension(name); + + if (!extension.isEmpty()) { + extension = "." + extension; + } + + int counter = 1; + CryptoFile result; + CloudFile cloudFile; + do { + String newFileName = nameWithoutExtension + " (" + counter + ")" + extension; + result = file(original.getParent(), newFileName, original.getSize()); + counter++; + + CloudFolder dirFolder = cloudContentRepository.folder(result.getCloudFile().getParent(), result.getCloudFile().getName()); + cloudFile = cloudContentRepository.file(dirFolder, LONG_NODE_FILE_CONTENT_CONTENTS + CLOUD_NODE_EXT, result.getSize()); + } while (cloudContentRepository.exists(cloudFile)); + return cloudFile; + } + + private void assertCryptoLongDirFileAlreadyExists(CloudFolder cryptoFolder) throws BackendException { + if (cloudContentRepository.exists(cloudContentRepository.file(cryptoFolder, CLOUD_FOLDER_DIR_FILE_PRE + CLOUD_NODE_EXT))) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.java new file mode 100644 index 000000000..4a635c3b3 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7.java @@ -0,0 +1,270 @@ +package org.cryptomator.data.cloud.crypto; + +import android.content.Context; + +import org.apache.commons.codec.binary.Base32; +import org.apache.commons.codec.binary.BaseNCodec; +import org.cryptomator.cryptolib.Cryptors; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.common.MessageDigestSupplier; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.AlreadyExistException; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.EmptyDirFileException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.ByteArrayDataSource; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.util.Optional; +import org.cryptomator.util.Supplier; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import timber.log.Timber; + +import static org.cryptomator.domain.usecases.ProgressAware.NO_OP_PROGRESS_AWARE; +import static org.cryptomator.util.Encodings.UTF_8; + +final class CryptoImplVaultFormatPre7 extends CryptoImplDecorator { + + private static final int SHORT_NAMES_MAX_LENGTH = 129; + private static final String DIR_PREFIX = "0"; + private static final String SYMLINK_PREFIX = "1S"; + private static final String LONG_NAME_FILE_EXT = ".lng"; + private static final String METADATA_DIR_NAME = "m"; + + private static final BaseNCodec BASE32 = new Base32(); + private static final Pattern BASE32_ENCRYPTED_NAME_PATTERN = Pattern.compile("^(0|1S)?(([A-Z2-7]{8})*[A-Z2-7=]{8})$"); + + CryptoImplVaultFormatPre7(Context context, Supplier cryptor, CloudContentRepository cloudContentRepository, CloudFolder storageLocation, DirIdCache dirIdCache) { + super(context, cryptor, cloudContentRepository, storageLocation, dirIdCache); + } + + @Override + CryptoFolder folder(CryptoFolder cryptoParent, String cleartextName) throws BackendException { + String dirFileName = encryptFolderName(cryptoParent, cleartextName); + CloudFile dirFile = cloudContentRepository.file(dirIdInfo(cryptoParent).getCloudFolder(), dirFileName); + return folder(cryptoParent, cleartextName, dirFile); + } + + @Override + CryptoFolder create(CryptoFolder folder) throws BackendException { + assertCryptoFolderAlreadyExists(folder); + DirIdCache.DirIdInfo dirIdInfo = dirIdInfo(folder); + CloudFolder createdCloudFolder = cloudContentRepository.create(dirIdInfo.getCloudFolder()); + byte[] dirId = dirIdInfo.getId().getBytes(UTF_8); + CloudFile createdDirFile = cloudContentRepository.write(folder.getDirFile(), ByteArrayDataSource.from(dirId), NO_OP_PROGRESS_AWARE, false, dirId.length); + CryptoFolder result = folder(folder, createdDirFile); + addFolderToCache(result, dirIdInfo.withCloudFolder(createdCloudFolder)); + return result; + } + + @Override + String encryptName(CryptoFolder cryptoParent, String name) throws BackendException { + return encryptName(cryptoParent, name, ""); + } + + private String encryptName(CryptoFolder cryptoParent, String name, String prefix) throws BackendException { + String ciphertextName = prefix + cryptor().fileNameCryptor().encryptFilename(name, dirIdInfo(cryptoParent).getId().getBytes(UTF_8)); + if (ciphertextName.length() > SHORT_NAMES_MAX_LENGTH) { + ciphertextName = deflate(ciphertextName); + } + return ciphertextName; + } + + private String deflate(String longFileName) throws BackendException { + byte[] longFilenameBytes = longFileName.getBytes(UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortFileName = BASE32.encodeAsString(hash) + LONG_NAME_FILE_EXT; + CloudFile metadataFile = metadataFile(shortFileName); + byte[] data = longFileName.getBytes(UTF_8); + try { + cloudContentRepository.create(metadataFile.getParent()); + } catch (AlreadyExistException e) { + } + cloudContentRepository.write(metadataFile, ByteArrayDataSource.from(data), NO_OP_PROGRESS_AWARE, true, data.length); + return shortFileName; + } + + private String inflate(String shortFileName) throws BackendException { + CloudFile metadataFile = metadataFile(shortFileName); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + cloudContentRepository.read(metadataFile, Optional.empty(), out, NO_OP_PROGRESS_AWARE); + return new String(out.toByteArray(), UTF_8); + } + + private CloudFile inflatePermanently(CloudFile cloudFile, String longFileName) throws BackendException { + Timber.tag("CryptoFs").i("inflatePermanently: %s -> %s", cloudFile.getName(), longFileName); + CloudFile newCiphertextFile = cloudContentRepository.file(cloudFile.getParent(), longFileName); + cloudContentRepository.move(cloudFile, newCiphertextFile); + return newCiphertextFile; + } + + private CloudFile metadataFile(String shortFilename) throws BackendException { + CloudFolder firstLevelFolder = cloudContentRepository.folder(metadataFolder(), shortFilename.substring(0, 2)); + CloudFolder secondLevelFolder = cloudContentRepository.folder(firstLevelFolder, shortFilename.substring(2, 4)); + return cloudContentRepository.file(secondLevelFolder, shortFilename); + } + + private CloudFolder metadataFolder() throws BackendException { + return cloudContentRepository.folder(storageLocation(), METADATA_DIR_NAME); + } + + @Override + List list(CryptoFolder cryptoFolder) throws BackendException { + DirIdCache.DirIdInfo dirIdInfo = dirIdInfo(cryptoFolder); + String dirId = dirIdInfo(cryptoFolder).getId(); + CloudFolder lvl2Dir = dirIdInfo.getCloudFolder(); + List ciphertextNodes = cloudContentRepository.list(lvl2Dir); + List result = new ArrayList<>(); + for (CloudNode node : ciphertextNodes) { + if (node instanceof CloudFile) { + ciphertextToCleartextNode(cryptoFolder, dirId, node).ifPresent(result::add); + } + } + return result; + } + + private Optional ciphertextToCleartextNode(CryptoFolder cryptoFolder, String dirId, CloudNode cloudNode) throws BackendException { + CloudFile cloudFile = (CloudFile) cloudNode; + String ciphertextName = cloudFile.getName(); + if (ciphertextName.endsWith(LONG_NAME_FILE_EXT)) { + try { + ciphertextName = inflate(ciphertextName); + if (ciphertextName.length() <= SHORT_NAMES_MAX_LENGTH) { + cloudFile = inflatePermanently(cloudFile, ciphertextName); + } + } catch (NoSuchCloudFileException e) { + Timber.tag("CryptoFs").e("Missing mFile: %s", ciphertextName); + return Optional.empty(); + } catch (BackendException e) { + Timber.tag("CryptoFs").e(e, "Failed to read mFile: %s", ciphertextName); + return Optional.empty(); + } + } + String cleartextName; + try { + cleartextName = decryptName(dirId, ciphertextName.toUpperCase()); + } catch (AuthenticationFailedException e) { + Timber.tag("CryptoFs").w("File name authentication failed: %s", cloudFile.getPath()); + return Optional.empty(); + } catch (IllegalArgumentException e) { + Timber.tag("CryptoFs").d("Illegal ciphertext filename: %s", cloudFile.getPath()); + return Optional.empty(); + } + if (cleartextName == null || ciphertextName.startsWith(SYMLINK_PREFIX)) { + return Optional.empty(); + } else if (ciphertextName.startsWith(DIR_PREFIX)) { + return Optional.of(folder(cryptoFolder, cleartextName, cloudFile)); + } else { + Optional cleartextSize = Optional.empty(); + if (cloudFile.getSize().isPresent()) { + long ciphertextSizeWithoutHeader = cloudFile.getSize().get() - cryptor().fileHeaderCryptor().headerSize(); + if (ciphertextSizeWithoutHeader >= 0) { + cleartextSize = Optional.of(Cryptors.cleartextSize(ciphertextSizeWithoutHeader, cryptor())); + } + } + return Optional.of(file(cryptoFolder, cleartextName, cloudFile, cleartextSize)); + } + } + + @Override + String decryptName(String dirId, String encryptedName) { + Optional ciphertextName = extractEncryptedName(encryptedName); + if (ciphertextName.isPresent()) { + return cryptor().fileNameCryptor().decryptFilename(ciphertextName.get(), dirId.getBytes(UTF_8)); + } else { + return null; + } + } + + @Override + Optional extractEncryptedName(String ciphertextName) { + Matcher matcher = BASE32_ENCRYPTED_NAME_PATTERN.matcher(ciphertextName); + if (matcher.find(0)) { + return Optional.of(matcher.group(2)); + } else { + return Optional.empty(); + } + } + + @Override + CryptoSymlink symlink(CryptoFolder cryptoParent, String cleartextName, String target) throws BackendException { + String ciphertextName = encryptSymlinkName(cryptoParent, cleartextName); + CloudFile cloudFile = cloudContentRepository.file(dirIdInfo(cryptoParent).getCloudFolder(), ciphertextName); + return new CryptoSymlink(cryptoParent, cleartextName, path(cryptoParent, cleartextName), target, cloudFile); + } + + private String encryptSymlinkName(CryptoFolder cryptoFolder, String name) throws BackendException { + return encryptName(cryptoFolder, name, SYMLINK_PREFIX); + } + + @Override + String encryptFolderName(CryptoFolder cryptoFolder, String name) throws BackendException { + return encryptName(cryptoFolder, name, DIR_PREFIX); + } + + @Override + CryptoFolder move(CryptoFolder source, CryptoFolder target) throws BackendException { + assertCryptoFolderAlreadyExists(target); + CryptoFolder result = folder(target.getParent(), target.getName(), cloudContentRepository.move(source.getDirFile(), target.getDirFile())); + + evictFromCache(source); + evictFromCache(target); + return result; + } + + @Override + CryptoFile move(CryptoFile source, CryptoFile target) throws BackendException { + assertCryptoFileAlreadyExists(target); + return file(target, cloudContentRepository.move(source.getCloudFile(), target.getCloudFile()), source.getSize()); + } + + @Override + void delete(CloudNode node) throws BackendException { + if (node instanceof CryptoFolder) { + CryptoFolder cryptoFolder = (CryptoFolder) node; + List cryptoSubfolders = deepCollectSubfolders(cryptoFolder); + for (CryptoFolder cryptoSubfolder : cryptoSubfolders) { + cloudContentRepository.delete(dirIdInfo(cryptoSubfolder).getCloudFolder()); + } + cloudContentRepository.delete(dirIdInfo(cryptoFolder).getCloudFolder()); + cloudContentRepository.delete(cryptoFolder.getDirFile()); + evictFromCache(cryptoFolder); + } else if (node instanceof CryptoFile) { + CryptoFile cryptoFile = (CryptoFile) node; + cloudContentRepository.delete(cryptoFile.getCloudFile()); + } + } + + @Override + String loadDirId(CryptoFolder folder) throws BackendException, EmptyDirFileException { + if (RootCryptoFolder.isRoot(folder)) { + return CryptoConstants.ROOT_DIR_ID; + } else if (cloudContentRepository.exists(folder.getDirFile())) { + return new String(loadContentsOfDirFile(folder), UTF_8); + } else { + return newDirId(); + } + } + + @Override + DirIdCache.DirIdInfo createDirIdInfo(CryptoFolder folder) throws BackendException { + String dirId = loadDirId(folder); + return dirIdCache.put(folder, createDirIdInfoFor(dirId)); + } + + @Override + public CryptoFile write(CryptoFile cryptoFile, DataSource data, ProgressAware progressAware, boolean replace, long length) throws BackendException { + return writeShortNameFile(cryptoFile, data, progressAware, replace, length); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoNode.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoNode.java new file mode 100644 index 000000000..bd22fca3f --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoNode.java @@ -0,0 +1,6 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.CloudNode; + +interface CryptoNode extends CloudNode { +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoSymlink.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoSymlink.java new file mode 100644 index 000000000..4c9d6e777 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptoSymlink.java @@ -0,0 +1,80 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class CryptoSymlink implements CloudFile, CryptoNode { + + private final String name; + private final String path; + private final String target; + private final CloudFile cloudFile; + private final CryptoFolder parent; + + public CryptoSymlink(CryptoFolder parent, String name, String path, String target, CloudFile cloudFile) { + this.parent = parent; + this.name = name; + this.path = path; + this.target = target; + this.cloudFile = cloudFile; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public CryptoFolder getParent() { + return parent; + } + + @Override + public Optional getSize() { + return Optional.of((long) target.length()); + } + + @Override + public Optional getModified() { + return cloudFile.getModified(); + } + + /** + * @return The actual file in the underlying, i.e. decorated, CloudContentRepository + */ + CloudFile getCloudFile() { + return cloudFile; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((CryptoSymlink) obj); + } + + private boolean internalEquals(CryptoSymlink obj) { + return path != null && path.equals(obj.path); + } + + @Override + public int hashCode() { + return path == null ? 0 : path.hashCode(); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/Cryptors.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/Cryptors.java new file mode 100644 index 000000000..6ac46823b --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/Cryptors.java @@ -0,0 +1,144 @@ +package org.cryptomator.data.cloud.crypto; + +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.MissingCryptorException; +import org.cryptomator.util.Optional; +import org.cryptomator.util.Supplier; + +public abstract class Cryptors { + + Cryptors() { + } + + public abstract boolean isEmpty(); + + public abstract int size(); + + public abstract Supplier get(Vault vault); + + public abstract Optional remove(Vault vault); + + public abstract boolean putIfAbsent(Vault vault, Cryptor cryptor); + + public static class Delegating extends Cryptors { + + private final Cryptors.Default fallback = new Cryptors.Default(); + + private volatile Cryptors.Default delegate; + + public synchronized void setDelegate(Cryptors.Default delegate) { + delegate.putAll(fallback.cryptors); + this.delegate = delegate; + } + + public synchronized void removeDelegate() { + fallback.putAll(delegate.cryptors); + this.delegate = null; + } + + @Override + public synchronized boolean isEmpty() { + return delegate().isEmpty(); + } + + @Override + public synchronized int size() { + return delegate().size(); + } + + @Override + public synchronized Supplier get(Vault vault) { + return delegate().get(vault); + } + + @Override + public synchronized Optional remove(Vault vault) { + return delegate().remove(vault); + } + + @Override + public synchronized boolean putIfAbsent(Vault vault, Cryptor cryptor) { + return delegate().putIfAbsent(vault, cryptor); + } + + private synchronized Cryptors delegate() { + if (delegate == null) { + return fallback; + } else { + return delegate; + } + } + + } + + public static class Default extends Cryptors { + + private final ConcurrentMap cryptors = new ConcurrentHashMap<>(); + + private Runnable onChangeListener = () -> { + }; + + public boolean isEmpty() { + return cryptors.isEmpty(); + } + + public int size() { + return cryptors.size(); + } + + public Supplier get(final Vault vault) { + return () -> { + Cryptor cryptor = cryptors.get(vault); + if (cryptor == null) { + throw new MissingCryptorException(); + } else { + return cryptor; + } + }; + } + + public Optional remove(Vault vault) { + Optional result = Optional.ofNullable(cryptors.remove(vault)); + if (result.isPresent()) { + onChangeListener.run(); + } + return result; + } + + public boolean putIfAbsent(Vault vault, Cryptor cryptor) { + if (cryptors.putIfAbsent(vault, cryptor) == null) { + onChangeListener.run(); + return true; + } else { + return false; + } + } + + public void setOnChangeListener(Runnable onChangeListener) { + this.onChangeListener = onChangeListener; + } + + public void putAll(Map cryptors) { + this.cryptors.putAll(cryptors); + onChangeListener.run(); + } + + public void destroyAll() { + while (!isEmpty()) { + Iterator cryptorIterator = cryptors.values().iterator(); + while (cryptorIterator.hasNext()) { + cryptorIterator.next().destroy(); + cryptorIterator.remove(); + } + } + onChangeListener.run(); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptorsModule.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptorsModule.java new file mode 100644 index 000000000..0463e7e8e --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/CryptorsModule.java @@ -0,0 +1,23 @@ +package org.cryptomator.data.cloud.crypto; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class CryptorsModule { + + private final Cryptors cryptors; + + public CryptorsModule(Cryptors cryptors) { + this.cryptors = cryptors; + } + + @Singleton + @Provides + public Cryptors provideCryptors() { + return cryptors; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCache.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCache.java new file mode 100644 index 000000000..289d2d84e --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCache.java @@ -0,0 +1,38 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.CloudFolder; + +interface DirIdCache { + + DirIdInfo get(CryptoFolder folder); + + DirIdInfo put(CryptoFolder folder, DirIdInfo dirIdInfo); + + void evict(CryptoFolder folder); + + void evictSubFoldersOf(CryptoFolder cryptoFolder); + + class DirIdInfo { + + private final String id; + private final CloudFolder cloudFolder; + + DirIdInfo(String id, CloudFolder cloudFolder) { + this.id = id; + this.cloudFolder = cloudFolder; + } + + public String getId() { + return id; + } + + public CloudFolder getCloudFolder() { + return cloudFolder; + } + + DirIdInfo withCloudFolder(CloudFolder cloudFolder) { + return new DirIdInfo(id, cloudFolder); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormat7.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormat7.java new file mode 100644 index 000000000..853366aa3 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormat7.java @@ -0,0 +1,78 @@ +package org.cryptomator.data.cloud.crypto; + +import java.util.Map; + +import android.util.LruCache; + +class DirIdCacheFormat7 implements DirIdCache { + + private static final int MAX_SIZE = 1024; + + private final LruCache cache = new LruCache<>(MAX_SIZE); + + DirIdCacheFormat7() { + } + + @Override + public DirIdInfo get(CryptoFolder folder) { + return cache.get(DirIdCacheKey.toKey(folder)); + } + + @Override + public DirIdInfo put(CryptoFolder folder, DirIdInfo dirIdInfo) { + DirIdCacheKey key = DirIdCacheKey.toKey(folder); + cache.put(key, dirIdInfo); + return dirIdInfo; + } + + @Override + public void evict(CryptoFolder folder) { + DirIdCacheKey key = DirIdCacheKey.toKey(folder); + cache.remove(key); + } + + @Override + public void evictSubFoldersOf(CryptoFolder folder) { + Map cacheSnapshot = cache.snapshot(); + for (Map.Entry cacheEntry : cacheSnapshot.entrySet()) { + if (cacheEntry.getKey().path.startsWith(folder.getPath() + "/")) { + cache.remove(cacheEntry.getKey()); + } + } + } + + private static class DirIdCacheKey { + + static DirIdCacheKey toKey(CryptoFolder folder) { + return new DirIdCacheKey(folder.getPath()); + } + + private final String path; + + private DirIdCacheKey(String path) { + this.path = path; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + return internalEquals((DirIdCacheKey) obj); + } + + private boolean internalEquals(DirIdCacheKey o) { + return (path == null ? o.path == null : path.equals(o.path)); + } + + @Override + public int hashCode() { + final int prime = 31; + int hash = 1940604225; + hash = hash * prime + (path == null ? 0 : path.hashCode()); + return hash; + } + + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormatPre7.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormatPre7.java new file mode 100644 index 000000000..2235420ee --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/DirIdCacheFormatPre7.java @@ -0,0 +1,86 @@ +package org.cryptomator.data.cloud.crypto; + +import android.util.LruCache; + +import org.cryptomator.domain.CloudFile; + +import java.util.Date; + +class DirIdCacheFormatPre7 implements DirIdCache { + + private static final int MAX_SIZE = 1024; + + private final LruCache cache = new LruCache<>(MAX_SIZE); + + DirIdCacheFormatPre7() { + } + + public DirIdInfo get(CryptoFolder folder) { + return cache.get(DirIdCacheKey.toKey(folder)); + } + + public DirIdInfo put(CryptoFolder folder, DirIdInfo dirIdInfo) { + DirIdCacheKey key = DirIdCacheKey.toKey(folder); + cache.put(key, dirIdInfo); + cache.remove(key.withoutModified()); + return dirIdInfo; + } + + public void evict(CryptoFolder folder) { + DirIdCacheKey key = DirIdCacheKey.toKey(folder); + cache.remove(key); + cache.remove(key.withoutModified()); + } + + @Override + public void evictSubFoldersOf(CryptoFolder cryptoFolder) { + // no implementation needed + } + + private static class DirIdCacheKey { + + static DirIdCacheKey toKey(CryptoFolder folder) { + return new DirIdCacheKey(folder.getDirFile()); + } + + private final String path; + private final Date modified; + + private DirIdCacheKey(CloudFile dirFile) { + this.path = dirFile == null ? null : dirFile.getPath(); + this.modified = dirFile == null ? null : dirFile.getModified().orElse(null); + } + + private DirIdCacheKey(String path) { + this.path = path; + this.modified = null; + } + + DirIdCacheKey withoutModified() { + return new DirIdCacheKey(path); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + return internalEquals((DirIdCacheKey) obj); + } + + private boolean internalEquals(DirIdCacheKey o) { + return (path == null ? o.path == null : path.equals(o.path)) // + && (modified == null ? o.modified == null : modified.equals(o.modified)); + } + + @Override + public int hashCode() { + final int prime = 31; + int hash = 1940604225; + hash = hash * prime + (path == null ? 0 : path.hashCode()); + hash = hash * prime + (modified == null ? 0 : modified.hashCode()); + return hash; + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/crypto/RootCryptoFolder.java b/data/src/main/java/org/cryptomator/data/cloud/crypto/RootCryptoFolder.java new file mode 100644 index 000000000..80594edcc --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/crypto/RootCryptoFolder.java @@ -0,0 +1,27 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; + +class RootCryptoFolder extends CryptoFolder { + + public static boolean isRoot(CryptoFolder folder) { + return folder instanceof RootCryptoFolder; + } + + private final CryptoCloud cloud; + + public RootCryptoFolder(CryptoCloud cloud) { + super(null, "", "", null); + this.cloud = cloud; + } + + @Override + public Cloud getCloud() { + return cloud; + } + + @Override + public CryptoFolder withCloud(Cloud cloud) { + return new RootCryptoFolder((CryptoCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxClientFactory.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxClientFactory.java new file mode 100644 index 000000000..efddfdf07 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxClientFactory.java @@ -0,0 +1,56 @@ +package org.cryptomator.data.cloud.dropbox; + +import android.content.Context; + +import com.dropbox.core.DbxRequestConfig; +import com.dropbox.core.http.OkHttp3Requestor; +import com.dropbox.core.v2.DbxClientV2; + +import org.cryptomator.data.BuildConfig; +import org.cryptomator.data.cloud.okhttplogging.HttpLoggingInterceptor; + +import java.util.Locale; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import timber.log.Timber; + +import static org.cryptomator.data.util.NetworkTimeout.CONNECTION; +import static org.cryptomator.data.util.NetworkTimeout.READ; +import static org.cryptomator.data.util.NetworkTimeout.WRITE; + +class DropboxClientFactory { + + private DbxClientV2 sDbxClient; + + public DbxClientV2 getClient(String accessToken, Context context) { + if (sDbxClient == null) { + sDbxClient = createDropboxClient(accessToken, context); + } + return sDbxClient; + } + + private DbxClientV2 createDropboxClient(String accessToken, Context context) { + String userLocale = Locale.getDefault().toString(); + + OkHttpClient okHttpClient = new OkHttpClient() // + .newBuilder() // + .connectTimeout(CONNECTION.getTimeout(), CONNECTION.getUnit()) // + .readTimeout(READ.getTimeout(), READ.getUnit()) // + .writeTimeout(WRITE.getTimeout(), WRITE.getUnit()) // + .addInterceptor(httpLoggingInterceptor(context)) // + .build(); + + DbxRequestConfig requestConfig = DbxRequestConfig // + .newBuilder("Cryptomator-Android/" + BuildConfig.VERSION_NAME) // + .withUserLocale(userLocale) // + .withHttpRequestor(new OkHttp3Requestor(okHttpClient)) // + .build(); + + return new DbxClientV2(requestConfig, accessToken); + } + + private static Interceptor httpLoggingInterceptor(Context context) { + return new HttpLoggingInterceptor(message -> Timber.tag("OkHttp").d(message), context); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepository.java new file mode 100644 index 000000000..8bae83e9a --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepository.java @@ -0,0 +1,213 @@ +package org.cryptomator.data.cloud.dropbox; + +import android.content.Context; + +import com.dropbox.core.DbxException; +import com.dropbox.core.InvalidAccessTokenException; +import com.dropbox.core.NetworkIOException; +import com.dropbox.core.v2.files.CreateFolderErrorException; +import com.dropbox.core.v2.files.DeleteErrorException; +import com.dropbox.core.v2.files.DownloadErrorException; +import com.dropbox.core.v2.files.ListFolderErrorException; +import com.dropbox.core.v2.files.RelocationErrorException; + +import org.cryptomator.data.cloud.InterceptingCloudContentRepository; +import org.cryptomator.domain.DropboxCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NetworkConnectionException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +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; +import static org.cryptomator.util.ExceptionUtil.extract; + +class DropboxCloudContentRepository extends InterceptingCloudContentRepository { + + private final DropboxCloud cloud; + + public DropboxCloudContentRepository(DropboxCloud cloud, Context context) { + super(new Intercepted(cloud, context)); + this.cloud = cloud; + } + + @Override + protected void throwWrappedIfRequired(Exception e) throws BackendException { + throwConnectionErrorIfRequired(e); + throwWrongCredentialsExceptionIfRequired(e); + } + + private void throwConnectionErrorIfRequired(Exception e) throws NetworkConnectionException { + if (contains(e, NetworkIOException.class)) { + throw new NetworkConnectionException(e); + } + } + + private void throwWrongCredentialsExceptionIfRequired(Exception e) { + if (contains(e, InvalidAccessTokenException.class)) { + throw new WrongCredentialsException(cloud); + } + } + + private static class Intercepted implements CloudContentRepository { + + private final DropboxImpl cloud; + + public Intercepted(DropboxCloud cloud, Context context) { + this.cloud = new DropboxImpl(cloud, context); + } + + public DropboxFolder root(DropboxCloud cloud) { + return this.cloud.root(); + } + + @Override + public DropboxFolder resolve(DropboxCloud cloud, String path) { + return this.cloud.resolve(path); + } + + @Override + public DropboxFile file(DropboxFolder parent, String name) { + return cloud.file(parent, name); + } + + @Override + public DropboxFile file(DropboxFolder parent, String name, Optional size) throws BackendException { + return cloud.file(parent, name, size); + } + + @Override + public DropboxFolder folder(DropboxFolder parent, String name) { + return cloud.folder(parent, name); + } + + @Override + public boolean exists(DropboxNode node) throws BackendException { + try { + return cloud.exists(node); + } catch (DbxException e) { + throw new FatalBackendException(e); + } + } + + @Override + public List list(DropboxFolder folder) throws BackendException { + try { + return cloud.list(folder); + } catch (DbxException e) { + if (e instanceof ListFolderErrorException) { + if (((ListFolderErrorException) e).errorValue.getPathValue().isNotFound()) { + throw new NoSuchCloudFileException(); + } + } + throw new FatalBackendException(e); + } + } + + @Override + public DropboxFolder create(DropboxFolder folder) throws BackendException { + try { + return cloud.create(folder); + } catch (DbxException e) { + if (e instanceof CreateFolderErrorException) { + throw new CloudNodeAlreadyExistsException(folder.getName()); + } + throw new FatalBackendException(e); + } + } + + @Override + public DropboxFolder move(DropboxFolder source, DropboxFolder target) throws BackendException { + try { + return (DropboxFolder) cloud.move(source, target); + } catch (DbxException e) { + if (e instanceof RelocationErrorException) { + if (extract(e, RelocationErrorException.class).get().errorValue.isFromLookup()) { + throw new NoSuchCloudFileException(source.getName()); + } + throw new CloudNodeAlreadyExistsException(target.getName()); + } + throw new FatalBackendException(e); + } + } + + @Override + public DropboxFile move(DropboxFile source, DropboxFile target) throws BackendException { + try { + return (DropboxFile) cloud.move(source, target); + } catch (DbxException e) { + if (e instanceof RelocationErrorException) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + throw new FatalBackendException(e); + } + } + + @Override + public DropboxFile write(DropboxFile uploadFile, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return cloud.write(uploadFile, data, progressAware, replace, size); + } catch (IOException | DbxException e) { + if (contains(e, NoSuchCloudFileException.class)) { + throw new NoSuchCloudFileException(uploadFile.getName()); + } + throw new FatalBackendException(e); + } + } + + @Override + public void read(DropboxFile file, Optional encryptedTmpFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + cloud.read(file, encryptedTmpFile, data, progressAware); + } catch (IOException | DbxException e) { + if (contains(e, DownloadErrorException.class)) { + if (extract(e, DownloadErrorException.class).get().errorValue.getPathValue().isNotFound()) { + throw new NoSuchCloudFileException(file.getName()); + } + } + throw new FatalBackendException(e); + } + } + + @Override + public void delete(DropboxNode node) throws BackendException { + try { + cloud.delete(node); + } catch (DbxException e) { + if (contains(e, DeleteErrorException.class)) { + if (extract(e, DeleteErrorException.class).get().errorValue.getPathLookupValue().isNotFound()) { + throw new NoSuchCloudFileException(node.getName()); + } + } + throw new FatalBackendException(e); + } + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(DropboxCloud cloud) throws BackendException { + try { + return this.cloud.currentAccount(); + } catch (DbxException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void logout(DropboxCloud cloud) throws BackendException { + // empty + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepositoryFactory.java new file mode 100644 index 000000000..bba187e95 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudContentRepositoryFactory.java @@ -0,0 +1,35 @@ +package org.cryptomator.data.cloud.dropbox; + +import android.content.Context; + +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.DropboxCloud; +import org.cryptomator.domain.repository.CloudContentRepository; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.domain.CloudType.DROPBOX; + +@Singleton +public class DropboxCloudContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final Context context; + + @Inject + public DropboxCloudContentRepositoryFactory(Context context) { + this.context = context; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == DROPBOX; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + return new DropboxCloudContentRepository((DropboxCloud) cloud, context); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudNodeFactory.java new file mode 100644 index 000000000..d5f5439e9 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxCloudNodeFactory.java @@ -0,0 +1,39 @@ +package org.cryptomator.data.cloud.dropbox; + +import org.cryptomator.util.Optional; + +import com.dropbox.core.v2.files.FileMetadata; +import com.dropbox.core.v2.files.FolderMetadata; +import com.dropbox.core.v2.files.Metadata; + +class DropboxCloudNodeFactory { + + public static DropboxFile from(DropboxFolder parent, FileMetadata metadata) { + return new DropboxFile(parent, metadata.getName(), metadata.getPathDisplay(), Optional.ofNullable(metadata.getSize()), Optional.ofNullable(metadata.getServerModified())); + } + + public static DropboxFile file(DropboxFolder parent, String name, Optional size, String path) { + return new DropboxFile(parent, name, path, size, Optional.empty()); + } + + public static DropboxFolder from(DropboxFolder parent, FolderMetadata metadata) { + return new DropboxFolder(parent, metadata.getName(), getNodePath(parent, metadata.getName())); + } + + private static String getNodePath(DropboxFolder parent, String name) { + return parent.getPath() + "/" + name; + } + + public static DropboxFolder folder(DropboxFolder parent, String name, String path) { + return new DropboxFolder(parent, name, path); + } + + public static DropboxNode from(DropboxFolder parent, Metadata metadata) { + if (metadata instanceof FileMetadata) { + return from(parent, (FileMetadata) metadata); + } else { + return from(parent, (FolderMetadata) metadata); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFile.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFile.java new file mode 100644 index 000000000..b1fa31b25 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFile.java @@ -0,0 +1,55 @@ +package org.cryptomator.data.cloud.dropbox; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class DropboxFile implements CloudFile, DropboxNode { + + private final DropboxFolder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + + public DropboxFile(DropboxFolder 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 DropboxFolder 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/dropbox/DropboxFolder.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFolder.java new file mode 100644 index 000000000..c762ff925 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxFolder.java @@ -0,0 +1,42 @@ +package org.cryptomator.data.cloud.dropbox; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +class DropboxFolder implements CloudFolder, DropboxNode { + + private final DropboxFolder parent; + private final String name; + private final String path; + + public DropboxFolder(DropboxFolder 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 DropboxFolder getParent() { + return parent; + } + + @Override + public DropboxFolder withCloud(Cloud cloud) { + return new DropboxFolder(parent.withCloud(cloud), name, path); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxImpl.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxImpl.java new file mode 100644 index 000000000..19fa607b2 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxImpl.java @@ -0,0 +1,468 @@ +package org.cryptomator.data.cloud.dropbox; + +import android.content.Context; + +import com.dropbox.core.DbxException; +import com.dropbox.core.NetworkIOException; +import com.dropbox.core.RetryException; +import com.dropbox.core.v2.DbxClientV2; +import com.dropbox.core.v2.files.CommitInfo; +import com.dropbox.core.v2.files.CreateFolderResult; +import com.dropbox.core.v2.files.FileMetadata; +import com.dropbox.core.v2.files.FolderMetadata; +import com.dropbox.core.v2.files.GetMetadataErrorException; +import com.dropbox.core.v2.files.ListFolderResult; +import com.dropbox.core.v2.files.Metadata; +import com.dropbox.core.v2.files.RelocationResult; +import com.dropbox.core.v2.files.UploadSessionCursor; +import com.dropbox.core.v2.files.UploadSessionFinishErrorException; +import com.dropbox.core.v2.files.UploadSessionLookupErrorException; +import com.dropbox.core.v2.files.WriteMode; +import com.dropbox.core.v2.users.FullAccount; +import com.tomclaw.cache.DiskLruCache; + +import org.cryptomator.data.util.TransferredBytesAwareInputStream; +import org.cryptomator.data.util.TransferredBytesAwareOutputStream; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.DropboxCloud; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.authentication.AuthenticationException; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +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.crypto.CredentialCryptor; +import org.cryptomator.util.file.LruFileCacheUtil; + +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 timber.log.Timber; + +import static org.cryptomator.domain.usecases.cloud.Progress.progress; +import static org.cryptomator.util.file.LruFileCacheUtil.Cache.DROPBOX; +import static org.cryptomator.util.file.LruFileCacheUtil.retrieveFromLruCache; +import static org.cryptomator.util.file.LruFileCacheUtil.storeToLruCache; + +class DropboxImpl { + + private static final long CHUNKED_UPLOAD_CHUNK_SIZE = 8L << 20; + private static final int CHUNKED_UPLOAD_MAX_ATTEMPTS = 5; + + private final DropboxClientFactory clientFactory = new DropboxClientFactory(); + private final DropboxCloud cloud; + private final RootDropboxFolder root; + private final Context context; + private final SharedPreferencesHandler sharedPreferencesHandler; + + private DiskLruCache diskLruCache; + + DropboxImpl(DropboxCloud cloud, Context context) { + if (cloud.accessToken() == null) { + throw new NoAuthenticationProvidedException(cloud); + } + this.cloud = cloud; + this.root = new RootDropboxFolder(cloud); + this.context = context; + + sharedPreferencesHandler = new SharedPreferencesHandler(context); + } + + private DbxClientV2 client() throws AuthenticationException { + return clientFactory.getClient(decrypt(cloud.accessToken()), context); + } + + private String decrypt(String password) { + return CredentialCryptor // + .getInstance(context) // + .decrypt(password); + } + + public DropboxFolder root() { + return root; + } + + public DropboxFolder resolve(String path) { + if (path.startsWith("/")) { + path = path.substring(1); + } + String[] names = path.split("/"); + DropboxFolder folder = root; + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + public DropboxFile file(CloudFolder folder, String name) { + return file(folder, name, Optional.empty()); + } + + public DropboxFile file(CloudFolder folder, String name, Optional size) { + return DropboxCloudNodeFactory.file( // + (DropboxFolder) folder, // + name, // + size, // + folder.getPath() + '/' + name); + } + + public DropboxFolder folder(CloudFolder folder, String name) { + return DropboxCloudNodeFactory.folder( // + (DropboxFolder) folder, // + name, // + folder.getPath() + '/' + name); + } + + public boolean exists(CloudNode node) throws AuthenticationException, DbxException { + try { + Metadata metadata = client() // + .files() // + .getMetadata(node.getPath()); + if (node instanceof CloudFolder) { + return metadata instanceof FolderMetadata; + } else { + return metadata instanceof FileMetadata; + } + } catch (GetMetadataErrorException e) { + if (e.errorValue.isPath()) { + return false; + } + throw e; + } + } + + public List list(CloudFolder folder) throws AuthenticationException, DbxException { + List result = new ArrayList<>(); + ListFolderResult listFolderResult = null; + do { + if (listFolderResult == null) { + listFolderResult = client() // + .files() // + .listFolder(folder.getPath()); + } else { + String cursor = listFolderResult.getCursor(); + listFolderResult = client() // + .files() // + .listFolderContinue(cursor); + } + List entryMetadata = listFolderResult.getEntries(); + for (Metadata metadata : entryMetadata) { + result.add(DropboxCloudNodeFactory.from( // + (DropboxFolder) folder, // + metadata)); + } + } while (listFolderResult.getHasMore()); + return result; + } + + public DropboxFolder create(CloudFolder folder) throws AuthenticationException, DbxException { + CreateFolderResult createFolderResult = client() // + .files() // + .createFolderV2(folder.getPath()); + + return DropboxCloudNodeFactory.from( // + (DropboxFolder) folder.getParent(), // + createFolderResult.getMetadata()); + } + + public CloudNode move(CloudNode source, CloudNode target) throws AuthenticationException, DbxException { + RelocationResult relocationResult = client() // + .files() // + .moveV2(source.getPath(), target.getPath()); + + return DropboxCloudNodeFactory.from( // + (DropboxFolder) target.getParent(), // + relocationResult.getMetadata()); + } + + public DropboxFile write(DropboxFile file, DataSource data, final ProgressAware progressAware, boolean replace, long size) + throws AuthenticationException, DbxException, IOException, CloudNodeAlreadyExistsException { + if (exists(file) && !replace) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + progressAware.onProgress(Progress.started(UploadState.upload(file))); + WriteMode writeMode = WriteMode.ADD; + if (replace) { + writeMode = WriteMode.OVERWRITE; + } + // "Upload the file with simple upload API if it is small enough, otherwise use chunked + // upload API for better performance. Arbitrarily chose 2 times our chunk size as the + // deciding factor. This should really depend on your network." + // Source: https://github.com/dropbox/dropbox-sdk-java/blob/master/examples/upload-file/src/main/java/com/dropbox/core/examples/upload_file/Main.java + if (size <= (2 * CHUNKED_UPLOAD_CHUNK_SIZE)) { + uploadFile(file, data, progressAware, writeMode, size); + } else { + chunkedUploadFile(file, data, progressAware, writeMode, size); + } + FileMetadata metadata = (FileMetadata) client() // + .files() // + .getMetadata(file.getPath()); + + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + + return DropboxCloudNodeFactory.from( // + file.getParent(), // + metadata); + } + + private void uploadFile(final DropboxFile file, DataSource data, final ProgressAware progressAware, WriteMode writeMode, final long size) // + throws AuthenticationException, DbxException, IOException { + try (TransferredBytesAwareInputStream in = new TransferredBytesAwareInputStream(data.open(context)) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }) { + client() // + .files() // + .uploadBuilder(file.getPath()) // + .withMode(writeMode) // + .uploadAndFinish(in); + } + } + + private void chunkedUploadFile(final DropboxFile file, DataSource data, final ProgressAware progressAware, WriteMode writeMode, final long size) + throws AuthenticationException, DbxException, IOException { + // Assert our file is at least the chunk upload size. We make this assumption in the code + // below to simplify the logic. + if (size < CHUNKED_UPLOAD_CHUNK_SIZE) { + throw new FatalBackendException("File too small, use uploadFile() instead."); + } + + long uploaded = 0L; + DbxException thrown = null; + + try (InputStream stream = data.open(context)) { + + // Chunked uploads have 3 phases, each of which can accept uploaded bytes: + // + // (1) Start: initiate the upload and get an upload session ID + // (2) Append: upload chunks of the file to append to our session + // (3) Finish: commit the upload and close the session + // + // We track how many bytes we uploaded to determine which phase we should be in. + String sessionId = null; + for (int i = 0; i < CHUNKED_UPLOAD_MAX_ATTEMPTS; i++) { + if (i > 0) { + Timber.v("Retrying chunked upload (" + (i + 1) + " / " + CHUNKED_UPLOAD_MAX_ATTEMPTS + " attempts)"); + } + + try { + // if this is a retry, make sure seek to the correct offset + stream.skip(uploaded); + + // (1) Start + if (sessionId == null) { + sessionId = client() // + .files() // + .uploadSessionStart() // + .uploadAndFinish(new TransferredBytesAwareInputStream(stream) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }, CHUNKED_UPLOAD_CHUNK_SIZE).getSessionId(); + uploaded += CHUNKED_UPLOAD_CHUNK_SIZE; + + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(uploaded)); + } + + UploadSessionCursor cursor = new UploadSessionCursor(sessionId, uploaded); + + // (2) Append + while ((size - uploaded) > CHUNKED_UPLOAD_CHUNK_SIZE) { + final long fullyUploaded = uploaded; + client() // + .files() // + .uploadSessionAppendV2(cursor) // + .uploadAndFinish(new TransferredBytesAwareInputStream(stream) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(fullyUploaded + transferred)); + } + }, CHUNKED_UPLOAD_CHUNK_SIZE); + uploaded += CHUNKED_UPLOAD_CHUNK_SIZE; + + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(uploaded)); + + cursor = new UploadSessionCursor(sessionId, uploaded); + } + + // (3) Finish + long remaining = size - uploaded; + CommitInfo commitInfo = CommitInfo // + .newBuilder(file.getPath()) // + .withMode(writeMode) // + .build(); + + client() // + .files() // + .uploadSessionFinish(cursor, commitInfo) // + .uploadAndFinish(stream, remaining); + + return; + } catch (RetryException ex) { + thrown = ex; + // RetryExceptions are never automatically retried by the client for uploads. Must + // catch this exception even if DbxRequestConfig.getMaxRetries() > 0. + sleepQuietly(ex.getBackoffMillis()); + } catch (NetworkIOException ex) { + thrown = ex; + // Network issue with Dropbox (maybe a timeout?), try again. + } catch (UploadSessionLookupErrorException ex) { + if (ex.errorValue.isIncorrectOffset()) { + thrown = ex; + // Server offset into the stream doesn't match our offset (uploaded). Seek to + // the expected offset according to the server and try again. + uploaded = ex. // + errorValue. // + getIncorrectOffsetValue(). // + getCorrectOffset(); + } else { + throw new FatalBackendException(ex); + } + } catch (UploadSessionFinishErrorException ex) { + if (ex.errorValue.isLookupFailed() && ex.errorValue.getLookupFailedValue().isIncorrectOffset()) { + thrown = ex; + // Server offset into the stream doesn't match our offset (uploaded). Seek to + // the expected offset according to the server and try again. + uploaded = ex. // + errorValue. // + getLookupFailedValue(). // + getIncorrectOffsetValue(). // + getCorrectOffset(); + } else { + throw new FatalBackendException(ex); + } + } + } + } + + throw new FatalBackendException("Maxed out upload attempts to Dropbox.", thrown); + } + + public void read(CloudFile file, Optional encryptedTmpFile, OutputStream data, final ProgressAware progressAware) throws DbxException, IOException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + + Optional cacheKey = Optional.empty(); + Optional cacheFile = Optional.empty(); + + if (sharedPreferencesHandler.useLruCache() && createLruCache(sharedPreferencesHandler.lruCacheSize())) { + final FileMetadata fileMetadata = (FileMetadata) client() // + .files() // + .getMetadata(file.getPath()); + cacheKey = Optional.of(fileMetadata.getId() + fileMetadata.getRev()); + java.io.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("DropboxImpl").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 CloudFile file, // + final OutputStream data, // + final Optional encryptedTmpFile, // + final Optional cacheKey, // + final ProgressAware progressAware) throws DbxException, IOException { + try (TransferredBytesAwareOutputStream out = new TransferredBytesAwareOutputStream(data) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(DownloadState.download(file)) // + .between(0) // + .and(file.getSize().orElse(Long.MAX_VALUE)) // + .withValue(transferred)); + } + }) { + client() // + .files() // + .download(file.getPath()) // + .download(out); + } + + if (sharedPreferencesHandler.useLruCache() && encryptedTmpFile.isPresent() && cacheKey.isPresent()) { + try { + storeToLruCache(diskLruCache, cacheKey.get(), encryptedTmpFile.get()); + } catch (IOException e) { + Timber.tag("DropboxImpl").e(e, "Failed to write downloaded file in LRU cache"); + } + } + } + + private boolean createLruCache(int cacheSize) { + if (diskLruCache == null) { + try { + diskLruCache = DiskLruCache.create(new LruFileCacheUtil(context).resolve(DROPBOX), cacheSize); + } catch (IOException e) { + Timber.tag("DropboxImpl").e(e, "Failed to setup LRU cache"); + return false; + } + } + + return true; + } + + public void delete(CloudNode node) throws AuthenticationException, DbxException { + client() // + .files() // + .deleteV2(node.getPath()); + } + + public String currentAccount() throws AuthenticationException, DbxException { + FullAccount currentAccount = client() // + .users() // + .getCurrentAccount(); + return currentAccount.getName().getDisplayName(); + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ex) { + throw new FatalBackendException("Error uploading to Dropbox: interrupted during backoff."); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxNode.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxNode.java new file mode 100644 index 000000000..665e7d0a2 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/DropboxNode.java @@ -0,0 +1,6 @@ +package org.cryptomator.data.cloud.dropbox; + +import org.cryptomator.domain.CloudNode; + +interface DropboxNode extends CloudNode { +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/dropbox/RootDropboxFolder.java b/data/src/main/java/org/cryptomator/data/cloud/dropbox/RootDropboxFolder.java new file mode 100644 index 000000000..e0be4aa01 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/dropbox/RootDropboxFolder.java @@ -0,0 +1,24 @@ +package org.cryptomator.data.cloud.dropbox; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.DropboxCloud; + +class RootDropboxFolder extends DropboxFolder { + + private final DropboxCloud cloud; + + public RootDropboxFolder(DropboxCloud cloud) { + super(null, "", ""); + this.cloud = cloud; + } + + @Override + public DropboxCloud getCloud() { + return cloud; + } + + @Override + public DropboxFolder withCloud(Cloud cloud) { + return new RootDropboxFolder((DropboxCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/FixedGoogleAccountCredential.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/FixedGoogleAccountCredential.java new file mode 100644 index 000000000..d0525a4eb --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/FixedGoogleAccountCredential.java @@ -0,0 +1,116 @@ +package org.cryptomator.data.cloud.googledrive; + +import android.accounts.Account; +import android.content.Context; + +import com.google.android.gms.auth.GoogleAuthException; +import com.google.android.gms.auth.GoogleAuthUtil; +import com.google.android.gms.auth.GooglePlayServicesAvailabilityException; +import com.google.android.gms.auth.UserRecoverableAuthException; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAuthIOException; +import com.google.api.client.googleapis.extensions.android.gms.auth.GooglePlayServicesAvailabilityIOException; +import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException; +import com.google.api.client.http.HttpExecuteInterceptor; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.util.BackOffUtils; +import com.google.api.client.util.Beta; +import com.google.api.client.util.Joiner; +import com.google.api.client.util.Preconditions; +import com.google.api.client.util.Sleeper; + +import org.cryptomator.data.util.NetworkTimeout; + +import java.io.IOException; +import java.util.Collection; + +import static com.google.android.gms.auth.GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE; + +class FixedGoogleAccountCredential extends GoogleAccountCredential { + + private String accountName; + + public static FixedGoogleAccountCredential usingOAuth2(Context context, Collection scopes) { + Preconditions.checkArgument(scopes != null && scopes.iterator().hasNext()); + String scopesStr = "oauth2:" + Joiner.on(' ').join(scopes); + return new FixedGoogleAccountCredential(context, scopesStr); + } + + private FixedGoogleAccountCredential(Context context, String scopesStr) { + super(context, scopesStr); + } + + @Override + public void initialize(HttpRequest request) { + FixedRequestHandler handler = new FixedRequestHandler(); + request.setInterceptor(handler); + request.setUnsuccessfulResponseHandler(handler); + request.setConnectTimeout((int) NetworkTimeout.CONNECTION.asMilliseconds()); + request.setReadTimeout((int) NetworkTimeout.READ.asMilliseconds()); + } + + void setAccountName(String accountName) { + this.accountName = accountName; + } + + @Override + public String getToken() throws IOException, GoogleAuthException { + if (getBackOff() != null) { + getBackOff().reset(); + } + + while (true) { + try { + Account accountDetails = new Account(accountName, GOOGLE_ACCOUNT_TYPE); + return GoogleAuthUtil.getToken(getContext(), accountDetails, getScope()); + } catch (IOException e) { + // network or server error, so retry using back-off policy + try { + if (getBackOff() == null || !BackOffUtils.next(Sleeper.DEFAULT, getBackOff())) { + throw e; + } + } catch (InterruptedException e2) { + // ignore + } + } + } + } + + @Beta + class FixedRequestHandler implements HttpExecuteInterceptor, HttpUnsuccessfulResponseHandler { + + /** Whether we've received a 401 error code indicating the token is invalid. */ + boolean received401; + String token; + + @Override + public void intercept(HttpRequest request) throws IOException { + try { + token = getToken(); + request.getHeaders().setAuthorization("Bearer " + token); + } catch (GooglePlayServicesAvailabilityException e) { + throw new GooglePlayServicesAvailabilityIOException(e); + } catch (UserRecoverableAuthException e) { + throw new UserRecoverableAuthIOException(e); + } catch (GoogleAuthException e) { + throw new GoogleAuthIOException(e); + } + } + + @Override + public boolean handleResponse(HttpRequest request, HttpResponse response, boolean supportsRetry) throws IOException { + if (response.getStatusCode() == 401 && !received401) { + received401 = true; + try { + GoogleAuthUtil.clearToken(getContext(), token); + } catch (GoogleAuthException e) { + throw new IOException(e); + } + return true; + } + return false; + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveClientFactory.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveClientFactory.java new file mode 100644 index 000000000..6775d7e8c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveClientFactory.java @@ -0,0 +1,34 @@ +package org.cryptomator.data.cloud.googledrive; + +import android.content.Context; + +import com.google.api.client.extensions.android.http.AndroidHttp; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; + +import org.cryptomator.data.BuildConfig; +import org.cryptomator.domain.exception.FatalBackendException; + +import java.util.Collections; + +class GoogleDriveClientFactory { + + private final Context context; + + GoogleDriveClientFactory(Context context) { + this.context = context; + } + + Drive getClient(String accountName) throws FatalBackendException { + try { + FixedGoogleAccountCredential credential = FixedGoogleAccountCredential.usingOAuth2(context, Collections.singleton(DriveScopes.DRIVE)); + credential.setAccountName(accountName); + return new Drive.Builder(AndroidHttp.newCompatibleTransport(), JacksonFactory.getDefaultInstance(), credential) // + .setApplicationName("Cryptomator-Android/" + BuildConfig.VERSION_NAME) // + .build(); + } catch (Exception e) { + throw new FatalBackendException(e); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepository.java new file mode 100644 index 000000000..a7e4b86c0 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepository.java @@ -0,0 +1,213 @@ +package org.cryptomator.data.cloud.googledrive; + +import android.content.Context; + +import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpStatusCodes; + +import org.cryptomator.data.cloud.InterceptingCloudContentRepository; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.GoogleDriveCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NetworkConnectionException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.authentication.UserRecoverableAuthenticationException; +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.ExceptionUtil; +import org.cryptomator.util.Optional; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.SocketTimeoutException; +import java.util.List; + +import static org.cryptomator.util.ExceptionUtil.contains; +import static org.cryptomator.util.ExceptionUtil.extract; + +class GoogleDriveCloudContentRepository extends InterceptingCloudContentRepository { + + private final GoogleDriveCloud cloud; + + GoogleDriveCloudContentRepository(Context context, GoogleDriveCloud cloud, GoogleDriveIdCache idCache) { + super(new Intercepted(context, cloud, idCache)); + this.cloud = cloud; + } + + @Override + protected void throwWrappedIfRequired(Exception e) throws BackendException { + throwConnectionErrorIfRequired(e); + throwUserRecoverableAuthenticationExceptionIfRequired(e); + throwNoSuchCloudFileExceptionIfRequired(e); + } + + private void throwUserRecoverableAuthenticationExceptionIfRequired(Exception e) { + Optional userRecoverableAuthIOException = extract(e, UserRecoverableAuthIOException.class); + if (userRecoverableAuthIOException.isPresent()) { + throw new UserRecoverableAuthenticationException(cloud, userRecoverableAuthIOException.get().getIntent()); + } + } + + private void throwConnectionErrorIfRequired(Exception e) throws NetworkConnectionException { + if (contains(e, SocketTimeoutException.class) || contains(e, IOException.class, ExceptionUtil.thatHasMessage("NetworkError"))) { + throw new NetworkConnectionException(e); + } + } + + private void throwNoSuchCloudFileExceptionIfRequired(Exception e) throws NoSuchCloudFileException { + if (contains(e, GoogleJsonResponseException.class)) { + if (extract(e, GoogleJsonResponseException.class).get().getStatusCode() == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { + throw new NoSuchCloudFileException(); + } + } + } + + private static class Intercepted implements CloudContentRepository { + + private final GoogleDriveImpl impl; + + public Intercepted(Context context, GoogleDriveCloud cloud, GoogleDriveIdCache idCache) { + this.impl = new GoogleDriveImpl(context, cloud, idCache); + } + + @Override + public GoogleDriveFolder root(GoogleDriveCloud cloud) throws BackendException { + return impl.root(); + } + + @Override + public GoogleDriveFolder resolve(GoogleDriveCloud cloud, String path) throws BackendException { + try { + return impl.resolve(path); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFile file(GoogleDriveFolder parent, String name) throws BackendException { + try { + return impl.file(parent, name); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFile file(GoogleDriveFolder parent, String name, Optional size) throws BackendException { + try { + return impl.file(parent, name, size); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFolder folder(GoogleDriveFolder parent, String name) throws BackendException { + try { + return impl.folder(parent, name); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public boolean exists(GoogleDriveNode node) throws BackendException { + try { + return impl.exists(node); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public List list(GoogleDriveFolder folder) throws BackendException { + try { + return impl.list(folder); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFolder create(GoogleDriveFolder folder) throws BackendException { + try { + return impl.create(folder); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFolder move(GoogleDriveFolder source, GoogleDriveFolder target) throws BackendException { + try { + if (source.getDriveId() == null) { + throw new NoSuchCloudFileException(source.getName()); + } + return (GoogleDriveFolder) impl.move(source, target); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFile move(GoogleDriveFile source, GoogleDriveFile target) throws BackendException { + try { + return (GoogleDriveFile) impl.move(source, target); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public GoogleDriveFile write(GoogleDriveFile file, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return impl.write(file, data, progressAware, replace, size); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void read(GoogleDriveFile file, Optional encryptedTmpFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + if (file.getDriveId() == null) { + throw new NoSuchCloudFileException(file.getName()); + } + impl.read(file, encryptedTmpFile, data, progressAware); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void delete(GoogleDriveNode node) throws BackendException { + try { + impl.delete(node); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(GoogleDriveCloud cloud) throws BackendException { + try { + return impl.currentAccount(); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void logout(GoogleDriveCloud cloud) throws BackendException { + // empty + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepositoryFactory.java new file mode 100644 index 000000000..b48527e77 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudContentRepositoryFactory.java @@ -0,0 +1,35 @@ +package org.cryptomator.data.cloud.googledrive; + +import android.content.Context; + +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.GoogleDriveCloud; +import org.cryptomator.domain.repository.CloudContentRepository; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class GoogleDriveCloudContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final Context context; + private final GoogleDriveIdCache idCache; + + @Inject + public GoogleDriveCloudContentRepositoryFactory(Context context, GoogleDriveIdCache idCache) { + this.context = context; + this.idCache = idCache; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == CloudType.GOOGLE_DRIVE; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + return new GoogleDriveCloudContentRepository(context, (GoogleDriveCloud) cloud, idCache); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudNodeFactory.java new file mode 100644 index 000000000..a3f35e33a --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveCloudNodeFactory.java @@ -0,0 +1,59 @@ +package org.cryptomator.data.cloud.googledrive; + +import com.google.api.services.drive.model.File; + +import org.cryptomator.util.Optional; + +import java.util.Date; + +class GoogleDriveCloudNodeFactory { + + public static GoogleDriveFile file(GoogleDriveFolder parent, File file) { + return new GoogleDriveFile(parent, file.getName(), getNodePath(parent, file.getName()), file.getId(), getFileSize(file), getModified(file)); + } + + public static GoogleDriveFile file(GoogleDriveFolder parent, String name, Optional size) { + return new GoogleDriveFile(parent, name, getNodePath(parent, name), null, size, Optional.empty()); + } + + private static Optional getModified(File file) { + return file.getModifiedTime() != null ? Optional.of(new Date(file.getModifiedTime().getValue())) : Optional.empty(); + } + + private static Optional getFileSize(File file) { + return file.getSize() != null ? Optional.of(file.getSize()) : Optional.empty(); + } + + public static GoogleDriveFile file(GoogleDriveFolder parent, String name, Optional size, String path, String driveId) { + return new GoogleDriveFile(parent, name, path, driveId, size, Optional.empty()); + } + + public static GoogleDriveFolder folder(GoogleDriveFolder parent, File file) { + return new GoogleDriveFolder(parent, file.getName(), getNodePath(parent, file.getName()), file.getId()); + } + + public static GoogleDriveFolder folder(GoogleDriveFolder parent, String name) { + return new GoogleDriveFolder(parent, name, getNodePath(parent, name), null); + } + + public static GoogleDriveFolder folder(GoogleDriveFolder parent, String name, String path, String driveId) { + return new GoogleDriveFolder(parent, name, path, driveId); + } + + public static GoogleDriveNode from(GoogleDriveFolder parent, File file) { + if (isFolder(file)) { + return folder(parent, file); + } else { + return file(parent, file); + } + } + + public static boolean isFolder(File file) { + return file.getMimeType().equals("application/vnd.google-apps.folder"); + } + + public static String getNodePath(GoogleDriveFolder parent, String name) { + return parent.getPath() + "/" + name; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFile.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFile.java new file mode 100644 index 000000000..b0716a8ae --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFile.java @@ -0,0 +1,61 @@ +package org.cryptomator.data.cloud.googledrive; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class GoogleDriveFile implements CloudFile, GoogleDriveNode { + + private final GoogleDriveFolder parent; + private final String name; + private final String path; + private final String driveId; + private final Optional size; + private final Optional modified; + + public GoogleDriveFile(GoogleDriveFolder parent, String name, String path, String driveId, Optional size, Optional modified) { + this.parent = parent; + this.name = name; + this.path = path; + this.driveId = driveId; + 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 getDriveId() { + return driveId; + } + + @Override + public GoogleDriveFolder 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/googledrive/GoogleDriveFolder.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFolder.java new file mode 100644 index 000000000..60d2bf93a --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveFolder.java @@ -0,0 +1,49 @@ +package org.cryptomator.data.cloud.googledrive; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +class GoogleDriveFolder implements CloudFolder, GoogleDriveNode { + + private final GoogleDriveFolder parent; + private final String name; + private final String path; + private final String driveId; + + public GoogleDriveFolder(GoogleDriveFolder parent, String name, String path, String driveId) { + this.parent = parent; + this.name = name; + this.path = path; + this.driveId = driveId; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public String getDriveId() { + return driveId; + } + + @Override + public GoogleDriveFolder getParent() { + return parent; + } + + @Override + public GoogleDriveFolder withCloud(Cloud cloud) { + return new GoogleDriveFolder(parent.withCloud(cloud), name, path, driveId); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCache.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCache.java new file mode 100644 index 000000000..b75ce3334 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCache.java @@ -0,0 +1,77 @@ +package org.cryptomator.data.cloud.googledrive; + +import android.util.LruCache; + +import org.cryptomator.domain.CloudFolder; + +import javax.inject.Inject; + +class GoogleDriveIdCache { + + private final LruCache cache; + + @Inject + GoogleDriveIdCache() { + cache = new LruCache<>(1000); + } + + public NodeInfo get(String path) { + return cache.get(path); + } + + T cache(T value) { + add(value); + return value; + } + + public void add(GoogleDriveIdCloudNode node) { + add(node.getPath(), new NodeInfo(node)); + } + + private void add(String path, NodeInfo info) { + cache.put(path, info); + } + + public void remove(GoogleDriveIdCloudNode node) { + remove(node.getPath()); + } + + private void remove(String path) { + removeChildren(path); + cache.remove(path); + } + + private void removeChildren(String path) { + String prefix = path + '/'; + for (String key : cache.snapshot().keySet()) { + if (key.startsWith(prefix)) { + cache.remove(key); + } + } + } + + static class NodeInfo { + + private final String id; + private final boolean isFolder; + + private NodeInfo(GoogleDriveIdCloudNode node) { + this(node.getDriveId(), node instanceof CloudFolder); + } + + NodeInfo(String id, boolean isFolder) { + this.id = id; + this.isFolder = isFolder; + } + + public String getId() { + return id; + } + + public boolean isFolder() { + return isFolder; + } + + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCloudNode.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCloudNode.java new file mode 100644 index 000000000..83f70c745 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveIdCloudNode.java @@ -0,0 +1,9 @@ +package org.cryptomator.data.cloud.googledrive; + +import org.cryptomator.domain.CloudNode; + +interface GoogleDriveIdCloudNode extends CloudNode { + + String getDriveId(); + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveImpl.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveImpl.java new file mode 100644 index 000000000..48df97447 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveImpl.java @@ -0,0 +1,448 @@ +package org.cryptomator.data.cloud.googledrive; + +import static org.cryptomator.data.cloud.googledrive.GoogleDriveCloudNodeFactory.from; +import static org.cryptomator.data.cloud.googledrive.GoogleDriveCloudNodeFactory.isFolder; +import static org.cryptomator.domain.usecases.cloud.Progress.progress; +import static org.cryptomator.util.file.LruFileCacheUtil.retrieveFromLruCache; +import static org.cryptomator.util.file.LruFileCacheUtil.storeToLruCache; +import static org.cryptomator.util.file.LruFileCacheUtil.Cache.GOOGLE_DRIVE; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.cryptomator.data.util.TransferredBytesAwareGoogleContentInputStream; +import org.cryptomator.data.util.TransferredBytesAwareOutputStream; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.GoogleDriveCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +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.file.LruFileCacheUtil; + +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpResponseException; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.About; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.FileList; +import com.google.api.services.drive.model.Revision; +import com.google.api.services.drive.model.RevisionList; +import com.tomclaw.cache.DiskLruCache; + +import android.content.Context; + +import timber.log.Timber; + +class GoogleDriveImpl { + + private static final int STATUS_REQUEST_RANGE_NOT_SATISFIABLE = 416; + + private final GoogleDriveIdCache idCache; + + private final Context context; + private final GoogleDriveCloud googleDriveCloud; + private final SharedPreferencesHandler sharedPreferencesHandler; + private final RootGoogleDriveFolder root; + + private DiskLruCache diskLruCache; + + GoogleDriveImpl(Context context, GoogleDriveCloud googleDriveCloud, GoogleDriveIdCache idCache) { + if (googleDriveCloud.accessToken() == null) { + throw new NoAuthenticationProvidedException(googleDriveCloud); + } + this.context = context; + this.googleDriveCloud = googleDriveCloud; + this.idCache = idCache; + this.root = new RootGoogleDriveFolder(googleDriveCloud); + + sharedPreferencesHandler = new SharedPreferencesHandler(context); + } + + private Drive client() { + return new GoogleDriveClientFactory(context) // + .getClient(googleDriveCloud.accessToken()); + } + + public GoogleDriveFolder root() { + return root; + } + + public GoogleDriveFolder resolve(String path) throws IOException { + if (path.startsWith("/")) { + path = path.substring(1); + } + String[] names = path.split("/"); + GoogleDriveFolder folder = root; + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + private Optional findFile(String parentDriveId, String name) throws IOException { + Drive.Files.List fileListQuery = client().files().list() // + .setFields("files(id,mimeType,name,size)") // + .setSupportsAllDrives(true); + + if (parentDriveId != null && parentDriveId.equals("root")) { + fileListQuery.setQ("name contains '" + name + "' and '" + parentDriveId + "' in parents and trashed = false or sharedWithMe"); + } else { + fileListQuery.setQ("name contains '" + name + "' and '" + parentDriveId + "' in parents and trashed = false"); + } + + FileList files = fileListQuery.execute(); + + for (File file : files.getFiles()) { + if (name.equals(file.getName())) { + return Optional.of(file); + } + } + return Optional.empty(); + } + + public GoogleDriveFile file(GoogleDriveFolder parent, String name) throws IOException { + return file(parent, name, Optional.empty()); + } + + public GoogleDriveFile file(GoogleDriveFolder parent, String name, Optional size) throws IOException { + if (parent.getDriveId() == null) { + return GoogleDriveCloudNodeFactory.file(parent, name, size); + } + String path = GoogleDriveCloudNodeFactory.getNodePath(parent, name); + GoogleDriveIdCache.NodeInfo nodeInfo = idCache.get(path); + if (nodeInfo != null && !nodeInfo.isFolder()) { + return GoogleDriveCloudNodeFactory.file( // + parent, // + name, // + size, // + path, // + nodeInfo.getId()); + } + + Optional file = findFile(parent.getDriveId(), name); + if (file.isPresent()) { + if (!isFolder(file.get())) { + return idCache.cache(GoogleDriveCloudNodeFactory.file(parent, file.get())); + } + } + + return GoogleDriveCloudNodeFactory.file(parent, name, size); + } + + public GoogleDriveFolder folder(GoogleDriveFolder parent, String name) throws IOException { + if (parent.getDriveId() == null) { + return GoogleDriveCloudNodeFactory.folder(parent, name); + } + String path = GoogleDriveCloudNodeFactory.getNodePath(parent, name); + GoogleDriveIdCache.NodeInfo nodeInfo = idCache.get(path); + if (nodeInfo != null && nodeInfo.isFolder()) { + return GoogleDriveCloudNodeFactory.folder( // + parent, // + name, // + path, // + nodeInfo.getId()); + } + Optional folder = findFile(parent.getDriveId(), name); + if (folder.isPresent()) { + if (isFolder(folder.get())) { + return idCache.cache( // + GoogleDriveCloudNodeFactory.folder(parent, folder.get())); + } + } + + return GoogleDriveCloudNodeFactory.folder(parent, name); + } + + public boolean exists(GoogleDriveNode node) throws IOException { + try { + Optional file = findFile( // + node.getParent().getDriveId(), // + node.getName()); + boolean fileExists = file.isPresent(); + if (fileExists) { + idCache.add(from( // + node.getParent(), // + file.get())); + } + return fileExists; + } catch (GoogleJsonResponseException e) { + return false; + } + } + + public List list(GoogleDriveFolder folder) throws IOException { + List result = new ArrayList<>(); + String pageToken = null; + do { + Drive.Files.List fileListQuery = client() // + .files() // + .list() // + .setFields("nextPageToken,files(id,mimeType,modifiedTime,name,size)") // + .setPageSize(1000) // + .setSupportsAllDrives(true).setIncludeItemsFromAllDrives(true).setPageToken(pageToken); + + if (folder.getDriveId().equals("root")) { + fileListQuery.setQ("'" + folder.getDriveId() + "' in parents and trashed = false or sharedWithMe"); + } else { + fileListQuery.setQ("'" + folder.getDriveId() + "' in parents and trashed = false"); + } + + FileList fileList = fileListQuery.execute(); + + for (File file : fileList.getFiles()) { + result.add(idCache.cache(from(folder, file))); + } + pageToken = fileList.getNextPageToken(); + } while (pageToken != null); + return result; + } + + public GoogleDriveFolder create(GoogleDriveFolder folder) throws IOException { + if (folder.getParent().getDriveId() == null) { + folder = new GoogleDriveFolder( // + create(folder.getParent()), // + folder.getName(), // + folder.getPath(), // + folder.getDriveId()); + } + File metadata = new File(); + metadata.setName(folder.getName()); + metadata.setMimeType("application/vnd.google-apps.folder"); + metadata.setParents( // + Collections.singletonList(folder.getParent().getDriveId())); + + File createdFolder = client() // + .files() // + .create(metadata) // + .setFields("id,name") // + .setSupportsAllDrives(true).execute(); + + return idCache.cache( // + GoogleDriveCloudNodeFactory.folder( // + folder.getParent(), // + createdFolder)); + } + + public GoogleDriveNode move(GoogleDriveNode source, GoogleDriveNode target) throws IOException, CloudNodeAlreadyExistsException { + if (exists(target)) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + + File metadata = new File(); + metadata.setName(target.getName()); + + File movedFile = client() // + .files() // + .update(source.getDriveId(), metadata) // + .setFields("id,mimeType,modifiedTime,name,size") // + .setAddParents(target.getParent().getDriveId()) // + .setRemoveParents(source.getParent().getDriveId()) // + .setSupportsAllDrives(true).execute(); + + idCache.remove(source); + return idCache.cache(from(target.getParent(), movedFile)); + } + + public GoogleDriveFile write(final GoogleDriveFile file, DataSource data, final ProgressAware progressAware, boolean replace, final long size) // + throws IOException, BackendException { + if (exists(file) && !replace) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + if (file.getParent().getDriveId() == null) { + throw new NoSuchCloudFileException(String.format("The parent folder of %s doesn't have a driveId. The file would remain in root folder", file.getPath())); + } + + File metadata = new File(); + metadata.setName(file.getName()); + + progressAware.onProgress(Progress.started(UploadState.upload(file))); + File uploadedFile; + if (file.getDriveId() != null && replace) { + try (TransferredBytesAwareGoogleContentInputStream in = new TransferredBytesAwareGoogleContentInputStream(null, data.open(context), size) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }) { + uploadedFile = client() // + .files() // + .update( // + file.getDriveId(), // + metadata, // + in) + .setFields("id,modifiedTime,name,size") // + .setSupportsAllDrives(true) // + .execute(); + } + } else { + metadata.setParents( // + Collections.singletonList(file.getParent().getDriveId())); + + try (TransferredBytesAwareGoogleContentInputStream in = new TransferredBytesAwareGoogleContentInputStream(null, data.open(context), size) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }) { + uploadedFile = client() // + .files() // + .create(metadata, in).setFields("id,modifiedTime,name,size") // + .setSupportsAllDrives(true) // + .execute(); + } + } + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + return idCache.cache( // + GoogleDriveCloudNodeFactory.file(file.getParent(), uploadedFile)); + } + + public void read(final GoogleDriveFile file, Optional encryptedTmpFile, OutputStream data, final ProgressAware progressAware) throws IOException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + + Optional cacheKey = Optional.empty(); + Optional cacheFile = Optional.empty(); + + if (sharedPreferencesHandler.useLruCache() && createLruCache(sharedPreferencesHandler.lruCacheSize())) { + List revisions = new ArrayList<>(); + String pageToken = null; + do { + final RevisionList revisionList = client() // + .revisions() // + .list(file.getDriveId()) // + .setPageToken(pageToken).execute(); // + + revisions.addAll(revisionList.getRevisions()); + + pageToken = revisionList.getNextPageToken(); + } while (pageToken != null); + + Collections.sort(revisions, (revision1, revision2) -> { + Long modified1 = revision1.getModifiedTime().getValue(); + Long modified2 = revision2.getModifiedTime().getValue(); + return Integer.compare(modified1.compareTo(modified2), 0); + }); + + int revisionIndex = revisions.size() > 0 ? revisions.size() - 1 : 0; + cacheKey = Optional.of(file.getDriveId() + revisions.get(revisionIndex).getId()); + java.io.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("GoogleDriveImpl").w(e, "Error while retrieving content from Cache, get from web request"); + writeToDate(file, data, encryptedTmpFile, cacheKey, progressAware); + } + } else { + writeToDate(file, data, encryptedTmpFile, cacheKey, progressAware); + } + + progressAware.onProgress(Progress.completed(DownloadState.download(file))); + } + + private void writeToDate(final GoogleDriveFile file, // + final OutputStream data, // + final Optional encryptedTmpFile, // + final Optional cacheKey, // + final ProgressAware progressAware) throws IOException { + try (TransferredBytesAwareOutputStream out = new TransferredBytesAwareOutputStream(data) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(DownloadState.download(file)) // + .between(0) // + .and(file.getSize().orElse(Long.MAX_VALUE)) // + .withValue(transferred)); + } + }) { + client() // + .files() // + .get(file.getDriveId()) // + .setAlt("media") // + .setSupportsAllDrives(true) // + .executeMediaAndDownloadTo(out); + } catch (HttpResponseException e) { + ignoreEmptyFileErrorAndRethrowOthers(e, file); + } + + if (sharedPreferencesHandler.useLruCache() && encryptedTmpFile.isPresent() && cacheKey.isPresent()) { + try { + storeToLruCache(diskLruCache, cacheKey.get(), encryptedTmpFile.get()); + } catch (IOException e) { + Timber.tag("GoogleDriveImpl").e(e, "Failed to write downloaded file in LRU cache"); + } + } + } + + private boolean createLruCache(int cacheSize) { + if (diskLruCache == null) { + try { + diskLruCache = DiskLruCache.create(new LruFileCacheUtil(context).resolve(GOOGLE_DRIVE), cacheSize); + } catch (IOException e) { + Timber.tag("GoogleDriveImpl").e(e, "Failed to setup LRU cache"); + return false; + } + } + + return true; + } + + /* + * Workaround a bug in gdrive which does not allow to download empty files. + * + * In this case an HttpResponseException with status code 416 is thrown. The filesize is checked. + * If zero, the exception is ignored - nothing has been read, so the OutputStream is in the correct + * state. + */ + private void ignoreEmptyFileErrorAndRethrowOthers(HttpResponseException e, GoogleDriveFile file) throws IOException { + if (e.getStatusCode() == STATUS_REQUEST_RANGE_NOT_SATISFIABLE) { + Optional foundFile = findFile( // + file.getParent().getDriveId(), // + file.getName()); + if (sizeOfFile(foundFile) == 0) { + return; + } + } + throw e; + } + + private long sizeOfFile(Optional foundFile) { + if (foundFile.isAbsent() || isFolder(foundFile.get())) { + return -1; + } + return foundFile.get().getSize(); + } + + public void delete(GoogleDriveNode node) throws IOException { + client().files().delete(node.getDriveId()).setSupportsAllDrives(true).execute(); + idCache.remove(node); + } + + public String currentAccount() throws IOException { + About about = client().about().get().execute(); + return about.getUser().getDisplayName(); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveNode.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveNode.java new file mode 100644 index 000000000..478a32afb --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/GoogleDriveNode.java @@ -0,0 +1,10 @@ +package org.cryptomator.data.cloud.googledrive; + +interface GoogleDriveNode extends GoogleDriveIdCloudNode { + + @Override + String getDriveId(); + + @Override + GoogleDriveFolder getParent(); +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/googledrive/RootGoogleDriveFolder.java b/data/src/main/java/org/cryptomator/data/cloud/googledrive/RootGoogleDriveFolder.java new file mode 100644 index 000000000..474fc530c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/googledrive/RootGoogleDriveFolder.java @@ -0,0 +1,24 @@ +package org.cryptomator.data.cloud.googledrive; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.GoogleDriveCloud; + +public class RootGoogleDriveFolder extends GoogleDriveFolder { + + private final GoogleDriveCloud cloud; + + public RootGoogleDriveFolder(GoogleDriveCloud cloud) { + super(null, "", "", "root"); + this.cloud = cloud; + } + + @Override + public GoogleDriveCloud getCloud() { + return cloud; + } + + @Override + public GoogleDriveFolder withCloud(Cloud cloud) { + return new RootGoogleDriveFolder((GoogleDriveCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/LocalStorageContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/local/LocalStorageContentRepositoryFactory.java new file mode 100644 index 000000000..1a7427a60 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/LocalStorageContentRepositoryFactory.java @@ -0,0 +1,62 @@ +package org.cryptomator.data.cloud.local; + +import android.content.Context; +import android.os.Build; + +import org.cryptomator.data.cloud.local.file.LocalStorageContentRepository; +import org.cryptomator.data.cloud.local.storageaccessframework.LocalStorageAccessFrameworkContentRepository; +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.util.file.MimeTypes; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static android.Manifest.permission.READ_EXTERNAL_STORAGE; +import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static androidx.core.content.ContextCompat.checkSelfPermission; +import static org.cryptomator.domain.CloudType.LOCAL; + +@Singleton +public class LocalStorageContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final Context context; + private final MimeTypes mimeTypes; + + @Inject + public LocalStorageContentRepositoryFactory(Context context, MimeTypes mimeTypes) { + this.context = context; + this.mimeTypes = mimeTypes; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == LOCAL; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + if (!hasPermissions(WRITE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE)) { + throw new NoAuthenticationProvidedException(cloud); + } + if (((LocalStorageCloud) cloud).rootUri() != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return new LocalStorageAccessFrameworkContentRepository(context, mimeTypes, (LocalStorageCloud) cloud); + } else { + return new LocalStorageContentRepository(context, (LocalStorageCloud) cloud); + } + } + + private boolean hasPermissions(String... permissions) { + for (String permission : permissions) { + if (checkSelfPermission(context, permission) != PERMISSION_GRANTED) { + return false; + } + } + return true; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFile.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFile.java new file mode 100644 index 000000000..9e9c17a4d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFile.java @@ -0,0 +1,54 @@ +package org.cryptomator.data.cloud.local.file; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class LocalFile implements CloudFile, LocalNode { + + private final LocalFolder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + + LocalFile(LocalFolder 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 LocalFolder 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/local/file/LocalFolder.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFolder.java new file mode 100644 index 000000000..490441dd8 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalFolder.java @@ -0,0 +1,42 @@ +package org.cryptomator.data.cloud.local.file; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +class LocalFolder implements CloudFolder, LocalNode { + + private final LocalFolder parent; + private final String name; + private final String path; + + LocalFolder(LocalFolder 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 LocalFolder getParent() { + return parent; + } + + @Override + public LocalFolder withCloud(Cloud cloud) { + return new LocalFolder(parent.withCloud(cloud), name, path); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalNode.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalNode.java new file mode 100644 index 000000000..aa3ee16e2 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalNode.java @@ -0,0 +1,6 @@ +package org.cryptomator.data.cloud.local.file; + +import org.cryptomator.domain.CloudNode; + +interface LocalNode extends CloudNode { +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageContentRepository.java new file mode 100644 index 000000000..38ad0722d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageContentRepository.java @@ -0,0 +1,121 @@ +package org.cryptomator.data.cloud.local.file; + +import android.content.Context; + +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +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.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +import static org.cryptomator.util.ExceptionUtil.contains; + +public class LocalStorageContentRepository implements CloudContentRepository { + + private final LocalStorageImpl localStorageImpl; + + public LocalStorageContentRepository(Context context, LocalStorageCloud localStorageCloud) { + this.localStorageImpl = new LocalStorageImpl(context, localStorageCloud); + } + + @Override + public LocalFolder root(LocalStorageCloud cloud) throws BackendException { + return localStorageImpl.root(); + } + + @Override + public LocalFolder resolve(LocalStorageCloud cloud, String path) throws BackendException { + return localStorageImpl.resolve(path); + } + + @Override + public LocalFile file(LocalFolder parent, String name) throws BackendException { + return localStorageImpl.file(parent, name); + } + + @Override + public LocalFile file(LocalFolder parent, String name, Optional size) throws BackendException { + return localStorageImpl.file(parent, name, size); + } + + @Override + public LocalFolder folder(LocalFolder parent, String name) throws BackendException { + return localStorageImpl.folder(parent, name); + } + + @Override + public boolean exists(LocalNode node) throws BackendException { + return localStorageImpl.exists(node); + } + + @Override + public List list(LocalFolder folder) throws BackendException { + return localStorageImpl.list(folder); + } + + @Override + public LocalFolder create(LocalFolder folder) throws BackendException { + return localStorageImpl.create(folder); + } + + @Override + public LocalFolder move(LocalFolder source, LocalFolder target) throws BackendException { + return (LocalFolder) localStorageImpl.move(source, target); + } + + @Override + public LocalFile move(LocalFile source, LocalFile target) throws BackendException { + return (LocalFile) localStorageImpl.move(source, target); + } + + @Override + public LocalFile write(LocalFile file, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return localStorageImpl.write(file, data, progressAware, replace, size); + } catch (IOException e) { + if (contains(e, FileNotFoundException.class)) { + throw new NoSuchCloudFileException(file.getName()); + } + throw new FatalBackendException(e); + } + } + + @Override + public void read(LocalFile file, Optional tmpEncryptedFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + localStorageImpl.read(file, data, progressAware); + } catch (IOException e) { + if (contains(e, FileNotFoundException.class)) { + throw new NoSuchCloudFileException(file.getName()); + } + throw new FatalBackendException(e); + } + } + + @Override + public void delete(LocalNode node) throws BackendException { + localStorageImpl.delete(node); + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(LocalStorageCloud cloud) throws BackendException { + return null; + } + + @Override + public void logout(LocalStorageCloud cloud) throws BackendException { + // empty + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageImpl.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageImpl.java new file mode 100644 index 000000000..b893e5cd3 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageImpl.java @@ -0,0 +1,193 @@ +package org.cryptomator.data.cloud.local.file; + +import android.content.Context; + +import org.cryptomator.data.util.TransferredBytesAwareInputStream; +import org.cryptomator.data.util.TransferredBytesAwareOutputStream; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +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 java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.cryptomator.data.util.CopyStream.copyStreamToStream; +import static org.cryptomator.domain.usecases.cloud.Progress.progress; + +class LocalStorageImpl { + + private final Context context; + private final RootLocalFolder root; + + LocalStorageImpl(Context context, LocalStorageCloud localStorageCloud) { + this.context = context; + this.root = new RootLocalFolder(localStorageCloud); + } + + public LocalFolder root() { + return root; + } + + public LocalFolder resolve(String path) { + if (path.startsWith(root.getPath())) { + path = path.substring(root.getPath().length() + 1); + } + String[] names = path.split("/"); + LocalFolder folder = root; + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + public LocalFile file(CloudFolder folder, String name) { + return file(folder, name, Optional.empty()); + } + + public LocalFile file(CloudFolder folder, String name, Optional size) { + return LocalStorageNodeFactory.file( // + (LocalFolder) folder, // + name, // + folder.getPath() + '/' + name, // + size, // + Optional.empty()); + } + + public LocalFolder folder(CloudFolder folder, String name) { + return LocalStorageNodeFactory.folder( // + (LocalFolder) folder, // + name, // + folder.getPath() + '/' + name); + } + + public boolean exists(CloudNode node) { + return new File(node.getPath()).exists(); + } + + public List list(LocalFolder folder) throws BackendException { + List result = new ArrayList<>(); + File localDirectory = new File(folder.getPath()); + if (!exists(folder)) { + throw new NoSuchCloudFileException(); + } + for (File file : localDirectory.listFiles()) { + result.add(LocalStorageNodeFactory.from(folder, file)); + } + return result; + } + + public LocalFolder create(LocalFolder folder) throws BackendException { + File createFolder = new File(folder.getPath()); + if (createFolder.exists()) { + throw new CloudNodeAlreadyExistsException(folder.getName()); + } + if (!createFolder.mkdirs()) { + throw new FatalBackendException("Couldn't create a local folder at " + folder.getPath()); + } + + return LocalStorageNodeFactory.folder( // + folder.getParent(), // + createFolder); + } + + public LocalNode move(CloudNode source, CloudNode target) throws BackendException { + File sourceFile = new File(source.getPath()); + File targetFile = new File(target.getPath()); + if (targetFile.exists()) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + if (!sourceFile.exists()) { + throw new NoSuchCloudFileException(source.getName()); + } + if (!sourceFile.renameTo(targetFile)) { + throw new FatalBackendException("Couldn't move " + source.getPath() + " to " + target.getPath()); + } + return LocalStorageNodeFactory.from((LocalFolder) target.getParent(), targetFile); + } + + public void delete(CloudNode node) { + File fileOrDirectory = new File(node.getPath()); + if (!deleteRecursive(fileOrDirectory)) { + throw new FatalBackendException("Couldn't delete local CloudNode " + fileOrDirectory); + } + } + + private boolean deleteRecursive(File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (File child : fileOrDirectory.listFiles()) { + deleteRecursive(child); + } + } + return fileOrDirectory.delete(); + } + + public LocalFile write(final CloudFile file, DataSource data, final ProgressAware progressAware, boolean replace, final long size) throws IOException, BackendException { + if (exists(file) && !replace) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + progressAware.onProgress(Progress.started(UploadState.upload(file))); + File localFile = new File(file.getPath()); + + try (OutputStream out = new FileOutputStream(localFile); TransferredBytesAwareInputStream in = new TransferredBytesAwareInputStream(data.open(context)) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(file)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }) { + copyStreamToStream(in, out); + } + + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + + return LocalStorageNodeFactory.file( // + (LocalFolder) file.getParent(), // + file.getName(), // + localFile.getPath(), // + Optional.of(localFile.length()), // + Optional.of(new Date(localFile.lastModified()))); + } + + public void read(final LocalFile file, OutputStream data, final ProgressAware progressAware) throws IOException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + File localFile = new File(file.getPath()); + + try (InputStream in = new FileInputStream(localFile); TransferredBytesAwareOutputStream out = new TransferredBytesAwareOutputStream(data) { + @Override + public void bytesTransferred(long transferred) { + progressAware // + .onProgress(progress(DownloadState.download(file)) // + .between(0) // + .and(localFile.length()) // + .withValue(transferred)); + } + }) { + copyStreamToStream(in, out); + } + + progressAware.onProgress(Progress.completed(DownloadState.download(file))); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageNodeFactory.java new file mode 100644 index 000000000..30cc58eb6 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/LocalStorageNodeFactory.java @@ -0,0 +1,34 @@ +package org.cryptomator.data.cloud.local.file; + +import org.cryptomator.util.Optional; + +import java.io.File; +import java.util.Date; + +class LocalStorageNodeFactory { + + public static LocalNode from(LocalFolder parent, File file) { + if (file.isDirectory()) { + return folder(parent, file); + } else { + return file( // + parent, // + file.getName(), // + file.getPath(), // + Optional.of(file.length()), // + Optional.of(new Date(file.lastModified()))); + } + } + + public static LocalFolder folder(LocalFolder parent, File file) { + return folder(parent, file.getName(), file.getPath()); + } + + public static LocalFolder folder(LocalFolder parent, String name, String path) { + return new LocalFolder(parent, name, path); + } + + public static LocalFile file(LocalFolder folder, String name, String path, Optional size, Optional modified) { + return new LocalFile(folder, name, path, size, modified); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/file/RootLocalFolder.java b/data/src/main/java/org/cryptomator/data/cloud/local/file/RootLocalFolder.java new file mode 100644 index 000000000..19e715958 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/file/RootLocalFolder.java @@ -0,0 +1,26 @@ +package org.cryptomator.data.cloud.local.file; + +import android.os.Environment; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.LocalStorageCloud; + +public class RootLocalFolder extends LocalFolder { + + private final LocalStorageCloud localStorageCloud; + + public RootLocalFolder(LocalStorageCloud localStorageCloud) { + super(null, "", Environment.getExternalStorageDirectory().getPath()); + this.localStorageCloud = localStorageCloud; + } + + @Override + public Cloud getCloud() { + return localStorageCloud; + } + + @Override + public RootLocalFolder withCloud(Cloud cloud) { + return new RootLocalFolder((LocalStorageCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/DocumentIdCache.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/DocumentIdCache.java new file mode 100644 index 000000000..aefe70d95 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/DocumentIdCache.java @@ -0,0 +1,74 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.util.LruCache; + +import org.cryptomator.domain.CloudFolder; + +class DocumentIdCache { + + private final LruCache cache; + + DocumentIdCache() { + cache = new LruCache<>(1000); + } + + public NodeInfo get(String path) { + return cache.get(path); + } + + T cache(T value) { + add(value); + return value; + } + + public void add(LocalStorageAccessNode node) { + add(node.getPath(), new NodeInfo(node)); + } + + private void add(String path, NodeInfo info) { + cache.put(path, info); + } + + public void remove(LocalStorageAccessNode node) { + remove(node.getPath()); + } + + private void remove(String path) { + removeChildren(path); + cache.remove(path); + } + + private void removeChildren(String path) { + String prefix = path + '/'; + for (String key : cache.snapshot().keySet()) { + if (key.startsWith(prefix)) { + cache.remove(key); + } + } + } + + static class NodeInfo { + + private final String id; + private final boolean isFolder; + + private NodeInfo(LocalStorageAccessNode node) { + this(node.getDocumentId(), node instanceof CloudFolder); + } + + NodeInfo(String id, boolean isFolder) { + this.id = id; + this.isFolder = isFolder; + } + + public String getId() { + return id; + } + + public boolean isFolder() { + return isFolder; + } + + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFile.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFile.java new file mode 100644 index 000000000..33a73aa60 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFile.java @@ -0,0 +1,94 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.net.Uri; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +import static android.net.Uri.parse; + +class LocalStorageAccessFile implements CloudFile, LocalStorageAccessNode { + + private final LocalStorageAccessFolder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + private final String documentId; + private final String documentUri; + + LocalStorageAccessFile(LocalStorageAccessFolder parent, String name, String path, Optional size, Optional modified, String documentId, String documentUri) { + this.parent = parent; + this.name = name; + this.path = path; + this.size = size; + this.modified = modified; + this.documentId = documentId; + this.documentUri = documentUri; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public Uri getUri() { + return parse(documentUri); + } + + @Override + public LocalStorageAccessFolder getParent() { + return parent; + } + + @Override + public String getDocumentId() { + return documentId; + } + + @Override + public Optional getSize() { + return size; + } + + @Override + public Optional getModified() { + return modified; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + return internalEquals((LocalStorageAccessFile) obj); + } + + private boolean internalEquals(LocalStorageAccessFile o) { + return path.equals(o.path); + } + + @Override + public int hashCode() { + final int prime = 31; + int hash = 56127034; + hash = hash * prime + path.hashCode(); + return hash; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFolder.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFolder.java new file mode 100644 index 000000000..cf6e9071d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFolder.java @@ -0,0 +1,85 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.net.Uri; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +import static android.net.Uri.parse; + +class LocalStorageAccessFolder implements CloudFolder, LocalStorageAccessNode { + + private final LocalStorageAccessFolder parent; + private final String name; + private final String path; + private final String documentId; + private final String documentUri; + + LocalStorageAccessFolder(LocalStorageAccessFolder parent, String name, String path, String documentId, String documentUri) { + this.parent = parent; + this.name = name; + this.path = path; + this.documentId = documentId; + this.documentUri = documentUri; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public Uri getUri() { + if (documentUri == null) { + return null; + } + + return parse(documentUri); + } + + @Override + public LocalStorageAccessFolder getParent() { + return parent; + } + + @Override + public String getDocumentId() { + return documentId; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + return internalEquals((LocalStorageAccessFolder) obj); + } + + private boolean internalEquals(LocalStorageAccessFolder o) { + return path.equals(o.path); + } + + @Override + public int hashCode() { + final int prime = 31; + int hash = 341797327; + hash = hash * prime + path.hashCode(); + return hash; + } + + @Override + public LocalStorageAccessFolder withCloud(Cloud cloud) { + return new LocalStorageAccessFolder(parent.withCloud(cloud), name, path, documentId, documentUri); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkContentRepository.java new file mode 100644 index 000000000..809ea2cc6 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkContentRepository.java @@ -0,0 +1,123 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.content.Context; +import android.os.Build; + +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +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 org.cryptomator.util.file.MimeTypes; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +import androidx.annotation.RequiresApi; + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +public class LocalStorageAccessFrameworkContentRepository implements CloudContentRepository { + + private final LocalStorageAccessFrameworkImpl localStorageAccessFramework; + + public LocalStorageAccessFrameworkContentRepository(Context context, MimeTypes mimeTypes, LocalStorageCloud localStorageCloud) { + this.localStorageAccessFramework = new LocalStorageAccessFrameworkImpl(context, mimeTypes, localStorageCloud, new DocumentIdCache()); + } + + @Override + public LocalStorageAccessFolder root(LocalStorageCloud cloud) throws BackendException { + return localStorageAccessFramework.root(); + } + + @Override + public LocalStorageAccessFolder resolve(LocalStorageCloud cloud, String path) throws BackendException { + return localStorageAccessFramework.resolve(path); + } + + @Override + public LocalStorageAccessFile file(LocalStorageAccessFolder parent, String name) throws BackendException { + return localStorageAccessFramework.file(parent, name); + } + + @Override + public LocalStorageAccessFile file(LocalStorageAccessFolder parent, String name, Optional size) throws BackendException { + return localStorageAccessFramework.file(parent, name, size); + } + + @Override + public LocalStorageAccessFolder folder(LocalStorageAccessFolder parent, String name) throws BackendException { + return localStorageAccessFramework.folder(parent, name); + } + + @Override + public boolean exists(LocalStorageAccessNode node) throws BackendException { + return localStorageAccessFramework.exists(node); + } + + @Override + public List list(LocalStorageAccessFolder folder) throws BackendException { + return localStorageAccessFramework.list(folder); + } + + @Override + public LocalStorageAccessFolder create(LocalStorageAccessFolder folder) throws BackendException { + return localStorageAccessFramework.create(folder); + } + + @Override + public LocalStorageAccessFolder move(LocalStorageAccessFolder source, LocalStorageAccessFolder target) throws BackendException { + if (source.getDocumentId() == null) { + throw new NoSuchCloudFileException(source.getName()); + } + return (LocalStorageAccessFolder) localStorageAccessFramework.move(source, target); + } + + @Override + public LocalStorageAccessFile move(LocalStorageAccessFile source, LocalStorageAccessFile target) throws BackendException { + return (LocalStorageAccessFile) localStorageAccessFramework.move(source, target); + } + + @Override + public LocalStorageAccessFile write(LocalStorageAccessFile file, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return localStorageAccessFramework.write(file, data, progressAware, replace, size); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void read(LocalStorageAccessFile file, Optional tmpEnctypted, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + if (file.getDocumentId() == null) { + throw new NoSuchCloudFileException(file.getName()); + } + localStorageAccessFramework.read(file, data, progressAware); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void delete(LocalStorageAccessNode node) throws BackendException { + localStorageAccessFramework.delete(node); + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(LocalStorageCloud cloud) throws BackendException { + return null; + } + + @Override + public void logout(LocalStorageCloud cloud) throws BackendException { + // empty + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkImpl.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkImpl.java new file mode 100644 index 000000000..9e58eefad --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkImpl.java @@ -0,0 +1,536 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import static org.cryptomator.data.cloud.local.storageaccessframework.LocalStorageAccessFrameworkNodeFactory.from; +import static org.cryptomator.data.util.CopyStream.closeQuietly; +import static org.cryptomator.data.util.CopyStream.copyStreamToStream; +import static org.cryptomator.domain.usecases.ProgressAware.NO_OP_PROGRESS_AWARE; +import static org.cryptomator.domain.usecases.cloud.Progress.progress; + +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import org.cryptomator.data.util.TransferredBytesAwareInputStream; +import org.cryptomator.data.util.TransferredBytesAwareOutputStream; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.NotFoundException; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +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.Supplier; +import org.cryptomator.util.file.MimeType; +import org.cryptomator.util.file.MimeTypes; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.UriPermission; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; + +import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; + +import timber.log.Timber; + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class LocalStorageAccessFrameworkImpl { + + private final Context context; + private final RootLocalStorageAccessFolder root; + private final DocumentIdCache idCache; + private final MimeTypes mimeTypes; + + LocalStorageAccessFrameworkImpl(Context context, MimeTypes mimeTypes, LocalStorageCloud cloud, DocumentIdCache documentIdCache) { + this.mimeTypes = mimeTypes; + if (!hasUriPermissions(context, cloud.rootUri())) { + throw new NoAuthenticationProvidedException(cloud); + } + this.context = context; + this.root = new RootLocalStorageAccessFolder(cloud); + this.idCache = documentIdCache; + } + + private boolean hasUriPermissions(Context context, String uri) { + Optional uriPermission = uriPermissionFor(context, uri); + return uriPermission.isPresent() // + && uriPermission.get().isReadPermission() // + && uriPermission.get().isWritePermission(); + } + + private Optional uriPermissionFor(Context context, String uri) { + for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) { + if (uri.equals(uriPermission.getUri().toString())) { + return Optional.of(uriPermission); + } + } + return Optional.empty(); + } + + public LocalStorageAccessFolder root() { + return root; + } + + public LocalStorageAccessFolder resolve(String path) throws BackendException { + if (path.startsWith("/")) { + path = path.substring(1); + } + + String[] names = path.split("/"); + LocalStorageAccessFolder folder = root; + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + public LocalStorageAccessFile file(LocalStorageAccessFolder parent, String name) throws BackendException { + return file( // + parent, // + name, // + Optional.empty()); + } + + public LocalStorageAccessFile file(LocalStorageAccessFolder parent, String name, Optional size) throws BackendException { + if (parent.getDocumentId() == null) { + return LocalStorageAccessFrameworkNodeFactory.file( // + parent, // + name, // + size); + } + String path = LocalStorageAccessFrameworkNodeFactory.getNodePath(parent, name); + DocumentIdCache.NodeInfo nodeInfo = idCache.get(path); + if (nodeInfo != null && !nodeInfo.isFolder()) { + return LocalStorageAccessFrameworkNodeFactory.file( // + parent, // + name, // + path, // + size, // + nodeInfo.getId()); + } + List cloudNodes = listFilesWithNameFilter(parent, name); + if (cloudNodes.size() > 0) { + LocalStorageAccessNode cloudNode = cloudNodes.get(0); + if (cloudNode instanceof LocalStorageAccessFile) { + return idCache.cache((LocalStorageAccessFile) cloudNode); + } + } + return LocalStorageAccessFrameworkNodeFactory.file( // + parent, // + name, // + size); + } + + public LocalStorageAccessFolder folder(LocalStorageAccessFolder parent, String name) throws BackendException { + if (parent.getDocumentId() == null) { + return LocalStorageAccessFrameworkNodeFactory.folder( // + parent, // + name); + } + String path = LocalStorageAccessFrameworkNodeFactory.getNodePath(parent, name); + DocumentIdCache.NodeInfo nodeInfo = idCache.get(path); + if (nodeInfo != null && nodeInfo.isFolder()) { + return LocalStorageAccessFrameworkNodeFactory.folder( // + parent, // + name, // + nodeInfo.getId()); + } + List cloudNodes = listFilesWithNameFilter(parent, name); + if (cloudNodes.size() > 0) { + LocalStorageAccessNode cloudNode = cloudNodes.get(0); + if (cloudNode instanceof LocalStorageAccessFolder) { + return idCache.cache((LocalStorageAccessFolder) cloudNode); + } + } + + return LocalStorageAccessFrameworkNodeFactory.folder( // + parent, // + name); + } + + private List listFilesWithNameFilter(LocalStorageAccessFolder parent, String name) throws BackendException { + if (parent.getUri() == null) { + List parents = listFilesWithNameFilter(parent.getParent(), parent.getName()); + if (parents.isEmpty() || !(parents.get(0) instanceof LocalStorageAccessFolder)) { + throw new NoSuchCloudFileException(name); + } + parent = (LocalStorageAccessFolder) parents.get(0); + } + Cursor childCursor = null; + try { + childCursor = contentResolver() // + .query( // + DocumentsContract.buildChildDocumentsUriUsingTree( // + parent.getUri(), // + parent.getDocumentId()), + new String[] {Document.COLUMN_DISPLAY_NAME, // cursor position 0 + Document.COLUMN_MIME_TYPE, // cursor position 1 + Document.COLUMN_SIZE, // cursor position 2 + Document.COLUMN_LAST_MODIFIED, // cursor position 3 + Document.COLUMN_DOCUMENT_ID // cursor position 4 + }, // + null, // + null, // + null); + + List result = new ArrayList<>(); + while (childCursor != null && childCursor.moveToNext()) { + if (childCursor.getString(0).equals(name)) { + result.add(idCache.cache(from(parent, childCursor))); + } + } + return result; + } catch (IllegalArgumentException e) { + if (e.getMessage().contains(FileNotFoundException.class.getCanonicalName())) { + throw new NoSuchCloudFileException(name); + } + throw new FatalBackendException(e); + } finally { + closeQuietly(childCursor); + } + } + + public boolean exists(LocalStorageAccessNode node) throws BackendException { + try { + + List cloudNodes = listFilesWithNameFilter( // + node.getParent(), // + node.getName()); + + boolean documentExists = cloudNodes.size() > 0; + + if (documentExists) { + idCache.add(cloudNodes.get(0)); + } + + return documentExists; + } catch (NoSuchCloudFileException e) { + return false; + } + } + + public List list(LocalStorageAccessFolder folder) throws BackendException { + Cursor childCursor = contentResolver() // + .query( // + DocumentsContract.buildChildDocumentsUriUsingTree( // + folder.getUri(), // + folder.getDocumentId()), + new String[] { // + Document.COLUMN_DISPLAY_NAME, // cursor position 0 + Document.COLUMN_MIME_TYPE, // cursor position 1 + Document.COLUMN_SIZE, // cursor position 2 + Document.COLUMN_LAST_MODIFIED, // cursor position 3 + Document.COLUMN_DOCUMENT_ID // cursor position 4 + }, null, null, null); + + try { + List result = new ArrayList<>(); + while (childCursor != null && childCursor.moveToNext()) { + result.add(idCache.cache(from(folder, childCursor))); + } + return result; + } finally { + closeQuietly(childCursor); + } + } + + public LocalStorageAccessFolder create(LocalStorageAccessFolder folder) throws BackendException { + if (folder // + .getParent() // + .getDocumentId() == null) { + folder = new LocalStorageAccessFolder( // + create(folder.getParent()), // + folder.getName(), // + folder.getPath(), // + null, // + null); + } + Uri createdDocument; + try { + createdDocument = DocumentsContract.createDocument( // + contentResolver(), // + folder.getParent().getUri(), // + Document.MIME_TYPE_DIR, // + folder.getName()); + } catch (FileNotFoundException e) { + throw new NoSuchCloudFileException(folder.getName()); + } + return idCache.cache( // + LocalStorageAccessFrameworkNodeFactory.folder( // + folder.getParent(), // + buildDocumentFile(createdDocument))); + } + + public LocalStorageAccessNode move(LocalStorageAccessNode source, LocalStorageAccessNode target) throws BackendException { + if (exists(target)) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + + idCache.remove(source); + idCache.remove(target); + boolean isRename = !source // + .getName() // + .equals(target.getName()); + boolean isMove = !source // + .getParent() // + .equals(target.getParent()); + LocalStorageAccessNode renamedSource = source; + if (isRename) { + renamedSource = rename(source, target.getName()); + } + if (isMove) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return idCache.cache( // + moveForApiStartingFrom24(renamedSource, target)); + } else { + return idCache.cache( // + moveForApiBelow24(renamedSource, target)); + } + } + return renamedSource; + } + + private LocalStorageAccessNode rename(LocalStorageAccessNode source, String name) throws NoSuchCloudFileException { + Uri newUri = null; + try { + newUri = DocumentsContract.renameDocument( // + contentResolver(), // + source.getUri(), // + name); + } catch (FileNotFoundException e) { + // Bug in Android 9 see #460 + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.P) { + throw new NoSuchCloudFileException(source.getName()); + } + } + + // Bug in Android 9 see #460 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { + try { + List cloudNodes = listFilesWithNameFilter( // + source.getParent(), // + name); + + newUri = cloudNodes.get(0).getUri(); + } catch (BackendException e) { + Timber.tag("LocalStgeAccessFrkImpl").e(e); + } + } + + return LocalStorageAccessFrameworkNodeFactory.from( // + source.getParent(), // + buildDocumentFile(newUri)); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + private LocalStorageAccessNode moveForApiStartingFrom24(LocalStorageAccessNode source, LocalStorageAccessNode target) throws NoSuchCloudFileException { + Uri movedTargetUri; + try { + movedTargetUri = DocumentsContract.moveDocument( // + contentResolver(), // + source.getUri(), // + source.getParent().getUri(), // + target.getParent().getUri()); + } catch (FileNotFoundException e) { + throw new NoSuchCloudFileException(source.getName()); + } + return from( // + target.getParent(), // + buildDocumentFile(movedTargetUri)); + } + + private LocalStorageAccessNode moveForApiBelow24(LocalStorageAccessNode source, LocalStorageAccessNode target) throws BackendException { + try { + LocalStorageAccessNode result; + if (source instanceof CloudFolder) { + result = moveForApiBelow24( // + (LocalStorageAccessFolder) source, // + (LocalStorageAccessFolder) target); + } else { + result = moveForApiBelow24( // + (LocalStorageAccessFile) source, // + (LocalStorageAccessFile) target); + } + delete(source); + return result; + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private LocalStorageAccessFolder moveForApiBelow24(LocalStorageAccessFolder source, LocalStorageAccessFolder target) throws IOException, BackendException { + if (!exists(target.getParent())) { + throw new NoSuchCloudFileException(target.getParent().getPath()); + } + LocalStorageAccessFolder createdFolder = create(target); + for (CloudNode child : list(source)) { + if (child instanceof CloudFolder) { + moveForApiBelow24( // + (LocalStorageAccessFolder) child, // + folder(target, child.getName())); + } else { + moveForApiBelow24( // + (LocalStorageAccessFile) child, // + file(target, child.getName())); + } + } + return createdFolder; + } + + private LocalStorageAccessFile moveForApiBelow24(final LocalStorageAccessFile source, LocalStorageAccessFile target) throws IOException, BackendException { + DataSource dataSource = new DataSource() { + @Override + public void close() throws IOException { + // do nothing + } + + @Override + public Optional size(Context context) { + return source.getSize(); + } + + @Override + public InputStream open(Context context) throws IOException { + return contentResolver().openInputStream(source.getUri()); + } + + @Override + public DataSource decorate(DataSource delegate) { + return delegate; + } + }; + return write(target, dataSource, NO_OP_PROGRESS_AWARE, true, source.getSize().get()); + } + + public LocalStorageAccessFile write( // + LocalStorageAccessFile file, // + final DataSource data, // + final ProgressAware progressAware, // + final boolean replace, // + final long size) throws IOException, BackendException { + + progressAware.onProgress(Progress.started(UploadState.upload(file))); + Optional fileUri = existingFileUri(file); + if (fileUri.isPresent() && !replace) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + if (file.getParent().getUri() == null) { + LocalStorageAccessFolder parent = (LocalStorageAccessFolder) listFilesWithNameFilter(file.getParent().getParent(), file.getParent().getName()).get(0); + String tmpFileUri = fileUri.isPresent() ? fileUri.get().toString() : ""; + file = new LocalStorageAccessFile(parent, file.getName(), file.getPath(), file.getSize(), file.getModified(), file.getDocumentId(), tmpFileUri); + } + + final LocalStorageAccessFile tmpFile = file; + + Uri uploadUri = fileUri.orElseGet(createNewDocumentSupplier(tmpFile)); + if (uploadUri == null) { + throw new NotFoundException(tmpFile.getName()); + } + + try (OutputStream out = contentResolver().openOutputStream(uploadUri); // + TransferredBytesAwareInputStream in = new TransferredBytesAwareInputStream(data.open(context)) { + @Override + public void bytesTransferred(long transferred) { + progressAware // + .onProgress(progress(UploadState.upload(tmpFile)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }) { + if (out instanceof FileOutputStream) { + ((FileOutputStream) out).getChannel().truncate(0); + } + + copyStreamToStream(in, out); + } + + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + + return LocalStorageAccessFrameworkNodeFactory.file( // + file.getParent(), // + buildDocumentFile(uploadUri)); + } + + private Supplier createNewDocumentSupplier(final LocalStorageAccessFile file) { + return () -> { + MimeType mimeType = mimeTypes.fromFilename(file.getName()) // + .orElse(MimeType.APPLICATION_OCTET_STREAM); + try { + return DocumentsContract.createDocument( // + contentResolver(), // + file.getParent().getUri(), // + mimeType.toString(), // + file.getName()); + } catch (FileNotFoundException e) { + return null; + } + }; + } + + private Optional existingFileUri(LocalStorageAccessFile file) throws BackendException { + List nodes = listFilesWithNameFilter( // + file.getParent(), // + file.getName()); + if (nodes.size() > 0) { + return Optional.of(nodes.get(0).getUri()); + } else { + return Optional.empty(); + } + } + + public void read(final LocalStorageAccessFile file, final OutputStream data, final ProgressAware progressAware) throws IOException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + + try (InputStream in = contentResolver().openInputStream(file.getUri()); // + TransferredBytesAwareOutputStream out = new TransferredBytesAwareOutputStream(data) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress(progress(DownloadState.download(file)) // + .between(0) // + .and(file.getSize().orElse(Long.MAX_VALUE)) // + .withValue(transferred)); + } + }) { + copyStreamToStream(in, out); + } + + progressAware.onProgress(Progress.completed(DownloadState.download(file))); + } + + public void delete(LocalStorageAccessNode node) throws NoSuchCloudFileException { + try { + DocumentsContract.deleteDocument( // + contentResolver(), // + node.getUri()); + } catch (FileNotFoundException e) { + throw new NoSuchCloudFileException(node.getName()); + } + idCache.remove(node); + } + + private DocumentFile buildDocumentFile(Uri fileUri) { + return DocumentFile.fromSingleUri(context, fileUri); + } + + private ContentResolver contentResolver() { + return context.getContentResolver(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkNodeFactory.java new file mode 100644 index 000000000..f2cf4df7e --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessFrameworkNodeFactory.java @@ -0,0 +1,124 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.database.Cursor; +import android.os.Build; +import android.provider.DocumentsContract; + +import org.cryptomator.util.Optional; + +import java.util.Date; + +import androidx.annotation.RequiresApi; +import androidx.documentfile.provider.DocumentFile; + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class LocalStorageAccessFrameworkNodeFactory { + + public static LocalStorageAccessNode from(LocalStorageAccessFolder parent, Cursor cursor) { + if (isFolder(cursor)) { + return folder(parent, cursor); + } else { + return file(parent, cursor); + } + } + + private static LocalStorageAccessFile file(LocalStorageAccessFolder parent, Cursor cursor) { + return new LocalStorageAccessFile( // + parent, // + cursor.getString(0), // + getNodePath(parent, cursor.getString(0)), // + Optional.of(cursor.getLong(2)), // + Optional.of(new Date(cursor.getLong(3))), // + cursor.getString(4), // + getDocumentUri(parent, cursor.getString(4))); + } + + private static LocalStorageAccessFolder folder(LocalStorageAccessFolder parent, Cursor cursor) { + return new LocalStorageAccessFolder(parent, // + cursor.getString(0), // + getNodePath(parent, cursor.getString(0)), // + cursor.getString(4), // + getDocumentUri(parent, cursor.getString(4))); + } + + public static LocalStorageAccessNode from(LocalStorageAccessFolder parent, DocumentFile documentFile) { + if (isFolder(documentFile)) { + return folder(parent, documentFile); + } else { + return file(parent, documentFile); + } + } + + public static LocalStorageAccessFolder folder(LocalStorageAccessFolder parent, DocumentFile directory) { + return new LocalStorageAccessFolder(parent, // + directory.getName(), // + getNodePath(parent, directory.getName()), // + DocumentsContract.getDocumentId(directory.getUri()), // + directory.getUri().toString()); + } + + public static LocalStorageAccessFile file(LocalStorageAccessFolder parent, DocumentFile documentFile) { + return new LocalStorageAccessFile( // + parent, // + documentFile.getName(), // + getNodePath(parent, documentFile.getName()), // + Optional.of(documentFile.length()), // + Optional.of(new Date(documentFile.lastModified())), // + DocumentsContract.getTreeDocumentId(documentFile.getUri()), // + documentFile.getUri().toString()); + } + + public static LocalStorageAccessFile file(LocalStorageAccessFolder parent, String name, Optional size) { + return new LocalStorageAccessFile(// + parent, // + name, // + getNodePath(parent, name), // + size, // + Optional.empty(), // + null, // + null); + } + + public static LocalStorageAccessFile file(LocalStorageAccessFolder parent, String name, String path, Optional size, String documentId) { + return new LocalStorageAccessFile(parent, // + name, // + path, // + size, // + Optional.empty(), // + documentId, // + getDocumentUri(parent, documentId)); + } + + public static LocalStorageAccessFolder folder(LocalStorageAccessFolder parent, String name) { + return new LocalStorageAccessFolder(parent, // + name, // + getNodePath(parent, name), // + null, // + null); + } + + public static LocalStorageAccessFolder folder(LocalStorageAccessFolder parent, String name, String documentId) { + return new LocalStorageAccessFolder(parent, // + name, // + getNodePath(parent, name), // + documentId, // + getDocumentUri(parent, documentId)); + } + + private static String getDocumentUri(LocalStorageAccessFolder parent, String documentId) { + return DocumentsContract.buildDocumentUriUsingTree(parent.getUri(), documentId).toString(); + } + + private static boolean isFolder(DocumentFile file) { + return file.isDirectory(); + } + + private static boolean isFolder(Cursor cursor) { + return cursor.getString(1).equals(DocumentsContract.Document.MIME_TYPE_DIR); + } + + public static String getNodePath(LocalStorageAccessFolder parent, String name) { + return parent.getPath() + "/" + name; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessNode.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessNode.java new file mode 100644 index 000000000..d4254dc75 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/LocalStorageAccessNode.java @@ -0,0 +1,15 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.net.Uri; + +import org.cryptomator.domain.CloudNode; + +public interface LocalStorageAccessNode extends CloudNode { + + Uri getUri(); + + LocalStorageAccessFolder getParent(); + + String getDocumentId(); + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/RootLocalStorageAccessFolder.java b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/RootLocalStorageAccessFolder.java new file mode 100644 index 000000000..a5736c89b --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/local/storageaccessframework/RootLocalStorageAccessFolder.java @@ -0,0 +1,41 @@ +package org.cryptomator.data.cloud.local.storageaccessframework; + +import android.os.Build; +import android.provider.DocumentsContract; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.LocalStorageCloud; + +import androidx.annotation.RequiresApi; + +import static android.net.Uri.parse; + +@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) +public class RootLocalStorageAccessFolder extends LocalStorageAccessFolder { + + private final LocalStorageCloud localStorageCloud; + + public RootLocalStorageAccessFolder(LocalStorageCloud localStorageCloud) { + super(null, // + "", // + "", // + DocumentsContract.getTreeDocumentId( // + parse(localStorageCloud.rootUri())), // + DocumentsContract.buildChildDocumentsUriUsingTree( // + parse(localStorageCloud.rootUri()), // + DocumentsContract.getTreeDocumentId( // + parse(localStorageCloud.rootUri()))) + .toString()); + this.localStorageCloud = localStorageCloud; + } + + @Override + public Cloud getCloud() { + return localStorageCloud; + } + + @Override + public LocalStorageAccessFolder withCloud(Cloud cloud) { + return new RootLocalStorageAccessFolder((LocalStorageCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HeaderNames.java b/data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HeaderNames.java new file mode 100644 index 000000000..58295d849 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HeaderNames.java @@ -0,0 +1,20 @@ +package org.cryptomator.data.cloud.okhttplogging; + +import java.util.HashSet; +import java.util.Set; + +class HeaderNames { + + private final Set lowercaseNames = new HashSet<>(); + + public HeaderNames(String... headerNames) { + for (String headerName : headerNames) { + lowercaseNames.add(headerName.toLowerCase()); + } + } + + public boolean contains(String headerName) { + return lowercaseNames.contains(headerName.toLowerCase()); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HttpLoggingInterceptor.java b/data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HttpLoggingInterceptor.java new file mode 100644 index 000000000..a905d4344 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/okhttplogging/HttpLoggingInterceptor.java @@ -0,0 +1,147 @@ +package org.cryptomator.data.cloud.okhttplogging; + +import android.content.Context; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +import okhttp3.Connection; +import okhttp3.Headers; +import okhttp3.Interceptor; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import static android.preference.PreferenceManager.getDefaultSharedPreferences; +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +public final class HttpLoggingInterceptor implements Interceptor { + + private static final HeaderNames EXCLUDED_HEADERS = new HeaderNames(// + // headers excluded because they are logged separately: + "Content-Type", "Content-Length", + // headers excluded because they contain sensitive information: + "Authorization", // + "WWW-Authenticate", // + "Cookie", // + "Set-Cookie" // + ); + + public interface Logger { + void log(String message); + } + + public HttpLoggingInterceptor(Logger logger, Context context) { + this.logger = logger; + this.context = context; + } + + private final Logger logger; + private final Context context; + + @NotNull + @Override + public Response intercept(@NotNull Chain chain) throws IOException { + if (debugModeEnabled(context)) { + return proceedWithLogging(chain); + } else { + return chain.proceed(chain.request()); + } + } + + private Response proceedWithLogging(Chain chain) throws IOException { + Request request = chain.request(); + logRequest(request, chain); + return getAndLogResponse(request, chain); + } + + private void logRequest(Request request, Chain chain) throws IOException { + logRequestStart(request, chain); + logContentTypeAndLength(request); + logHeaders(request.headers()); + logRequestEnd(request); + } + + private Response getAndLogResponse(Request request, Chain chain) throws IOException { + long startOfRequestMs = System.nanoTime(); + Response response = getResponseLoggingExceptions(request, chain); + long requestDurationMs = NANOSECONDS.toMillis(System.nanoTime() - startOfRequestMs); + logResponse(response, requestDurationMs); + return response; + } + + private Response getResponseLoggingExceptions(Request request, Chain chain) throws IOException { + try { + return chain.proceed(request); + } catch (Exception e) { + logger.log("<-- HTTP FAILED: " + e); + throw e; + } + } + + private void logResponse(Response response, long requestDurationMs) { + logResponseStart(response, requestDurationMs); + logHeaders(response.headers()); + logger.log("<-- END HTTP"); + } + + private void logRequestStart(Request request, Chain chain) throws IOException { + Connection connection = chain.connection(); + Protocol protocol = connection != null ? connection.protocol() : Protocol.HTTP_1_1; + String bodyLength = hasBody(request) ? request.body().contentLength() + "-byte body" : "unknown length"; + + logger.log(format("--> %s %s %s (%s)", // + request.method(), // + request.url(), // + protocol, // + bodyLength // + )); + } + + private void logContentTypeAndLength(Request request) throws IOException { + // Request body headers are only present when installed as a network interceptor. Force + // them to be included (when available) so there values are known. + if (hasBody(request)) { + RequestBody body = request.body(); + if (body.contentType() != null) { + logger.log("Content-Type: " + body.contentType()); + } + if (body.contentLength() != -1) { + logger.log("Content-Length: " + body.contentLength()); + } + } + } + + private void logRequestEnd(Request request) { + logger.log("--> END " + request.method()); + } + + private void logResponseStart(Response response, long requestDurationMs) { + logger.log("<-- " + response.code() + ' ' + response.message() + ' ' + response.request().url() + " (" + requestDurationMs + "ms" + ')'); + } + + private boolean hasBody(Request request) { + return request.body() != null; + } + + private void logHeaders(Headers headers) { + for (int i = 0, count = headers.size(); i < count; i++) { + String name = headers.name(i); + if (isExcludedHeader(name)) { + continue; + } + logger.log(name + ": " + headers.value(i)); + } + } + + private static boolean debugModeEnabled(Context context) { + return getDefaultSharedPreferences(context).getBoolean("debugMode", false); + } + + private boolean isExcludedHeader(String name) { + return EXCLUDED_HEADERS.contains(name); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/MSAAuthAndroidAdapterImpl.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/MSAAuthAndroidAdapterImpl.java new file mode 100644 index 000000000..1cf2556cf --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/MSAAuthAndroidAdapterImpl.java @@ -0,0 +1,25 @@ +package org.cryptomator.data.cloud.onedrive; + +import android.content.Context; + +import org.cryptomator.data.BuildConfig; +import org.cryptomator.data.cloud.onedrive.graph.MSAAuthAndroidAdapter; + +public class MSAAuthAndroidAdapterImpl extends MSAAuthAndroidAdapter { + + private static final String[] SCOPES = new String[] {"https://graph.microsoft.com/Files.ReadWrite", "offline_access", "openid"}; + + public MSAAuthAndroidAdapterImpl(Context context, String refreshToken) { + super(context, refreshToken); + } + + @Override + public String getClientId() { + return BuildConfig.ONEDRIVE_API_KEY; + } + + @Override + public String[] getScopes() { + return SCOPES; + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveClientFactory.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveClientFactory.java new file mode 100644 index 000000000..6130c9ac1 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveClientFactory.java @@ -0,0 +1,78 @@ +package org.cryptomator.data.cloud.onedrive; + +import android.content.Context; + +import com.microsoft.graph.authentication.IAuthenticationProvider; +import com.microsoft.graph.core.DefaultClientConfig; +import com.microsoft.graph.models.extensions.IGraphServiceClient; +import com.microsoft.graph.requests.extensions.GraphServiceClient; + +import org.cryptomator.data.cloud.okhttplogging.HttpLoggingInterceptor; +import org.cryptomator.data.cloud.onedrive.graph.IAuthenticationAdapter; + +import java.util.concurrent.atomic.AtomicReference; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import timber.log.Timber; + +import static org.cryptomator.data.util.NetworkTimeout.CONNECTION; +import static org.cryptomator.data.util.NetworkTimeout.READ; +import static org.cryptomator.data.util.NetworkTimeout.WRITE; + +public class OnedriveClientFactory { + + private final AtomicReference graphServiceClient = new AtomicReference<>(); + private final IAuthenticationAdapter authenticationAdapter; + + private static OnedriveClientFactory instance; + + private final Context context; + + private OnedriveClientFactory(Context context, String refreshToken) { + this.context = context; + this.authenticationAdapter = new MSAAuthAndroidAdapterImpl(context, refreshToken); + } + + public static OnedriveClientFactory instance(Context context, String accessToken) { + if (instance == null) { + instance = new OnedriveClientFactory(context, accessToken); + } + return instance; + } + + public IGraphServiceClient client() { + if (graphServiceClient.get() == null) { + + OkHttpClient.Builder builder = new OkHttpClient() // + .newBuilder() // + .connectTimeout(CONNECTION.getTimeout(), CONNECTION.getUnit()) // + .readTimeout(READ.getTimeout(), READ.getUnit()) // + .writeTimeout(WRITE.getTimeout(), WRITE.getUnit()) // + .addInterceptor(httpLoggingInterceptor(context)); + + OnedriveHttpProvider onedriveHttpProvider = new OnedriveHttpProvider(new DefaultClientConfig() { + @Override + public IAuthenticationProvider getAuthenticationProvider() { + return getAuthenticationAdapter(); + } + }, builder.build()); + + graphServiceClient.set(GraphServiceClient // + .builder() // + .authenticationProvider(getAuthenticationAdapter()) // + .httpProvider(onedriveHttpProvider) // + .buildClient()); + } + return graphServiceClient.get(); + } + + private static Interceptor httpLoggingInterceptor(Context context) { + return new HttpLoggingInterceptor(message -> Timber.tag("OkHttp").d(message), context); + } + + public synchronized IAuthenticationAdapter getAuthenticationAdapter() { + return authenticationAdapter; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepository.java new file mode 100644 index 000000000..56b46baf0 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepository.java @@ -0,0 +1,165 @@ +package org.cryptomator.data.cloud.onedrive; + +import android.content.Context; + +import com.microsoft.graph.core.GraphErrorCodes; + +import org.cryptomator.data.cloud.InterceptingCloudContentRepository; +import org.cryptomator.data.cloud.onedrive.graph.ClientException; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.OnedriveCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NetworkConnectionException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +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.net.SocketTimeoutException; +import java.util.List; + +import static org.cryptomator.util.ExceptionUtil.contains; + +class OnedriveCloudContentRepository extends InterceptingCloudContentRepository { + + private final OnedriveCloud cloud; + + public OnedriveCloudContentRepository(OnedriveCloud cloud, Context context) { + super(new Intercepted(cloud, context)); + this.cloud = cloud; + } + + @Override + protected void throwWrappedIfRequired(Exception e) throws BackendException { + throwNetworkConnectionExceptionIfRequired(e); + throwWrongCredentialsExceptionIfRequired(e); + } + + private void throwNetworkConnectionExceptionIfRequired(Exception e) throws NetworkConnectionException { + if (contains(e, SocketTimeoutException.class)) { + throw new NetworkConnectionException(e); + } + } + + private void throwWrongCredentialsExceptionIfRequired(Exception e) { + if (isAuthenticationError(e)) { + throw new WrongCredentialsException(cloud); + } + } + + private boolean isAuthenticationError(Throwable e) { + return e != null // + && ((e instanceof ClientException && ((ClientException) e).errorCode().equals(GraphErrorCodes.AUTHENTICATION_FAILURE)) // + || isAuthenticationError(e.getCause())); + } + + private static class Intercepted implements CloudContentRepository { + + private final OnedriveImpl oneDriveImpl; + + public Intercepted(OnedriveCloud cloud, Context context) { + this.oneDriveImpl = new OnedriveImpl(cloud, context, new OnedriveIdCache()); + } + + @Override + public OnedriveFolder root(OnedriveCloud cloud) { + return oneDriveImpl.root(); + } + + @Override + public OnedriveFolder resolve(OnedriveCloud cloud, String path) { + return oneDriveImpl.resolve(path); + } + + @Override + public OnedriveFile file(OnedriveFolder parent, String name) { + return oneDriveImpl.file(parent, name); + } + + @Override + public OnedriveFile file(OnedriveFolder parent, String name, Optional size) { + return oneDriveImpl.file(parent, name, size); + } + + @Override + public OnedriveFolder folder(OnedriveFolder parent, String name) { + return oneDriveImpl.folder(parent, name); + } + + @Override + public boolean exists(OnedriveNode node) throws BackendException { + return oneDriveImpl.exists(node); + } + + @Override + public List list(OnedriveFolder folder) throws BackendException { + return oneDriveImpl.list(folder); + } + + @Override + public OnedriveFolder create(OnedriveFolder folder) throws BackendException { + return oneDriveImpl.create(folder); + } + + @Override + public OnedriveFolder move(OnedriveFolder source, OnedriveFolder target) throws BackendException { + return (OnedriveFolder) oneDriveImpl.move(source, target); + } + + @Override + public OnedriveFile move(OnedriveFile source, OnedriveFile target) throws BackendException { + return (OnedriveFile) oneDriveImpl.move(source, target); + } + + @Override + public OnedriveFile write(OnedriveFile file, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return oneDriveImpl.write(file, data, progressAware, replace, size); + } catch (BackendException e) { + if (contains(e, NoSuchCloudFileException.class)) { + throw new NoSuchCloudFileException(file.getName()); + } + throw e; + } + } + + @Override + public void read(OnedriveFile file, Optional tmpEncryptedFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + oneDriveImpl.read(file, tmpEncryptedFile, data, progressAware); + } catch (IOException | BackendException e) { + if (contains(e, NoSuchCloudFileException.class)) { + throw new NoSuchCloudFileException(file.getName()); + } else if (e instanceof IOException) { + throw new FatalBackendException(e); + } else if (e instanceof BackendException) { + throw (BackendException) e; + } + } + } + + @Override + public void delete(OnedriveNode node) throws BackendException { + oneDriveImpl.delete(node); + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(OnedriveCloud cloud) throws BackendException { + return oneDriveImpl.currentAccount(); + } + + @Override + public void logout(OnedriveCloud cloud) { + oneDriveImpl.logout(); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepositoryFactory.java new file mode 100644 index 000000000..7dff2b352 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudContentRepositoryFactory.java @@ -0,0 +1,34 @@ +package org.cryptomator.data.cloud.onedrive; + +import android.content.Context; + +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.OnedriveCloud; +import org.cryptomator.domain.repository.CloudContentRepository; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.domain.CloudType.ONEDRIVE; + +@Singleton +public class OnedriveCloudContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final Context context; + + @Inject + public OnedriveCloudContentRepositoryFactory(Context context) { + this.context = context; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == ONEDRIVE; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + return new OnedriveCloudContentRepository((OnedriveCloud) cloud, context); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudNodeFactory.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudNodeFactory.java new file mode 100644 index 000000000..b8ed36f4b --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveCloudNodeFactory.java @@ -0,0 +1,77 @@ +package org.cryptomator.data.cloud.onedrive; + +import com.microsoft.graph.models.extensions.DriveItem; + +import org.cryptomator.util.Optional; + +import java.util.Date; + +class OnedriveCloudNodeFactory { + + public static OnedriveNode from(OnedriveFolder parent, DriveItem item) { + if (isFolder(item)) { + return folder(parent, item); + } else { + return file(parent, item); + } + } + + private static OnedriveFile file(OnedriveFolder parent, DriveItem item) { + return new OnedriveFile(parent, item.name, getNodePath(parent, item.name), Optional.ofNullable(item.size), lastModified(item)); + } + + public static OnedriveFile file(OnedriveFolder parent, DriveItem item, Optional lastModified) { + return new OnedriveFile(parent, item.name, getNodePath(parent, item.name), Optional.ofNullable(item.size), lastModified); + } + + public static OnedriveFile file(OnedriveFolder parent, String name, Optional size) { + return new OnedriveFile(parent, name, getNodePath(parent, name), size, Optional.empty()); + } + + public static OnedriveFile file(OnedriveFolder parent, String name, Optional size, String path) { + return new OnedriveFile(parent, name, path, size, Optional.empty()); + } + + public static OnedriveFolder folder(OnedriveFolder parent, DriveItem item) { + return new OnedriveFolder(parent, item.name, getNodePath(parent, item.name)); + } + + public static OnedriveFolder folder(OnedriveFolder parent, String name) { + return new OnedriveFolder(parent, name, getNodePath(parent, name)); + } + + public static OnedriveFolder folder(OnedriveFolder parent, String name, String path) { + return new OnedriveFolder(parent, name, path); + } + + private static String getNodePath(OnedriveFolder parent, String name) { + return parent.getPath() + "/" + name; + } + + public static String getId(DriveItem item) { + return item.remoteItem != null // + ? item.remoteItem.id // + : item.id; + } + + public static String getDriveId(DriveItem item) { + return item.remoteItem != null // + ? item.remoteItem.parentReference.driveId // + : item.parentReference != null // + ? item.parentReference.driveId // + : null; + } + + public static boolean isFolder(DriveItem item) { + return item.folder != null || (item.remoteItem != null && item.remoteItem.folder != null); + } + + private static Optional lastModified(DriveItem item) { + if (item.lastModifiedDateTime == null) { + return Optional.empty(); + } else { + return Optional.of(item.lastModifiedDateTime.getTime()); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFile.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFile.java new file mode 100644 index 000000000..d1bd6cbc1 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFile.java @@ -0,0 +1,59 @@ +package org.cryptomator.data.cloud.onedrive; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +class OnedriveFile implements CloudFile, OnedriveNode { + + private final OnedriveFolder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + + public OnedriveFile(OnedriveFolder 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 boolean isFolder() { + return false; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public OnedriveFolder 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/onedrive/OnedriveFolder.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFolder.java new file mode 100644 index 000000000..c51a22fa4 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveFolder.java @@ -0,0 +1,47 @@ +package org.cryptomator.data.cloud.onedrive; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +class OnedriveFolder implements CloudFolder, OnedriveNode { + + private final OnedriveFolder parent; + private final String name; + private final String path; + + public OnedriveFolder(OnedriveFolder parent, String name, String path) { + this.parent = parent; + this.name = name; + this.path = path; + } + + @Override + public boolean isFolder() { + return true; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public OnedriveFolder getParent() { + return parent; + } + + @Override + public OnedriveFolder withCloud(Cloud cloud) { + return new OnedriveFolder(parent.withCloud(cloud), name, path); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveHttpProvider.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveHttpProvider.java new file mode 100644 index 000000000..392e817ff --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveHttpProvider.java @@ -0,0 +1,577 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) 2015 Microsoft Corporation +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF WILDCARD_MIME_TYPE KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR WILDCARD_MIME_TYPE CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// ------------------------------------------------------------------------------ +package org.cryptomator.data.cloud.onedrive; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.concurrent.TimeUnit; + +import com.google.common.annotations.VisibleForTesting; +import com.microsoft.graph.authentication.IAuthenticationProvider; +import com.microsoft.graph.concurrency.ICallback; +import com.microsoft.graph.concurrency.IExecutors; +import com.microsoft.graph.concurrency.IProgressCallback; +import com.microsoft.graph.core.ClientException; +import com.microsoft.graph.core.Constants; +import com.microsoft.graph.core.DefaultConnectionConfig; +import com.microsoft.graph.core.IClientConfig; +import com.microsoft.graph.core.IConnectionConfig; +import com.microsoft.graph.http.GraphServiceException; +import com.microsoft.graph.http.HttpMethod; +import com.microsoft.graph.http.HttpResponseCode; +import com.microsoft.graph.http.HttpResponseHeadersHelper; +import com.microsoft.graph.http.IHttpProvider; +import com.microsoft.graph.http.IHttpRequest; +import com.microsoft.graph.http.IStatefulResponseHandler; +import com.microsoft.graph.httpcore.HttpClients; +import com.microsoft.graph.httpcore.ICoreAuthenticationProvider; +import com.microsoft.graph.httpcore.middlewareoption.RedirectOptions; +import com.microsoft.graph.httpcore.middlewareoption.RetryOptions; +import com.microsoft.graph.logger.ILogger; +import com.microsoft.graph.logger.LoggerLevel; +import com.microsoft.graph.options.HeaderOption; +import com.microsoft.graph.serializer.ISerializer; + +import org.jetbrains.annotations.NotNull; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.BufferedSink; + +/** + * Http provider based off of URLConnection. + */ +public class OnedriveHttpProvider implements IHttpProvider { + + private final HttpResponseHeadersHelper responseHeadersHelper = new HttpResponseHeadersHelper(); + + /** + * The serializer + */ + private final ISerializer serializer; + + /** + * The authentication provider + */ + private final IAuthenticationProvider authenticationProvider; + + /** + * The executors + */ + private final IExecutors executors; + + /** + * The logger + */ + private final ILogger logger; + + /** + * The connection config + */ + private IConnectionConfig connectionConfig; + + /** + * The OkHttpClient that handles all requests + */ + private OkHttpClient corehttpClient; + + /** + * Creates the DefaultHttpProvider + * + * @param serializer the serializer + * @param authenticationProvider the authentication provider + * @param executors the executors + * @param logger the logger for diagnostic information + */ + public OnedriveHttpProvider(final ISerializer serializer, final IAuthenticationProvider authenticationProvider, final IExecutors executors, final ILogger logger) { + this.serializer = serializer; + this.authenticationProvider = authenticationProvider; + this.executors = executors; + this.logger = logger; + } + + /** + * Creates the DefaultHttpProvider + * + * @param clientConfig the client configuration to use for the provider + * @param httpClient the http client to execute the requests with + */ + public OnedriveHttpProvider(final IClientConfig clientConfig, final OkHttpClient httpClient) { + this(clientConfig.getSerializer(), clientConfig.getAuthenticationProvider(), clientConfig.getExecutors(), clientConfig.getLogger()); + this.corehttpClient = httpClient; + } + + /** + * Gets the serializer for this HTTP provider + * + * @return the serializer for this provider + */ + @Override + public ISerializer getSerializer() { + return serializer; + } + + /** + * Sends the HTTP request asynchronously + * + * @param request the request description + * @param callback the callback to be called after success or failure + * @param resultClass the class of the response from the service + * @param serializable the object to send to the service in the body of the request + * @param the type of the response object + * @param the type of the object to send to the service in the body of the request + */ + @Override + public void send(final IHttpRequest request, final ICallback callback, final Class resultClass, final Body serializable) { + final IProgressCallback progressCallback; + if (callback instanceof IProgressCallback) { + progressCallback = (IProgressCallback) callback; + } else { + progressCallback = null; + } + + executors.performOnBackground(() -> { + try { + executors.performOnForeground(sendRequestInternal(request, resultClass, serializable, progressCallback, null), callback); + } catch (final ClientException e) { + executors.performOnForeground(e, callback); + } + }); + } + + /** + * Sends the HTTP request + * + * @param request the request description + * @param resultClass the class of the response from the service + * @param serializable the object to send to the service in the body of the request + * @param the type of the response object + * @param the type of the object to send to the service in the body of the request + * @return the result from the request + * @throws ClientException an exception occurs if the request was unable to complete for any reason + */ + @Override + public Result send(final IHttpRequest request, final Class resultClass, final Body serializable) throws ClientException { + return send(request, resultClass, serializable, null); + } + + /** + * Sends the HTTP request + * + * @param request the request description + * @param resultClass the class of the response from the service + * @param serializable the object to send to the service in the body of the request + * @param handler the handler for stateful response + * @param the type of the response object + * @param the type of the object to send to the service in the body of the request + * @param the response handler for stateful response + * @return the result from the request + * @throws ClientException this exception occurs if the request was unable to complete for any reason + */ + public Result send(final IHttpRequest request, final Class resultClass, final Body serializable, final IStatefulResponseHandler handler) + throws ClientException { + return sendRequestInternal(request, resultClass, serializable, null, handler); + } + + /** + * Sends the HTTP request + * + * @param request the request description + * @param resultClass the class of the response from the service + * @param serializable the object to send to the service in the body of the request + * @param progress the progress callback for the request + * @param the type of the response object + * @param the type of the object to send to the service in the body of the request + * @return the result from the request + * @throws ClientException an exception occurs if the request was unable to complete for any reason + */ + public Request getHttpRequest(final IHttpRequest request, final Class resultClass, final Body serializable, final IProgressCallback progress) throws ClientException { + final int defaultBufferSize = 4096; + + final URL requestUrl = request.getRequestUrl(); + logger.logDebug("Starting to send request, URL " + requestUrl.toString()); + + if (this.connectionConfig == null) { + this.connectionConfig = new DefaultConnectionConfig(); + } + + // Request level middleware options + RedirectOptions redirectOptions = new RedirectOptions(request.getMaxRedirects() > 0 ? request.getMaxRedirects() : this.connectionConfig.getMaxRedirects(), + request.getShouldRedirect() != null ? request.getShouldRedirect() : this.connectionConfig.getShouldRedirect()); + RetryOptions retryOptions = new RetryOptions(request.getShouldRetry() != null ? request.getShouldRetry() : this.connectionConfig.getShouldRetry(), + request.getMaxRetries() > 0 ? request.getMaxRetries() : this.connectionConfig.getMaxRetries(), request.getDelay() > 0 ? request.getDelay() : this.connectionConfig.getDelay()); + + Request coreHttpRequest = convertIHttpRequestToOkHttpRequest(request); + Request.Builder corehttpRequestBuilder = coreHttpRequest.newBuilder().tag(RedirectOptions.class, redirectOptions).tag(RetryOptions.class, retryOptions); + + String contenttype = null; + + logger.logDebug("Request Method " + request.getHttpMethod().toString()); + List requestHeaders = request.getHeaders(); + + for (HeaderOption headerOption : requestHeaders) { + if (headerOption.getName().equalsIgnoreCase(Constants.CONTENT_TYPE_HEADER_NAME)) { + contenttype = headerOption.getValue().toString(); + break; + } + } + + final byte[] bytesToWrite; + corehttpRequestBuilder.addHeader("Accept", "*/*"); + if (serializable == null) { + // Send an empty body through with a POST request + // This ensures that the Content-Length header is properly set + if (request.getHttpMethod() == HttpMethod.POST) { + bytesToWrite = new byte[0]; + if (contenttype == null) { + contenttype = Constants.BINARY_CONTENT_TYPE; + } + } else { + bytesToWrite = null; + } + } else if (serializable instanceof byte[]) { + logger.logDebug("Sending byte[] as request body"); + bytesToWrite = (byte[]) serializable; + + // If the user hasn't specified a Content-Type for the request + if (!hasHeader(requestHeaders, Constants.CONTENT_TYPE_HEADER_NAME)) { + corehttpRequestBuilder.addHeader(Constants.CONTENT_TYPE_HEADER_NAME, Constants.BINARY_CONTENT_TYPE); + contenttype = Constants.BINARY_CONTENT_TYPE; + } + } else { + logger.logDebug("Sending " + serializable.getClass().getName() + " as request body"); + final String serializeObject = serializer.serializeObject(serializable); + try { + bytesToWrite = serializeObject.getBytes(Constants.JSON_ENCODING); + } catch (final UnsupportedEncodingException ex) { + final ClientException clientException = new ClientException("Unsupported encoding problem: ", ex); + logger.logError("Unsupported encoding problem: " + ex.getMessage(), ex); + throw clientException; + } + + // If the user hasn't specified a Content-Type for the request + if (!hasHeader(requestHeaders, Constants.CONTENT_TYPE_HEADER_NAME)) { + corehttpRequestBuilder.addHeader(Constants.CONTENT_TYPE_HEADER_NAME, Constants.JSON_CONTENT_TYPE); + contenttype = Constants.JSON_CONTENT_TYPE; + } + } + + RequestBody requestBody = null; + // Handle cases where we've got a body to process. + if (bytesToWrite != null) { + final String mediaContentType = contenttype; + requestBody = new RequestBody() { + @Override + public long contentLength() { + return bytesToWrite.length; + } + + @Override + public void writeTo(@NotNull BufferedSink sink) throws IOException { + OutputStream out = sink.outputStream(); + int writtenSoFar = 0; + BufferedOutputStream bos = new BufferedOutputStream(out); + int toWrite; + do { + toWrite = Math.min(defaultBufferSize, bytesToWrite.length - writtenSoFar); + bos.write(bytesToWrite, writtenSoFar, toWrite); + writtenSoFar = writtenSoFar + toWrite; + if (progress != null) { + executors.performOnForeground(writtenSoFar, bytesToWrite.length, progress); + } + } while (toWrite > 0); + bos.close(); + out.close(); + } + + @Override + public MediaType contentType() { + return MediaType.parse(mediaContentType); + } + }; + } + + corehttpRequestBuilder.method(request.getHttpMethod().toString(), requestBody); + return corehttpRequestBuilder.build(); + } + + /** + * Sends the HTTP request + * + * @param request the request description + * @param resultClass the class of the response from the service + * @param serializable the object to send to the service in the body of the request + * @param progress the progress callback for the request + * @param handler the handler for stateful response + * @param the type of the response object + * @param the type of the object to send to the service in the body of the request + * @param the response handler for stateful response + * @return the result from the request + * @throws ClientException an exception occurs if the request was unable to complete for any reason + */ + @SuppressWarnings("unchecked") + private Result sendRequestInternal(final IHttpRequest request, final Class resultClass, final Body serializable, final IProgressCallback progress, + final IStatefulResponseHandler handler) throws ClientException { + + try { + if (this.connectionConfig == null) { + this.connectionConfig = new DefaultConnectionConfig(); + } + if (this.corehttpClient == null) { + final ICoreAuthenticationProvider authProvider = request1 -> request1; + this.corehttpClient = HttpClients.createDefault(authProvider).newBuilder().connectTimeout(connectionConfig.getConnectTimeout(), TimeUnit.MILLISECONDS) + .readTimeout(connectionConfig.getReadTimeout(), TimeUnit.MILLISECONDS).followRedirects(false) // TODO https://github.com/microsoftgraph/msgraph-sdk-java/issues/516 + .protocols(Collections.singletonList(Protocol.HTTP_1_1)) // https://stackoverflow.com/questions/62031298/sockettimeout-on-java-11-but-not-on-java-8 + .build(); + } + if (authenticationProvider != null) { // TODO https://github.com/microsoftgraph/msgraph-sdk-java/issues/517 + authenticationProvider.authenticateRequest(request); + } + Request coreHttpRequest = getHttpRequest(request, resultClass, serializable, progress); + Response response = corehttpClient.newCall(coreHttpRequest).execute(); + InputStream in = null; + boolean isBinaryStreamInput = false; + try { + + // Call being executed + + if (handler != null) { + handler.configConnection(response); + } + + logger.logDebug(String.format("Response code %d, %s", response.code(), response.message())); + + if (handler != null) { + logger.logDebug("StatefulResponse is handling the HTTP response."); + return handler.generateResult(request, response, this.getSerializer(), this.logger); + } + + if (response.code() >= HttpResponseCode.HTTP_CLIENT_ERROR) { + logger.logDebug("Handling error response"); + in = response.body().byteStream(); + handleErrorResponse(request, serializable, response); + } + + if (response.code() == HttpResponseCode.HTTP_NOBODY || response.code() == HttpResponseCode.HTTP_NOT_MODIFIED) { + logger.logDebug("Handling response with no body"); + return handleEmptyResponse(responseHeadersHelper.getResponseHeadersAsMapOfStringList(response), resultClass); + } + + if (response.code() == HttpResponseCode.HTTP_ACCEPTED) { + logger.logDebug("Handling accepted response"); + return handleEmptyResponse(responseHeadersHelper.getResponseHeadersAsMapOfStringList(response), resultClass); + } + + in = new BufferedInputStream(response.body().byteStream()); + + final Map headers = responseHeadersHelper.getResponseHeadersAsMapStringString(response); + + if (response.body() == null || response.body().contentLength() == 0) + return (Result) null; + + final String contentType = headers.get(Constants.CONTENT_TYPE_HEADER_NAME); + if (contentType != null && resultClass != InputStream.class && contentType.contains(Constants.JSON_CONTENT_TYPE)) { + logger.logDebug("Response json"); + return handleJsonResponse(in, responseHeadersHelper.getResponseHeadersAsMapOfStringList(response), resultClass); + } else if (resultClass == InputStream.class) { + logger.logDebug("Response binary"); + isBinaryStreamInput = true; + return (Result) handleBinaryStream(in); + } else { + return (Result) null; + } + } finally { + if (!isBinaryStreamInput) { + try { + if (in != null) + in.close(); + } catch (IOException e) { + logger.logError(e.getMessage(), e); + } + if (response != null) + response.close(); + } + } + } catch (final GraphServiceException ex) { + final boolean shouldLogVerbosely = logger.getLoggingLevel() == LoggerLevel.DEBUG; + logger.logError("Graph service exception " + ex.getMessage(shouldLogVerbosely), ex); + throw ex; + } catch (final Exception ex) { + final ClientException clientException = new ClientException("Error during http request", ex); + logger.logError("Error during http request", clientException); + throw clientException; + } + } + + private Request convertIHttpRequestToOkHttpRequest(IHttpRequest request) { + if (request != null) { + Request.Builder requestBuilder = new Request.Builder(); + requestBuilder.url(request.getRequestUrl()); + for (final HeaderOption header : request.getHeaders()) { + requestBuilder.addHeader(header.getName(), header.getValue().toString()); + } + return requestBuilder.build(); + } + return null; + } + + /** + * Handles the event of an error response + * + * @param request the request that caused the failed response + * @param serializable the body of the request + * @param connection the URL connection + * @param the type of the request body + * @throws IOException an exception occurs if there were any problems interacting with the connection object + */ + private void handleErrorResponse(final IHttpRequest request, final Body serializable, final Response response) throws IOException { + throw GraphServiceException.createFromConnection(request, serializable, serializer, response, logger); + } + + /** + * Handles the cause where the response is a binary stream + * + * @param in the input stream from the response + * @return the input stream to return to the caller + */ + private InputStream handleBinaryStream(final InputStream in) { + return in; + } + + /** + * Handles the cause where the response is a JSON object + * + * @param in the input stream from the response + * @param responseHeaders the response header + * @param clazz the class of the response object + * @param the type of the response object + * @return the JSON object + */ + private Result handleJsonResponse(final InputStream in, Map> responseHeaders, final Class clazz) { + if (clazz == null) { + return null; + } + + final String rawJson = streamToString(in); + return getSerializer().deserializeObject(rawJson, clazz, responseHeaders); + } + + /** + * Handles the case where the response body is empty + * + * @param responseHeaders the response headers + * @param clazz the type of the response object + * @return the JSON object + */ + private Result handleEmptyResponse(Map> responseHeaders, final Class clazz) throws UnsupportedEncodingException { + // Create an empty object to attach the response headers to + InputStream in = new ByteArrayInputStream("{}".getBytes(Constants.JSON_ENCODING)); + return handleJsonResponse(in, responseHeaders, clazz); + } + + /** + * Reads in a stream and converts it into a string + * + * @param input the response body stream + * @return the string result + */ + public static String streamToString(final InputStream input) { + final String httpStreamEncoding = "UTF-8"; + final String endOfFile = "\\A"; + final Scanner scanner = new Scanner(input, httpStreamEncoding); + String scannerString = ""; + try { + scanner.useDelimiter(endOfFile); + scannerString = scanner.next(); + } finally { + scanner.close(); + } + return scannerString; + } + + /** + * Searches for the given header in a list of HeaderOptions + * + * @param headers the list of headers to search through + * @param header the header name to search for (case insensitive) + * @return true if the header has already been set + */ + @VisibleForTesting + static boolean hasHeader(List headers, String header) { + for (HeaderOption option : headers) { + if (option.getName().equalsIgnoreCase(header)) { + return true; + } + } + return false; + } + + @VisibleForTesting + public ILogger getLogger() { + return logger; + } + + @VisibleForTesting + public IExecutors getExecutors() { + return executors; + } + + @VisibleForTesting + public IAuthenticationProvider getAuthenticationProvider() { + return authenticationProvider; + } + + /** + * Get connection config for read and connect timeout in requests + * + * @return Connection configuration to be used for timeout values + */ + public IConnectionConfig getConnectionConfig() { + if (this.connectionConfig == null) { + this.connectionConfig = new DefaultConnectionConfig(); + } + return connectionConfig; + } + + /** + * Set connection config for read and connect timeout in requests + * + * @param connectionConfig Connection configuration to be used for timeout values + */ + public void setConnectionConfig(IConnectionConfig connectionConfig) { + this.connectionConfig = connectionConfig; + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCache.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCache.java new file mode 100644 index 000000000..4fde4e633 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCache.java @@ -0,0 +1,89 @@ +package org.cryptomator.data.cloud.onedrive; + +import android.util.LruCache; + +import org.cryptomator.domain.CloudFolder; + +import javax.inject.Inject; + +class OnedriveIdCache { + + private final LruCache cache; + + @Inject + OnedriveIdCache() { + cache = new LruCache<>(1000); + } + + public NodeInfo get(String path) { + return cache.get(path); + } + + public T cache(T value) { + add(value); + return value; + } + + private void add(OnedriveIdCloudNode node) { + add(node.getPath(), new NodeInfo(node)); + } + + public void add(String path, NodeInfo info) { + cache.put(path, info); + } + + public void remove(OnedriveIdCloudNode node) { + remove(node.getPath()); + } + + public void remove(String path) { + removeChildren(path); + cache.remove(path); + } + + void removeChildren(String path) { + String prefix = path + '/'; + for (String key : cache.snapshot().keySet()) { + if (key.startsWith(prefix)) { + cache.remove(key); + } + } + } + + static class NodeInfo { + + private final String id; + private final String driveId; + private final boolean isFolder; + private final String cTag; + + private NodeInfo(OnedriveIdCloudNode node) { + this(node.getId(), node.getDriveId(), node instanceof CloudFolder, ""); + } + + NodeInfo(String id, String driveId, boolean isFolder, String cTag) { + this.id = id; + this.driveId = driveId; + this.isFolder = isFolder; + this.cTag = cTag; + } + + public String getId() { + return id; + } + + public String getDriveId() { + return driveId; + } + + public boolean isFolder() { + return isFolder; + } + + public String getcTag() { + return cTag; + } + + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCloudNode.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCloudNode.java new file mode 100644 index 000000000..872928729 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveIdCloudNode.java @@ -0,0 +1,11 @@ +package org.cryptomator.data.cloud.onedrive; + +import org.cryptomator.domain.CloudNode; + +public interface OnedriveIdCloudNode extends CloudNode { + + String getId(); + + String getDriveId(); + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveImpl.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveImpl.java new file mode 100644 index 000000000..8173004e9 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveImpl.java @@ -0,0 +1,549 @@ +package org.cryptomator.data.cloud.onedrive; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.Nullable; + +import com.microsoft.graph.concurrency.ChunkedUploadProvider; +import com.microsoft.graph.http.GraphServiceException; +import com.microsoft.graph.models.extensions.DriveItem; +import com.microsoft.graph.models.extensions.DriveItemUploadableProperties; +import com.microsoft.graph.models.extensions.Folder; +import com.microsoft.graph.models.extensions.IGraphServiceClient; +import com.microsoft.graph.models.extensions.ItemReference; +import com.microsoft.graph.models.extensions.UploadSession; +import com.microsoft.graph.options.Option; +import com.microsoft.graph.options.QueryOption; +import com.microsoft.graph.requests.extensions.IDriveItemCollectionPage; +import com.microsoft.graph.requests.extensions.IDriveItemContentStreamRequest; +import com.microsoft.graph.requests.extensions.IDriveRequestBuilder; +import com.tomclaw.cache.DiskLruCache; + +import org.cryptomator.data.cloud.onedrive.graph.ClientException; +import org.cryptomator.data.cloud.onedrive.graph.ICallback; +import org.cryptomator.data.cloud.onedrive.graph.IProgressCallback; +import org.cryptomator.data.util.TransferredBytesAwareOutputStream; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.OnedriveCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +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.ExceptionUtil; +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.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import timber.log.Timber; + +import static java.util.Collections.singletonList; +import static org.cryptomator.data.util.CopyStream.copyStreamToStream; +import static org.cryptomator.data.util.CopyStream.toByteArray; +import static org.cryptomator.domain.usecases.cloud.Progress.progress; +import static org.cryptomator.util.file.LruFileCacheUtil.Cache.ONEDRIVE; +import static org.cryptomator.util.file.LruFileCacheUtil.retrieveFromLruCache; +import static org.cryptomator.util.file.LruFileCacheUtil.storeToLruCache; + +class OnedriveImpl { + + private static final long CHUNKED_UPLOAD_MAX_SIZE = 4L << 20; + private static final int CHUNKED_UPLOAD_CHUNK_SIZE = 327680 * 32; + private static final int CHUNKED_UPLOAD_MAX_ATTEMPTS = 5; + + private final OnedriveCloud cloud; + private final Context context; + private static final String REPLACE_MODE = "replace"; + private static final String NON_REPLACING_MODE = "rename"; + private final OnedriveIdCache nodeInfoCache; + private final OnedriveClientFactory clientFactory; + private final SharedPreferencesHandler sharedPreferencesHandler; + + private DiskLruCache diskLruCache; + + OnedriveImpl(OnedriveCloud cloud, Context context, OnedriveIdCache nodeInfoCache) { + if (cloud.accessToken() == null) { + throw new NoAuthenticationProvidedException(cloud); + } + this.cloud = cloud; + this.context = context; + this.nodeInfoCache = nodeInfoCache; + this.clientFactory = OnedriveClientFactory.instance(context, cloud.accessToken()); + + sharedPreferencesHandler = new SharedPreferencesHandler(context); + } + + private IGraphServiceClient client() { + return clientFactory.client(); + } + + private IDriveRequestBuilder drive(String driveId) { + return driveId == null ? client().me().drive() : client().drives(driveId); + } + + public OnedriveFolder root() { + return new RootOnedriveFolder(cloud); + } + + public OnedriveFolder resolve(String path) { + if (path.startsWith("/")) { + path = path.substring(1); + } + String[] names = path.split("/"); + OnedriveFolder folder = root(); + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + public OnedriveFile file(OnedriveFolder parent, String name) { + return file(parent, name, Optional.empty()); + } + + public OnedriveFile file(OnedriveFolder parent, String name, Optional size) { + return OnedriveCloudNodeFactory.file(parent, name, size); + } + + public OnedriveFolder folder(OnedriveFolder parent, String name) { + return OnedriveCloudNodeFactory.folder(parent, name); + } + + private DriveItem childByName(String parentId, String parentDriveId, String name) { + try { + return drive(parentDriveId) // + .items(parentId) // + .itemWithPath(Uri.encode(name)) // + .buildRequest() // + .get(); + } catch (GraphServiceException e) { + if (isNotFoundError(e)) { + return null; + } else { + throw e; + } + } + } + + private boolean isNotFoundError(GraphServiceException error) { + try { + Field responseCodeField = GraphServiceException.class.getDeclaredField("responseCode"); + responseCodeField.setAccessible(true); + Integer responseCode = (Integer) responseCodeField.get(error); + return responseCode == 404; + } catch (NoSuchFieldException e) { + throw new IllegalStateException(e); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + + public boolean exists(OnedriveNode node) { + try { + OnedriveIdCache.NodeInfo parentNodeInfo = nodeInfo(node.getParent()); + if (parentNodeInfo == null) { + removeNodeInfo(node); + return false; + } + DriveItem item = childByName(parentNodeInfo.getId(), parentNodeInfo.getDriveId(), node.getName()); + if (item == null) { + removeNodeInfo(node); + return false; + } + cacheNodeInfo(node, item); + return true; + } catch (ClientException e) { + if (ExceptionUtil.contains(e, SocketTimeoutException.class)) { + throw e; + } + return false; + } + } + + public List list(OnedriveFolder folder) throws BackendException { + List result = new ArrayList<>(); + OnedriveIdCache.NodeInfo nodeInfo = requireNodeInfo(folder); + IDriveItemCollectionPage page = drive(nodeInfo.getDriveId()) // + .items(nodeInfo.getId()) // + .children() // + .buildRequest() // + .get(); + do { + removeChildNodeInfo(folder); + for (DriveItem item : page.getCurrentPage()) { + result.add(cacheNodeInfo(OnedriveCloudNodeFactory.from(folder, item), item)); + } + if (page.getNextPage() != null) { + page = page.getNextPage() // + .buildRequest() // + .get(); + } else { + page = null; + } + } while (page != null); + return result; + } + + public OnedriveFolder create(OnedriveFolder folder) throws NoSuchCloudFileException { + OnedriveFolder parent = folder.getParent(); + if (nodeInfo(parent) == null) { + parent = create(folder.getParent()); + } + + final DriveItem folderToCreate = new DriveItem(); + folderToCreate.name = folder.getName(); + folderToCreate.folder = new Folder(); + + OnedriveIdCache.NodeInfo parentNodeInfo = requireNodeInfo(parent); + DriveItem createdFolder = drive(parentNodeInfo.getDriveId()) // + .items(parentNodeInfo.getId()).children() // + .buildRequest() // + .post(folderToCreate); + return cacheNodeInfo(OnedriveCloudNodeFactory.folder(parent, createdFolder), createdFolder); + } + + public OnedriveNode move(OnedriveNode source, OnedriveNode target) throws NoSuchCloudFileException, CloudNodeAlreadyExistsException { + if (exists(target)) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + + final DriveItem targetItem = new DriveItem(); + targetItem.name = target.getName(); + ItemReference targetParentReference = new ItemReference(); + OnedriveIdCache.NodeInfo targetNodeInfo = nodeInfo(target.getParent()); + targetParentReference.id = targetNodeInfo == null ? null : targetNodeInfo.getId(); + targetParentReference.driveId = targetNodeInfo == null ? null : targetNodeInfo.getDriveId(); + targetItem.parentReference = targetParentReference; + + OnedriveIdCache.NodeInfo sourceNodeInfo = requireNodeInfo(source); + DriveItem movedItem = drive(sourceNodeInfo.getDriveId())// + .items(sourceNodeInfo.getId()) // + .buildRequest() // + .patch(targetItem); + removeNodeInfo(source); + return cacheNodeInfo(OnedriveCloudNodeFactory.from(target.getParent(), movedItem), movedItem); + } + + public OnedriveFile write(final OnedriveFile file, DataSource data, final ProgressAware progressAware, boolean replace, final long size) throws BackendException { + if (exists(file) && !replace) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + progressAware.onProgress(Progress.started(UploadState.upload(file))); + String uploadMode = NON_REPLACING_MODE; + if (replace) { + uploadMode = REPLACE_MODE; + } + final Option conflictBehaviorOption = new QueryOption("@name.conflictBehavior", uploadMode); + final CompletableFuture result = new CompletableFuture<>(); + if (size <= CHUNKED_UPLOAD_MAX_SIZE) { + uploadFile(file, data, progressAware, result, conflictBehaviorOption); + } else { + try { + chunkedUploadFile(file, data, progressAware, result, conflictBehaviorOption, size); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + try { + return OnedriveCloudNodeFactory.file(file.getParent(), result.get(), Optional.of(new Date())); + } catch (ExecutionException | InterruptedException e) { + throw new FatalBackendException(e); + } + } + + private void uploadFile( // + final OnedriveFile file, // + DataSource data, // + final ProgressAware progressAware, // + final CompletableFuture result, // + Option conflictBehaviorOption) throws NoSuchCloudFileException { + OnedriveIdCache.NodeInfo parentNodeInfo = requireNodeInfo(file.getParent()); + try (InputStream in = data.open(context)) { + drive(parentNodeInfo.getDriveId()) // + .items(parentNodeInfo.getId())// + .itemWithPath(file.getName()) // + .content() // + .buildRequest(singletonList(conflictBehaviorOption)) // + .put(toByteArray(in), new IProgressCallback() { + @Override + public void progress(long current, long max) { + progressAware // + .onProgress(Progress.progress(UploadState.upload(file)) // + .between(0) // + .and(max) // + .withValue(current)); + } + + @Override + public void success(DriveItem item) { + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + result.complete(item); + cacheNodeInfo(file, item); + } + + @Override + public void failure(com.microsoft.graph.core.ClientException ex) { + result.fail(ex); + } + }); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private void chunkedUploadFile( // + final OnedriveFile file, // + DataSource data, // + final ProgressAware progressAware, // + final CompletableFuture result, // + Option conflictBehaviorOption, // + long size) throws IOException, NoSuchCloudFileException { + OnedriveIdCache.NodeInfo parentNodeInfo = requireNodeInfo(file.getParent()); + UploadSession uploadSession = drive(parentNodeInfo.getDriveId()) // + .items(parentNodeInfo.getId()) // + .itemWithPath(file.getName()) // + .createUploadSession(new DriveItemUploadableProperties()) // + .buildRequest() // + .post(); + + try (InputStream in = data.open(context)) { + new ChunkedUploadProvider<>(uploadSession, client(), in, size, DriveItem.class) // + .upload(singletonList(conflictBehaviorOption), new IProgressCallback() { + @Override + public void progress(long current, long max) { + progressAware.onProgress(Progress // + .progress(UploadState.upload(file)) // + .between(0) // + .and(max) // + .withValue(current)); + } + + @Override + public void success(DriveItem item) { + progressAware.onProgress(Progress.completed(UploadState.upload(file))); + result.complete(item); + cacheNodeInfo(file, item); + } + + @Override + public void failure(com.microsoft.graph.core.ClientException ex) { + result.fail(ex); + } + }, CHUNKED_UPLOAD_CHUNK_SIZE, CHUNKED_UPLOAD_MAX_ATTEMPTS); + } + } + + public void read(final OnedriveFile file, final Optional encryptedTmpFile, final OutputStream data, final ProgressAware progressAware) throws BackendException, IOException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + + Optional cacheKey = Optional.empty(); + Optional cacheFile = Optional.empty(); + + OnedriveIdCache.NodeInfo nodeInfo = requireNodeInfo(file); + + if (sharedPreferencesHandler.useLruCache() && createLruCache(sharedPreferencesHandler.lruCacheSize())) { + cacheKey = Optional.of(nodeInfo.getId() + nodeInfo.getcTag()); + java.io.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("OnedriveImpl").w(e, "Error while retrieving content from Cache, get from web request"); + writeToData(file, nodeInfo, data, encryptedTmpFile, cacheKey, progressAware); + } + } else { + writeToData(file, nodeInfo, data, encryptedTmpFile, cacheKey, progressAware); + } + } + + private void writeToData(final OnedriveFile file, // + final OnedriveIdCache.NodeInfo nodeInfo, // + final OutputStream data, // + final Optional encryptedTmpFile, // + final Optional cacheKey, // + final ProgressAware progressAware) throws IOException { + + final IDriveItemContentStreamRequest request = drive(nodeInfo.getDriveId()) // + .items(nodeInfo.getId()) // + .content() // + .buildRequest(); + + try (InputStream in = request.get(); // + TransferredBytesAwareOutputStream out = new TransferredBytesAwareOutputStream(data) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(DownloadState.download(file)) // + .between(0) // + .and(file.getSize().orElse(Long.MAX_VALUE)) // + .withValue(transferred)); + } + }) { + copyStreamToStream(in, out); + } + + if (sharedPreferencesHandler.useLruCache() && encryptedTmpFile.isPresent() && cacheKey.isPresent()) { + try { + storeToLruCache(diskLruCache, cacheKey.get(), encryptedTmpFile.get()); + } catch (IOException e) { + Timber.tag("OnedriveImpl").e(e, "Failed to write downloaded file in LRU cache"); + } + } + + progressAware.onProgress(Progress.completed(DownloadState.download(file))); + } + + private boolean createLruCache(int cacheSize) { + if (diskLruCache == null) { + try { + diskLruCache = DiskLruCache.create(new LruFileCacheUtil(context).resolve(ONEDRIVE), cacheSize); + } catch (IOException e) { + Timber.tag("OnedriveImpl").e(e, "Failed to setup LRU cache"); + return false; + } + } + + return true; + } + + public void delete(OnedriveNode node) throws NoSuchCloudFileException { + OnedriveIdCache.NodeInfo nodeInfo = requireNodeInfo(node); + drive(nodeInfo.getDriveId()) // + .items(nodeInfo.getId()) // + .buildRequest() // + .delete(); + removeNodeInfo(node); + } + + private OnedriveIdCache.NodeInfo requireNodeInfo(OnedriveNode node) throws NoSuchCloudFileException { + OnedriveIdCache.NodeInfo result = nodeInfo(node); + if (result == null) { + throw new NoSuchCloudFileException(node.getPath()); + } + return result; + } + + @Nullable + private OnedriveIdCache.NodeInfo nodeInfo(OnedriveNode node) { + OnedriveIdCache.NodeInfo result = nodeInfoCache.get(node.getPath()); + if (result == null) { + result = loadNodeInfo(node); + if (result == null) { + return null; + } else { + nodeInfoCache.add(node.getPath(), result); + } + } + if (result.isFolder() != node.isFolder()) { + return null; + } + return result; + } + + private T cacheNodeInfo(T node, DriveItem item) { + nodeInfoCache.add( // + node.getPath(), new OnedriveIdCache.NodeInfo( // + OnedriveCloudNodeFactory.getId(item), // + OnedriveCloudNodeFactory.getDriveId(item), // + OnedriveCloudNodeFactory.isFolder(item), // + item.cTag // + ) // + ); + return node; + } + + private void removeNodeInfo(OnedriveNode node) { + nodeInfoCache.remove(node.getPath()); + } + + private void removeChildNodeInfo(OnedriveFolder folder) { + nodeInfoCache.removeChildren(folder.getPath()); + } + + private OnedriveIdCache.NodeInfo loadNodeInfo(OnedriveNode node) { + if (node.getParent() == null) { + return loadRootNodeInfo(); + } else { + return loadNonRootNodeInfo(node); + } + } + + private OnedriveIdCache.NodeInfo loadRootNodeInfo() { + DriveItem item = drive(null).root().buildRequest().get(); + return new OnedriveIdCache.NodeInfo( // + OnedriveCloudNodeFactory.getId(item), // + OnedriveCloudNodeFactory.getDriveId(item), // + true, // + item.cTag // + ); + } + + private OnedriveIdCache.NodeInfo loadNonRootNodeInfo(OnedriveNode node) { + OnedriveIdCache.NodeInfo parentNodeInfo = nodeInfo(node.getParent()); + if (parentNodeInfo == null) { + return null; + } + DriveItem item = childByName(parentNodeInfo.getId(), parentNodeInfo.getDriveId(), node.getName()); + + if (item == null) { + return null; + } else { + String cTag = item.cTag; + + return new OnedriveIdCache.NodeInfo( // + OnedriveCloudNodeFactory.getId(item), // + OnedriveCloudNodeFactory.getDriveId(item), // + OnedriveCloudNodeFactory.isFolder(item), // + cTag // + ); + } + } + + public String currentAccount() { + return client().me().drive().buildRequest().get().owner.user.displayName; + } + + public void logout() { + final CompletableFuture result = new CompletableFuture<>(); + clientFactory.getAuthenticationAdapter().logout(new ICallback() { + @Override + public void success(Void aVoid) { + result.complete(null); + } + + @Override + public void failure(ClientException e) { + result.fail(e); + } + }); + try { + result.get(); + } catch (InterruptedException | ExecutionException e) { + throw new FatalBackendException(e); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveNode.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveNode.java new file mode 100644 index 000000000..c39225bac --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/OnedriveNode.java @@ -0,0 +1,15 @@ +package org.cryptomator.data.cloud.onedrive; + +import org.cryptomator.domain.CloudNode; + +public interface OnedriveNode extends CloudNode { + + boolean isFolder(); + + String getName(); + + String getPath(); + + OnedriveFolder getParent(); + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/RootOnedriveFolder.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/RootOnedriveFolder.java new file mode 100644 index 000000000..4b61380ae --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/RootOnedriveFolder.java @@ -0,0 +1,24 @@ +package org.cryptomator.data.cloud.onedrive; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.OnedriveCloud; + +class RootOnedriveFolder extends OnedriveFolder { + + private final OnedriveCloud oneDriveCloud; + + public RootOnedriveFolder(OnedriveCloud oneDriveCloud) { + super(null, "", ""); + this.oneDriveCloud = oneDriveCloud; + } + + @Override + public OnedriveCloud getCloud() { + return oneDriveCloud; + } + + @Override + public RootOnedriveFolder withCloud(Cloud cloud) { + return new RootOnedriveFolder((OnedriveCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ClientException.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ClientException.java new file mode 100644 index 000000000..ec4b8b67d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ClientException.java @@ -0,0 +1,29 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +import com.microsoft.graph.core.GraphErrorCodes; + +/** + * An exception from the client. + */ +public class ClientException extends com.microsoft.graph.core.ClientException { + + private static final long serialVersionUID = -10662352567392559L; + + private final Enum errorCode; + + /** + * Creates the client exception + * + * @param message the message to display + * @param ex the exception from + */ + public ClientException(final String message, final Throwable ex, Enum errorCode) { + super(message, ex); + + this.errorCode = errorCode; + } + + public Enum errorCode() { + return errorCode; + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IAuthenticationAdapter.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IAuthenticationAdapter.java new file mode 100644 index 000000000..653e5d8d6 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IAuthenticationAdapter.java @@ -0,0 +1,40 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +import android.app.Activity; + +import com.microsoft.graph.authentication.IAuthenticationProvider; + +/** + * An authentication adapter for signing requests, logging in, and logging out. + */ +public interface IAuthenticationAdapter extends IAuthenticationProvider { + + /** + * Logs out the user + * + * @param callback The callback when the logout is complete or an error occurs + */ + void logout(final ICallback callback); + + /** + * Login a user by popping UI + * + * @param activity The current activity + * @param callback The callback when the login is complete or an error occurs + */ + void login(final Activity activity, final ICallback callback); + + /** + * Login a user with no ui + * + * @param callback The callback when the login is complete or an error occurs + */ + void loginSilent(final ICallback callback); + + /** + * Gets the access token for the session of a logged in user + * + * @return the access token + */ + String getAccessToken() throws ClientException; +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ICallback.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ICallback.java new file mode 100644 index 000000000..1fe7783a4 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/ICallback.java @@ -0,0 +1,44 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +// ------------------------------------------------------------------------------ +// Copyright (c) 2017 Microsoft Corporation +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sub-license, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// ------------------------------------------------------------------------------ + +/** + * A callback that describes how to deal with success and failure + * + * @param the result type of the successful action + */ +public interface ICallback { + /** + * How successful results are handled + * + * @param result the result + */ + void success(final Result result); + + /** + * How failures are handled + * + * @param ex the exception + */ + void failure(final ClientException ex); +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IProgressCallback.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IProgressCallback.java new file mode 100644 index 000000000..37d2a97da --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/IProgressCallback.java @@ -0,0 +1,39 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +// ------------------------------------------------------------------------------ +// Copyright (c) 2017 Microsoft Corporation +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sub-license, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// ------------------------------------------------------------------------------ + +/** + * A callback that describes how to deal with success, failure, and progress + * + * @param the result type of the successful action + */ +public interface IProgressCallback extends com.microsoft.graph.concurrency.IProgressCallback { + + /** + * How progress updates are handled for this callback + * + * @param current the current amount of progress + * @param max the max amount of progress + */ + void progress(final long current, final long max); +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MSAAuthAndroidAdapter.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MSAAuthAndroidAdapter.java new file mode 100644 index 000000000..cc901de81 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MSAAuthAndroidAdapter.java @@ -0,0 +1,275 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +import android.app.Activity; +import android.content.Context; + +import com.microsoft.graph.http.IHttpRequest; +import com.microsoft.graph.options.HeaderOption; +import com.microsoft.services.msa.LiveAuthClient; +import com.microsoft.services.msa.LiveAuthException; +import com.microsoft.services.msa.LiveAuthListener; +import com.microsoft.services.msa.LiveConnectSession; +import com.microsoft.services.msa.LiveStatus; + +import org.cryptomator.util.crypto.CredentialCryptor; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; + +import timber.log.Timber; + +import static com.microsoft.graph.core.GraphErrorCodes.AUTHENTICATION_FAILURE; + +/** + * Supports login, logout, and signing requests with authorization information. + */ +public abstract class MSAAuthAndroidAdapter implements IAuthenticationAdapter { + + /** + * The authorization header name. + */ + private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; + + /** + * The bearer prefix. + */ + private static final String OAUTH_BEARER_PREFIX = "bearer "; + + /** + * The live auth client. + */ + private final LiveAuthClient mLiveAuthClient; + + /** + * The client id for this authenticator. + * http://graph.microsoft.io/en-us/app-registration + * + * @return The client id. + */ + protected abstract String getClientId(); + + /** + * The scopes for this application. + * http://graph.microsoft.io/en-us/docs/authorization/permission_scopes + * + * @return The scopes for this application. + */ + protected abstract String[] getScopes(); + + private Context context; + + /** + * Create a new instance of the provider + * + * @param context the application context instance + * @param refreshToken + */ + protected MSAAuthAndroidAdapter(final Context context, String refreshToken) { + this.context = context; + mLiveAuthClient = new LiveAuthClient(context, getClientId(), Arrays.asList(getScopes()), MicrosoftOAuth2Endpoint.getInstance(), refreshToken); + } + + @Override + public void authenticateRequest(final IHttpRequest request) { + Timber.tag("MSAAuthAndroidAdapter").d("Authenticating request, %s", request.getRequestUrl()); + + // If the request already has an authorization header, do not intercept it. + for (final HeaderOption option : request.getHeaders()) { + if (option.getName().equals(AUTHORIZATION_HEADER_NAME)) { + Timber.tag("MSAAuthAndroidAdapter").d("Found an existing authorization header!"); + return; + } + } + + try { + final String accessToken = getAccessToken(); + request.addHeader(AUTHORIZATION_HEADER_NAME, OAUTH_BEARER_PREFIX + accessToken); + } catch (ClientException e) { + final String message = "Unable to authenticate request, No active account found"; + final ClientException exception = new ClientException(message, e, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(exception, message); + throw exception; + } + } + + @Override + public String getAccessToken() throws ClientException { + if (hasValidSession()) { + Timber.tag("MSAAuthAndroidAdapter").d("Found account information"); + if (mLiveAuthClient.getSession().isExpired()) { + Timber.tag("MSAAuthAndroidAdapter").d("Account access token is expired, refreshing"); + loginSilentBlocking(); + } + return mLiveAuthClient.getSession().getAccessToken(); + } else { + final String message = "Unable to get access token, No active account found"; + final ClientException exception = new ClientException(message, null, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(exception, message); + throw exception; + } + } + + @Override + public void logout(final ICallback callback) { + Timber.tag("MSAAuthAndroidAdapter").d("Logout started"); + + if (callback == null) { + throw new IllegalArgumentException("callback"); + } + + mLiveAuthClient.logout(new LiveAuthListener() { + @Override + public void onAuthComplete(final LiveStatus status, final LiveConnectSession session, final Object userState) { + Timber.tag("MSAAuthAndroidAdapter").d("Logout complete"); + callback.success(null); + } + + @Override + public void onAuthError(final LiveAuthException exception, final Object userState) { + final ClientException clientException = new ClientException("Logout failure", exception, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(clientException); + callback.failure(clientException); + } + }); + } + + @Override + public void login(final Activity activity, final ICallback callback) { + Timber.tag("MSAAuthAndroidAdapter").d("Login started"); + + if (callback == null) { + throw new IllegalArgumentException("callback"); + } + + if (hasValidSession()) { + Timber.tag("MSAAuthAndroidAdapter").d("Already logged in"); + callback.success(null); + return; + } + + final LiveAuthListener listener = new LiveAuthListener() { + @Override + public void onAuthComplete(final LiveStatus status, final LiveConnectSession session, final Object userState) { + Timber.tag("MSAAuthAndroidAdapter").d(String.format("LiveStatus: %s, LiveConnectSession good?: %s, UserState %s", status, session != null, userState)); + + if (status == LiveStatus.NOT_CONNECTED) { + Timber.tag("MSAAuthAndroidAdapter").d("Received invalid login failure from silent authentication, ignoring."); + return; + } + + if (status == LiveStatus.CONNECTED) { + Timber.tag("MSAAuthAndroidAdapter").d("Login completed"); + callback.success(encrypt(session.getRefreshToken())); + return; + } + + final ClientException clientException = new ClientException("Unable to login successfully", null, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(clientException); + callback.failure(clientException); + } + + @Override + public void onAuthError(final LiveAuthException exception, final Object userState) { + final ClientException clientException = new ClientException("Login failure", exception, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(clientException); + callback.failure(clientException); + } + }; + + // Make sure the login process is started with the current activity information + activity.runOnUiThread(() -> mLiveAuthClient.login(activity, listener)); + } + + private String encrypt(String refreshToken) { + if (refreshToken == null) + return null; + return CredentialCryptor // + .getInstance(context) // + .encrypt(refreshToken); + } + + /** + * Login a user with no ui + * + * @param callback The callback when the login is complete or an error occurs + */ + @Override + public void loginSilent(final ICallback callback) { + Timber.tag("MSAAuthAndroidAdapter").d("Login silent started"); + + if (callback == null) { + throw new IllegalArgumentException("callback"); + } + + final LiveAuthListener listener = new LiveAuthListener() { + @Override + public void onAuthComplete(final LiveStatus status, final LiveConnectSession session, final Object userState) { + Timber.tag("MSAAuthAndroidAdapter").d(String.format("LiveStatus: %s, LiveConnectSession good?: %s, UserState %s", status, session != null, userState)); + + if (status == LiveStatus.CONNECTED) { + Timber.tag("MSAAuthAndroidAdapter").d("Login completed"); + callback.success(null); + return; + } + + final ClientException clientException = new ClientException("Unable to login silently", null, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(clientException); + callback.failure(clientException); + } + + @Override + public void onAuthError(final LiveAuthException exception, final Object userState) { + final ClientException clientException = new ClientException("Unable to login silently", null, AUTHENTICATION_FAILURE); + Timber.tag("MSAAuthAndroidAdapter").e(clientException); + callback.failure(clientException); + } + }; + + mLiveAuthClient.loginSilent(listener); + } + + /** + * Login silently while blocking for the call to return + * + * @return the result of the login attempt + * @throws ClientException The exception if there was an issue during the login attempt + */ + private Void loginSilentBlocking() throws ClientException { + Timber.tag("MSAAuthAndroidAdapter").d("Login silent blocking started"); + final SimpleWaiter waiter = new SimpleWaiter(); + final AtomicReference returnValue = new AtomicReference<>(); + final AtomicReference exceptionValue = new AtomicReference<>(); + + loginSilent(new ICallback() { + @Override + public void success(final Void aVoid) { + returnValue.set(aVoid); + waiter.signal(); + } + + @Override + public void failure(ClientException ex) { + exceptionValue.set(ex); + waiter.signal(); + } + }); + + waiter.waitForSignal(); + + // noinspection ThrowableResultOfMethodCallIgnored + if (exceptionValue.get() != null) { + throw exceptionValue.get(); + } + + return returnValue.get(); + } + + /** + * Is the session object valid + * + * @return true, if the session is valid (but not necessary unexpired) + */ + private boolean hasValidSession() { + return mLiveAuthClient.getSession() != null && mLiveAuthClient.getSession().getAccessToken() != null; + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MicrosoftOAuth2Endpoint.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MicrosoftOAuth2Endpoint.java new file mode 100644 index 000000000..168bb6de1 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/MicrosoftOAuth2Endpoint.java @@ -0,0 +1,41 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +import android.net.Uri; + +import com.microsoft.services.msa.OAuthConfig; + +class MicrosoftOAuth2Endpoint implements OAuthConfig { + /** + * The current instance of this class + */ + private static final MicrosoftOAuth2Endpoint sInstance = new MicrosoftOAuth2Endpoint(); + + /** + * The current instance of this class + * + * @return The instance + */ + static MicrosoftOAuth2Endpoint getInstance() { + return sInstance; + } + + @Override + public Uri getAuthorizeUri() { + return Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"); + } + + @Override + public Uri getDesktopUri() { + return Uri.parse("urn:ietf:wg:oauth:2.0:oob"); + } + + @Override + public Uri getLogoutUri() { + return Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/logout"); + } + + @Override + public Uri getTokenUri() { + return Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/SimpleWaiter.java b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/SimpleWaiter.java new file mode 100644 index 000000000..96f3e6231 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/onedrive/graph/SimpleWaiter.java @@ -0,0 +1,65 @@ +package org.cryptomator.data.cloud.onedrive.graph; + +// ------------------------------------------------------------------------------ +// Copyright (c) 2015 Microsoft Corporation +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// ------------------------------------------------------------------------------ + +/** + * A simple signal/waiter interface for synchronizing multi-threaded actions. + */ +public class SimpleWaiter { + + /** + * The internal lock object for this waiter. + */ + private final Object mInternalLock = new Object(); + + /** + * Indicates if this waiter has been triggered. + */ + private boolean mTriggerState; + + /** + * BLOCKING: Waits for the signal to be triggered, or returns immediately if it has already been triggered. + */ + public void waitForSignal() { + synchronized (mInternalLock) { + if (this.mTriggerState) { + return; + } + try { + mInternalLock.wait(); + } catch (final InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Triggers the signal for this waiter. + */ + public void signal() { + synchronized (mInternalLock) { + mTriggerState = true; + mInternalLock.notifyAll(); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/RootWebDavFolder.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/RootWebDavFolder.java new file mode 100644 index 000000000..069c32e29 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/RootWebDavFolder.java @@ -0,0 +1,24 @@ +package org.cryptomator.data.cloud.webdav; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.WebDavCloud; + +public class RootWebDavFolder extends WebDavFolder { + + private final WebDavCloud cloud; + + public RootWebDavFolder(WebDavCloud cloud) { + super(null, "", ""); + this.cloud = cloud; + } + + @Override + public Cloud getCloud() { + return cloud; + } + + @Override + public WebDavFolder withCloud(Cloud cloud) { + return new RootWebDavFolder((WebDavCloud) cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepository.java new file mode 100644 index 000000000..7b68d3308 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepository.java @@ -0,0 +1,236 @@ +package org.cryptomator.data.cloud.webdav; + +import org.cryptomator.data.cloud.InterceptingCloudContentRepository; +import org.cryptomator.data.cloud.webdav.network.ConnectionHandlerHandlerImpl; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.WebDavCloud; +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.NetworkConnectionException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.NotFoundException; +import org.cryptomator.domain.exception.NotImplementedException; +import org.cryptomator.domain.exception.NotTrustableCertificateException; +import org.cryptomator.domain.exception.UnauthorizedException; +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.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.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.List; + +import javax.inject.Singleton; +import javax.net.ssl.SSLHandshakeException; + +import static org.cryptomator.util.ExceptionUtil.contains; +import static org.cryptomator.util.ExceptionUtil.extract; + +@Singleton +class WebDavCloudContentRepository extends InterceptingCloudContentRepository { + + private final WebDavCloud cloud; + + private static final CharSequence START_OF_CERTIFICATE = "-----BEGIN CERTIFICATE-----"; + + WebDavCloudContentRepository(WebDavCloud cloud, ConnectionHandlerHandlerImpl connectionHandlerHandler) { + super(new Intercepted(cloud, connectionHandlerHandler)); + this.cloud = cloud; + } + + @Override + protected void throwWrappedIfRequired(Exception e) throws BackendException { + throwNetworkConnectionExceptionIfRequired(e); + throwCertificateUntrustedExceptionIfRequired(e); + throwForbiddenExceptionIfRequired(e); + throwUnauthorizedExceptionIfRequired(e); + throwNotImplementedExceptionIfRequired(e); + throwServerNotFoundExceptionIfRequired(e); + } + + private void throwServerNotFoundExceptionIfRequired(Exception e) { + if (contains(e, UnknownHostException.class)) { + throw new WebDavServerNotFoundException(cloud); + } + } + + private void throwNotImplementedExceptionIfRequired(Exception e) { + if (contains(e, NotImplementedException.class)) { + throw new WebDavNotSupportedException(cloud); + } + } + + private void throwUnauthorizedExceptionIfRequired(Exception e) { + if (contains(e, UnauthorizedException.class)) { + throw new WrongCredentialsException(cloud); + } + } + + private void throwForbiddenExceptionIfRequired(Exception e) { + if (contains(e, ForbiddenException.class)) { + throw new WrongCredentialsException(cloud); + } + } + + private void throwCertificateUntrustedExceptionIfRequired(Exception e) { + Optional notTrustableCertificateException = extract(e, NotTrustableCertificateException.class); + if (notTrustableCertificateException.isPresent()) { + throw new WebDavCertificateUntrustedAuthenticationException(cloud, notTrustableCertificateException.get().getMessage()); + } + Optional sslHandshakeException = extract(e, SSLHandshakeException.class); + if (sslHandshakeException.isPresent() && containsCertificate(e.getMessage())) { + throw new WebDavCertificateUntrustedAuthenticationException(cloud, sslHandshakeException.get().getMessage()); + } + } + + private boolean containsCertificate(String message) { + return message != null && message.contains(START_OF_CERTIFICATE); + } + + private void throwNetworkConnectionExceptionIfRequired(Exception e) throws NetworkConnectionException { + if (contains(e, SocketTimeoutException.class)) { + throw new NetworkConnectionException(e); + } + } + + private static class Intercepted implements CloudContentRepository { + + private final WebDavImpl webDavImpl; + + Intercepted(WebDavCloud cloud, ConnectionHandlerHandlerImpl connectionHandler) { + this.webDavImpl = new WebDavImpl(cloud, connectionHandler); + } + + public WebDavFolder root(WebDavCloud cloud) { + return webDavImpl.root(); + } + + @Override + public WebDavFolder resolve(WebDavCloud cloud, String path) throws BackendException { + return webDavImpl.resolve(path); + } + + @Override + public WebDavFile file(WebDavFolder parent, String name) throws BackendException { + return webDavImpl.file(parent, name); + } + + @Override + public WebDavFile file(WebDavFolder parent, String name, Optional size) throws BackendException { + return webDavImpl.file(parent, name, size); + } + + @Override + public WebDavFolder folder(WebDavFolder parent, String name) { + return webDavImpl.folder(parent, name); + } + + @Override + public boolean exists(WebDavNode node) throws BackendException { + return webDavImpl.exists(node); + } + + @Override + public List list(WebDavFolder folder) throws BackendException { + try { + return webDavImpl.list(folder); + } catch (BackendException e) { + if (contains(e, NotFoundException.class)) { + throw new NoSuchCloudFileException(); + } + throw e; + } + } + + @Override + public WebDavFolder create(WebDavFolder folder) throws BackendException { + return webDavImpl.create(folder); + } + + @Override + public WebDavFolder move(WebDavFolder source, WebDavFolder target) throws BackendException { + try { + return webDavImpl.move(source, target); + } catch (BackendException e) { + if (contains(e, NotFoundException.class)) { + throw new NoSuchCloudFileException(source.getName()); + } else if (contains(e, CloudNodeAlreadyExistsException.class)) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + throw e; + } + } + + @Override + public WebDavFile move(WebDavFile source, WebDavFile target) throws BackendException { + return webDavImpl.move(source, target); + } + + @Override + public WebDavFile write(WebDavFile uploadFile, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + return webDavImpl.write(uploadFile, data, progressAware, replace, size); + } catch (BackendException | IOException e) { + if (contains(e, NotFoundException.class)) { + throw new NoSuchCloudFileException(uploadFile.getName()); + } else if (e instanceof IOException) { + throw new FatalBackendException(e); + } else if (e instanceof FatalBackendException) { + throw (FatalBackendException) e; + } else { + throw new FatalBackendException(e); + } + } + } + + @Override + public void read(WebDavFile file, Optional tmpEncryptedFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + webDavImpl.read(file, data, progressAware); + } catch (BackendException | IOException e) { + if (contains(e, NotFoundException.class)) { + throw new NoSuchCloudFileException(file.getName()); + } else if (e instanceof IOException) { + throw new FatalBackendException(e); + } else if (e instanceof FatalBackendException) { + throw (FatalBackendException) e; + } + } + } + + @Override + public void delete(WebDavNode node) throws BackendException { + try { + webDavImpl.delete(node); + } catch (BackendException e) { + if (contains(e, NotFoundException.class)) { + throw new NoSuchCloudFileException(node.getName()); + } + throw e; + } + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(WebDavCloud cloud) throws BackendException { + return webDavImpl.currentAccount(); + } + + @Override + public void logout(WebDavCloud cloud) { + // empty + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepositoryFactory.java new file mode 100644 index 000000000..824da1503 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavCloudContentRepositoryFactory.java @@ -0,0 +1,36 @@ +package org.cryptomator.data.cloud.webdav; + +import org.cryptomator.data.cloud.webdav.network.ConnectionHandlerFactory; +import org.cryptomator.data.repository.CloudContentRepositoryFactory; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +import org.cryptomator.domain.repository.CloudContentRepository; + +import javax.inject.Inject; + +import static org.cryptomator.domain.CloudType.WEBDAV; + +public class WebDavCloudContentRepositoryFactory implements CloudContentRepositoryFactory { + + private final ConnectionHandlerFactory connectionHandlerFactory; + + @Inject + WebDavCloudContentRepositoryFactory(ConnectionHandlerFactory connectionHandlerFactory) { + this.connectionHandlerFactory = connectionHandlerFactory; + } + + @Override + public boolean supports(Cloud cloud) { + return cloud.type() == WEBDAV; + } + + @Override + public CloudContentRepository cloudContentRepositoryFor(Cloud cloud) { + WebDavCloud webDavCloud = (WebDavCloud) cloud; + if (webDavCloud.username() == null || webDavCloud.password() == null) { + throw new NoAuthenticationProvidedException(webDavCloud); + } + return new WebDavCloudContentRepository(webDavCloud, connectionHandlerFactory.createConnectionHandler(webDavCloud)); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFile.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFile.java new file mode 100644 index 000000000..48b342a74 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFile.java @@ -0,0 +1,58 @@ +package org.cryptomator.data.cloud.webdav; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; + +public class WebDavFile implements CloudFile, WebDavNode { + + private final WebDavFolder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + + public WebDavFile(WebDavFolder parent, String name, Optional size, Optional modified) { + this(parent, name, parent.getPath() + "/" + name, size, modified); + } + + public WebDavFile(WebDavFolder 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 WebDavFolder 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/webdav/WebDavFolder.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFolder.java new file mode 100644 index 000000000..9d62c998d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavFolder.java @@ -0,0 +1,55 @@ +package org.cryptomator.data.cloud.webdav; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; +import org.jetbrains.annotations.NotNull; + +import static java.lang.String.format; + +public class WebDavFolder implements CloudFolder, WebDavNode { + + private final WebDavFolder parent; + private final String name; + private final String path; + + public WebDavFolder(WebDavFolder parent, String name) { + this(parent, name, parent.getPath() + "/" + name); + } + + public WebDavFolder(WebDavFolder 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 WebDavFolder getParent() { + return parent; + } + + @Override + public WebDavFolder withCloud(Cloud cloud) { + return new WebDavFolder(parent.withCloud(cloud), name, path); + } + + @NotNull + @Override + public String toString() { + return format("WebDavFolder(%s)", path); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavImpl.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavImpl.java new file mode 100644 index 000000000..d1f1bbdf6 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavImpl.java @@ -0,0 +1,254 @@ +package org.cryptomator.data.cloud.webdav; + +import android.content.Context; + +import org.cryptomator.data.cloud.webdav.network.ConnectionHandlerHandlerImpl; +import org.cryptomator.data.util.CopyStream; +import org.cryptomator.data.util.TransferredBytesAwareInputStream; +import org.cryptomator.data.util.TransferredBytesAwareOutputStream; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NotFoundException; +import org.cryptomator.domain.exception.ParentFolderDoesNotExistException; +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 java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; + +import okhttp3.HttpUrl; + +import static org.cryptomator.domain.usecases.cloud.Progress.progress; + +class WebDavImpl { + + private final WebDavCloud cloud; + private final HttpUrl baseUrl; + private final RootWebDavFolder root; + private final ConnectionHandlerHandlerImpl connectionHandler; + + WebDavImpl(WebDavCloud cloud, ConnectionHandlerHandlerImpl connectionHandler) { + this.cloud = cloud; + this.baseUrl = HttpUrl.parse(cloud.url()); + this.root = new RootWebDavFolder(cloud); + this.connectionHandler = connectionHandler; + } + + public WebDavFolder root() { + return root; + } + + public WebDavFolder resolve(String path) { + if (path.startsWith("/")) { + path = path.substring(1); + } + String[] names = path.split("/"); + WebDavFolder folder = root; + for (String name : names) { + folder = folder(folder, name); + } + return folder; + } + + public WebDavFile file(CloudFolder parent, String name) { + return file(parent, name, Optional.empty()); + } + + public WebDavFile file(CloudFolder parent, String name, Optional size) { + return new WebDavFile((WebDavFolder) parent, name, parent.getPath() + '/' + name, size, Optional.empty()); + } + + public WebDavFolder folder(CloudFolder parent, String name) { + return new WebDavFolder((WebDavFolder) parent, name, parent.getPath() + '/' + name); + } + + public boolean exists(CloudNode node) throws BackendException { + try { + return connectionHandler // + .get(absoluteUriFrom(node.getPath()), // + node.getParent()) != null; + } catch (NotFoundException e) { + return false; + } + } + + public List list(WebDavFolder folder) throws BackendException { + return connectionHandler // + .dirList(absoluteUriFrom(folder.getPath()), // + folder); + } + + public WebDavFolder create(WebDavFolder folder) throws BackendException { + try { + return createExcludingParents(folder); + } catch (NotFoundException | ParentFolderDoesNotExistException e) { + create(folder.getParent()); + return createExcludingParents(folder); + } + } + + private WebDavFolder createExcludingParents(WebDavFolder folder) throws BackendException { + if (folder.getParent() == null) { + return folder; + } else { + return connectionHandler.createFolder( // + absoluteUriFrom(folder.getPath()), // + folder); + } + } + + public WebDavFolder move(CloudFolder source, CloudFolder target) throws BackendException { + moveFileOrFolder(source, target); + return new WebDavFolder( // + (WebDavFolder) target.getParent() // + , target.getName() // + , target.getPath()); + } + + public WebDavFile move(CloudFile source, CloudFile target) throws BackendException { + moveFileOrFolder(source, target); + return new WebDavFile( // + (WebDavFolder) target.getParent() // + , target.getName() // + , target.getPath() // + , source.getSize() // + , source.getModified()); + } + + private void moveFileOrFolder(CloudNode source, CloudNode target) throws BackendException { + if (exists(target)) { + throw new CloudNodeAlreadyExistsException(target.getName()); + } + + connectionHandler // + .move(absoluteUriFrom(source.getPath()), // + absoluteUriFrom(target.getPath())); + } + + public WebDavFile write(final WebDavFile uploadFile, DataSource data, final ProgressAware progressAware, boolean replace, final long size) // + throws BackendException, IOException { + if (exists(uploadFile) && !replace) { + throw new CloudNodeAlreadyExistsException("CloudNode already exists and replace is false"); + } + + progressAware.onProgress(Progress.started(UploadState.upload(uploadFile))); + + try (TransferredBytesAwareDataSource out = new TransferredBytesAwareDataSource(data) { + @Override + public void bytesTrasferred(long transferred) { + progressAware.onProgress( // + progress(UploadState.upload(uploadFile)) // + .between(0) // + .and(size) // + .withValue(transferred)); + } + }) { + connectionHandler // + .writeFile( // + absoluteUriFrom(uploadFile.getPath()), out); + } + + WebDavFile cloudFile = (WebDavFile) connectionHandler // + .get(absoluteUriFrom(uploadFile.getPath()), // + uploadFile.getParent()); + + if (cloudFile == null) { + throw new FatalBackendException("Unable to get CloudFile after upload."); + } + + return cloudFile; + } + + public void checkAuthenticationAndServerCompatibility(String url) throws BackendException { + connectionHandler.checkAuthenticationAndServerCompatibility(url); + } + + private static abstract class TransferredBytesAwareDataSource implements DataSource { + + private final DataSource data; + + TransferredBytesAwareDataSource(DataSource data) { + this.data = data; + } + + @Override + public Optional size(Context context) { + return data.size(context); + } + + @Override + public InputStream open(Context context) throws IOException { + return new TransferredBytesAwareInputStream(data.open(context)) { + @Override + public void bytesTransferred(long transferred) { + TransferredBytesAwareDataSource.this.bytesTrasferred(transferred); + } + }; + } + + @Override + public void close() throws IOException { + data.close(); + } + + public abstract void bytesTrasferred(long transferred); + + @Override + public DataSource decorate(DataSource delegate) { + return delegate; + } + } + + public void read(final CloudFile file, OutputStream data, final ProgressAware progressAware) throws BackendException, IOException { + progressAware.onProgress(Progress.started(DownloadState.download(file))); + + try (InputStream in = connectionHandler.readFile(absoluteUriFrom(file.getPath())); // + TransferredBytesAwareOutputStream out = new TransferredBytesAwareOutputStream(data) { + @Override + public void bytesTransferred(long transferred) { + progressAware.onProgress( // + progress(DownloadState.download(file)) // + .between(0) // + .and(file.getSize().orElse(Long.MAX_VALUE)) // + .withValue(transferred)); + } + }) { + CopyStream.copyStreamToStream(in, out); + } + + progressAware.onProgress(Progress.completed(DownloadState.download(file))); + } + + public void delete(CloudNode node) throws BackendException { + connectionHandler.delete(absoluteUriFrom(node.getPath())); + } + + private String absoluteUriFrom(String path) { + path = removeLeadingSlash(path); + + return baseUrl.newBuilder() // + .addPathSegments(path) // + .build() // + .toString(); + } + + private String removeLeadingSlash(String path) { + return path.length() > 0 && path.charAt(0) == '/' ? path.substring(1) : path; + } + + public String currentAccount() throws BackendException { + checkAuthenticationAndServerCompatibility(cloud.url()); + return cloud.url(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavNode.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavNode.java new file mode 100644 index 000000000..db3cf0066 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/WebDavNode.java @@ -0,0 +1,9 @@ +package org.cryptomator.data.cloud.webdav; + +import org.cryptomator.domain.CloudNode; + +public interface WebDavNode extends CloudNode { + + @Override + WebDavFolder getParent(); +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerFactory.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerFactory.java new file mode 100644 index 000000000..0c620b7ad --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerFactory.java @@ -0,0 +1,22 @@ +package org.cryptomator.data.cloud.webdav.network; + +import android.content.Context; + +import org.cryptomator.domain.WebDavCloud; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ConnectionHandlerFactory { + private final Context context; + + @Inject + public ConnectionHandlerFactory(Context context) { + this.context = context; + } + + public ConnectionHandlerHandlerImpl createConnectionHandler(WebDavCloud cloud) { + return new ConnectionHandlerHandlerImpl(new WebDavCompatibleHttpClient(cloud, context), context); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerHandlerImpl.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerHandlerImpl.java new file mode 100644 index 000000000..a7ce466fa --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/ConnectionHandlerHandlerImpl.java @@ -0,0 +1,57 @@ +package org.cryptomator.data.cloud.webdav.network; + +import android.content.Context; + +import org.cryptomator.data.cloud.webdav.WebDavFolder; +import org.cryptomator.data.cloud.webdav.WebDavNode; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.usecases.cloud.DataSource; + +import java.io.InputStream; +import java.util.List; + +import javax.inject.Inject; + +public class ConnectionHandlerHandlerImpl { + + private final WebDavClient webDavClient; + + @Inject + ConnectionHandlerHandlerImpl(WebDavCompatibleHttpClient httpClient, Context context) { + this.webDavClient = new WebDavClient(context, httpClient); + } + + public List dirList(String url, WebDavFolder listedFolder) throws BackendException { + return webDavClient.dirList(url, listedFolder); + } + + public void move(String from, String to) throws BackendException { + webDavClient.move(from, to); + } + + public WebDavNode get(String url, CloudFolder parent) throws BackendException { + return webDavClient.get(url, parent); + } + + public void writeFile(String url, DataSource data) throws BackendException { + webDavClient.writeFile(url, data); + } + + public void delete(String url) throws BackendException { + webDavClient.delete(url); + } + + public WebDavFolder createFolder(String path, WebDavFolder folder) throws BackendException { + return webDavClient.createFolder(path, folder); + } + + public InputStream readFile(String url) throws BackendException { + return webDavClient.readFile(url); + } + + public void checkAuthenticationAndServerCompatibility(String url) throws BackendException { + webDavClient.checkAuthenticationAndServerCompatibility(url); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/DataSourceBasedRequestBody.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/DataSourceBasedRequestBody.java new file mode 100644 index 000000000..bcf850f40 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/DataSourceBasedRequestBody.java @@ -0,0 +1,42 @@ +package org.cryptomator.data.cloud.webdav.network; + +import android.content.Context; + +import java.io.IOException; + +import org.cryptomator.domain.usecases.cloud.DataSource; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.BufferedSink; +import okio.Okio; + +class DataSourceBasedRequestBody extends RequestBody { + + public static RequestBody from(Context context, DataSource data) { + return new DataSourceBasedRequestBody(context, data); + } + + private final Context context; + private final DataSource data; + + private DataSourceBasedRequestBody(Context context, DataSource data) { + this.context = context; + this.data = data; + } + + @Override + public long contentLength() { + return data.size(context).get(); + } + + @Override + public MediaType contentType() { + return MediaType.parse("application/octet-stream"); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + sink.writeAll(Okio.buffer(Okio.source(data.open(context)))); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/DefaultTrustManager.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/DefaultTrustManager.java new file mode 100644 index 000000000..ad49b02da --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/DefaultTrustManager.java @@ -0,0 +1,66 @@ +package org.cryptomator.data.cloud.webdav.network; + +import org.cryptomator.domain.exception.NotTrustableCertificateException; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import static org.cryptomator.data.util.X509CertificateHelper.convertToPem; + +class DefaultTrustManager implements X509TrustManager { + + private final X509TrustManager delegate; + + public DefaultTrustManager() { + this.delegate = findDefaultTrustManager(); + } + + private static X509TrustManager findDefaultTrustManager() { + try { + return tryToFindDefaultTrustManager(); + } catch (KeyStoreException | NoSuchAlgorithmException e) { + throw new IllegalStateException("Failed to obtain default trust manager", e); + } + } + + private static X509TrustManager tryToFindDefaultTrustManager() throws NoSuchAlgorithmException, KeyStoreException { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + for (TrustManager trustManager : trustManagerFactory.getTrustManagers()) { + if (trustManager instanceof X509TrustManager) { + return (X509TrustManager) trustManager; + } + } + throw new IllegalStateException("Failed to obtain default trust manager: No X509TrustManager available."); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + delegate.checkClientTrusted(chain, authType); + } catch (CertificateException e) { + throw new NotTrustableCertificateException(convertToPem(chain[0]), e); + } + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + delegate.checkServerTrusted(chain, authType); + } catch (CertificateException e) { + throw new NotTrustableCertificateException(convertToPem(chain[0]), e); + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return delegate.getAcceptedIssuers(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PinningTrustManager.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PinningTrustManager.java new file mode 100644 index 000000000..232baa848 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PinningTrustManager.java @@ -0,0 +1,91 @@ +package org.cryptomator.data.cloud.webdav.network; + +import org.cryptomator.data.util.X509CertificateHelper; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.NotTrustableCertificateException; +import org.cryptomator.util.Optional; + +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.X509TrustManager; + +import okhttp3.CertificatePinner; + +/** + * An {@link X509TrustManager} which always trusts one specific certificate but denies all others. + */ +class PinningTrustManager implements X509TrustManager { + + private final String expectedPin; + + /** + * Creates a {@code PinningTrustManager} which trusts the provided certificate. + * + * @param trustedCertPemEncoded the {@link X509Certificate} to trust in PEM encoded form + */ + public PinningTrustManager(String trustedCertPemEncoded) { + try { + X509Certificate trustedCert = X509CertificateHelper.convertFromPem(trustedCertPemEncoded); + expectedPin = CertificatePinner.pin(trustedCert); + } catch (CertificateException e) { + throw new FatalBackendException(e); + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + if (!isPinnedCertificate(chain[0])) { + throw new NotTrustableCertificateException(X509CertificateHelper.convertToPem(chain[0])); + } + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + if (!isPinnedCertificate(chain[0])) { + throw new NotTrustableCertificateException(X509CertificateHelper.convertToPem(chain[0])); + } + } + + private boolean isPinnedCertificate(X509Certificate certificate) { + return expectedPin.equals(CertificatePinner.pin(certificate)); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + /** + * @return a HostnameVerifier accepting any host when the pinned certificate is used and denying all other + */ + public HostnameVerifier hostnameVerifier() { + return new HostnameVerifier() { + @Override + public boolean verify(String hostname, SSLSession session) { + Optional peerX509Cert = peerX509Cert(session); + if (peerX509Cert.isPresent()) { + return isPinnedCertificate(peerX509Cert.get()); + } else { + return false; + } + } + + private Optional peerX509Cert(SSLSession session) { + try { + Certificate[] certificates = session.getPeerCertificates(); + if (certificates != null && certificates.length > 0 && certificates[0] instanceof X509Certificate) { + return Optional.of((X509Certificate) certificates[0]); + } + } catch (SSLPeerUnverifiedException e) { + // leads to return of Optional.empty(), intended! + } + return Optional.empty(); + } + }; + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindEntryData.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindEntryData.java new file mode 100644 index 000000000..30858810d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindEntryData.java @@ -0,0 +1,88 @@ +package org.cryptomator.data.cloud.webdav.network; + +import org.cryptomator.data.cloud.webdav.WebDavFile; +import org.cryptomator.data.cloud.webdav.WebDavFolder; +import org.cryptomator.data.cloud.webdav.WebDavNode; +import org.cryptomator.util.Optional; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Date; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class PropfindEntryData { + private static final Pattern URI_PATTERN = Pattern.compile("^[a-z]+://[^/]+/(.*)$"); + + private String path; + private String[] pathSegments; + + private boolean file = true; + private Optional lastModified = Optional.empty(); + private Optional size = Optional.empty(); + + public void setPath(String pathOrUri) { + this.path = extractPath(pathOrUri); + this.pathSegments = path.split("/"); + } + + private String extractPath(String pathOrUri) { + Matcher matcher = URI_PATTERN.matcher(pathOrUri); + if (matcher.matches()) { + return urlDecode(matcher.group(1)); + } else if (!pathOrUri.startsWith("/")) { + return urlDecode("/" + pathOrUri); + } else { + return urlDecode(pathOrUri); + } + } + + void setLastModified(Optional lastModified) { + this.lastModified = lastModified; + } + + public void setSize(Optional size) { + this.size = size; + } + + public void setFile(boolean file) { + this.file = file; + } + + public String getPath() { + return path; + } + + public Optional getSize() { + return size; + } + + private boolean isFile() { + return file; + } + + public WebDavNode toCloudNode(WebDavFolder parent) { + if (isFile()) { + return new WebDavFile(parent, getName(), size, lastModified); + } else { + return new WebDavFolder(parent, getName()); + } + } + + private String urlDecode(String value) { + try { + return URLDecoder.decode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 must be supported by every JVM", e); + } + } + + int getDepth() { + return pathSegments.length; + } + + private String getName() { + return pathSegments[pathSegments.length - 1]; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParser.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParser.java new file mode 100644 index 000000000..2c7f5b998 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParser.java @@ -0,0 +1,188 @@ +package org.cryptomator.data.cloud.webdav.network; + +import org.cryptomator.data.cloud.webdav.WebDavFolder; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.util.Optional; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import timber.log.Timber; + +class PropfindResponseParser { + + private static final String TAG_RESPONSE = "response"; + private static final String TAG_HREF = "href"; + private static final String TAG_COLLECTION = "collection"; + private static final String TAG_LAST_MODIFIED = "getlastmodified"; + private static final String TAG_CONTENT_LENGTH = "getcontentlength"; + private static final String TAG_PROPSTAT = "propstat"; + private static final String TAG_STATUS = "status"; + private static final String STATUS_OK = "200"; + + private final WebDavFolder requestedFolder; + private final XmlPullParser xmlPullParser; + + PropfindResponseParser(WebDavFolder requestedFolder) { + this.requestedFolder = requestedFolder; + try { + this.xmlPullParser = XmlPullParserFactory.newInstance().newPullParser(); + } catch (XmlPullParserException e) { + throw new FatalBackendException(e); + } + } + + List parse(InputStream responseBody) throws XmlPullParserException, IOException { + List entryData = new ArrayList<>(); + xmlPullParser.setInput(responseBody, "UTF-8"); + + while (skipToStartOf(TAG_RESPONSE)) { + PropfindEntryData entry = parseResponse(); + if (entry != null) { + entryData.add(entry); + } + } + + return entryData; + } + + private boolean skipToStartOf(String tag) throws XmlPullParserException, IOException { + do { + xmlPullParser.next(); + } while (!endOfDocument() && !startOf(tag)); + return startOf(tag); + } + + private PropfindEntryData parseResponse() throws XmlPullParserException, IOException { + PropfindEntryData entry = null; + String path = null; + + while (nextTagUntilEndOf(TAG_RESPONSE)) { + if (tagIs(TAG_PROPSTAT)) { + entry = defaultIfNull(parsePropstatWith200Status(), entry); + } else if (tagIs(TAG_HREF)) { + path = textInCurrentTag().trim(); + } + } + + if (entry == null) { + Timber.tag("WebDAV").w("No propstat element with 200 status in response element. Entry ignored."); + Timber.tag("WebDAV").v("No propstat element with 200 status in response element. Entry ignored. Dir: %s, Path: %s", requestedFolder.getPath(), path); + return null; + } + if (path == null) { + Timber.tag("WebDAV").w("Missing href in response element. Entry ignored."); + Timber.tag("WebDAV").v("Missing href in response element. Entry ignored. Dir: %s", requestedFolder.getPath()); + return null; + } + + entry.setPath(path); + return entry; + } + + private PropfindEntryData parsePropstatWith200Status() throws IOException, XmlPullParserException { + PropfindEntryData result = new PropfindEntryData(); + boolean statusOk = false; + while (nextTagUntilEndOf(TAG_PROPSTAT)) { + if (tagIs(TAG_STATUS)) { + String text = textInCurrentTag().trim(); + String[] statusSegments = text.split(" "); + String code = statusSegments.length > 0 ? statusSegments[1] : ""; + statusOk = STATUS_OK.equals(code); + } else if (tagIs(TAG_COLLECTION)) { + result.setFile(false); + } else if (tagIs(TAG_LAST_MODIFIED)) { + result.setLastModified(parseDate(textInCurrentTag())); + } else if (tagIs(TAG_CONTENT_LENGTH)) { + result.setSize(parseLong(textInCurrentTag())); + } + } + if (statusOk) { + return result; + } else { + return null; + } + } + + private boolean nextTagUntilEndOf(String tag) throws XmlPullParserException, IOException { + do { + xmlPullParser.next(); + } while (!endOfDocument() && !startOfATag() && !endOf(tag)); + return startOfATag(); + } + + private boolean startOf(String tag) throws XmlPullParserException { + return startOfATag() && tagIs(tag); + } + + private boolean tagIs(String tag) { + return tag.equalsIgnoreCase(localName()); + } + + private boolean startOfATag() throws XmlPullParserException { + return xmlPullParser.getEventType() == XmlPullParser.START_TAG; + } + + private boolean endOf(String tag) throws XmlPullParserException { + return xmlPullParser.getEventType() == XmlPullParser.END_TAG && tag.equalsIgnoreCase(localName()); + } + + private String localName() { + String rawName = xmlPullParser.getName(); + String[] namespaceAndLocalName = rawName.split(":", 2); + return namespaceAndLocalName[namespaceAndLocalName.length - 1]; + } + + private boolean endOfDocument() throws XmlPullParserException { + return xmlPullParser.getEventType() == XmlPullParser.END_DOCUMENT; + } + + private String textInCurrentTag() throws IOException, XmlPullParserException { + if (!startOfATag()) { + throw new IllegalStateException("textInCurrentTag may only be called at start of a tag"); + } + StringBuilder result = new StringBuilder(); + int ident = 0; + do { + switch (xmlPullParser.next()) { + case XmlPullParser.TEXT: + result.append(xmlPullParser.getText()); + break; + case XmlPullParser.START_TAG: + ident++; + break; + case XmlPullParser.END_TAG: + ident--; + break; + } + } while (!endOfDocument() && ident >= 0); + return result.toString(); + } + + private PropfindEntryData defaultIfNull(PropfindEntryData value, PropfindEntryData defaultValue) { + return value == null ? defaultValue : value; + } + + private Optional parseDate(String text) { + try { + return Optional.of(new Date(text)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private Optional parseLong(String text) { + try { + return Optional.of(Long.parseLong(text)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/SSLSocketFactories.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/SSLSocketFactories.java new file mode 100644 index 000000000..9f66a72f3 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/SSLSocketFactories.java @@ -0,0 +1,24 @@ +package org.cryptomator.data.cloud.webdav.network; + +import org.cryptomator.domain.exception.FatalBackendException; + +import java.security.GeneralSecurityException; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +class SSLSocketFactories { + + public static SSLSocketFactory from(X509TrustManager trustManager) { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {trustManager}, null); + return sslContext.getSocketFactory(); + } catch (GeneralSecurityException e) { + throw new FatalBackendException(e); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavClient.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavClient.java new file mode 100644 index 000000000..90c0d7503 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavClient.java @@ -0,0 +1,318 @@ +package org.cryptomator.data.cloud.webdav.network; + +import static java.util.Collections.sort; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.cryptomator.data.cloud.webdav.WebDavFolder; +import org.cryptomator.data.cloud.webdav.WebDavNode; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.AlreadyExistException; +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.NotFoundException; +import org.cryptomator.domain.exception.ParentFolderDoesNotExistException; +import org.cryptomator.domain.exception.ServerNotWebdavCompatibleException; +import org.cryptomator.domain.exception.TypeMismatchException; +import org.cryptomator.domain.exception.UnauthorizedException; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.xmlpull.v1.XmlPullParserException; + +import android.content.Context; + +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +class WebDavClient { + + private final Context context; + private final WebDavCompatibleHttpClient httpClient; + + WebDavClient(Context context, WebDavCompatibleHttpClient httpClient) { + this.context = context; + this.httpClient = httpClient; + } + + List dirList(String url, WebDavFolder listedFolder) throws BackendException { + try (Response response = executePropfindRequest(url, PropfindDepth.ONE)) { + checkPropfindExecutionSucceeded(response.code()); + + List nodes = getEntriesFromResponse(listedFolder, response); + + return processDirList(nodes, listedFolder); + } catch (IOException | XmlPullParserException e) { + throw new FatalBackendException(e); + } + } + + public WebDavNode get(String url, CloudFolder parent) throws BackendException { + try (Response response = executePropfindRequest(url, PropfindDepth.ZERO)) { + checkPropfindExecutionSucceeded(response.code()); + + List nodes = getEntriesFromResponse((WebDavFolder) parent, response); + + return processGet(nodes, (WebDavFolder) parent); + } catch (IOException | XmlPullParserException e) { + throw new FatalBackendException(e); + } + } + + private Response executePropfindRequest(String url, PropfindDepth depth) throws IOException { + String body = "\n" // + + "\n" // + + "\n" // + + "\n" // + + "\n" // + + "\n" // + + "\n" // + + ""; + + Request.Builder builder = new Request.Builder() // + .method("PROPFIND", RequestBody.create(MediaType.parse(body), body)) // + .url(url) // + .header("DEPTH", depth.value) // + .header("Content-Type", "text/xml"); + + return httpClient.execute(builder); + } + + private void checkPropfindExecutionSucceeded(int responseCode) throws BackendException { + switch (responseCode) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + case HttpURLConnection.HTTP_NOT_FOUND: + throw new NotFoundException(); + } + + if (responseCode < 199 || responseCode > 300) { + throw new FatalBackendException("Response code isn't between 200 and 300: " + responseCode); + } + } + + private List getEntriesFromResponse(WebDavFolder listedFolder, Response response) throws IOException, XmlPullParserException { + try (final ResponseBody responseBody = response.body()) { + return new PropfindResponseParser(listedFolder).parse(responseBody.byteStream()); + } + } + + public void move(String from, String to) throws BackendException { + Request.Builder builder = new Request.Builder() // + .method("MOVE", null) // + .url(from) // + .header("Content-Type", "text/xml") // + .header("Destination", to) // + .header("Depth", "infinity") // + .header("Overwrite", "F"); + + try (Response response = httpClient.execute(builder)) { + if (!response.isSuccessful()) { + switch (response.code()) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + case HttpURLConnection.HTTP_NOT_FOUND: + throw new NotFoundException(); + case HttpURLConnection.HTTP_CONFLICT: + throw new ParentFolderDoesNotExistException(); + case HttpURLConnection.HTTP_PRECON_FAILED: + throw new CloudNodeAlreadyExistsException(to); + default: + throw new FatalBackendException("Response code isn't between 200 and 300: " + response.code()); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + InputStream readFile(String url) throws BackendException { + Request.Builder builder = new Request.Builder() // + .get() // + .url(url); + + Response response = null; + boolean success = false; + + try { + response = httpClient.execute(builder); + + if (response.isSuccessful()) { + success = true; + return response.body().byteStream(); + } else { + switch (response.code()) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + case HttpURLConnection.HTTP_NOT_FOUND: + throw new NotFoundException(); + case 416: // UNSATISFIABLE_RANGE + return new ByteArrayInputStream(new byte[0]); + default: + throw new FatalBackendException("Response code isn't between 200 and 300: " + response.code()); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } finally { + if (response != null && !success) { + response.close(); + } + } + } + + void writeFile(String url, DataSource data) throws BackendException { + Request.Builder builder = new Request.Builder() // + .put(DataSourceBasedRequestBody.from(context, data)) // + .url(url); + + try (Response response = httpClient.execute(builder)) { + if (!response.isSuccessful()) { + switch (response.code()) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + case HttpURLConnection.HTTP_BAD_METHOD: + throw new TypeMismatchException(); + case HttpURLConnection.HTTP_CONFLICT: // fall through + case HttpURLConnection.HTTP_NOT_FOUND: // necessary due to a bug in Nextcloud, see https://github.com/nextcloud/server/issues/23519 + throw new ParentFolderDoesNotExistException(); + default: + throw new FatalBackendException("Response code isn't between 200 and 300: " + response.code()); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + WebDavFolder createFolder(String path, WebDavFolder folder) throws BackendException { + Request.Builder builder = new Request.Builder() // + .method("MKCOL", null) // + .url(path); + + try (Response response = httpClient.execute(builder)) { + if (response.isSuccessful()) { + return folder; + } else { + switch (response.code()) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + case HttpURLConnection.HTTP_BAD_METHOD: + throw new AlreadyExistException(); + case HttpURLConnection.HTTP_CONFLICT: + throw new ParentFolderDoesNotExistException(); + default: + throw new FatalBackendException("Response code isn't between 200 and 300: " + response.code()); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + public void delete(String url) throws BackendException { + Request.Builder builder = new Request.Builder() // + .delete() // + .url(url); + + try (Response response = httpClient.execute(builder)) { + if (!response.isSuccessful()) { + switch (response.code()) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + case HttpURLConnection.HTTP_NOT_FOUND: + throw new NotFoundException(String.format("Node %s doesn't exists", url)); + default: + throw new FatalBackendException("Response code isn't between 200 and 300: " + response.code()); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + void checkAuthenticationAndServerCompatibility(String url) throws BackendException { + final Request.Builder optionsRequest = new Request.Builder() // + .method("OPTIONS", null) // + .url(url); + + try (Response response = httpClient.execute(optionsRequest)) { + if (response.isSuccessful()) { + final boolean containsDavHeader = response.headers().names().contains("DAV"); + if (!containsDavHeader) { + throw new ServerNotWebdavCompatibleException(); + } + } else { + switch (response.code()) { + case HttpURLConnection.HTTP_UNAUTHORIZED: + throw new UnauthorizedException(); + case HttpURLConnection.HTTP_FORBIDDEN: + throw new ForbiddenException(); + default: + throw new FatalBackendException("Response code isn't between 200 and 300: " + response.code()); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + + try (Response response = executePropfindRequest(url, PropfindDepth.ZERO)) { + checkPropfindExecutionSucceeded(response.code()); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private List processDirList(List entryData, WebDavFolder requestedFolder) { + List result = new ArrayList<>(); + sort(entryData, ASCENDING_BY_DEPTH); + // after sorting the first entry is the parent + // because it's depth is 1 smaller than the depth + // ot the other entries, thus we skip the first entry + for (PropfindEntryData childEntry : entryData.subList(1, entryData.size())) { + result.add(childEntry.toCloudNode(requestedFolder)); + } + return result; + } + + private WebDavNode processGet(List entryData, WebDavFolder requestedFolder) { + sort(entryData, ASCENDING_BY_DEPTH); + return entryData.size() >= 1 ? entryData.get(0).toCloudNode(requestedFolder) : null; + } + + private final Comparator ASCENDING_BY_DEPTH = (o1, o2) -> o1.getDepth() - o2.getDepth(); + + private enum PropfindDepth { + ZERO("0"), // + ONE("1"), // + INFINITY("infinity"); + + private final String value; + + PropfindDepth(final String value) { + this.value = value; + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavCompatibleHttpClient.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavCompatibleHttpClient.java new file mode 100644 index 000000000..888cdd042 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavCompatibleHttpClient.java @@ -0,0 +1,165 @@ +package org.cryptomator.data.cloud.webdav.network; + +import static com.google.common.net.HttpHeaders.CACHE_CONTROL; +import static org.cryptomator.data.util.NetworkTimeout.CONNECTION; +import static org.cryptomator.data.util.NetworkTimeout.READ; +import static org.cryptomator.data.util.NetworkTimeout.WRITE; +import static org.cryptomator.util.file.LruFileCacheUtil.Cache.WEBDAV; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.X509TrustManager; + +import org.cryptomator.data.cloud.okhttplogging.HttpLoggingInterceptor; +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.domain.exception.UnableToDecryptWebdavPasswordException; +import org.cryptomator.util.SharedPreferencesHandler; +import org.cryptomator.util.crypto.CredentialCryptor; +import org.cryptomator.util.file.LruFileCacheUtil; + +import com.burgstaller.okhttp.AuthenticationCacheInterceptor; +import com.burgstaller.okhttp.CachingAuthenticatorDecorator; +import com.burgstaller.okhttp.DispatchingAuthenticator; +import com.burgstaller.okhttp.basic.BasicAuthenticator; +import com.burgstaller.okhttp.digest.CachingAuthenticator; +import com.burgstaller.okhttp.digest.Credentials; +import com.burgstaller.okhttp.digest.DigestAuthenticator; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import okhttp3.Authenticator; +import okhttp3.Cache; +import okhttp3.CacheControl; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import timber.log.Timber; + +class WebDavCompatibleHttpClient { + + private final WebDavRedirectHandler webDavRedirectHandler; + + WebDavCompatibleHttpClient(WebDavCloud cloud, Context context) { + final SharedPreferencesHandler sharedPreferencesHandler = new SharedPreferencesHandler(context); + this.webDavRedirectHandler = new WebDavRedirectHandler(httpClientFor(cloud, context, sharedPreferencesHandler.useLruCache(), sharedPreferencesHandler.lruCacheSize())); + } + + Response execute(Request.Builder requestBuilder) throws IOException { + return execute(requestBuilder.build()); + } + + private Response execute(Request request) throws IOException { + return webDavRedirectHandler.executeFollowingRedirects(request); + } + + private static OkHttpClient httpClientFor(WebDavCloud webDavCloud, Context context, boolean useLruCache, int lruCacheSize) { + final Map authCache = new ConcurrentHashMap<>(); + + OkHttpClient.Builder builder = new OkHttpClient() // + .newBuilder() // + .connectTimeout(CONNECTION.getTimeout(), CONNECTION.getUnit()) // + .readTimeout(READ.getTimeout(), READ.getUnit()) // + .writeTimeout(WRITE.getTimeout(), WRITE.getUnit()) // + .followRedirects(false) // + .addInterceptor(httpLoggingInterceptor(context)) // + .authenticator(httpAuthenticator(context, webDavCloud, authCache)) // + .addInterceptor(new AuthenticationCacheInterceptor(authCache)); + + if (useLruCache) { + final Cache cache = new Cache(new LruFileCacheUtil(context).resolve(WEBDAV), lruCacheSize); + builder.cache(cache) // + .addNetworkInterceptor(provideCacheInterceptor()) // + .addInterceptor(provideOfflineCacheInterceptor(context)); + } + + X509TrustManager trustManager; + if (usingWebDavWithSelfSignedCertificate(webDavCloud)) { + PinningTrustManager pinningTrustManager = new PinningTrustManager(webDavCloud.certificate()); + trustManager = pinningTrustManager; + builder.hostnameVerifier(pinningTrustManager.hostnameVerifier()); + } else { + trustManager = new DefaultTrustManager(); + } + builder.sslSocketFactory(SSLSocketFactories.from(trustManager), trustManager); + + return builder.build(); + } + + private static Interceptor provideOfflineCacheInterceptor(final Context context) { + return chain -> { + Request request = chain.request(); + + if (isNetworkAvailable(context)) { + final CacheControl cacheControl = new CacheControl.Builder() // + .maxAge(0, TimeUnit.DAYS) // + .build(); + + request = request.newBuilder() // + .cacheControl(cacheControl) // + .build(); + } + + return chain.proceed(request); + }; + } + + private static Interceptor provideCacheInterceptor() { + return chain -> { + final Response response = chain.proceed(chain.request()); + final CacheControl cacheControl = new CacheControl.Builder() // + .maxAge(0, TimeUnit.DAYS) // + .build(); + + return response.newBuilder() // + .removeHeader("Pragma") // + .removeHeader("Cache-Control") // + .header(CACHE_CONTROL, cacheControl.toString()) // + .build(); + }; + } + + private static Authenticator httpAuthenticator(Context context, WebDavCloud webDavCloud, Map authCache) { + Credentials credentials = new Credentials(webDavCloud.username(), decryptPassword(context, webDavCloud.password())); + final BasicAuthenticator basicAuthenticator = new BasicAuthenticator(credentials); + final DigestAuthenticator digestAuthenticator = new DigestAuthenticator(credentials); + + Authenticator result = new DispatchingAuthenticator // + .Builder() // + .with("digest", digestAuthenticator) // + .with("basic", basicAuthenticator) // + .build(); + result = new CachingAuthenticatorDecorator(result, authCache); + + return result; + } + + private static String decryptPassword(Context context, String password) throws UnableToDecryptWebdavPasswordException { + try { + return CredentialCryptor // + .getInstance(context) // + .decrypt(password); + } catch (RuntimeException e) { + throw new UnableToDecryptWebdavPasswordException(e); + } + } + + private static Interceptor httpLoggingInterceptor(Context context) { + return new HttpLoggingInterceptor(message -> Timber.tag("OkHttp").d(message), context); + } + + private static boolean usingWebDavWithSelfSignedCertificate(WebDavCloud webDavCloud) { + return webDavCloud.certificate() != null; + } + + private static boolean isNetworkAvailable(final Context context) { + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + return activeNetworkInfo != null && activeNetworkInfo.isConnected(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavRedirectHandler.java b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavRedirectHandler.java new file mode 100644 index 000000000..7c1219a74 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/cloud/webdav/network/WebDavRedirectHandler.java @@ -0,0 +1,96 @@ +package org.cryptomator.data.cloud.webdav.network; + +import java.io.IOException; +import java.net.ProtocolException; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +class WebDavRedirectHandler { + + private static final int MAX_REDIRECT_COUNT = 20; + private static final Request NO_REDIRECTED_REQUEST = null; + private static final HttpUrl NO_REDIRECT_URL = null; + + private final OkHttpClient httpClient; + + WebDavRedirectHandler(OkHttpClient httpClient) { + this.httpClient = httpClient; + } + + public Response executeFollowingRedirects(Request request) throws IOException { + Response response; + int redirectCount = 0; + do { + if (redirectCount > MAX_REDIRECT_COUNT) { + throw new ProtocolException("Too many redirects: " + redirectCount); + } + response = httpClient.newCall(request).execute(); + request = redirectedRequestFor(response); + redirectCount++; + } while (request != NO_REDIRECTED_REQUEST); + return response; + } + + private Request redirectedRequestFor(Response response) { + switch (response.code()) { + case 300: // fall through + case 301: // fall through + case 302: // fall through + case 303: // fall through + case 307: // fall through + case 308: + return createRedirectedRequest(response); + default: + return NO_REDIRECTED_REQUEST; + } + } + + private Request createRedirectedRequest(Response response) { + HttpUrl url = redirectUrl(response); + if (url == NO_REDIRECT_URL) { + return NO_REDIRECTED_REQUEST; + } + return createRedirectedRequest(response, url); + } + + private Request createRedirectedRequest(Response response, HttpUrl url) { + Request.Builder requestBuilder = response.request().newBuilder().url(url); + if (methodShouldBeChangedToGet(response)) { + changeMethodToGet(requestBuilder); + } + if (!connectionMatches(response.request().url(), url)) { + requestBuilder.removeHeader("Authorization"); + } + return requestBuilder.build(); + } + + private boolean methodShouldBeChangedToGet(Response response) { + return response.code() == 300 // + || response.code() == 303; + } + + private void changeMethodToGet(Request.Builder requestBuilder) { + requestBuilder.method("GET", null) // + .removeHeader("Transfer-Encoding") // + .removeHeader("Content-Length") // + .removeHeader("Content-Type"); + } + + private boolean connectionMatches(HttpUrl url1, HttpUrl url2) { + return url1.scheme().equals(url2.scheme()) // + && url1.host().equals(url2.host()) // + && url1.port() == url2.port(); + } + + private HttpUrl redirectUrl(Response response) { + String location = response.header("Location"); + if (location == null) { + return NO_REDIRECT_URL; + } + return response.request().url().resolve(location); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java b/data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java new file mode 100755 index 000000000..a4ab1f220 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java @@ -0,0 +1,21 @@ +package org.cryptomator.data.db; + +import java.util.List; + +class CompoundDatabaseUpgrade extends DatabaseUpgrade { + + private final List upgrades; + + public CompoundDatabaseUpgrade(List upgrades) { + super(upgrades.get(0).from(), upgrades.get(upgrades.size() - 1).to()); + this.upgrades = upgrades; + } + + @Override + protected void internalApplyTo(org.greenrobot.greendao.database.Database db, int origin) { + for (DatabaseUpgrade upgrade : upgrades) { + upgrade.applyTo(db, origin); + } + } + +} diff --git a/data/src/main/java/org/cryptomator/data/db/Database.java b/data/src/main/java/org/cryptomator/data/db/Database.java new file mode 100644 index 000000000..7f24da91d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Database.java @@ -0,0 +1,53 @@ +package org.cryptomator.data.db; + +import org.cryptomator.data.db.entities.DaoMaster; +import org.cryptomator.data.db.entities.DaoSession; +import org.cryptomator.data.db.entities.DatabaseEntity; + +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class Database { + + private final DaoSession daoSession; + + @Inject + public Database(DatabaseFactory databaseFactory) { + DaoMaster daoMaster = new DaoMaster(databaseFactory.getWritableDatabase()); + daoSession = daoMaster.newSession(); + } + + public T load(Class type, long id) { + return daoSession.load(type, id); + } + + public void delete(T entity) { + daoSession.delete(entity); + } + + public List loadAll(Class type) { + return daoSession.loadAll(type); + } + + public T create(T entity) { + long id = daoSession.insert(entity); + return load((Class) entity.getClass(), id); + } + + public T store(T entity) { + Long id = entity.getId(); + if (id == null) { + id = daoSession.insert(entity); + } else { + daoSession.update(entity); + } + return load((Class) entity.getClass(), id); + } + + public void clearCache() { + daoSession.clear(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java b/data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java new file mode 100644 index 000000000..36cea49ae --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java @@ -0,0 +1,57 @@ +package org.cryptomator.data.db; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; + +import org.cryptomator.data.db.entities.DaoMaster; +import org.greenrobot.greendao.database.Database; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import timber.log.Timber; + +import static org.cryptomator.data.db.entities.DaoMaster.SCHEMA_VERSION; + +@Singleton +class DatabaseFactory extends DaoMaster.OpenHelper { + + private static final String DATABASE_NAME = "Cryptomator"; + + private final DatabaseUpgrades databaseUpgrades; + + @Inject + public DatabaseFactory(Context context, DatabaseUpgrades databaseUpgrades) { + super(context, DATABASE_NAME); + this.databaseUpgrades = databaseUpgrades; + } + + @Override + public void onConfigure(SQLiteDatabase db) { + super.onConfigure(db); + + Timber.tag("Database").i("Configure v%d", db.getVersion()); + + if (!db.isReadOnly()) { + db.setForeignKeyConstraintsEnabled(true); + } + } + + @Override + public void onCreate(Database db) { + Timber.tag("Database").i("Create v%s", SCHEMA_VERSION); + databaseUpgrades.getUpgrade(0, SCHEMA_VERSION).applyTo(db, 0); + } + + @Override + public void onUpgrade(Database db, int oldVersion, int newVersion) { + Timber.tag("Database").i("Upgrade v" + oldVersion + " to v" + newVersion); + databaseUpgrades.getUpgrade(oldVersion, newVersion).applyTo(db, oldVersion); + } + + @Override + public void onOpen(SQLiteDatabase db) { + super.onOpen(db); + Timber.tag("Database").i("Open v%s", db.getVersion()); + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java new file mode 100644 index 000000000..da2fb34df --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java @@ -0,0 +1,41 @@ +package org.cryptomator.data.db; + +import org.greenrobot.greendao.database.Database; + +import timber.log.Timber; + +abstract class DatabaseUpgrade implements Comparable { + + private final int from; + private final int to; + + DatabaseUpgrade(int from, int to) { + this.from = from; + this.to = to; + } + + public int from() { + return from; + } + + public int to() { + return to; + } + + @Override + public int compareTo(DatabaseUpgrade other) { + int compareByFrom = from - other.from; + if (compareByFrom != 0) { + return compareByFrom; + } + return to - other.to; + } + + final void applyTo(Database db, int origin) { + Timber.tag("DatabaseUpgrade").i("Running %s (%d -> %d)", getClass().getSimpleName(), from, to); + internalApplyTo(db, origin); + } + + protected abstract void internalApplyTo(Database db, int origin); + +} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java new file mode 100644 index 000000000..f188e34ff --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java @@ -0,0 +1,79 @@ +package org.cryptomator.data.db; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static java.lang.String.format; + +@Singleton +class DatabaseUpgrades { + + private final Map> availableUpgrades; + + @Inject + public DatabaseUpgrades( // + Upgrade0To1 upgrade0To1, // + Upgrade1To2 upgrade1To2, // + Upgrade2To3 upgrade2To3) { + + availableUpgrades = defineUpgrades( // + upgrade0To1, // + upgrade1To2, // + upgrade2To3); + } + + private Map> defineUpgrades(DatabaseUpgrade... upgrades) { + Map> result = new HashMap<>(); + for (DatabaseUpgrade upgrade : upgrades) { + if (!result.containsKey(upgrade.from())) { + result.put(upgrade.from(), new ArrayList<>()); + } + result.get(upgrade.from()).add(upgrade); + } + for (List list : result.values()) { + Collections.sort(list, reverseOrder()); + } + return result; + } + + public DatabaseUpgrade getUpgrade(int oldVersion, int newVersion) { + List upgrades = new ArrayList<>(10); + if (!findUpgrades(upgrades, oldVersion, newVersion)) { + throw new IllegalStateException(format("No upgrade path from %d to %d", oldVersion, newVersion)); + } + return new CompoundDatabaseUpgrade(upgrades); + } + + private boolean findUpgrades(List upgrades, int oldVersion, int newVersion) { + if (oldVersion == newVersion) { + return true; + } + + List upgradesFromOldVersion = availableUpgrades.get(oldVersion); + if (upgradesFromOldVersion == null) { + return false; + } + for (DatabaseUpgrade upgrade : upgradesFromOldVersion) { + if (upgrade.to() > newVersion) { + continue; + } + upgrades.add(upgrade); + if (findUpgrades(upgrades, upgrade.to(), newVersion)) { + return true; + } + upgrades.remove(upgrades.size() - 1); + } + return false; + } + + private static Comparator reverseOrder() { + return (a, b) -> b.compareTo(a); + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/Sql.java b/data/src/main/java/org/cryptomator/data/db/Sql.java new file mode 100644 index 000000000..3125db5ef --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Sql.java @@ -0,0 +1,472 @@ +package org.cryptomator.data.db; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import org.greenrobot.greendao.database.Database; + +import java.util.ArrayList; +import java.util.List; + +import static java.lang.String.format; +import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnConstraint.NOT_NULL; +import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnConstraint.PRIMARY_KEY; +import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnType.BOOLEAN; +import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnType.INTEGER; +import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnType.TEXT; + +class Sql { + + public static SqlInsertBuilder insertInto(String table) { + return new SqlInsertBuilder(table); + } + + public static SqlAlterTableBuilder alterTable(String table) { + return new SqlAlterTableBuilder(table); + } + + public static SqlDropTableBuilder dropTable(String table) { + return new SqlDropTableBuilder(table); + } + + public static SqlCreateTableBuilder createTable(String table) { + return new SqlCreateTableBuilder(table); + } + + public static SqlDeleteBuilder deleteFrom(String table) { + return new SqlDeleteBuilder(table); + } + + public static SqlUniqueIndexBuilder createUniqueIndex(String indexName) { + return new SqlUniqueIndexBuilder(indexName); + } + + public static SqlDropIndexBuilder dropIndex(String index) { + return new SqlDropIndexBuilder(index); + } + + public static SqlUpdateBuilder update(String tableName) { + return new SqlUpdateBuilder(tableName); + } + + public static Criterion eq(final String value) { + return (column, whereClause, whereArgs) -> { + whereClause.append('"').append(column).append("\" = ?"); + whereArgs.add(value); + }; + } + + public static Criterion isNull() { + return (column, whereClause, whereArgs) -> whereClause.append('"').append(column).append("\" IS NULL"); + } + + public static Criterion eq(final Long value) { + return (column, whereClause, whereArgs) -> whereClause.append('"').append(column).append("\" = ").append(value); + } + + public static ValueHolder toString(final String value) { + return (column, contentValues) -> contentValues.put(column, value); + } + + public static ValueHolder toInteger(final Long value) { + return (column, contentValues) -> contentValues.put(column, value); + } + + public static ValueHolder toNull() { + return (column, contentValues) -> contentValues.putNull(column); + } + + public static class SqlUpdateBuilder { + + private final String tableName; + + private final StringBuilder whereClause = new StringBuilder(); + private final List whereArgs = new ArrayList<>(); + private final ContentValues contentValues = new ContentValues(); + + public SqlUpdateBuilder(String tableName) { + this.tableName = tableName; + } + + public SqlUpdateBuilder set(String column, ValueHolder value) { + value.put(column, contentValues); + return this; + } + + public SqlUpdateBuilder where(String column, Criterion criterion) { + if (whereClause.length() > 0) { + whereClause.append(" AND "); + } + criterion.appendTo(column, whereClause, whereArgs); + return this; + } + + public void executeOn(Database wrapped) { + if (contentValues.size() == 0) { + throw new IllegalStateException("At least one value must be set"); + } + SQLiteDatabase db = unwrap(wrapped); + db.update(tableName, contentValues, whereClause.toString(), whereArgs.toArray(new String[whereArgs.size()])); + } + + } + + public static class SqlDropIndexBuilder { + + private final String index; + + private SqlDropIndexBuilder(String index) { + this.index = index; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + db.execSQL(format("DROP INDEX \"%s\"", index)); + } + + } + + public static class SqlUniqueIndexBuilder { + + private final String indexName; + private String table; + private final StringBuilder columns = new StringBuilder(); + + private SqlUniqueIndexBuilder(String indexName) { + this.indexName = indexName; + } + + public SqlUniqueIndexBuilder on(String table) { + this.table = table; + return this; + } + + public SqlUniqueIndexBuilder asc(String column) { + if (columns.length() > 0) { + columns.append(','); + } + columns.append('"').append(column).append('"').append(" ASC"); + return this; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + db.execSQL(format("CREATE UNIQUE INDEX \"%s\" ON \"%s\" (%s)", indexName, table, columns)); + } + } + + public static class SqlDropTableBuilder { + + private final String table; + + private SqlDropTableBuilder(String table) { + this.table = table; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + db.execSQL(format("DROP TABLE \"%s\"", table)); + } + + } + + public static class SqlAlterTableBuilder { + + private final String table; + private String newName; + private final StringBuilder columns = new StringBuilder(); + + private SqlAlterTableBuilder(String table) { + this.table = table; + } + + public SqlAlterTableBuilder renameTo(String newName) { + this.newName = newName; + return this; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + db.execSQL(format("ALTER TABLE \"%s\" RENAME TO \"%s\"", table, newName)); + } + } + + public static class SqlInsertSelectBuilder { + + private static final int NOT_FOUND = -1; + private final String table; + private String[] columns; + private final String[] selectedColumns; + + private String sourceTableName; + + private final StringBuilder joinClauses = new StringBuilder(); + + private SqlInsertSelectBuilder(String table, String[] columns) { + this.table = table; + this.columns = columns; + this.selectedColumns = columns; + } + + public SqlInsertSelectBuilder from(String sourceTableName) { + this.sourceTableName = sourceTableName; + return this; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + StringBuilder query = new StringBuilder() // + .append("INSERT INTO \"").append(table).append("\" ("); + appendColumns(query, columns, false); + query.append(") SELECT "); + appendColumns(query, selectedColumns, true); + query.append(" FROM \"").append(sourceTableName).append('"'); + query.append(joinClauses); + db.execSQL(query.toString()); + } + + private void appendColumns(StringBuilder query, String[] columns, boolean appendSourceTableName) { + boolean notFirst = false; + + for (String column : columns) { + if (notFirst) { + query.append(','); + } + + if (appendSourceTableName && column.indexOf('.') == NOT_FOUND) { + query.append('"').append(sourceTableName).append("\".\"").append(column).append('"'); + } else { + column = column.replace(".", "\".\""); + query.append('"').append(column).append('"'); + } + + notFirst = true; + } + } + + public SqlInsertSelectBuilder join(String targetTable, String sourceColumn) { + sourceColumn = sourceColumn.replace(".", "\".\""); + joinClauses.append(" JOIN \"") // + .append(targetTable) // + .append("\" ON \"") // + .append(sourceColumn) // + .append("\" = \"") // + .append(targetTable) // + .append("\".\"_id\" "); + + return this; + } + + public SqlInsertSelectBuilder columns(String... columns) { + + if (columns.length != selectedColumns.length) { + throw new IllegalStateException("Number of columns must match number of selected values"); + } + + this.columns = columns; + + return this; + } + } + + public static class SqlCreateTableBuilder { + + private final String table; + private final StringBuilder columns = new StringBuilder(); + + private final StringBuilder foreignKeys = new StringBuilder(); + + private SqlCreateTableBuilder(String table) { + this.table = table; + } + + public SqlCreateTableBuilder id() { + column("_id", INTEGER, PRIMARY_KEY); + return this; + } + + public SqlCreateTableBuilder optionalText(String name) { + column(name, TEXT); + return this; + } + + public SqlCreateTableBuilder requiredText(String name) { + column(name, TEXT, NOT_NULL); + return this; + } + + public SqlCreateTableBuilder optionalInt(String name) { + column(name, INTEGER); + return this; + } + + public SqlCreateTableBuilder requiredInt(String name) { + column(name, INTEGER, NOT_NULL); + return this; + } + + public SqlCreateTableBuilder optionalBool(String name) { + column(name, BOOLEAN); + return this; + } + + public SqlCreateTableBuilder requiredBool(String name) { + column(name, BOOLEAN, NOT_NULL); + return this; + } + + public SqlCreateTableBuilder column(String name, ColumnType type, ColumnConstraint... contraints) { + if (columns.length() > 0) { + columns.append(','); + } + columns.append(name).append(' ').append(type.getText()); + for (ColumnConstraint constraint : contraints) { + columns.append(' ').append(constraint.getText()); + } + return this; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + db.execSQL(format("CREATE TABLE \"%s\" (%s%s)", table, columns, foreignKeys)); + } + + public SqlCreateTableBuilder foreignKey(String column, String targetTable, ForeignKeyBehaviour... behaviours) { + foreignKeys // + .append(", CONSTRAINT FK_") // + .append(column) // + .append("_") // + .append(targetTable) // + .append(" FOREIGN KEY (") // + .append(column) // + .append(") REFERENCES ") // + .append(targetTable) // + .append("(_id)"); + + for (ForeignKeyBehaviour behaviour : behaviours) { + foreignKeys.append(" ").append(behaviour.getText()); + } + + return this; + } + + public enum ForeignKeyBehaviour { + ON_DELETE_SET_NULL("ON DELETE SET NULL"); + + private final String text; + + ForeignKeyBehaviour(String text) { + this.text = text; + } + + public String getText() { + return text; + } + } + + public enum ColumnType { + INTEGER("INTEGER"), BOOLEAN("INTEGER"), TEXT("TEXT"); + + private final String text; + + ColumnType(String text) { + this.text = text; + } + + public String getText() { + return text; + } + } + + public enum ColumnConstraint { + NOT_NULL("NOT NULL"), PRIMARY_KEY("PRIMARY KEY"); + + private final String text; + + ColumnConstraint(String text) { + this.text = text; + } + + public String getText() { + return text; + } + } + + } + + public static class SqlInsertBuilder { + + private final String table; + private final ContentValues contentValues = new ContentValues(); + + private SqlInsertBuilder(String table) { + this.table = table; + } + + public SqlInsertSelectBuilder select(String... columns) { + return new SqlInsertSelectBuilder(table, columns); + } + + public SqlInsertBuilder text(String column, Object value) { + contentValues.put(column, value == null ? null : value.toString()); + return this; + } + + public SqlInsertBuilder integer(String column, Integer value) { + contentValues.put(column, value); + return this; + } + + public SqlInsertBuilder bool(String column, Boolean value) { + contentValues.put(column, value); + return this; + } + + public Long executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + return db.insertOrThrow(table, null, contentValues); + } + } + + public static class SqlDeleteBuilder { + + private final String table; + private String whereClause; + private String[] whereArgs; + + public SqlDeleteBuilder(String table) { + this.table = table; + } + + public SqlDeleteBuilder whereClause(String whereClause) { + this.whereClause = whereClause; + return this; + } + + public SqlDeleteBuilder whereArgs(String[] whereArgs) { + this.whereArgs = whereArgs; + return this; + } + + public void executeOn(Database wrapped) { + SQLiteDatabase db = unwrap(wrapped); + db.delete(table, whereClause, whereArgs); + } + } + + private static SQLiteDatabase unwrap(Database wrapped) { + return (SQLiteDatabase) wrapped.getRawDatabase(); + } + + public interface ValueHolder { + + void put(String column, ContentValues contentValues); + + } + + public interface Criterion { + + void appendTo(String column, StringBuilder whereClause, List whereArgs); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade0To1.java b/data/src/main/java/org/cryptomator/data/db/Upgrade0To1.java new file mode 100644 index 000000000..9ec8560ea --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade0To1.java @@ -0,0 +1,105 @@ +package org.cryptomator.data.db; + +import org.cryptomator.domain.CloudType; +import org.greenrobot.greendao.database.Database; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL; +import static org.cryptomator.data.db.Sql.createTable; +import static org.cryptomator.data.db.Sql.createUniqueIndex; +import static org.cryptomator.data.db.Sql.insertInto; + +@Singleton +class Upgrade0To1 extends DatabaseUpgrade { + + @Inject + public Upgrade0To1() { + super(0, 1); + } + + @Override + protected void internalApplyTo(Database db, int origin) { + createCloudEntityTable(db); + createVaultEntityTable(db); + + createDropboxCloud(db); + createGoogleDriveCloud(db); + createLocalStorageCloud(db); + createOnedriveCloud(db); + } + + private void createCloudEntityTable(Database db) { + createTable("CLOUD_ENTITY") // + .id() // + .requiredText("TYPE") // + .optionalText("ACCESS_TOKEN") // + .optionalText("WEBDAV_URL") // + .optionalText("USERNAME") // + .optionalText("WEBDAV_CERTIFICATE") // + .executeOn(db); + } + + private void createVaultEntityTable(Database db) { + createTable("VAULT_ENTITY") // + .id() // + .optionalInt("FOLDER_CLOUD_ID") // + .optionalText("FOLDER_PATH") // + .optionalText("FOLDER_NAME") // + .requiredText("CLOUD_TYPE") // + .optionalText("PASSWORD") // + .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", ON_DELETE_SET_NULL) // + .executeOn(db); + + createUniqueIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID") // + .on("VAULT_ENTITY") // + .asc("FOLDER_PATH") // + .asc("FOLDER_CLOUD_ID") // + .executeOn(db); + } + + private void createDropboxCloud(Database db) { + insertInto("CLOUD_ENTITY") // + .integer("_id", 1) // + .text("TYPE", CloudType.DROPBOX.name()) // + .text("ACCESS_TOKEN", null) // + .text("WEBDAV_URL", null) // + .text("USERNAME", null) // + .text("WEBDAV_CERTIFICATE", null) // + .executeOn(db); + } + + private void createGoogleDriveCloud(Database db) { + insertInto("CLOUD_ENTITY") // + .integer("_id", 2) // + .text("TYPE", CloudType.GOOGLE_DRIVE.name()) // + .text("ACCESS_TOKEN", null) // + .text("WEBDAV_URL", null) // + .text("USERNAME", null) // + .text("WEBDAV_CERTIFICATE", null) // + .executeOn(db); + } + + private void createOnedriveCloud(Database db) { + insertInto("CLOUD_ENTITY") // + .integer("_id", 3) // + .text("TYPE", CloudType.ONEDRIVE.name()) // + .text("ACCESS_TOKEN", null) // + .text("WEBDAV_URL", null) // + .text("USERNAME", null) // + .text("WEBDAV_CERTIFICATE", null) // + .executeOn(db); + } + + private void createLocalStorageCloud(Database db) { + insertInto("CLOUD_ENTITY") // + .integer("_id", 4) // + .text("TYPE", CloudType.LOCAL.name()) // + .text("ACCESS_TOKEN", null) // + .text("WEBDAV_URL", null) // + .text("USERNAME", null) // + .text("WEBDAV_CERTIFICATE", null) // + .executeOn(db); + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade1To2.java b/data/src/main/java/org/cryptomator/data/db/Upgrade1To2.java new file mode 100644 index 000000000..0c62f2e43 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade1To2.java @@ -0,0 +1,52 @@ +package org.cryptomator.data.db; + +import static org.cryptomator.data.db.Sql.createTable; +import static org.cryptomator.data.db.Sql.insertInto; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.greenrobot.greendao.database.Database; + +@Singleton +class Upgrade1To2 extends DatabaseUpgrade { + + @Inject + Upgrade1To2() { + super(1, 2); + } + + @Override + protected void internalApplyTo(Database db, int origin) { + createUpdateCheckTable(db); + createInitialUpdateStatus(db); + } + + private void createUpdateCheckTable(Database db) { + db.beginTransaction(); + try { + createTable("UPDATE_CHECK_ENTITY") // + .id() // + .optionalText("LICENSE_TOKEN") // + .optionalText("RELEASE_NOTE") // + .optionalText("VERSION") // + .optionalText("URL_TO_APK") // + .optionalText("URL_TO_RELEASE_NOTE") // + .executeOn(db); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void createInitialUpdateStatus(Database db) { + insertInto("UPDATE_CHECK_ENTITY") // + .integer("_id", 1) // + .bool("LICENSE_TOKEN", null) // + .text("RELEASE_NOTE", null) // + .text("VERSION", null) // + .text("URL_TO_APK", null) // + .text("URL_TO_RELEASE_NOTE", null) // + .executeOn(db); + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt new file mode 100644 index 000000000..465b5cd61 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt @@ -0,0 +1,45 @@ +package org.cryptomator.data.db + +import android.content.Context +import android.content.SharedPreferences +import org.cryptomator.data.db.entities.CloudEntityDao +import org.cryptomator.util.crypto.CredentialCryptor +import org.greenrobot.greendao.database.Database +import org.greenrobot.greendao.internal.DaoConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Upgrade2To3 @Inject constructor(private val context: Context) : DatabaseUpgrade(2, 3) { + + override fun internalApplyTo(db: Database, origin: Int) { + val clouds = CloudEntityDao(DaoConfig(db, CloudEntityDao::class.java)).loadAll() + db.beginTransaction() + try { + clouds.filter { cloud -> cloud.type == "DROPBOX" || cloud.type == "ONEDRIVE" } // + .map { + Sql.update("CLOUD_ENTITY") // + .where("TYPE", Sql.eq(it.type)) // + .set("ACCESS_TOKEN", Sql.toString(encrypt(if (it.type == "DROPBOX") it.accessToken else onedriveToken()))) // + .executeOn(db) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun encrypt(token: String?): String? { + return if (token == null) null else CredentialCryptor // + .getInstance(context) // + .encrypt(token) + } + + private fun onedriveToken(): String? { + val prefKey = "refresh_token" + val settings: SharedPreferences = context.getSharedPreferences("com.microsoft.live", Context.MODE_PRIVATE) + val value = settings.getString(prefKey, null) + settings.edit().remove(prefKey).commit() + return value + } +} 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 new file mode 100644 index 000000000..cbc9ab55e --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java @@ -0,0 +1,86 @@ +package org.cryptomator.data.db.entities; + +import org.greenrobot.greendao.annotation.Entity; +import org.greenrobot.greendao.annotation.Generated; +import org.greenrobot.greendao.annotation.Id; +import org.greenrobot.greendao.annotation.NotNull; + +@Entity +public class CloudEntity extends DatabaseEntity { + + @Id + private Long id; + + @NotNull + private String type; + + private String accessToken; + + private String webdavUrl; + + private String username; + + private String webdavCertificate; + + public String getAccessToken() { + return this.accessToken; + } + + public String getType() { + return this.type; + } + + public void setType(String type) { + this.type = type; + } + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getWebdavUrl() { + return webdavUrl; + } + + public void setWebdavUrl(String webdavUrl) { + this.webdavUrl = webdavUrl; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getWebdavCertificate() { + return webdavCertificate; + } + + public void setWebdavCertificate(String webdavCertificate) { + this.webdavCertificate = webdavCertificate; + } + + @Generated(hash = 2078985174) + public CloudEntity(Long id, @NotNull String type, String accessToken, String webdavUrl, String username, String webdavCertificate) { + this.id = id; + this.type = type; + this.accessToken = accessToken; + this.webdavUrl = webdavUrl; + this.username = username; + this.webdavCertificate = webdavCertificate; + } + + @Generated(hash = 1354152224) + public CloudEntity() { + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java new file mode 100755 index 000000000..c40978e34 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java @@ -0,0 +1,10 @@ +package org.cryptomator.data.db.entities; + +public abstract class DatabaseEntity { + + DatabaseEntity() { + } + + public abstract Long getId(); + +} 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 new file mode 100644 index 000000000..911ec82e7 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java @@ -0,0 +1,84 @@ +package org.cryptomator.data.db.entities; + +import org.greenrobot.greendao.annotation.Entity; +import org.greenrobot.greendao.annotation.Generated; +import org.greenrobot.greendao.annotation.Id; + +@Entity +public class UpdateCheckEntity extends DatabaseEntity { + + @Id + private Long id; + + private String licenseToken; + + private String releaseNote; + + private String version; + + private String urlToApk; + + private String urlToReleaseNote; + + public UpdateCheckEntity() { + } + + @Generated(hash = 38676936) + public UpdateCheckEntity(Long id, String licenseToken, String releaseNote, String version, String urlToApk, String urlToReleaseNote) { + this.id = id; + this.licenseToken = licenseToken; + this.releaseNote = releaseNote; + this.version = version; + this.urlToApk = urlToApk; + this.urlToReleaseNote = urlToReleaseNote; + } + + @Override + public Long getId() { + return id; + } + + public String getLicenseToken() { + return this.licenseToken; + } + + public void setLicenseToken(String licenseToken) { + this.licenseToken = licenseToken; + } + + public void setId(Long id) { + this.id = id; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getUrlToApk() { + return this.urlToApk; + } + + public void setUrlToApk(String urlToApk) { + this.urlToApk = urlToApk; + } + + public String getReleaseNote() { + return this.releaseNote; + } + + public void setReleaseNote(String releaseNote) { + this.releaseNote = releaseNote; + } + + public String getUrlToReleaseNote() { + return this.urlToReleaseNote; + } + + public void setUrlToReleaseNote(String urlToReleaseNote) { + this.urlToReleaseNote = urlToReleaseNote; + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java new file mode 100644 index 000000000..75baf6005 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java @@ -0,0 +1,176 @@ +package org.cryptomator.data.db.entities; + +import org.greenrobot.greendao.DaoException; +import org.greenrobot.greendao.annotation.Entity; +import org.greenrobot.greendao.annotation.Generated; +import org.greenrobot.greendao.annotation.Id; +import org.greenrobot.greendao.annotation.Index; +import org.greenrobot.greendao.annotation.NotNull; +import org.greenrobot.greendao.annotation.ToOne; + +@Entity(indexes = {@Index(value = "folderPath,folderCloudId", unique = true)}) +public class VaultEntity extends DatabaseEntity { + + @Id + private Long id; + + private Long folderCloudId; + + @ToOne(joinProperty = "folderCloudId") + private CloudEntity folderCloud; + + private String folderPath; + + private String folderName; + + @NotNull + private String cloudType; + + private String password; + + /** + * Convenient call for {@link org.greenrobot.greendao.AbstractDao#refresh(Object)}. + * Entity must attached to an entity context. + */ + @Generated(hash = 1942392019) + public void refresh() { + if (myDao == null) { + throw new DaoException("Entity is detached from DAO context"); + } + myDao.refresh(this); + } + + /** + * Convenient call for {@link org.greenrobot.greendao.AbstractDao#update(Object)}. + * Entity must attached to an entity context. + */ + @Generated(hash = 713229351) + public void update() { + if (myDao == null) { + throw new DaoException("Entity is detached from DAO context"); + } + myDao.update(this); + } + + /** + * Convenient call for {@link org.greenrobot.greendao.AbstractDao#delete(Object)}. + * Entity must attached to an entity context. + */ + @Generated(hash = 128553479) + public void delete() { + if (myDao == null) { + throw new DaoException("Entity is detached from DAO context"); + } + myDao.delete(this); + } + + /** called by internal mechanisms, do not call yourself. */ + @Generated(hash = 1482096330) + public void setFolderCloud(CloudEntity folderCloud) { + synchronized (this) { + this.folderCloud = folderCloud; + folderCloudId = folderCloud == null ? null : folderCloud.getId(); + folderCloud__resolvedKey = folderCloudId; + } + } + + /** To-one relationship, resolved on first access. */ + @Generated(hash = 1508817413) + public CloudEntity getFolderCloud() { + Long __key = this.folderCloudId; + if (folderCloud__resolvedKey == null || !folderCloud__resolvedKey.equals(__key)) { + final DaoSession daoSession = this.daoSession; + if (daoSession == null) { + throw new DaoException("Entity is detached from DAO context"); + } + CloudEntityDao targetDao = daoSession.getCloudEntityDao(); + CloudEntity folderCloudNew = targetDao.load(__key); + synchronized (this) { + folderCloud = folderCloudNew; + folderCloud__resolvedKey = __key; + } + } + return folderCloud; + } + + /** Used for active entity operations. */ + @Generated(hash = 941685503) + private transient VaultEntityDao myDao; + + /** Used to resolve relations */ + @Generated(hash = 2040040024) + private transient DaoSession daoSession; + + @Generated(hash = 229273163) + private transient Long folderCloud__resolvedKey; + + public String getFolderPath() { + return this.folderPath; + } + + public void setFolderPath(String folderPath) { + this.folderPath = folderPath; + } + + public String getFolderName() { + return folderName; + } + + public void setFolderName(String folderName) { + this.folderName = folderName; + } + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getFolderCloudId() { + return this.folderCloudId; + } + + public void setFolderCloudId(Long folderCloudId) { + this.folderCloudId = folderCloudId; + } + + public String getCloudType() { + return this.cloudType; + } + + public void setCloudType(String cloudType) { + this.cloudType = cloudType; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } + + /** called by internal mechanisms, do not call yourself. */ + @Generated(hash = 674742652) + public void __setDaoSession(DaoSession daoSession) { + this.daoSession = daoSession; + myDao = daoSession != null ? daoSession.getVaultEntityDao() : null; + } + + @Generated(hash = 1196809909) + public VaultEntity(Long id, Long folderCloudId, String folderPath, String folderName, @NotNull String cloudType, String password) { + this.id = id; + this.folderCloudId = folderCloudId; + this.folderPath = folderPath; + this.folderName = folderName; + this.cloudType = cloudType; + this.password = password; + } + + @Generated(hash = 691253864) + public VaultEntity() { + } + +} 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 new file mode 100644 index 000000000..d0017352c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java @@ -0,0 +1,100 @@ +package org.cryptomator.data.db.mappers; + +import org.cryptomator.data.db.entities.CloudEntity; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.DropboxCloud; +import org.cryptomator.domain.GoogleDriveCloud; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.OnedriveCloud; +import org.cryptomator.domain.WebDavCloud; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.domain.DropboxCloud.aDropboxCloud; +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.WebDavCloud.aWebDavCloudCloud; + +@Singleton +public class CloudEntityMapper extends EntityMapper { + + @Inject + public CloudEntityMapper() { + } + + @Override + public Cloud fromEntity(CloudEntity entity) { + CloudType type = CloudType.valueOf(entity.getType()); + switch (type) { + case DROPBOX: + return aDropboxCloud() // + .withId(entity.getId()) // + .withAccessToken(entity.getAccessToken()) // + .withUsername(entity.getUsername()) // + .build(); + case GOOGLE_DRIVE: + return aGoogleDriveCloud() // + .withId(entity.getId()) // + .withAccessToken(entity.getAccessToken()) // + .withUsername(entity.getUsername()) // + .build(); + case ONEDRIVE: + return aOnedriveCloud() // + .withId(entity.getId()) // + .withAccessToken(entity.getAccessToken()) // + .withUsername(entity.getUsername()) // + .build(); + case LOCAL: + return aLocalStorage() // + .withId(entity.getId()) // + .withRootUri(entity.getAccessToken()).build(); + case WEBDAV: + return aWebDavCloudCloud() // + .withId(entity.getId()) // + .withUrl(entity.getWebdavUrl()) // + .withUsername(entity.getUsername()) // + .withPassword(entity.getAccessToken()) // + .withCertificate(entity.getWebdavCertificate()) // + .build(); + default: + throw new IllegalStateException("Unhandled enum constant " + type); + } + } + + @Override + public CloudEntity toEntity(Cloud domainObject) { + CloudEntity result = new CloudEntity(); + result.setId(domainObject.id()); + result.setType(domainObject.type().name()); + switch (domainObject.type()) { + case DROPBOX: + result.setAccessToken(((DropboxCloud) domainObject).accessToken()); + result.setUsername(((DropboxCloud) domainObject).username()); + break; + case GOOGLE_DRIVE: + result.setAccessToken(((GoogleDriveCloud) domainObject).accessToken()); + result.setUsername(((GoogleDriveCloud) domainObject).username()); + break; + case ONEDRIVE: + result.setAccessToken(((OnedriveCloud) domainObject).accessToken()); + result.setUsername(((OnedriveCloud) domainObject).username()); + break; + case LOCAL: + result.setAccessToken(((LocalStorageCloud) domainObject).rootUri()); + break; + case WEBDAV: + result.setAccessToken(((WebDavCloud) domainObject).password()); + result.setWebdavUrl(((WebDavCloud) domainObject).url()); + result.setUsername(((WebDavCloud) domainObject).username()); + result.setWebdavCertificate(((WebDavCloud) domainObject).certificate()); + break; + default: + throw new IllegalStateException("Unhandled enum constant " + domainObject.type()); + } + return result; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/db/mappers/EntityMapper.java b/data/src/main/java/org/cryptomator/data/db/mappers/EntityMapper.java new file mode 100644 index 000000000..63135bcc1 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/mappers/EntityMapper.java @@ -0,0 +1,34 @@ +package org.cryptomator.data.db.mappers; + +import org.cryptomator.data.db.entities.DatabaseEntity; +import org.cryptomator.domain.exception.BackendException; + +import java.util.ArrayList; +import java.util.List; + +public abstract class EntityMapper { + + EntityMapper() { + } + + public List fromEntities(Iterable entities) throws BackendException { + List result = new ArrayList<>(); + for (E entity : entities) { + result.add(fromEntity(entity)); + } + return result; + } + + public List toEntities(Iterable domainObjects) { + List result = new ArrayList<>(); + for (D domainObject : domainObjects) { + result.add(toEntity(domainObject)); + } + return result; + } + + protected abstract D fromEntity(E entity) throws BackendException; + + protected abstract E toEntity(D domainObject); + +} diff --git a/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java b/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java new file mode 100644 index 000000000..645ab3226 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java @@ -0,0 +1,56 @@ +package org.cryptomator.data.db.mappers; + +import org.cryptomator.data.db.entities.VaultEntity; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.domain.Vault.aVault; + +@Singleton +public class VaultEntityMapper extends EntityMapper { + + private final CloudEntityMapper cloudEntityMapper; + + @Inject + public VaultEntityMapper(CloudEntityMapper cloudEntityMapper) { + this.cloudEntityMapper = cloudEntityMapper; + } + + @Override + public Vault fromEntity(VaultEntity entity) throws BackendException { + return aVault() // + .withId(entity.getId()) // + .withName(entity.getFolderName()) // + .withPath(entity.getFolderPath()) // + .withCloud(cloudFrom(entity)) // + .withCloudType(CloudType.valueOf(entity.getCloudType())) // + .withSavedPassword(entity.getPassword()) // + .build(); + } + + private Cloud cloudFrom(VaultEntity entity) { + if (entity.getFolderCloud() == null) { + return null; + } + return cloudEntityMapper.fromEntity(entity.getFolderCloud()); + } + + @Override + public VaultEntity toEntity(Vault domainObject) { + VaultEntity entity = new VaultEntity(); + entity.setId(domainObject.getId()); + entity.setFolderPath(domainObject.getPath()); + entity.setFolderName(domainObject.getName()); + if (domainObject.getCloud() != null) { + entity.setFolderCloud(cloudEntityMapper.toEntity(domainObject.getCloud())); + } + entity.setCloudType(domainObject.getCloudType().name()); + entity.setPassword(domainObject.getPassword()); + return entity; + } +} diff --git a/data/src/main/java/org/cryptomator/data/exception/CloudError.java b/data/src/main/java/org/cryptomator/data/exception/CloudError.java new file mode 100644 index 000000000..6447d9a60 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/exception/CloudError.java @@ -0,0 +1,34 @@ +package org.cryptomator.data.exception; + +public enum CloudError { + + CREATE_FOLDER("Cannot create folder."), + + LIST_NODES("Cannot retrieve cloud content."), + + RENAME_FOLDER("Cannot rename folder."), + + RENAME_FILE("Cannot rename file."), + + DELETE_PATH("Cannot delete file/folder."), + + UPLOAD_FILE("Cannot upload file."), + + DOWNLOAD_FILE("Cannot download file."), + + CURRENT_ACCOUNT("Cannot retrieve account information."), + + MOVE_PATH("Cannot move file/folder."), + + TARGET_EXISTS(""); + + private final String errorMessage; + + CloudError(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/data/src/main/java/org/cryptomator/data/exception/DatabaseError.java b/data/src/main/java/org/cryptomator/data/exception/DatabaseError.java new file mode 100644 index 000000000..584d38a7f --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/exception/DatabaseError.java @@ -0,0 +1,15 @@ +package org.cryptomator.data.exception; + +public enum DatabaseError { + RENAME_VAULT("Cannot rename vault."), DELETE_VAULT("Cannot delete vault."); + + private final String errorMessage; + + DatabaseError(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/data/src/main/java/org/cryptomator/data/executor/JobExecutor.java b/data/src/main/java/org/cryptomator/data/executor/JobExecutor.java new file mode 100644 index 000000000..52b406901 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/executor/JobExecutor.java @@ -0,0 +1,55 @@ +package org.cryptomator.data.executor; + +import org.cryptomator.domain.executor.ThreadExecutor; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Decorated {@link java.util.concurrent.ThreadPoolExecutor} + */ +@Singleton +public class JobExecutor implements ThreadExecutor { + private static final int INITIAL_POOL_SIZE = 3; + private static final int MAX_POOL_SIZE = 5; + + // Sets the amount of time an idle thread waits before terminating + private static final int KEEP_ALIVE_TIME = 10; + + // Sets the Time Unit to seconds + private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS; + + private final ThreadPoolExecutor threadPoolExecutor; + + @Inject + public JobExecutor() { + BlockingQueue workQueue = new LinkedBlockingQueue<>(); + ThreadFactory threadFactory = new JobThreadFactory(); + this.threadPoolExecutor = new ThreadPoolExecutor(INITIAL_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, KEEP_ALIVE_TIME_UNIT, workQueue, threadFactory); + } + + @Override + public void execute(@NotNull Runnable runnable) { + if (runnable == null) { + throw new IllegalArgumentException("Runnable to execute cannot be null"); + } + this.threadPoolExecutor.execute(runnable); + } + + private static class JobThreadFactory implements ThreadFactory { + private static final String THREAD_NAME = "android_"; + private int counter = 0; + + @Override + public Thread newThread(@NotNull Runnable runnable) { + return new Thread(runnable, THREAD_NAME + counter++); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/repository/CloudContentRepositoryFactory.java b/data/src/main/java/org/cryptomator/data/repository/CloudContentRepositoryFactory.java new file mode 100644 index 000000000..6d6d2b7c5 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/repository/CloudContentRepositoryFactory.java @@ -0,0 +1,23 @@ +package org.cryptomator.data.repository; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.authentication.AuthenticationException; +import org.cryptomator.domain.exception.authentication.NoAuthenticationProvidedException; +import org.cryptomator.domain.repository.CloudContentRepository; + +public interface CloudContentRepositoryFactory { + + boolean supports(Cloud cloud); + + /** + * Creates a new {@link CloudContentRepository}. + * + * @param cloud the {@link Cloud} to access through the {@code CloudContentRepository} + * @return the created {@code CloudContentRepository} + * + * @throws NoAuthenticationProvidedException if the cloud has not been authenticated + * @throws AuthenticationException if an authentication error occurs while accessing the cloud + */ + CloudContentRepository cloudContentRepositoryFor(Cloud cloud); + +} diff --git a/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java new file mode 100644 index 000000000..d68d2ec6d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java @@ -0,0 +1,127 @@ +package org.cryptomator.data.repository; + +import org.cryptomator.data.cloud.crypto.CryptoCloudFactory; +import org.cryptomator.data.db.Database; +import org.cryptomator.data.db.entities.CloudEntity; +import org.cryptomator.data.db.mappers.CloudEntityMapper; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.usecases.vault.UnlockToken; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +class CloudRepositoryImpl implements CloudRepository { + + private final Database database; + private final CryptoCloudFactory cryptoCloudFactory; + private final CloudEntityMapper mapper; + private final DispatchingCloudContentRepository dispatchingCloudContentRepository; + + @Inject + public CloudRepositoryImpl(CloudEntityMapper mapper, // + CryptoCloudFactory cryptoCloudFactory, // + Database database, // + DispatchingCloudContentRepository dispatchingCloudContentRepository) { + this.database = database; + this.cryptoCloudFactory = cryptoCloudFactory; + this.mapper = mapper; + this.dispatchingCloudContentRepository = dispatchingCloudContentRepository; + } + + @Override + public List clouds(CloudType cloudType) throws BackendException { + List cloudsFromType = new ArrayList<>(); + List allClouds = mapper.fromEntities(database.loadAll(CloudEntity.class)); + + for (Cloud cloud : allClouds) { + if (cloud.type().equals(cloudType)) { + cloudsFromType.add(cloud); + } + } + + return cloudsFromType; + } + + @Override + public List allClouds() throws BackendException { + return mapper.fromEntities(database.loadAll(CloudEntity.class)); + } + + @Override + public Cloud store(Cloud cloud) { + if (!cloud.persistent()) { + throw new IllegalArgumentException("Can not store non persistent cloud"); + } + + Cloud storedCloud = mapper.fromEntity(database.store(mapper.toEntity(cloud))); + + dispatchingCloudContentRepository.removeCloudContentRepositoryFor(storedCloud); + database.clearCache(); + + return storedCloud; + } + + @Override + public void delete(Cloud cloud) { + if (cloud.predefined()) { + throw new IllegalArgumentException("Can not delete predefined cloud"); + } + if (!cloud.persistent()) { + throw new IllegalArgumentException("Can not delete non persistent cloud"); + } + database.delete(mapper.toEntity(cloud)); + } + + @Override + public void create(CloudFolder location, CharSequence password) throws BackendException { + cryptoCloudFactory.create(location, password); + } + + @Override + public Cloud decryptedViewOf(Vault vault) throws BackendException { + return cryptoCloudFactory.decryptedViewOf(vault); + } + + @Override + public Cloud unlock(Vault vault, CharSequence password) throws BackendException { + Vault vaultWithVersion = cryptoCloudFactory.unlock(vault, password); + return decryptedViewOf(vaultWithVersion); + } + + @Override + public Cloud unlock(UnlockToken token, CharSequence password) throws BackendException { + Vault vaultWithVersion = cryptoCloudFactory.unlock(token, password); + return decryptedViewOf(vaultWithVersion); + } + + @Override + public UnlockToken prepareUnlock(Vault vault) throws BackendException { + return cryptoCloudFactory.createUnlockToken(vault); + } + + @Override + public boolean isVaultPasswordValid(Vault vault, CharSequence password) throws BackendException { + return cryptoCloudFactory.isVaultPasswordValid(vault, password); + } + + @Override + public void lock(Vault vault) throws BackendException { + dispatchingCloudContentRepository.removeCloudContentRepositoryFor(decryptedViewOf(vault)); + cryptoCloudFactory.lock(vault); + } + + @Override + public void changePassword(Vault vault, String oldPassword, String newPassword) throws BackendException { + cryptoCloudFactory.changePassword(vault, oldPassword, newPassword); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.java b/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.java new file mode 100644 index 000000000..0577c47e0 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/repository/DispatchingCloudContentRepository.java @@ -0,0 +1,252 @@ +package org.cryptomator.data.repository; + +import org.cryptomator.data.cloud.CloudContentRepositoryFactories; +import org.cryptomator.data.cloud.crypto.CryptoCloud; +import org.cryptomator.data.cloud.crypto.CryptoCloudContentRepositoryFactory; +import org.cryptomator.data.util.NetworkConnectionCheck; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.authentication.AuthenticationException; +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.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class DispatchingCloudContentRepository implements CloudContentRepository { + + private final Map delegates = new WeakHashMap<>(); + private final CloudContentRepositoryFactories cloudContentRepositoryFactories; + private final NetworkConnectionCheck networkConnectionCheck; + private final CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory; + + @Inject + public DispatchingCloudContentRepository(CloudContentRepositoryFactories cloudContentRepositoryFactories, NetworkConnectionCheck networkConnectionCheck, + CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory) { + this.cloudContentRepositoryFactories = cloudContentRepositoryFactories; + this.networkConnectionCheck = networkConnectionCheck; + this.cryptoCloudContentRepositoryFactory = cryptoCloudContentRepositoryFactory; + } + + @Override + public CloudFolder root(Cloud cloud) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(cloud); + return delegateFor(cloud).root(cloud); + } catch (AuthenticationException e) { + delegates.remove(cloud); + throw e; + } + } + + @Override + public CloudFolder resolve(Cloud cloud, String path) throws BackendException { + try { + // do not check for network connection + return delegateFor(cloud).resolve(cloud, path); + } catch (AuthenticationException e) { + delegates.remove(cloud); + throw e; + } + } + + @Override + public CloudFile file(CloudFolder parent, String name) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(parent.getCloud()); + return delegateFor(parent).file(parent, name); + } catch (AuthenticationException e) { + delegates.remove(parent.getCloud()); + throw e; + } + } + + @Override + public CloudFile file(CloudFolder parent, String name, Optional size) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(parent.getCloud()); + return delegateFor(parent).file(parent, name, size); + } catch (AuthenticationException e) { + delegates.remove(parent.getCloud()); + throw e; + } + } + + @Override + public CloudFolder folder(CloudFolder parent, String name) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(parent.getCloud()); + return delegateFor(parent).folder(parent, name); + } catch (AuthenticationException e) { + delegates.remove(parent.getCloud()); + throw e; + } + } + + @Override + public boolean exists(CloudNode node) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(node.getCloud()); + return delegateFor(node).exists(node); + } catch (AuthenticationException e) { + delegates.remove(node.getCloud()); + throw e; + } + } + + @Override + public List list(CloudFolder folder) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(folder.getCloud()); + return delegateFor(folder).list(folder); + } catch (AuthenticationException e) { + delegates.remove(folder.getCloud()); + throw e; + } + } + + @Override + public CloudFolder create(CloudFolder folder) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(folder.getCloud()); + return delegateFor(folder).create(folder); + } catch (AuthenticationException e) { + delegates.remove(folder.getCloud()); + throw e; + } + } + + @Override + public CloudFolder move(CloudFolder source, CloudFolder target) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(source.getCloud()); + if (!source.getCloud().equals(target.getCloud())) { + throw new IllegalArgumentException("Cloud of parameters must match"); + } + return delegateFor(source).move(source, target); + } catch (AuthenticationException e) { + delegates.remove(source.getCloud()); + throw e; + } + } + + @Override + public CloudFile move(CloudFile source, CloudFile target) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(source.getCloud()); + if (!source.getCloud().equals(target.getCloud())) { + throw new IllegalArgumentException("Cloud of parameters must match"); + } + return delegateFor(source).move(source, target); + } catch (AuthenticationException e) { + delegates.remove(source.getCloud()); + throw e; + } + } + + @Override + public CloudFile write(CloudFile source, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(source.getCloud()); + return delegateFor(source).write(source, data, progressAware, replace, size); + } catch (AuthenticationException e) { + delegates.remove(source.getCloud()); + throw e; + } + } + + @Override + public void read(CloudFile file, Optional tempEncryptedFile, OutputStream data, ProgressAware progressAware) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(file.getCloud()); + delegateFor(file).read(file, tempEncryptedFile, data, progressAware); + } catch (AuthenticationException e) { + delegates.remove(file.getCloud()); + throw e; + } + } + + @Override + public void delete(CloudNode node) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(node.getCloud()); + delegateFor(node).delete(node); + } catch (AuthenticationException e) { + delegates.remove(node.getCloud()); + throw e; + } + } + + @Override + public String checkAuthenticationAndRetrieveCurrentAccount(Cloud cloud) throws BackendException { + try { + networkConnectionCheck.assertConnectionIsPresent(cloud); + return delegateFor(cloud).checkAuthenticationAndRetrieveCurrentAccount(cloud); + } catch (AuthenticationException e) { + delegates.remove(cloud); + throw e; + } + } + + @Override + public void logout(Cloud cloud) throws BackendException { + delegateFor(cloud).logout(cloud); + removeCloudContentRepositoryFor(cloud); + } + + public void removeCloudContentRepositoryFor(Cloud cloud) { + Iterator clouds = delegates.keySet().iterator(); + while (clouds.hasNext()) { + Cloud current = clouds.next(); + if (cloud.equals(current)) { + clouds.remove(); + } else if (cloudIsDelegateOfCryptoCloud(current, cloud)) { + cryptoCloudContentRepositoryFactory.deregisterCryptor(((CryptoCloud) current).getVault(), false); + } + } + } + + private boolean cloudIsDelegateOfCryptoCloud(Cloud potentialCryptoCloud, Cloud cloud) { + if (potentialCryptoCloud instanceof CryptoCloud) { + CryptoCloud cryptoCloud = (CryptoCloud) potentialCryptoCloud; + Cloud delegate = cryptoCloud.getVault().getCloud(); + return cloud.equals(delegate); + } + return false; + } + + private CloudContentRepository delegateFor(CloudNode cloudNode) { + return delegateFor(cloudNode.getCloud()); + } + + private CloudContentRepository delegateFor(Cloud cloud) { + if (!delegates.containsKey(cloud)) { + delegates.put(cloud, createCloudContentRepositoryFor(cloud)); + } + return delegates.get(cloud); + } + + private CloudContentRepository createCloudContentRepositoryFor(Cloud cloud) { + for (CloudContentRepositoryFactory cloudContentRepositoryFactory : cloudContentRepositoryFactories) { + if (cloudContentRepositoryFactory.supports(cloud)) { + return cloudContentRepositoryFactory.cloudContentRepositoryFor(cloud); + } + } + throw new IllegalStateException("Unsupported cloud " + cloud); + } +} diff --git a/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java b/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java new file mode 100644 index 000000000..d45e28d47 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java @@ -0,0 +1,50 @@ +package org.cryptomator.data.repository; + +import org.cryptomator.cryptolib.Cryptors; +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.repository.UpdateCheckRepository; +import org.cryptomator.domain.repository.VaultRepository; + +import java.security.SecureRandom; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class RepositoryModule { + + @Singleton + @Provides + public CryptorProvider provideCryptorProvider() { + return Cryptors.version1(new SecureRandom()); + } + + @Singleton + @Provides + public CloudRepository provideCloudRepository(CloudRepositoryImpl cloudRepository) { + return cloudRepository; + } + + @Singleton + @Provides + public VaultRepository provideVaultRepository(VaultRepositoryImpl vaultRepository) { + return vaultRepository; + } + + @Singleton + @Provides + public CloudContentRepository provideCloudContentRepository(DispatchingCloudContentRepository cloudContentRepository) { + return cloudContentRepository; + } + + @Singleton + @Provides + public UpdateCheckRepository provideBetaStatusRepository(UpdateCheckRepositoryImpl updateCheckRepository) { + return updateCheckRepository; + } + +} diff --git a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java new file mode 100644 index 000000000..ebcecbc28 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java @@ -0,0 +1,242 @@ +package org.cryptomator.data.repository; + +import com.google.common.io.BaseEncoding; + +import java.io.File; +import java.io.IOException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.net.ssl.SSLHandshakeException; + +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.repository.UpdateCheckRepository; +import org.cryptomator.domain.usecases.UpdateCheck; +import org.cryptomator.util.Optional; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okio.BufferedSink; +import okio.Okio; + +@Singleton +public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { + + private static final String HOSTNAME_LATEST_VERSION = "https://static.cryptomator.org/android/latest-version.json"; + + private final Database database; + private final OkHttpClient httpClient; + + @Inject + UpdateCheckRepositoryImpl(Database database) { + this.httpClient = httpClient(); + this.database = database; + } + + private OkHttpClient httpClient() { + return new OkHttpClient // + .Builder().addInterceptor(new UserAgentInterceptor()) // + .build(); + } + + @Override + public Optional getUpdateCheck(final String appVersion) throws BackendException { + LatestVersion latestVersion = loadLatestVersion(); + + if (appVersion.equals(latestVersion.version)) { + return Optional.empty(); + } + + final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); + + if (entity.getVersion() != null && entity.getVersion().equals(latestVersion.version)) { + return Optional.of(new UpdateCheckImpl("", entity)); + } + + UpdateCheck updateCheck = loadUpdateStatus(latestVersion); + entity.setUrlToApk(updateCheck.getUrlApk()); + entity.setVersion(updateCheck.getVersion()); + + database.store(entity); + + return Optional.of(updateCheck); + } + + @Nullable + @Override + public String getLicense() { + return database.load(UpdateCheckEntity.class, 1L).getLicenseToken(); + } + + @Override + public void setLicense(String license) { + final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); + + entity.setLicenseToken(license); + + database.store(entity); + } + + @Override + public void update(File file) throws GeneralUpdateErrorException { + try { + final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); + + final Request request = new Request // + .Builder() // + .url(entity.getUrlToApk()).build(); + + final Response response = httpClient.newCall(request).execute(); + + if (response.isSuccessful()) { + final BufferedSink sink = Okio.buffer(Okio.sink(file)); + sink.writeAll(response.body().source()); + sink.close(); + } else { + throw new GeneralUpdateErrorException("Failed to load update file, status code is not correct: " + response.code()); + } + } catch (IOException e) { + throw new GeneralUpdateErrorException("Failed to load update. General error occurred.", e); + } + } + + private LatestVersion loadLatestVersion() throws BackendException { + try { + final Request request = new Request // + .Builder() // + .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); + } + } + + private UpdateCheck loadUpdateStatus(LatestVersion latestVersion) throws BackendException { + try { + final Request request = new Request // + .Builder() // + .url(latestVersion.urlReleaseNote) // + .build(); + return toUpdateCheck(httpClient.newCall(request).execute(), latestVersion); + } catch (IOException e) { + throw new GeneralUpdateErrorException("Failed to update. General error occurred.", e); + } + } + + private LatestVersion toLatestVersion(Response response) throws IOException, GeneralUpdateErrorException { + if (response.isSuccessful()) { + return new LatestVersion(response.body().string()); + } else { + throw new GeneralUpdateErrorException("Failed to update. Wrong status code in response from server: " + response.code()); + } + } + + private UpdateCheck toUpdateCheck(Response response, LatestVersion latestVersion) throws IOException, GeneralUpdateErrorException { + if (response.isSuccessful()) { + final String releaseNote = response.body().string(); + return new UpdateCheckImpl(releaseNote, latestVersion); + } else { + throw new GeneralUpdateErrorException("Failed to update. Wrong status code in response from server: " + response.code()); + } + } + + private class LatestVersion { + + private final String version; + private final String urlApk; + private final String urlReleaseNote; + + LatestVersion(String json) throws GeneralUpdateErrorException { + try { + Claims jws = Jwts // + .parserBuilder().setSigningKey(getPublicKey()) // + .build() // + .parseClaimsJws(json) // + .getBody(); + + version = jws.get("version", String.class); + urlApk = jws.get("url", String.class); + urlReleaseNote = jws.get("release_notes", String.class); + } catch (Exception e) { + throw new GeneralUpdateErrorException("Failed to parse latest version", e); + } + } + } + + private static class UpdateCheckImpl implements UpdateCheck { + private final String releaseNote; + private final String version; + private final String urlApk; + private final String urlReleaseNote; + + private UpdateCheckImpl(String releaseNote, LatestVersion latestVersion) { + this.releaseNote = releaseNote; + this.version = latestVersion.version; + this.urlApk = latestVersion.urlApk; + this.urlReleaseNote = latestVersion.urlReleaseNote; + } + + private UpdateCheckImpl(String releaseNote, UpdateCheckEntity updateCheckEntity) { + this.releaseNote = releaseNote; + this.version = updateCheckEntity.getVersion(); + this.urlApk = updateCheckEntity.getUrlToApk(); + this.urlReleaseNote = updateCheckEntity.getUrlToReleaseNote(); + } + + @Override + public String releaseNote() { + return releaseNote; + } + + @Override + public String getVersion() { + return version; + } + + @Override + public String getUrlApk() { + return urlApk; + } + + @Override + public String getUrlReleaseNote() { + return urlReleaseNote; + } + } + + private ECPublicKey getPublicKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + final byte[] publicKey = BaseEncoding // + .base64() // + .decode("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELOYa5ax7QZvS92HJYCBPBiR2wWfX" + "P9/Oq/yl2J1yg0Vovetp8i1A3yCtoqdHVdVytM1wNV0JXgRbWuNTAr9nlQ=="); + + Key key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(publicKey)); + if (key instanceof ECPublicKey) { + return (ECPublicKey) key; + } else { + throw new FatalBackendException("Key not an EC public key."); + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java new file mode 100644 index 000000000..2c4e17934 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java @@ -0,0 +1,93 @@ +package org.cryptomator.data.repository; + +import android.database.sqlite.SQLiteConstraintException; + +import org.cryptomator.data.cloud.crypto.CryptoCloudContentRepositoryFactory; +import org.cryptomator.data.cloud.crypto.CryptoCloudFactory; +import org.cryptomator.data.db.Database; +import org.cryptomator.data.db.entities.VaultEntity; +import org.cryptomator.data.db.mappers.VaultEntityMapper; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.VaultAlreadyExistException; +import org.cryptomator.domain.repository.VaultRepository; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.cryptomator.domain.Vault.aCopyOf; + +@Singleton +class VaultRepositoryImpl implements VaultRepository { + + private final Database database; + private final VaultEntityMapper mapper; + private final CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory; + private final DispatchingCloudContentRepository dispatchingCloudContentRepository; + private final CryptoCloudFactory cryptoCloudFactory; + + @Inject + public VaultRepositoryImpl( // + VaultEntityMapper mapper, // + CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory, // + CryptoCloudFactory cryptoCloudFactory, // + DispatchingCloudContentRepository dispatchingCloudContentRepository, // + Database database) { + this.mapper = mapper; + this.database = database; + this.cryptoCloudContentRepositoryFactory = cryptoCloudContentRepositoryFactory; + this.cryptoCloudFactory = cryptoCloudFactory; + this.dispatchingCloudContentRepository = dispatchingCloudContentRepository; + } + + @Override + public List vaults() throws BackendException { + List result = new ArrayList<>(); + for (Vault vault : mapper.fromEntities(database.loadAll(VaultEntity.class))) { + result.add(aCopyOf(vault).withUnlocked(isUnlocked(vault)).build()); + } + return result; + } + + @Override + public Vault store(Vault vault) throws BackendException { + try { + return mapper.fromEntity(database.store(mapper.toEntity(vault))); + } catch (SQLiteConstraintException e) { + throw new VaultAlreadyExistException(); + } + } + + @Override + public Long delete(Vault vault) throws BackendException { + deregisterUnlocked(vault); + dispatchingCloudContentRepository.removeCloudContentRepositoryFor(cryptoCloudFactory.decryptedViewOf(vault)); + database.delete(mapper.toEntity(vault)); + return vault.getId(); + } + + @Override + public Vault load(Long id) throws BackendException { + Vault vault = mapper.fromEntity(database.load(VaultEntity.class, id)); + return aCopyOf(vault).withUnlocked(isUnlocked(vault)).build(); + } + + private void deregisterUnlocked(Vault vault) { + if (isUnlocked(vault)) { + cryptoCloudContentRepositoryFactory.deregisterCryptor(vault); + } + } + + private boolean isUnlocked(Vault vault) { + return cryptoCloudContentRepositoryFactory.cryptorIsRegisteredFor(vault); + } + + @Override + public void assertUnlocked(Vault vault) { + cryptoCloudContentRepositoryFactory.assertCryptorRegisteredFor(vault); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/util/CopyStream.java b/data/src/main/java/org/cryptomator/data/util/CopyStream.java new file mode 100644 index 000000000..4e7537572 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/CopyStream.java @@ -0,0 +1,65 @@ +package org.cryptomator.data.util; + +import org.cryptomator.domain.exception.FatalBackendException; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class CopyStream { + + private static final int DEFAULT_COPY_BUFFER_SIZE = 16 << 10; // 16 KiB + + public static void copyStreamToStream(InputStream in, OutputStream out) { + copyStreamToStream(in, out, new byte[DEFAULT_COPY_BUFFER_SIZE]); + } + + private static void copyStreamToStream(InputStream in, OutputStream out, byte[] copyBuffer) { + while (true) { + int count; + try { + count = in.read(copyBuffer); + } catch (IOException ex) { + throw new FatalBackendException(ex); + } + + if (count == -1) + break; + + try { + out.write(copyBuffer, 0, count); + } catch (IOException ex) { + throw new FatalBackendException(ex); + } + } + } + + public static void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (IOException e) { + // ignore + } + } + } + + public static byte[] toByteArray(InputStream inputStream) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int read; + byte[] data = new byte[1024]; + try { + while ((read = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, read); + } + buffer.flush(); + } catch (IOException e) { + throw new FatalBackendException(e); + } + return buffer.toByteArray(); + } +} diff --git a/data/src/main/java/org/cryptomator/data/util/NetworkConnectionCheck.java b/data/src/main/java/org/cryptomator/data/util/NetworkConnectionCheck.java new file mode 100644 index 000000000..d140a3e01 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/NetworkConnectionCheck.java @@ -0,0 +1,52 @@ +package org.cryptomator.data.util; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.NetworkConnectionException; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class NetworkConnectionCheck { + + private final Context context; + + @Inject + NetworkConnectionCheck(Context context) { + this.context = context; + } + + public void assertConnectionIsPresent(Cloud cloud) throws NetworkConnectionException { + if (cloud.requiresNetwork() && !isPresent()) { + throw new NetworkConnectionException(); + } + } + + public boolean isPresent() { + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + return networkInfo != null // + && networkInfo.isConnectedOrConnecting(); + } + + public boolean checkWifiOnAndConnected() { + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Network activeNetwork = connectivityManager.getActiveNetwork(); + return connectivityManager.getNetworkCapabilities(activeNetwork).hasTransport(NetworkCapabilities.TRANSPORT_WIFI); + } else { + final WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (wifiManager.isWifiEnabled()) { + return wifiManager.getConnectionInfo().getNetworkId() != -1; // fails on devices post 8.x + } + return false; + } + } +} diff --git a/data/src/main/java/org/cryptomator/data/util/NetworkTimeout.java b/data/src/main/java/org/cryptomator/data/util/NetworkTimeout.java new file mode 100644 index 000000000..72cc734dc --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/NetworkTimeout.java @@ -0,0 +1,32 @@ +package org.cryptomator.data.util; + +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.TimeUnit.MINUTES; + +public enum NetworkTimeout { + + CONNECTION(2L, MINUTES), // + READ(2L, MINUTES), // + WRITE(2L, MINUTES); + + private final long timeout; + private final TimeUnit unit; + + NetworkTimeout(long timeout, TimeUnit unit) { + this.timeout = timeout; + this.unit = unit; + } + + public long getTimeout() { + return timeout; + } + + public TimeUnit getUnit() { + return unit; + } + + public long asMilliseconds() { + return unit.toMillis(timeout); + } +} diff --git a/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareGoogleContentInputStream.java b/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareGoogleContentInputStream.java new file mode 100644 index 000000000..f35f4adbb --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareGoogleContentInputStream.java @@ -0,0 +1,49 @@ +package org.cryptomator.data.util; + +import com.google.api.client.http.AbstractInputStreamContent; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; + +public abstract class TransferredBytesAwareGoogleContentInputStream extends AbstractInputStreamContent implements Closeable { + + private final InputStream data; + private final long size; + + /** + * @param size the size of the data to upload or less than zero if not known + */ + public TransferredBytesAwareGoogleContentInputStream(String type, InputStream data, long size) { + super(type); + this.data = new TransferredBytesAwareInputStream(data) { + @Override + public void bytesTransferred(long transferred) { + TransferredBytesAwareGoogleContentInputStream.this.bytesTransferred(transferred); + } + }; + this.size = size; + } + + @Override + public InputStream getInputStream() throws IOException { + return data; + } + + @Override + public long getLength() throws IOException { + return size; + } + + @Override + public boolean retrySupported() { + return false; + } + + @Override + public void close() throws IOException { + data.close(); + } + + public abstract void bytesTransferred(long transferred); +} diff --git a/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareInputStream.java b/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareInputStream.java new file mode 100644 index 000000000..af92e92b6 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareInputStream.java @@ -0,0 +1,68 @@ +package org.cryptomator.data.util; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; + +public abstract class TransferredBytesAwareInputStream extends InputStream { + + private static final int EOF = -1; + + private final InputStream in; + + private long transferred; + + public TransferredBytesAwareInputStream(InputStream in) { + this.in = in; + } + + @Override + public int read() throws IOException { + int result = in.read(); + if (result != EOF) { + bytesTransferred(++transferred); + } + return result; + } + + @Override + public int read(byte @NotNull [] b) throws IOException { + int result = in.read(b); + if (result != EOF) { + transferred += result; + bytesTransferred(transferred); + } + return result; + } + + @Override + public int read(byte @NotNull [] b, int off, int len) throws IOException { + int result = in.read(b, off, len); + if (result != EOF) { + transferred += result; + bytesTransferred(transferred); + } + return result; + } + + @Override + public void close() throws IOException { + in.close(); + } + + @Override + public int available() throws IOException { + return in.available(); + } + + @Override + public long skip(long n) throws IOException { + long result = in.skip(n); + transferred += result; + bytesTransferred(transferred); + return result; + } + + public abstract void bytesTransferred(long transferred); +} diff --git a/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareOutputStream.java b/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareOutputStream.java new file mode 100644 index 000000000..3f1f27bca --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/TransferredBytesAwareOutputStream.java @@ -0,0 +1,50 @@ +package org.cryptomator.data.util; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.OutputStream; + +public abstract class TransferredBytesAwareOutputStream extends OutputStream { + + private final OutputStream out; + + private long transferred; + + public TransferredBytesAwareOutputStream(OutputStream out) { + this.out = out; + } + + @Override + public void write(byte @NotNull [] b) throws IOException { + out.write(b); + transferred += b.length; + bytesTransferred(transferred); + } + + @Override + public void write(byte @NotNull [] b, int off, int len) throws IOException { + out.write(b, off, len); + transferred += len; + bytesTransferred(transferred); + } + + @Override + public void write(int i) throws IOException { + out.write(i); + bytesTransferred(++transferred); + } + + @Override + public void close() throws IOException { + out.close(); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + public abstract void bytesTransferred(long transferred); + +} diff --git a/data/src/main/java/org/cryptomator/data/util/UserAgentInterceptor.java b/data/src/main/java/org/cryptomator/data/util/UserAgentInterceptor.java new file mode 100644 index 000000000..f6c9d4427 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/UserAgentInterceptor.java @@ -0,0 +1,21 @@ +package org.cryptomator.data.util; + +import org.cryptomator.data.BuildConfig; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +public class UserAgentInterceptor implements Interceptor { + + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + String userAgent = "Cryptomator-Android/" + BuildConfig.VERSION_NAME + " " + System.getProperty("http.agent"); + Request requestWithUserAgent = originalRequest.newBuilder().header("User-Agent", userAgent).build(); + return chain.proceed(requestWithUserAgent); + } + +} diff --git a/data/src/main/java/org/cryptomator/data/util/X509CertificateHelper.java b/data/src/main/java/org/cryptomator/data/util/X509CertificateHelper.java new file mode 100644 index 000000000..933936c18 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/X509CertificateHelper.java @@ -0,0 +1,41 @@ +package org.cryptomator.data.util; + +import android.util.Base64; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.codec.digest.DigestUtils; + +public class X509CertificateHelper { + + private static final String CERT_BEGIN = "-----BEGIN CERTIFICATE-----\n"; + private static final String CERT_END = "-----END CERTIFICATE-----"; + + public static String convertToPem(X509Certificate cert) throws CertificateEncodingException { + String pemCertPre = Base64.encodeToString(cert.getEncoded(), Base64.DEFAULT); + return CERT_BEGIN + pemCertPre + CERT_END; + } + + public static X509Certificate convertFromPem(String pem) throws CertificateException { + byte[] decoded = Base64 // + .decode(pem.replaceAll(CERT_BEGIN, "").replaceAll(CERT_END, ""), Base64.DEFAULT); + + return (X509Certificate) CertificateFactory // + .getInstance("X.509") // + .generateCertificate(new ByteArrayInputStream(decoded)); + } + + public static String getFingerprintFormatted(X509Certificate certificate) throws CertificateEncodingException { + String hash = new String(Hex.encodeHex(DigestUtils.sha1(certificate.getEncoded()))) // + .toUpperCase() // + .replaceAll("(.{2})", "$1:"); + hash = hash.substring(0, hash.length() - 1); + return "SHA-256 " + hash; + } + +} diff --git a/data/src/test/java/org/cryptomator/data/ApplicationStub.java b/data/src/test/java/org/cryptomator/data/ApplicationStub.java new file mode 100755 index 000000000..079304c80 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/ApplicationStub.java @@ -0,0 +1,6 @@ +package org.cryptomator.data; + +import android.app.Application; + +public class ApplicationStub extends Application { +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/CloudFileMatcher.java b/data/src/test/java/org/cryptomator/data/cloud/CloudFileMatcher.java new file mode 100644 index 000000000..7f60c4475 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/CloudFileMatcher.java @@ -0,0 +1,46 @@ +package org.cryptomator.data.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudNode; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import static org.hamcrest.core.AllOf.allOf; +import static org.hamcrest.core.Is.is; + +public class CloudFileMatcher extends TypeSafeDiagnosingMatcher { + + private final Matcher delegate; + + private CloudFileMatcher(CloudFile file) { + super(file.getClass()); + + this.delegate = allOf(Matchers.hasProperty("path", is(file.getPath())), // + Matchers.hasProperty("modified", is(file.getModified())), // + Matchers.hasProperty("size", is(file.getSize())), // + Matchers.hasProperty("name", is(file.getName())), // + Matchers.hasProperty("parent", is(file.getParent()))); + } + + @Override + public void describeTo(Description description) { + delegate.describeTo(description); + } + + @Override + protected boolean matchesSafely(CloudNode item, Description mismatchDescription) { + + if (delegate.matches(item)) { + return true; + } + + mismatchDescription.appendText("not ").appendDescriptionOf(delegate); + return false; + } + + public static CloudFileMatcher cloudFile(CloudFile file) { + return new CloudFileMatcher(file); + } +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/CloudFolderMatcher.java b/data/src/test/java/org/cryptomator/data/cloud/CloudFolderMatcher.java new file mode 100644 index 000000000..3db412b79 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/CloudFolderMatcher.java @@ -0,0 +1,44 @@ +package org.cryptomator.data.cloud; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import static org.hamcrest.core.AllOf.allOf; +import static org.hamcrest.core.Is.is; + +public class CloudFolderMatcher extends TypeSafeDiagnosingMatcher { + + private final Matcher delegate; + + private CloudFolderMatcher(CloudFolder folder) { + super(folder.getClass()); + + this.delegate = allOf(Matchers.hasProperty("path", is(folder.getPath())), // + Matchers.hasProperty("name", is(folder.getName())), // + Matchers.hasProperty("parent", is(folder.getParent()))); + } + + @Override + public void describeTo(Description description) { + delegate.describeTo(description); + } + + @Override + protected boolean matchesSafely(CloudNode item, Description mismatchDescription) { + + if (delegate.matches(item)) { + return true; + } + + mismatchDescription.appendText("not ").appendDescriptionOf(delegate); + return false; + } + + public static CloudFolderMatcher cloudFolder(CloudFolder folder) { + return new CloudFolderMatcher(folder); + } +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7Test.java b/data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7Test.java new file mode 100644 index 000000000..88407ec9d --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormat7Test.java @@ -0,0 +1,1091 @@ +package org.cryptomator.data.cloud.crypto; + +import android.content.Context; + +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileContentCryptor; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.api.FileHeaderCryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; +import org.cryptomator.cryptolib.common.MessageDigestSupplier; +import org.cryptomator.data.util.CopyStream; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.ByteArrayDataSource; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.util.Encodings; +import org.cryptomator.util.Optional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalMatchers; +import org.mockito.Answers; +import org.mockito.Mockito; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * + * path/to/vault/d/00 + * ├─ Directory 1 + * │ ├─ Directory 2 + * │ ├─ Directory 3x250 + * │ │ ├─ Directory 4x250 + * │ │ └─ File 5x250 + * │ └─ File 3 + * ├─ File 1 + * ├─ File 2 + * ├─ File 4 + * + */ +public class CryptoImplVaultFormat7Test { + + private final String dirIdRoot = ""; + private final String dirId1 = "dir1-id"; + private final String dirId2 = "dir2-id"; + + private Cloud cloud; + private CryptoCloud cryptoCloud; + private Context context; + private Cryptor cryptor; + private CloudContentRepository cloudContentRepository; + private DirIdCache dirIdCache; + private FileNameCryptor fileNameCryptor; + private FileContentCryptor fileContentCryptor; + private FileHeaderCryptor fileHeaderCryptor; + + private CryptoImplVaultFormat7 inTest; + + private TestFolder rootFolder = new RootTestFolder(cloud); + private TestFolder d = new TestFolder(rootFolder, "d", "/d"); + private TestFolder lvl2Dir = new TestFolder(d, "00", "/d/00"); + private TestFolder aaFolder = new TestFolder(lvl2Dir, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + private RootCryptoFolder root; + private CryptoFile cryptoFile1; + private CryptoFile cryptoFile2; + private CryptoFile cryptoFile4; + private CryptoFolder cryptoFolder1; + + @BeforeEach + public void setup() throws BackendException { + cloud = Mockito.mock(Cloud.class); + cryptoCloud = Mockito.mock(CryptoCloud.class); + context = Mockito.mock(Context.class); + cryptor = Mockito.mock(Cryptor.class); + cloudContentRepository = Mockito.mock(CloudContentRepository.class, Answers.RETURNS_DEEP_STUBS); + dirIdCache = Mockito.mock(DirIdCache.class); + fileNameCryptor = Mockito.mock(FileNameCryptor.class); + fileContentCryptor = Mockito.mock(FileContentCryptor.class); + fileHeaderCryptor = Mockito.mock(FileHeaderCryptor.class); + + Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + Mockito.when(cryptor.fileContentCryptor()).thenReturn(fileContentCryptor); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); + + root = new RootCryptoFolder(cryptoCloud); + inTest = new CryptoImplVaultFormat7(context, () -> cryptor, cloudContentRepository, rootFolder, dirIdCache); + + Mockito.when(fileNameCryptor.hashDirectoryId(dirIdRoot)).thenReturn("00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + Mockito.when(fileNameCryptor.hashDirectoryId(dirId1)).thenReturn("11BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + Mockito.when(fileNameCryptor.hashDirectoryId(dirId2)).thenReturn("22CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir1", dirIdRoot.getBytes())).thenReturn("Directory 1"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file1", dirIdRoot.getBytes())).thenReturn("File 1"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file2", dirIdRoot.getBytes())).thenReturn("File 2"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir2", dirId1.getBytes())).thenReturn("Directory 2"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file3", dirId1.getBytes())).thenReturn("File 3"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file4", dirIdRoot.getBytes())).thenReturn("File 4"); + + TestFile testFile1 = new TestFile(aaFolder, "file1.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file1.c9r", Optional.empty(), Optional.empty()); + TestFile testFile2 = new TestFile(aaFolder, "file2.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file2.c9r", Optional.empty(), Optional.empty()); + TestFile testFile4 = new TestFile(aaFolder, "file4.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4.c9r", Optional.empty(), Optional.empty()); + TestFolder testDir1 = new TestFolder(aaFolder, "dir1.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir1.c9r"); + TestFile testDir1DirFile = new TestFile(testDir1, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir1.c9r/dir.c9r", Optional.empty(), Optional.empty()); + + ArrayList rootItems = new ArrayList() { + { + add(testFile1); + add(testFile2); + add(testFile4); + add(testDir1); + } + }; + + cryptoFile1 = new CryptoFile(root, "File 1", "/File 1", Optional.of(15l), testFile1); + cryptoFile2 = new CryptoFile(root, "File 2", "/File 2", Optional.empty(), testFile2); + cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + cryptoFolder1 = new CryptoFolder(root, "Directory 1", "/Directory 1", testDir1DirFile); + + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "00")).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")).thenReturn(aaFolder); + Mockito.when(cloudContentRepository.file(testDir1, "dir.c9r")).thenReturn(testDir1DirFile); + Mockito.when(cloudContentRepository.exists(testDir1DirFile)).thenReturn(true); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(dirId1.getBytes()), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFolder1.getDirFile()), Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.when(cloudContentRepository.list(aaFolder)).thenReturn(rootItems); + Mockito.when(dirIdCache.put(Mockito.eq(root), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("", aaFolder)); + } + + @Test + @DisplayName("list(\"/\")") + public void testListRoot() throws BackendException { + List rootDirContent = inTest.list(root); + + Matchers.contains(rootDirContent, cryptoFile1); + Matchers.contains(rootDirContent, cryptoFile2); + Matchers.contains(rootDirContent, cryptoFile4); + Matchers.contains(rootDirContent, cryptoFolder1); + } + + @Test + @DisplayName("list(\"/Directory 1/Directory 3x250\")") + public void testListDirectory3x250() throws BackendException { + String dir3Name = "Directory " + Strings.repeat("3", 250); + String dir3Cipher = "dir" + Strings.repeat("3", 250); + + byte[] longFilenameBytes = (dir3Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + TestFolder ddLvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + TestFolder testDir3 = new TestFolder(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName); + TestFile testDir3DirFile = new TestFile(testDir3, "dir.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir3NameFile = new TestFile(testDir3, "name.c9s", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/name.c9s", Optional.empty(), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir3Name, dirId1.getBytes())).thenReturn(dir3Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir3Cipher, dirId1.getBytes())).thenReturn(dir3Name); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(cloudContentRepository.file(testDir3, "name.c9s")).thenReturn(testDir3NameFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 1", dirIdRoot.getBytes())).thenReturn("dir1"); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 2", dirId1.getBytes())).thenReturn("dir2"); + + CryptoFolder cryptoFolder3 = new CryptoFolder(cryptoFolder1, dir3Name, "/Directory 1/" + dir3Name, testDir3DirFile); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream("dir3-id".getBytes()), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFolder3.getDirFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + /* + * │ ├─ Directory 3x250 + * │ │ ├─ Directory 4x250 + * │ │ └─ File 5x250 + */ + + String dir4Name = "Directory " + Strings.repeat("4", 250); + String dir4Cipher = "dir" + Strings.repeat("4", 250); + + byte[] longFilenameBytes4 = (dir4Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash4 = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes4); + String shortenedFileName4 = BaseEncoding.base64Url().encode(hash4) + ".c9s"; + + TestFolder directory4x250 = new TestFolder(ddFolder, shortenedFileName4, "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + shortenedFileName4); + TestFile testDir4DirFile = new TestFile(directory4x250, "dir.c9r", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName4 + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir4NameFile = new TestFile(directory4x250, "name.c9s", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName4 + "/name.c9s", Optional.empty(), Optional.empty()); + + Mockito.when(cloudContentRepository.file(directory4x250, "dir.c9r")).thenReturn(testDir4DirFile); + Mockito.when(cloudContentRepository.file(directory4x250, "name.c9s")).thenReturn(testDir4NameFile); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir4Name, "dir3-id".getBytes())).thenReturn(dir4Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir4Cipher, "dir3-id".getBytes())).thenReturn(dir4Name); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(dir4Cipher.getBytes("UTF-8")), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(testDir4NameFile), Mockito.any(), Mockito.any(), Mockito.any()); + + ArrayList dir4Files = new ArrayList() { + { + add(testDir4DirFile); + add(testDir4NameFile); + } + }; + + String file5Name = "File " + Strings.repeat("5", 250); + String file5Cipher = "file" + Strings.repeat("5", 250); + + byte[] longFilenameBytes5 = (file5Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash5 = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes5); + String shortenedFileName5 = BaseEncoding.base64Url().encode(hash5) + ".c9s"; + + TestFolder directory5x250 = new TestFolder(ddFolder, shortenedFileName5, "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + shortenedFileName5); + TestFile testFile5ContentFile = new TestFile(directory5x250, "contents.c9r", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName5 + "/contents.c9r", Optional.empty(), Optional.empty()); + TestFile testFile5NameFile = new TestFile(directory5x250, "name.c9s", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName5 + "/name.c9s", Optional.empty(), Optional.empty()); + + Mockito.when(cloudContentRepository.file(directory5x250, "contents.c9r")).thenReturn(testFile5ContentFile); + Mockito.when(cloudContentRepository.file(directory5x250, "name.c9s")).thenReturn(testFile5NameFile); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file5Name, "dir3-id".getBytes())).thenReturn(file5Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), file5Cipher, "dir3-id".getBytes())).thenReturn(file5Name); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(file5Cipher.getBytes("UTF-8")), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(testFile5NameFile), Mockito.any(), Mockito.any(), Mockito.any()); + + ArrayList dir5Files = new ArrayList() { + { + add(testFile5ContentFile); + add(testFile5NameFile); + } + }; + + ArrayList dir3Items = new ArrayList() { + { + add(directory4x250); + add(directory5x250); + } + }; + + Mockito.when(cloudContentRepository.exists(testDir3DirFile)).thenReturn(true); + Mockito.when(cloudContentRepository.list(ddFolder)).thenReturn(dir3Items); + Mockito.when(cloudContentRepository.list(directory4x250)).thenReturn(dir4Files); + Mockito.when(cloudContentRepository.list(directory5x250)).thenReturn(dir5Files); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + List folder3Content = inTest.list(cryptoFolder3); + + Matchers.contains(folder3Content, new CryptoFolder(cryptoFolder3, dir4Name, "/Directory 1/" + dir3Name + "/" + dir4Name, testDir4DirFile)); + Matchers.contains(folder3Content, new CryptoFile(cryptoFolder3, file5Name, "/Directory 1/" + dir3Name + "/" + file5Name, Optional.empty(), testFile5ContentFile)); + } + + @Test + @DisplayName("read(\"/File 1\", NO_PROGRESS_AWARE)") + public void testReadFromShortFile() throws BackendException { + byte[] file1Content = "hhhhhTOPSECRET!TOPSECRET!TOPSECRET!TOPSECRET!".getBytes(); + FileHeader header = Mockito.mock(FileHeader.class); + + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(8); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "File 1", dirIdRoot.getBytes())).thenReturn("file1"); + Mockito.when(fileHeaderCryptor.decryptHeader(UTF_8.encode("hhhhh"))).thenReturn(header); + Mockito.when(fileContentCryptor.decryptChunk(Mockito.eq(UTF_8.encode("TOPSECRET!")), Mockito.anyLong(), Mockito.eq(header), Mockito.anyBoolean())).then(invocation -> UTF_8.encode("geheim!!")); + + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(file1Content), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFile1.getCloudFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1000); + + inTest.read(cryptoFile1, outputStream, ProgressAware.NO_OP_PROGRESS_AWARE); + + assertThat(outputStream.toString(), is("geheim!!geheim!!geheim!!geheim!!")); + } + + @Test + @DisplayName("read(\"/File 15x250\", NO_PROGRESS_AWARE)") + public void testReadFromLongFile() throws BackendException { + String file3Name = "File " + Strings.repeat("15", 250); + + byte[] longFilenameBytes = file3Name.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder testFile3Folder = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testFile3ContentFile = new TestFile(testFile3Folder, "content.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/content.c9r", Optional.empty(), Optional.empty()); + + byte[] file1Content = "hhhhhTOPSECRET!TOPSECRET!TOPSECRET!TOPSECRET!".getBytes(); + FileHeader header = Mockito.mock(FileHeader.class); + + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(8); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + + Mockito.when(fileHeaderCryptor.decryptHeader(UTF_8.encode("hhhhh"))).thenReturn(header); + Mockito.when(fileContentCryptor.decryptChunk(Mockito.eq(UTF_8.encode("TOPSECRET!")), Mockito.anyLong(), Mockito.eq(header), Mockito.anyBoolean())).then(invocation -> UTF_8.encode("geheim!!")); + + CryptoFile cryptoFile15 = new CryptoFile(root, file3Name, "/" + file3Name, Optional.empty(), testFile3ContentFile); + + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(file1Content), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFile15.getCloudFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1000); + + inTest.read(cryptoFile15, outputStream, ProgressAware.NO_OP_PROGRESS_AWARE); + + assertThat(outputStream.toString(), is("geheim!!geheim!!geheim!!geheim!!")); + + } + + @Test + @DisplayName("write(\"/File 1\", text, NO_PROGRESS_AWARE, replace=false, 10bytes)") + public void testWriteToShortFile() throws BackendException { + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "File 1", dirIdRoot.getBytes())).thenReturn("file1"); + + FileHeader header = Mockito.mock(FileHeader.class); + Mockito.when(fileHeaderCryptor.create()).thenReturn(header); + Mockito.when(fileHeaderCryptor.encryptHeader(header)).thenReturn(ByteBuffer.wrap("hhhhh".getBytes())); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.encryptChunk(Mockito.any(ByteBuffer.class), Mockito.anyLong(), Mockito.any(FileHeader.class))).thenAnswer(invocation -> { + ByteBuffer input = invocation.getArgument(0); + String inStr = UTF_8.decode(input).toString(); + return ByteBuffer.wrap(inStr.toLowerCase().getBytes(UTF_8)); + }); + + Mockito.when(cloudContentRepository.write(Mockito.eq(cryptoFile1.getCloudFile()), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is("hhhhhtopsecret!")); + return invocationOnMock.getArgument(0); + }); + + CryptoFile cryptoFile = inTest.write(cryptoFile1, ByteArrayDataSource.from("TOPSECRET!".getBytes(UTF_8)), ProgressAware.NO_OP_PROGRESS_AWARE, false, 10l); + assertThat(cryptoFile, is(cryptoFile1)); + } + + @Test + @DisplayName("write(\"/File 15x250\", text, NO_PROGRESS_AWARE, replace=false, 10bytes)") + public void testWriteToLongFile() throws BackendException { + String file15Name = "File " + Strings.repeat("15", 250); + String file15Cipher = "file" + Strings.repeat("15", 250); + + byte[] longFilenameBytes = (file15Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder testFile3Folder = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testFile3WhatTheHellCLoudFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + TestFile testFile15ContentFile = new TestFile(testFile3Folder, "contents.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/contents.c9r", Optional.of(10l), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file15Name, dirIdRoot.getBytes())).thenReturn(file15Cipher); + + CryptoFile cryptoFile15 = new CryptoFile(root, file15Name, "/" + file15Name, Optional.of(15l), testFile3WhatTheHellCLoudFile); + + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testFile3Folder); + Mockito.when(cloudContentRepository.file(testFile3Folder, "contents.c9r", Optional.of(10l))).thenReturn(testFile15ContentFile); + + Mockito.when(cloudContentRepository.exists(testFile3Folder)).thenReturn(true); + + FileHeader header = Mockito.mock(FileHeader.class); + Mockito.when(fileHeaderCryptor.create()).thenReturn(header); + Mockito.when(fileHeaderCryptor.encryptHeader(header)).thenReturn(ByteBuffer.wrap("hhhhh".getBytes())); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.encryptChunk(Mockito.any(ByteBuffer.class), Mockito.anyLong(), Mockito.any(FileHeader.class))).thenAnswer(invocation -> { + ByteBuffer input = invocation.getArgument(0); + String inStr = UTF_8.decode(input).toString(); + return ByteBuffer.wrap(inStr.toLowerCase().getBytes(UTF_8)); + }); + + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile15ContentFile), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is("hhhhhtopsecret!")); + return invocationOnMock.getArgument(0); + }); + + CryptoFile cryptoFile = inTest.write(cryptoFile15, ByteArrayDataSource.from("TOPSECRET!".getBytes(UTF_8)), ProgressAware.NO_OP_PROGRESS_AWARE, false, 10l); + assertThat(cryptoFile, is(cryptoFile15)); + + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile15ContentFile), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + } + + @Test + @DisplayName("write(\"/File 15x250\", text, NO_PROGRESS_AWARE, replace=false, 10bytes)") + public void testWriteToLongFileUsingAutoRename() throws BackendException { + String file15Name = "File " + Strings.repeat("15", 250); + String file15Cipher = "file" + Strings.repeat("15", 250); + + byte[] longFilenameBytes = (file15Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder testFile15Folder = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testFile15WhatTheHellCLoudFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + TestFile testFile15ContentFile = new TestFile(testFile15Folder, "contents.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/contents.c9r", Optional.of(10l), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file15Name, dirIdRoot.getBytes())).thenReturn(file15Cipher); + + String file15CipherRename = file15Cipher + "(1)"; + byte[] hashRename = MessageDigestSupplier.SHA1.get().digest((file15CipherRename + ".c9r").getBytes(Encodings.UTF_8)); + String shortenedFileNameRename = BaseEncoding.base64Url().encode(hashRename) + ".c9s"; + + TestFolder testFile15FolderRename = new TestFolder(aaFolder, shortenedFileNameRename, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileNameRename); + TestFile testFile15WhatTheHellCloudFileRename = new TestFile(aaFolder, shortenedFileNameRename, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileNameRename, Optional.of(20l), Optional.empty()); + + TestFile testFile15ContentFileRename = new TestFile(testFile15FolderRename, "contents.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileNameRename + "/contents.c9r", Optional.of(10l), + Optional.empty()); + TestFile testFile15NameFileRename = new TestFile(testFile15FolderRename, "name.c9s", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileNameRename + "/name.c9s", Optional.of(511l), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file15Name + " (1)", dirIdRoot.getBytes())).thenReturn(file15Cipher + "(1)"); + + CryptoFile cryptoFile15 = new CryptoFile(root, file15Name, "/" + file15Name, Optional.of(15l), testFile15WhatTheHellCLoudFile); + + Mockito.when(cloudContentRepository.file(testFile15Folder, "contents.c9r", Optional.of(10l))).thenReturn(testFile15ContentFile); + Mockito.when(cloudContentRepository.exists(testFile15ContentFile)).thenReturn(true); + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testFile15Folder); + Mockito.when(cloudContentRepository.exists(testFile15Folder)).thenReturn(true); + Mockito.when(cloudContentRepository.file(testFile15Folder, "contents.c9r", Optional.of(10l))).thenReturn(testFile15ContentFile); + Mockito.when(cloudContentRepository.file(testFile15FolderRename, "contents.c9r", Optional.of(15l))).thenReturn(testFile15ContentFileRename); + + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileNameRename)).thenReturn(testFile15FolderRename); + Mockito.when(cloudContentRepository.exists(testFile15FolderRename)).thenReturn(false); + Mockito.when(cloudContentRepository.create(testFile15FolderRename)).thenReturn(testFile15FolderRename); + Mockito.when(cloudContentRepository.file(testFile15FolderRename, "name.c9s", Optional.of(511l))).thenReturn(testFile15NameFileRename); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile15NameFileRename), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is(file15CipherRename + ".c9r")); + return invocationOnMock.getArgument(0); + }); + Mockito.when(cloudContentRepository.file(aaFolder, shortenedFileNameRename, Optional.of(20l))).thenReturn(testFile15WhatTheHellCloudFileRename); + + FileHeader header = Mockito.mock(FileHeader.class); + Mockito.when(fileHeaderCryptor.create()).thenReturn(header); + Mockito.when(fileHeaderCryptor.encryptHeader(header)).thenReturn(ByteBuffer.wrap("hhhhh".getBytes())); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.encryptChunk(Mockito.any(ByteBuffer.class), Mockito.anyLong(), Mockito.any(FileHeader.class))).thenAnswer(invocation -> { + ByteBuffer input = invocation.getArgument(0); + String inStr = UTF_8.decode(input).toString(); + return ByteBuffer.wrap(inStr.toLowerCase().getBytes(UTF_8)); + }); + + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile15ContentFileRename), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is("hhhhhtopsecret!")); + return invocationOnMock.getArgument(0); + }); + + CryptoFile cryptoFile = inTest.write(cryptoFile15, ByteArrayDataSource.from("TOPSECRET!".getBytes(UTF_8)), ProgressAware.NO_OP_PROGRESS_AWARE, false, 10l); + assertThat(cryptoFile, is(cryptoFile15)); + + Mockito.verify(cloudContentRepository).create(testFile15FolderRename); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile15NameFileRename), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile15ContentFileRename), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + } + + @Test + @DisplayName("create(\"/Directory 3/\")") + public void testCreateShortFolder() throws BackendException { + /* + * + * path/to/vault/d + * ├─ Directory 1 + * │ ├─ ... + * ├─ Directory 3 + * ├─ ... + * + */ + + lvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + TestFolder testDir3 = new TestFolder(aaFolder, "dir3.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir3.c9r"); + TestFile testDir3DirFile = new TestFile(testDir3, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir3.c9r/dir.c9r", Optional.empty(), Optional.empty()); + CryptoFolder cryptoFolder3 = new CryptoFolder(root, "Directory 3", "/Directory 3", testDir3DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 3", dirIdRoot.getBytes())).thenReturn("dir3"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir3", dirIdRoot.getBytes())).thenReturn("Directory 3"); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + Mockito.when(cloudContentRepository.create(lvl2Dir)).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.create(ddFolder)).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.create(testDir3)).thenReturn(testDir3); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenReturn(testDir3DirFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir3.c9r")).thenReturn(null); + + CloudFolder cloudFolder = inTest.create(cryptoFolder3); + assertThat(cloudFolder, is(cryptoFolder3)); + + Mockito.verify(cloudContentRepository).create(ddFolder); + Mockito.verify(cloudContentRepository).create(testDir3); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + } + + @Test + @DisplayName("create(\"/Directory 3x250/\")") + public void testCreateLongFolder() throws BackendException { + /* + * + * path/to/vault/d + * ├─ Directory 1 + * │ ├─ ... + * ├─ Directory 3x250 + * ├─ ... + * + */ + String dir3Name = "Directory " + Strings.repeat("3", 250); + String dir3Cipher = "dir" + Strings.repeat("3", 250); + byte[] longFilenameBytes = (dir3Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder ddLvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + TestFolder testDir3 = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testDir3DirFile = new TestFile(testDir3, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir3NameFile = new TestFile(testDir3, "name.c9s", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/name.c9s", Optional.of(257L), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir3Name, dirIdRoot.getBytes())).thenReturn(dir3Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir3Cipher, dirIdRoot.getBytes())).thenReturn(dir3Name); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testDir3); + Mockito.when(cloudContentRepository.exists(testDir3)).thenReturn(false); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(cloudContentRepository.file(testDir3, "name.c9s", Optional.of(257L))).thenReturn(testDir3NameFile); + + CryptoFolder cryptoFolder3 = new CryptoFolder(root, dir3Name, "/" + dir3Name, testDir3DirFile); + + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + Mockito.when(cloudContentRepository.create(ddLvl2Dir)).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.create(ddFolder)).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.create(testDir3)).thenReturn(testDir3); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is("dir3-id")); + return testDir3DirFile; + }); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String nameContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(nameContent, is(dir3Cipher + ".c9r")); + return testDir3NameFile; + }); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir3.c9r")).thenReturn(null); + + CloudFolder cloudFolder = inTest.folder(root, dir3Name); + cloudFolder = inTest.create(cryptoFolder3); + assertThat(cloudFolder, is(cryptoFolder3)); + + Mockito.verify(cloudContentRepository).create(ddFolder); + Mockito.verify(cloudContentRepository).create(testDir3); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir3NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + } + + @Test + @DisplayName("delete(\"/File 4\")") + public void testDeleteShortFile() throws BackendException { + inTest.delete(cryptoFile4); + Mockito.verify(cloudContentRepository).delete(cryptoFile4.getCloudFile()); + } + + @Test + @DisplayName("delete(\"/File 15x250\")") + public void testDeleteLongFile() throws BackendException { + String file15Name = "File " + Strings.repeat("15", 250); + String file15Cipher = "file" + Strings.repeat("15", 250); + + byte[] longFilenameBytes = (file15Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder testFile3Folder = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testFile3ContentFile = new TestFile(testFile3Folder, "content.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/content.c9r", Optional.empty(), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file15Name, dirIdRoot.getBytes())).thenReturn(file15Cipher); + + CryptoFile cryptoFile15 = new CryptoFile(root, file15Name, "/" + file15Name, Optional.of(15l), testFile3ContentFile); + + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testFile3Folder); + + inTest.delete(cryptoFile15); + + Mockito.verify(cloudContentRepository).delete(testFile3Folder); + } + + @Test + @DisplayName("delete(\"/Directory 1/Directory 2/\")") + public void testDeleteSingleShortFolder() throws BackendException { + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + TestFolder ccLvl2Dir = new TestFolder(d, "22", "/d/22"); + TestFolder ccFolder = new TestFolder(ccLvl2Dir, "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "/d/22/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 1", dirIdRoot.getBytes())).thenReturn("dir1"); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 2", dirId1.getBytes())).thenReturn("dir2"); + + TestFolder testDir2 = new TestFolder(bbFolder, "dir2.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/dir2.c9r"); + TestFile testDir2DirFile = new TestFile(testDir2, "dir.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/dir2.c9r/dir.c9r", Optional.empty(), Optional.empty()); + + CryptoFolder cryptoFolder2 = new CryptoFolder(cryptoFolder1, "Directory 2", "/Directory 1/Directory 2", testDir2DirFile); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(dirId2.getBytes()), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFolder2.getDirFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + ArrayList dir1Items = new ArrayList() { + { + add(testDir2); + } + }; + + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "22")).thenReturn(ccLvl2Dir); + Mockito.when(cloudContentRepository.folder(ccLvl2Dir, "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC")).thenReturn(ccFolder); + Mockito.when(cloudContentRepository.file(testDir2, "dir.c9r")).thenReturn(testDir2DirFile); + Mockito.when(cloudContentRepository.list(bbFolder)).thenReturn(dir1Items); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder2), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId2, ccFolder)); + Mockito.when(cloudContentRepository.exists(testDir2DirFile)).thenReturn(true); + Mockito.when(cloudContentRepository.list(ccFolder)).thenReturn(new ArrayList()); + + inTest.delete(cryptoFolder2); + + Mockito.verify(cloudContentRepository).delete(ccFolder); + Mockito.verify(cloudContentRepository).delete(testDir2); + Mockito.verify(dirIdCache).evict(cryptoFolder2); + } + + @Test + @DisplayName("delete(\"/Directory 3x250\")") + public void testDeleteSingleLongFolder() throws BackendException { + String dir3Name = "Directory " + Strings.repeat("3", 250); + String dir3Cipher = "dir" + Strings.repeat("3", 250); + byte[] longFilenameBytes = (dir3Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder ddLvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + TestFolder testDir3 = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testDir3DirFile = new TestFile(testDir3, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir3NameFile = new TestFile(testDir3, "name.c9s", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/name.c9s", Optional.of(257L), Optional.empty()); + + CryptoFolder cryptoFolder3 = new CryptoFolder(root, dir3Name, "/" + dir3Name, testDir3DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir3Name, dirIdRoot.getBytes())).thenReturn(dir3Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir3Cipher, dirIdRoot.getBytes())).thenReturn(dir3Name); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testDir3); + Mockito.when(cloudContentRepository.exists(testDir3)).thenReturn(false); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(cloudContentRepository.file(testDir3, "name.c9s", Optional.of(257L))).thenReturn(testDir3NameFile); + Mockito.when(cloudContentRepository.list(ddFolder)).thenReturn(new ArrayList()); + + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + inTest.delete(cryptoFolder3); + + Mockito.verify(cloudContentRepository).delete(ddFolder); + Mockito.verify(cloudContentRepository).delete(testDir3); + Mockito.verify(dirIdCache).evict(cryptoFolder3); + } + + @Test + @DisplayName("move(\"/File 4\", \"/Directory 1/File 4\")") + public void testMoveShortFileToNewShortFile() throws BackendException { + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4 = new TestFile(aaFolder, "file4.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4.c9r", Optional.empty(), Optional.empty()); + TestFile testMovedFile4 = new TestFile(bbFolder, "file4.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/file4.c9r", Optional.empty(), Optional.empty()); + CryptoFile cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, "File 4", "/Directory 1/File 4", Optional.empty(), testMovedFile4); + + Mockito.when(cloudContentRepository.file(aaFolder, "file4.c9r")).thenReturn(testFile4); + Mockito.when(cloudContentRepository.file(bbFolder, "file4.c9r")).thenReturn(testMovedFile4); + Mockito.when(cloudContentRepository.move(testFile4, testMovedFile4)).thenReturn(testMovedFile4); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(cloudContentRepository.folder(bbFolder, "file4.c9r")).thenReturn(null); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "File 4", dirId1.getBytes())).thenReturn("file4"); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "File 4", dirIdRoot.getBytes())).thenReturn("file4"); + + CryptoFile result = inTest.move(cryptoFile4, cryptoMovedFile4); + + Assertions.assertEquals("File 4", result.getName()); + + Mockito.verify(cloudContentRepository).move(testFile4, testMovedFile4); + } + + @Test + @DisplayName("move(\"/File 4\", \"/Directory 1/File 4x250\")") + public void testMoveShortFileToNewLongFile() throws BackendException { + String file4Name = "File " + Strings.repeat("4", 250); + String file4Cipher = "file" + Strings.repeat("4", 250); + byte[] longFilenameBytes = (file4Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4 = new TestFile(aaFolder, "file4.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4.c9r", Optional.empty(), Optional.empty()); + CryptoFile cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + + TestFolder testDir4 = new TestFolder(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName); + TestFile testFile4ContentFile = new TestFile(testDir4, "contents.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/contents.c9r", Optional.empty(), Optional.empty()); + TestFile testFile4NameFile = new TestFile(testDir4, "name.c9s", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/name.c9s", Optional.of(258L), Optional.empty()); + + TestFile testFile4WhatTheHellCLoudFile = new TestFile(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName, Optional.empty(), Optional.empty()); // ugly hack + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, file4Name, "/Directory 1/" + file4Name, Optional.empty(), testFile4WhatTheHellCLoudFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "file4.c9r")).thenReturn(testFile4); + Mockito.when(cloudContentRepository.file(testDir4, "contents.c9r")).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.file(testDir4, "name.c9s")).thenReturn(testFile4NameFile); + Mockito.when(cloudContentRepository.file(testDir4, "name.c9s", Optional.of(258L))).thenReturn(testFile4NameFile); + Mockito.when(cloudContentRepository.file(bbFolder, shortenedFileName, Optional.ofNullable(null))).thenReturn(testFile4WhatTheHellCLoudFile); + Mockito.when(cloudContentRepository.move(testFile4, testFile4ContentFile)).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(cloudContentRepository.folder(bbFolder, "file4.c9r")).thenReturn(null); + Mockito.when(cloudContentRepository.folder(bbFolder, shortenedFileName)).thenReturn(testDir4); + Mockito.when(cloudContentRepository.create(testDir4)).thenReturn(testDir4); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(file4Cipher + ".c9r")); + return testFile4NameFile; + }); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "File 4", dirIdRoot.getBytes())).thenReturn("file4"); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file4Name, dirId1.getBytes())).thenReturn(file4Cipher); + + CloudFile targetFile = inTest.file(cryptoFolder1, file4Name); // needed due to ugly side effect + CryptoFile result = inTest.move(cryptoFile4, cryptoMovedFile4); + + Assertions.assertEquals(file4Name, result.getName()); + + Mockito.verify(cloudContentRepository).create(testDir4); + Mockito.verify(cloudContentRepository).move(testFile4, testFile4ContentFile); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + } + + @Test + @DisplayName("move(\"/File 4x250\", \"/Directory 1/File 4x250\")") + public void testMoveLongFileToNewLongFile() throws BackendException { + String file4Name = "File " + Strings.repeat("4", 250); + String file4Cipher = "file" + Strings.repeat("4", 250); + byte[] longFilenameBytes = (file4Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFolder testDir4Old = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testFile4ContentFileOld = new TestFile(testDir4Old, "contents.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/contents.c9r", Optional.empty(), Optional.empty()); + + CryptoFile cryptoFile4Old = new CryptoFile(root, file4Name, "/" + file4Name, Optional.empty(), testFile4ContentFileOld); + + TestFolder testDir4 = new TestFolder(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName); + TestFile testFile4ContentFile = new TestFile(testDir4, "contents.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/contents.c9r", Optional.empty(), Optional.empty()); + TestFile testFile4NameFile = new TestFile(testDir4, "name.c9s", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/name.c9s", Optional.of(258L), Optional.empty()); + + TestFile testFile4WhatTheHellCLoudFile = new TestFile(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName, Optional.empty(), Optional.empty()); // ugly hack + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, file4Name, "/Directory 1/" + file4Name, Optional.empty(), testFile4WhatTheHellCLoudFile); + + Mockito.when(cloudContentRepository.file(testDir4, "contents.c9r")).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.file(testDir4, "name.c9s")).thenReturn(testFile4NameFile); + Mockito.when(cloudContentRepository.file(testDir4, "name.c9s", Optional.of(258L))).thenReturn(testFile4NameFile); + Mockito.when(cloudContentRepository.file(bbFolder, shortenedFileName, Optional.ofNullable(null))).thenReturn(testFile4WhatTheHellCLoudFile); + Mockito.when(cloudContentRepository.file(testDir4Old, "contents.c9r")).thenReturn(testFile4ContentFileOld); + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testDir4Old); + Mockito.when(cloudContentRepository.move(testFile4ContentFileOld, testFile4ContentFile)).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(cloudContentRepository.folder(bbFolder, "file4.c9r")).thenReturn(null); + Mockito.when(cloudContentRepository.folder(bbFolder, shortenedFileName)).thenReturn(testDir4); + Mockito.when(cloudContentRepository.create(testDir4)).thenReturn(testDir4); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(file4Cipher + ".c9r")); + return testFile4NameFile; + }); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file4Name, dirIdRoot.getBytes())).thenReturn(file4Cipher); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file4Name, dirId1.getBytes())).thenReturn(file4Cipher); + + CloudFile targetFile = inTest.file(cryptoFolder1, file4Name); // needed due to ugly side effect + CryptoFile result = inTest.move(cryptoFile4Old, cryptoMovedFile4); + + Assertions.assertEquals(file4Name, result.getName()); + + Mockito.verify(cloudContentRepository).create(testDir4); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).move(testFile4ContentFileOld, testFile4ContentFile); + Mockito.verify(cloudContentRepository).delete(testDir4Old); + } + + @Test + @DisplayName("move(\"/Directory 1/File 4x250\", \"/File 4\")") + public void testMoveLongFileToNewShortFile() throws BackendException { + String file4Name = "File " + Strings.repeat("4", 250); + String file4Cipher = "file" + Strings.repeat("4", 250); + byte[] longFilenameBytes = (file4Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4 = new TestFile(aaFolder, "file4.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4.c9r", Optional.empty(), Optional.empty()); + CryptoFile cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + + TestFolder testDir4 = new TestFolder(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName); + TestFile testFile4ContentFile = new TestFile(testDir4, "contents.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/contents.c9r", Optional.empty(), Optional.empty()); + TestFile testFile4NameFile = new TestFile(testDir4, "name.c9s", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/name.c9s", Optional.of(258L), Optional.empty()); + + TestFile testFile4WhatTheHellCLoudFile = new TestFile(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName, Optional.empty(), Optional.empty()); // ugly hack + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, file4Name, "/Directory 1/" + file4Name, Optional.empty(), testFile4ContentFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "file4.c9r")).thenReturn(testFile4); + Mockito.when(cloudContentRepository.file(testDir4, "contents.c9r")).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.file(testDir4, "name.c9s")).thenReturn(testFile4NameFile); + Mockito.when(cloudContentRepository.file(testDir4, "name.c9s", Optional.of(258L))).thenReturn(testFile4NameFile); + Mockito.when(cloudContentRepository.file(bbFolder, shortenedFileName, Optional.ofNullable(null))).thenReturn(testFile4WhatTheHellCLoudFile); // bad + Mockito.when(cloudContentRepository.move(testFile4, testFile4ContentFile)).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(cloudContentRepository.folder(bbFolder, "file4.c9r")).thenReturn(null); + Mockito.when(cloudContentRepository.folder(bbFolder, shortenedFileName)).thenReturn(testDir4); + Mockito.when(cloudContentRepository.create(testDir4)).thenReturn(testDir4); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(file4Cipher + ".c9r")); + return testFile4NameFile; + }); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "File 4", dirIdRoot.getBytes())).thenReturn("file4"); + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), file4Name, dirId1.getBytes())).thenReturn(file4Cipher); + + CryptoFile result = inTest.move(cryptoMovedFile4, cryptoFile4); + + Mockito.verify(cloudContentRepository).delete(testDir4); + Mockito.verify(cloudContentRepository).move(testFile4ContentFile, testFile4); + } + + @Test + @DisplayName("move(\"/Directory 1\", \"/Directory 15\")") + public void testMoveShortFolderToNewShortFolder() throws BackendException { + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFolder testDir15 = new TestFolder(aaFolder, "dir15.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir15.c9r"); + TestFile testDir15DirFile = new TestFile(testDir15, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir15.c9r/dir.c9r", Optional.empty(), Optional.empty()); + + CryptoFolder cryptoFolder15 = new CryptoFolder(root, "Directory 15", "/Directory 15/", testDir15DirFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir15.c9r", Optional.ofNullable(null))) + .thenReturn(new TestFile(aaFolder, "dir15.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir15.c9r", Optional.empty(), Optional.empty())); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder15), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(cloudContentRepository.create(testDir15)).thenReturn(testDir15); + Mockito.when(cloudContentRepository.file(testDir15, "dir.c9r")).thenReturn(testDir15DirFile); + Mockito.when(cloudContentRepository.move(cryptoFolder1.getDirFile(), testDir15DirFile)).thenReturn(testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 15", dirIdRoot.getBytes())).thenReturn(dirId1); + + CryptoFolder result = inTest.move(cryptoFolder1, cryptoFolder15); + + Mockito.verify(cloudContentRepository).create(testDir15); + Mockito.verify(cloudContentRepository).move(cryptoFolder1.getDirFile(), testDir15DirFile); + Mockito.verify(cloudContentRepository).delete(cryptoFolder1.getDirFile().getParent()); + } + + @Test + @DisplayName("move(\"/Directory 1\", \"/Directory 15x200\")") + public void testMoveShortFolderToNewLongFolder() throws BackendException { + String dir15Name = "Dir " + Strings.repeat("15", 250); + String dir15Cipher = "dir" + Strings.repeat("15", 250); + byte[] longFilenameBytes = (dir15Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFolder testDir15 = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testDir15DirFile = new TestFile(testDir15, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir15NameFile = new TestFile(testDir15, "name.c9s", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/name.c9s", Optional.of(507L), Optional.empty()); + + CryptoFolder cryptoFolder15 = new CryptoFolder(root, dir15Name, "/" + dir15Name, testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir15Name, dirId1.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir15.c9r", Optional.ofNullable(null))) + .thenReturn(new TestFile(aaFolder, "dir15.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir15.c9r", Optional.empty(), Optional.empty())); + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testDir15); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder15), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(cloudContentRepository.create(testDir15)).thenReturn(testDir15); + Mockito.when(cloudContentRepository.file(testDir15, "dir.c9r")).thenReturn(testDir15DirFile); + Mockito.when(cloudContentRepository.file(testDir15, "name.c9s", Optional.of(507L))).thenReturn(testDir15NameFile); + Mockito.when(cloudContentRepository.move(cryptoFolder1.getDirFile(), testDir15DirFile)).thenReturn(testDir15DirFile); + Mockito.when(cloudContentRepository.create(testDir15)).thenReturn(testDir15); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir15Name, dirIdRoot.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir15NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(dir15Cipher + ".c9r")); + return testDir15NameFile; + }); + + CryptoFolder targetFile = inTest.folder(root, dir15Name); // needed due to ugly side effect + CryptoFolder result = inTest.move(cryptoFolder1, cryptoFolder15); + + Mockito.verify(cloudContentRepository).create(testDir15); + Mockito.verify(cloudContentRepository).move(cryptoFolder1.getDirFile(), testDir15DirFile); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir15NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).delete(cryptoFolder1.getDirFile().getParent()); + } + + @Test + @DisplayName("move(\"/Directory 15x200\", \"/Directory 3000\")") + public void testMoveLongFolderToNewShortFolder() throws BackendException { + String dir15Name = "Dir " + Strings.repeat("15", 250); + String dir15Cipher = "dir" + Strings.repeat("15", 250); + byte[] longFilenameBytes = (dir15Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFolder testDir15 = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testDir15DirFile = new TestFile(testDir15, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir15NameFile = new TestFile(testDir15, "name.c9s", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/name.c9s", Optional.of(507L), Optional.empty()); + + CryptoFolder cryptoFolder15 = new CryptoFolder(root, dir15Name, "/" + dir15Name, testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir15Name, dirId1.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir15.c9r", Optional.ofNullable(null))) + .thenReturn(new TestFile(aaFolder, "dir15.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir15.c9r", Optional.empty(), Optional.empty())); + Mockito.when(cloudContentRepository.folder(aaFolder, shortenedFileName)).thenReturn(testDir15); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder15), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(cloudContentRepository.create(testDir15)).thenReturn(testDir15); + Mockito.when(cloudContentRepository.file(testDir15, "dir.c9r")).thenReturn(testDir15DirFile); + Mockito.when(cloudContentRepository.file(testDir15, "name.c9s", Optional.of(507L))).thenReturn(testDir15NameFile); + Mockito.when(cloudContentRepository.move(cryptoFolder1.getDirFile(), testDir15DirFile)).thenReturn(testDir15DirFile); + Mockito.when(cloudContentRepository.create(testDir15)).thenReturn(testDir15); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), dir15Name, dirIdRoot.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir15NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(dir15Cipher + ".c9r")); + return testDir15NameFile; + }); + + lvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + TestFolder testDir3 = new TestFolder(aaFolder, "dir3.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir3.c9r"); + TestFile testDir3DirFile = new TestFile(testDir3, "dir.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir3.c9r/dir.c9r", Optional.empty(), Optional.empty()); + CryptoFolder cryptoFolder3 = new CryptoFolder(root, "Directory 3", "/Directory 3", testDir3DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), "Directory 3", dirIdRoot.getBytes())).thenReturn("dir3"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir3", dirIdRoot.getBytes())).thenReturn("Directory 3"); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.folder(aaFolder, "dir3.c9r")).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + Mockito.when(cloudContentRepository.create(lvl2Dir)).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.create(ddFolder)).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.create(testDir3)).thenReturn(testDir3); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenReturn(testDir3DirFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir3.c9r")).thenReturn(null); + + CryptoFolder targetFile = inTest.folder(root, cryptoFolder3.getName()); // needed due to ugly side effect + CryptoFolder result = inTest.move(cryptoFolder15, cryptoFolder3); + + Mockito.verify(cloudContentRepository).create(testDir3); + Mockito.verify(cloudContentRepository).move(testDir15DirFile, cryptoFolder3.getDirFile()); + Mockito.verify(cloudContentRepository).delete(testDir15); + } + +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7Test.java b/data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7Test.java new file mode 100644 index 000000000..71466776f --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/crypto/CryptoImplVaultFormatPre7Test.java @@ -0,0 +1,937 @@ +package org.cryptomator.data.cloud.crypto; + +import android.content.Context; + +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; + +import org.apache.commons.codec.binary.Base32; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileContentCryptor; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.api.FileHeaderCryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; +import org.cryptomator.cryptolib.common.MessageDigestSupplier; +import org.cryptomator.data.util.CopyStream; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.ByteArrayDataSource; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.util.Encodings; +import org.cryptomator.util.Optional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalMatchers; +import org.mockito.Answers; +import org.mockito.Mockito; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * + * path/to/vault/d/00 + * ├─ Directory 1 + * │ ├─ Directory 2 + * │ ├─ Directory 3x250 + * │ │ ├─ Directory 4x250 + * │ │ └─ File 5x250 + * │ └─ File 3 + * ├─ File 1 + * ├─ File 2 + * ├─ File 4 + * + */ +class CryptoImplVaultFormatPre7Test { + + private final String dirIdRoot = ""; + private final String dirId1 = "dir1-id"; + private final String dirId2 = "dir2-id"; + + private Cloud cloud; + private CryptoCloud cryptoCloud; + private Context context; + private Cryptor cryptor; + private CloudContentRepository cloudContentRepository; + private DirIdCache dirIdCache; + private FileNameCryptor fileNameCryptor; + private FileContentCryptor fileContentCryptor; + private FileHeaderCryptor fileHeaderCryptor; + + private CryptoImplVaultFormatPre7 inTest; + + private TestFolder rootFolder = new RootTestFolder(cloud); + private TestFolder d = new TestFolder(rootFolder, "d", "/d"); + private TestFolder m = new TestFolder(rootFolder, "m", "/m"); + private TestFolder lvl2Dir = new TestFolder(d, "00", "/d/00"); + private TestFolder aaFolder = new TestFolder(lvl2Dir, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + private RootCryptoFolder root; + private CryptoFile cryptoFile1; + private CryptoFile cryptoFile2; + private CryptoFile cryptoFile4; + private CryptoFolder cryptoFolder1; + + @BeforeEach + public void setup() throws BackendException { + cloud = Mockito.mock(Cloud.class); + cryptoCloud = Mockito.mock(CryptoCloud.class); + context = Mockito.mock(Context.class); + cryptor = Mockito.mock(Cryptor.class); + cloudContentRepository = Mockito.mock(CloudContentRepository.class, Answers.RETURNS_DEEP_STUBS); + dirIdCache = Mockito.mock(DirIdCache.class); + fileNameCryptor = Mockito.mock(FileNameCryptor.class); + fileContentCryptor = Mockito.mock(FileContentCryptor.class); + fileHeaderCryptor = Mockito.mock(FileHeaderCryptor.class); + + Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + Mockito.when(cryptor.fileNameCryptor()).thenReturn(fileNameCryptor); + Mockito.when(cryptor.fileContentCryptor()).thenReturn(fileContentCryptor); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(fileHeaderCryptor); + + root = new RootCryptoFolder(cryptoCloud); + inTest = new CryptoImplVaultFormatPre7(context, () -> cryptor, cloudContentRepository, rootFolder, dirIdCache); + + Mockito.when(fileNameCryptor.hashDirectoryId(dirIdRoot)).thenReturn("00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + Mockito.when(fileNameCryptor.hashDirectoryId(dirId1)).thenReturn("11BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + Mockito.when(fileNameCryptor.hashDirectoryId(dirId2)).thenReturn("22CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir1", dirIdRoot.getBytes())).thenReturn("Directory 1"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file1", dirIdRoot.getBytes())).thenReturn("File 1"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file2", dirIdRoot.getBytes())).thenReturn("File 2"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir2", dirId1.getBytes())).thenReturn("Directory 2"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file3", dirId1.getBytes())).thenReturn("File 3"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "file4", dirIdRoot.getBytes())).thenReturn("File 4"); + + TestFile testFile1 = new TestFile(aaFolder, "file1.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file1.c9r", Optional.empty(), Optional.empty()); + TestFile testFile2 = new TestFile(aaFolder, "file2.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file2.c9r", Optional.empty(), Optional.empty()); + TestFile testFile4 = new TestFile(aaFolder, "file4.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4.c9r", Optional.empty(), Optional.empty()); + TestFile testDir1 = new TestFile(aaFolder, "0dir1", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/0dir1", Optional.empty(), Optional.empty()); + + ArrayList rootItems = new ArrayList() { + { + add(testFile1); + add(testFile2); + add(testFile4); + add(testDir1); + } + }; + + cryptoFile1 = new CryptoFile(root, "File 1", "/File 1", Optional.of(15l), testFile1); + cryptoFile2 = new CryptoFile(root, "File 2", "/File 2", Optional.empty(), testFile2); + cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + cryptoFolder1 = new CryptoFolder(root, "Directory 1", "/Directory 1", testDir1); + + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "00")).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")).thenReturn(aaFolder); + Mockito.when(cloudContentRepository.file(aaFolder, "0dir1")).thenReturn(testDir1); + Mockito.when(cloudContentRepository.exists(testDir1)).thenReturn(true); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(dirId1.getBytes()), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFolder1.getDirFile()), Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.when(cloudContentRepository.list(aaFolder)).thenReturn(rootItems); + Mockito.when(dirIdCache.put(Mockito.eq(root), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("", aaFolder)); + } + + @Test + @DisplayName("list(\"/\")") + public void testListRoot() throws BackendException { + List rootDirContent = inTest.list(root); + + Matchers.contains(rootDirContent, cryptoFile1); + Matchers.contains(rootDirContent, cryptoFile2); + Matchers.contains(rootDirContent, cryptoFile4); + Matchers.contains(rootDirContent, cryptoFolder1); + } + + @Test + @DisplayName("list(\"/Directory 1/Directory 3x250\")") + public void testListDirectory3x250() throws BackendException { + String dir3Name = "Directory " + Strings.repeat("3", 250); + String dir3Cipher = "dir" + Strings.repeat("3", 250); + + byte[] longFilenameBytes = (dir3Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + TestFolder ddLvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + TestFolder testDir3 = new TestFolder(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName); + TestFile testDir3DirFile = new TestFile(testDir3, "dir.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir3NameFile = new TestFile(testDir3, "name.c9s", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName + "/name.c9s", Optional.empty(), Optional.empty()); + + Mockito.when(fileNameCryptor.encryptFilename(dir3Name, dirId1.getBytes())).thenReturn(dir3Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir3Cipher, dirId1.getBytes())).thenReturn(dir3Name); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.file(testDir3, "dir.c9r")).thenReturn(testDir3DirFile); + Mockito.when(cloudContentRepository.file(testDir3, "name.c9s")).thenReturn(testDir3NameFile); + + Mockito.when(fileNameCryptor.encryptFilename("Directory 1", dirIdRoot.getBytes())).thenReturn("dir1"); + Mockito.when(fileNameCryptor.encryptFilename("Directory 2", dirId1.getBytes())).thenReturn("dir2"); + + CryptoFolder cryptoFolder3 = new CryptoFolder(cryptoFolder1, dir3Name, "/Directory 1/" + dir3Name, testDir3DirFile); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream("dir3-id".getBytes()), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFolder3.getDirFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + /* + * │ ├─ Directory 3x250 + * │ │ ├─ Directory 4x250 + * │ │ └─ File 5x250 + */ + + String dir4Name = "Directory " + Strings.repeat("4", 250); + String dir4Cipher = "dir" + Strings.repeat("4", 250); + + byte[] longFilenameBytes4 = (dir4Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash4 = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes4); + String shortenedFileName4 = BaseEncoding.base64Url().encode(hash4) + ".c9s"; + + TestFolder directory4x250 = new TestFolder(ddFolder, shortenedFileName4, "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + shortenedFileName4); + TestFile testDir4DirFile = new TestFile(directory4x250, "dir.c9r", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName4 + "/dir.c9r", Optional.empty(), Optional.empty()); + TestFile testDir4NameFile = new TestFile(directory4x250, "name.c9s", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName4 + "/name.c9s", Optional.empty(), Optional.empty()); + + Mockito.when(cloudContentRepository.file(directory4x250, "dir.c9r")).thenReturn(testDir4DirFile); + Mockito.when(cloudContentRepository.file(directory4x250, "name.c9s")).thenReturn(testDir4NameFile); + Mockito.when(fileNameCryptor.encryptFilename(dir4Name, "dir3-id".getBytes())).thenReturn(dir4Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir4Cipher, "dir3-id".getBytes())).thenReturn(dir4Name); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(dir4Cipher.getBytes("UTF-8")), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(testDir4NameFile), Mockito.any(), Mockito.any(), Mockito.any()); + + ArrayList dir4Files = new ArrayList() { + { + add(testDir4DirFile); + add(testDir4NameFile); + } + }; + + String file5Name = "File " + Strings.repeat("5", 250); + String file5Cipher = "file" + Strings.repeat("5", 250); + + byte[] longFilenameBytes5 = (file5Cipher + ".c9r").getBytes(Encodings.UTF_8); + byte[] hash5 = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes5); + String shortenedFileName5 = BaseEncoding.base64Url().encode(hash5) + ".c9s"; + + TestFolder directory5x250 = new TestFolder(ddFolder, shortenedFileName5, "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + shortenedFileName5); + TestFile testFile5ContentFile = new TestFile(directory5x250, "contents.c9r", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName5 + "/contents.c9r", Optional.empty(), Optional.empty()); + TestFile testFile5NameFile = new TestFile(directory5x250, "name.c9s", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD/" + shortenedFileName5 + "/name.c9s", Optional.empty(), Optional.empty()); + + Mockito.when(cloudContentRepository.file(directory5x250, "contents.c9r")).thenReturn(testFile5ContentFile); + Mockito.when(cloudContentRepository.file(directory5x250, "name.c9s")).thenReturn(testFile5NameFile); + Mockito.when(fileNameCryptor.encryptFilename(file5Name, "dir3-id".getBytes())).thenReturn(file5Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), file5Cipher, "dir3-id".getBytes())).thenReturn(file5Name); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(file5Cipher.getBytes("UTF-8")), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(testFile5NameFile), Mockito.any(), Mockito.any(), Mockito.any()); + + ArrayList dir5Files = new ArrayList() { + { + add(testFile5ContentFile); + add(testFile5NameFile); + } + }; + + ArrayList dir3Items = new ArrayList() { + { + add(directory4x250); + add(directory5x250); + } + }; + + Mockito.when(cloudContentRepository.exists(testDir3DirFile)).thenReturn(true); + Mockito.when(cloudContentRepository.list(ddFolder)).thenReturn(dir3Items); + Mockito.when(cloudContentRepository.list(directory4x250)).thenReturn(dir4Files); + Mockito.when(cloudContentRepository.list(directory5x250)).thenReturn(dir5Files); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + List folder3Content = inTest.list(cryptoFolder3); + + Matchers.contains(folder3Content, new CryptoFolder(cryptoFolder3, dir4Name, "/Directory 1/" + dir3Name + "/" + dir4Name, testDir4DirFile)); + Matchers.contains(folder3Content, new CryptoFile(cryptoFolder3, file5Name, "/Directory 1/" + dir3Name + "/" + file5Name, Optional.empty(), testFile5ContentFile)); + } + + @Test + @DisplayName("read(\"/File 1\", NO_PROGRESS_AWARE)") + public void testReadFromShortFile() throws BackendException { + byte[] file1Content = "hhhhhTOPSECRET!TOPSECRET!TOPSECRET!TOPSECRET!".getBytes(); + FileHeader header = Mockito.mock(FileHeader.class); + + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(8); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + + Mockito.when(fileNameCryptor.encryptFilename("File 1", dirIdRoot.getBytes())).thenReturn("file1"); + Mockito.when(fileHeaderCryptor.decryptHeader(UTF_8.encode("hhhhh"))).thenReturn(header); + Mockito.when(fileContentCryptor.decryptChunk(Mockito.eq(UTF_8.encode("TOPSECRET!")), Mockito.anyLong(), Mockito.eq(header), Mockito.anyBoolean())).then(invocation -> UTF_8.encode("geheim!!")); + + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(file1Content), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFile1.getCloudFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1000); + + inTest.read(cryptoFile1, outputStream, ProgressAware.NO_OP_PROGRESS_AWARE); + + assertThat(outputStream.toString(), is("geheim!!geheim!!geheim!!geheim!!")); + } + + @Test + @DisplayName("read(\"/File 15x250\", NO_PROGRESS_AWARE)") + public void testReadFromLongFile() throws BackendException { + String file3Name = "File " + Strings.repeat("15", 250); + + byte[] longFilenameBytes = file3Name.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base64Url().encode(hash) + ".c9s"; + + TestFolder testFile3Folder = new TestFolder(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName); + TestFile testFile3ContentFile = new TestFile(testFile3Folder, "content.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName + "/content.c9r", Optional.empty(), Optional.empty()); + + byte[] file1Content = "hhhhhTOPSECRET!TOPSECRET!TOPSECRET!TOPSECRET!".getBytes(); + FileHeader header = Mockito.mock(FileHeader.class); + + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(8); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + + Mockito.when(fileHeaderCryptor.decryptHeader(UTF_8.encode("hhhhh"))).thenReturn(header); + Mockito.when(fileContentCryptor.decryptChunk(Mockito.eq(UTF_8.encode("TOPSECRET!")), Mockito.anyLong(), Mockito.eq(header), Mockito.anyBoolean())).then(invocation -> UTF_8.encode("geheim!!")); + + CryptoFile cryptoFile15 = new CryptoFile(root, file3Name, "/" + file3Name, Optional.empty(), testFile3ContentFile); + + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(file1Content), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFile15.getCloudFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1000); + + inTest.read(cryptoFile15, outputStream, ProgressAware.NO_OP_PROGRESS_AWARE); + + assertThat(outputStream.toString(), is("geheim!!geheim!!geheim!!geheim!!")); + + } + + @Test + @DisplayName("write(\"/File 1\", text, NO_PROGRESS_AWARE, replace=false, 10bytes)") + public void testWriteToShortFile() throws BackendException { + Mockito.when(fileNameCryptor.encryptFilename("File 1", dirIdRoot.getBytes())).thenReturn("file1"); + + FileHeader header = Mockito.mock(FileHeader.class); + Mockito.when(fileHeaderCryptor.create()).thenReturn(header); + Mockito.when(fileHeaderCryptor.encryptHeader(header)).thenReturn(ByteBuffer.wrap("hhhhh".getBytes())); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.encryptChunk(Mockito.any(ByteBuffer.class), Mockito.anyLong(), Mockito.any(FileHeader.class))).thenAnswer(invocation -> { + ByteBuffer input = invocation.getArgument(0); + String inStr = UTF_8.decode(input).toString(); + return ByteBuffer.wrap(inStr.toLowerCase().getBytes(UTF_8)); + }); + + Mockito.when(cloudContentRepository.write(Mockito.eq(cryptoFile1.getCloudFile()), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is("hhhhhtopsecret!")); + return invocationOnMock.getArgument(0); + }); + + CryptoFile cryptoFile = inTest.write(cryptoFile1, ByteArrayDataSource.from("TOPSECRET!".getBytes(UTF_8)), ProgressAware.NO_OP_PROGRESS_AWARE, false, 10l); + assertThat(cryptoFile, is(cryptoFile1)); + } + + @Test + @DisplayName("write(\"/File 15x250\", text, NO_PROGRESS_AWARE, replace=false, 10bytes)") + public void testWriteToLongFile() throws BackendException { + String file15Name = "File " + Strings.repeat("15", 250); + String file15Cipher = "file" + Strings.repeat("15", 250); + + byte[] longFilenameBytes = file15Cipher.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = new Base32().encodeAsString(hash) + ".lng"; + + CloudFile metaDataDFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + CloudFile metaDataMFile = metadataFile(shortenedFileName); + + CryptoFile cryptoFile15 = new CryptoFile(root, file15Name, "/" + file15Name, Optional.of(15l), metaDataDFile); + + Mockito.when(fileNameCryptor.encryptFilename(file15Name, dirIdRoot.getBytes())).thenReturn(file15Cipher); + + FileHeader header = Mockito.mock(FileHeader.class); + Mockito.when(fileHeaderCryptor.create()).thenReturn(header); + Mockito.when(fileHeaderCryptor.encryptHeader(header)).thenReturn(ByteBuffer.wrap("hhhhh".getBytes())); + Mockito.when(fileHeaderCryptor.headerSize()).thenReturn(5); + Mockito.when(fileContentCryptor.cleartextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.ciphertextChunkSize()).thenReturn(10); + Mockito.when(fileContentCryptor.encryptChunk(Mockito.any(ByteBuffer.class), Mockito.anyLong(), Mockito.any(FileHeader.class))).thenAnswer(invocation -> { + ByteBuffer input = invocation.getArgument(0); + String inStr = UTF_8.decode(input).toString(); + return ByteBuffer.wrap(inStr.toLowerCase().getBytes(UTF_8)); + }); + + Mockito.when(cloudContentRepository.write(Mockito.eq(metaDataDFile), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is("hhhhhtopsecret!")); + return invocationOnMock.getArgument(0); + }); + + Mockito.when(cloudContentRepository.write(Mockito.eq(metaDataMFile), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String encrypted = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(encrypted, is(file15Cipher)); + return invocationOnMock.getArgument(0); + }); + + CryptoFile res = inTest.file(root, file15Name); + CryptoFile cryptoFile = inTest.write(cryptoFile15, ByteArrayDataSource.from("TOPSECRET!".getBytes(UTF_8)), ProgressAware.NO_OP_PROGRESS_AWARE, false, 10l); + assertThat(cryptoFile, is(cryptoFile15)); + + Mockito.verify(cloudContentRepository).write(Mockito.eq(metaDataDFile), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).write(Mockito.eq(metaDataMFile), Mockito.any(DataSource.class), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + } + + @Test + @DisplayName("create(\"/Directory 3/\")") + public void testCreateShortFolder() throws BackendException { + /* + * + * path/to/vault/d + * ├─ Directory 1 + * │ ├─ ... + * ├─ Directory 3 + * ├─ ... + * + */ + + lvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + TestFile testDir3DirFile = new TestFile(aaFolder, "0dir3", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/0dir3", Optional.empty(), Optional.empty()); + CryptoFolder cryptoFolder3 = new CryptoFolder(root, "Directory 3", "/Directory 3", testDir3DirFile); + + Mockito.when(fileNameCryptor.encryptFilename("Directory 3", dirIdRoot.getBytes())).thenReturn("dir3"); + Mockito.when(fileNameCryptor.decryptFilename("dir3", dirIdRoot.getBytes())).thenReturn("Directory 3"); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.file(aaFolder, "0dir3")).thenReturn(testDir3DirFile); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + Mockito.when(cloudContentRepository.create(lvl2Dir)).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.create(ddFolder)).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenReturn(testDir3DirFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir3.c9r")).thenReturn(null); + + CloudFolder cloudFolder = inTest.create(cryptoFolder3); + assertThat(cloudFolder, is(cryptoFolder3)); + + Mockito.verify(cloudContentRepository).create(ddFolder); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + } + + @Test + @DisplayName("create(\"/Directory 3x250/\")") + public void testCreateLongFolder() throws BackendException { + /* + * + * path/to/vault/d + * ├─ Directory 1 + * │ ├─ ... + * ├─ Directory 3x250 + * ├─ ... + * + */ + String dir3Name = "Directory " + Strings.repeat("3", 250); + String dir3Cipher = "dir" + Strings.repeat("3", 250); + byte[] longFilenameBytes = ("0" + dir3Cipher).getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = new Base32().encodeAsString(hash) + ".lng"; + + TestFolder ddLvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + TestFile testDir3DirFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + + CloudFile testDir3NameFile = metadataFile(shortenedFileName); + + Mockito.when(fileNameCryptor.encryptFilename(dir3Name, dirIdRoot.getBytes())).thenReturn(dir3Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir3Cipher, dirIdRoot.getBytes())).thenReturn(dir3Name); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + + CryptoFolder cryptoFolder3 = new CryptoFolder(root, dir3Name, "/" + dir3Name, testDir3DirFile); + + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + + Mockito.when(cloudContentRepository.create(ddLvl2Dir)).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.create(ddFolder)).thenReturn(ddFolder); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is("dir3-id")); + return testDir3DirFile; + }); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String nameContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(nameContent, is("0" + dir3Cipher)); + return testDir3NameFile; + }); + + CloudFolder cloudFolder = inTest.folder(root, dir3Name); + cloudFolder = inTest.create(cryptoFolder3); + + assertThat(cloudFolder, is(cryptoFolder3)); + + Mockito.verify(cloudContentRepository).create(ddFolder); + Mockito.verify(cloudContentRepository).create(testDir3NameFile.getParent()); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir3NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + } + + @Test + @DisplayName("delete(\"/File 4\")") + public void testDeleteShortFile() throws BackendException { + inTest.delete(cryptoFile4); + Mockito.verify(cloudContentRepository).delete(cryptoFile4.getCloudFile()); + } + + @Test + @DisplayName("delete(\"/File 15x250\")") + public void testDeleteLongFile() throws BackendException { + String file15Name = "File " + Strings.repeat("15", 250); + String file15Cipher = "file" + Strings.repeat("15", 250); + + byte[] longFilenameBytes = file15Name.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = new Base32().encodeAsString(hash) + ".lng"; + + CloudFile metaDataFile = metadataFile(shortenedFileName); + + Mockito.when(fileNameCryptor.encryptFilename(file15Name, dirIdRoot.getBytes())).thenReturn(file15Cipher); + + CryptoFile cryptoFile15 = new CryptoFile(root, file15Name, "/" + file15Name, Optional.of(15l), metaDataFile); + + inTest.delete(cryptoFile15); + + Mockito.verify(cloudContentRepository).delete(metaDataFile); + } + + @Test + @DisplayName("delete(\"/Directory 1/Directory 2/\")") + public void testDeleteSingleShortFolder() throws BackendException { + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + TestFolder ccLvl2Dir = new TestFolder(d, "22", "/d/22"); + TestFolder ccFolder = new TestFolder(ccLvl2Dir, "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "/d/22/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); + + Mockito.when(fileNameCryptor.encryptFilename("Directory 1", dirIdRoot.getBytes())).thenReturn("dir1"); + Mockito.when(fileNameCryptor.encryptFilename("Directory 2", dirId1.getBytes())).thenReturn("dir2"); + + TestFile testDir2DirFile = new TestFile(bbFolder, "0dir2", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/0dir2", Optional.empty(), Optional.empty()); + + CryptoFolder cryptoFolder2 = new CryptoFolder(cryptoFolder1, "Directory 2", "/Directory 1/Directory 2", testDir2DirFile); + Mockito.doAnswer(invocation -> { + OutputStream out = invocation.getArgument(2); + CopyStream.copyStreamToStream(new ByteArrayInputStream(dirId2.getBytes()), out); + return null; + }).when(cloudContentRepository).read(Mockito.eq(cryptoFolder2.getDirFile()), Mockito.any(), Mockito.any(), Mockito.any()); + + ArrayList dir1Items = new ArrayList() { + { + add(testDir2DirFile); + } + }; + + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "22")).thenReturn(ccLvl2Dir); + Mockito.when(cloudContentRepository.folder(ccLvl2Dir, "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC")).thenReturn(ccFolder); + Mockito.when(cloudContentRepository.file(aaFolder, "0dir2")).thenReturn(testDir2DirFile); + Mockito.when(cloudContentRepository.list(bbFolder)).thenReturn(dir1Items); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder2), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId2, ccFolder)); + Mockito.when(cloudContentRepository.exists(testDir2DirFile)).thenReturn(true); + Mockito.when(cloudContentRepository.list(ccFolder)).thenReturn(new ArrayList()); + + inTest.delete(cryptoFolder2); + + Mockito.verify(cloudContentRepository).delete(ccFolder); + Mockito.verify(cloudContentRepository).delete(testDir2DirFile); + Mockito.verify(dirIdCache).evict(cryptoFolder2); + } + + @Test + @DisplayName("delete(\"/Directory 3x250\")") + public void testDeleteSingleLongFolder() throws BackendException { + /* + * + * path/to/vault/d + * ├─ Directory 1 + * │ ├─ ... + * ├─ Directory 3x250 + * ├─ ... + * + */ + String dir3Name = "Directory " + Strings.repeat("3", 250); + String dir3Cipher = "dir" + Strings.repeat("3", 250); + byte[] longFilenameBytes = ("0" + dir3Cipher).getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = new Base32().encodeAsString(hash) + ".lng"; + + TestFolder ddLvl2Dir = new TestFolder(d, "33", "/d/33"); + TestFolder ddFolder = new TestFolder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", "/d/33/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + TestFile testDir3DirFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + + CloudFile testDir3NameFile = metadataFile(shortenedFileName); + + Mockito.when(fileNameCryptor.encryptFilename(dir3Name, dirIdRoot.getBytes())).thenReturn(dir3Cipher); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), dir3Cipher, dirIdRoot.getBytes())).thenReturn(dir3Name); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(ddLvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + + CryptoFolder cryptoFolder3 = new CryptoFolder(root, dir3Name, "/" + dir3Name, testDir3DirFile); + + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.folder(d, "33")).thenReturn(ddLvl2Dir); + Mockito.when(cloudContentRepository.folder(lvl2Dir, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")).thenReturn(ddFolder); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder3), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo("dir3-id", ddFolder)); + Mockito.when(cloudContentRepository.file(aaFolder, shortenedFileName)).thenReturn(testDir3DirFile); + Mockito.when(cloudContentRepository.file(testDir3NameFile.getParent(), shortenedFileName, Optional.of(257L))).thenReturn(testDir3NameFile); + Mockito.when(cloudContentRepository.list(ddFolder)).thenReturn(new ArrayList()); + + inTest.delete(cryptoFolder3); + + Mockito.verify(cloudContentRepository).delete(ddFolder); + Mockito.verify(cloudContentRepository).delete(testDir3DirFile); + Mockito.verify(dirIdCache).evict(cryptoFolder3); + } + + @Test + @DisplayName("move(\"/File 4\", \"/Directory 1/File 4\")") + public void testMoveShortFileToNewShortFile() throws BackendException { + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4 = new TestFile(aaFolder, "file4", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4", Optional.empty(), Optional.empty()); + TestFile testMovedFile4 = new TestFile(bbFolder, "file4", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/file4", Optional.empty(), Optional.empty()); + CryptoFile cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, "File 4", "/Directory 1/File 4", Optional.empty(), testMovedFile4); + + Mockito.when(cloudContentRepository.file(aaFolder, "file4")).thenReturn(testFile4); + Mockito.when(cloudContentRepository.file(bbFolder, "file4")).thenReturn(testMovedFile4); + Mockito.when(cloudContentRepository.move(testFile4, testMovedFile4)).thenReturn(testMovedFile4); + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(cloudContentRepository.folder(bbFolder, "file4")).thenReturn(null); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename("File 4", dirId1.getBytes())).thenReturn("file4"); + Mockito.when(fileNameCryptor.encryptFilename("File 4", dirIdRoot.getBytes())).thenReturn("file4"); + + CryptoFile result = inTest.move(cryptoFile4, cryptoMovedFile4); + + Assertions.assertEquals("File 4", result.getName()); + + Mockito.verify(cloudContentRepository).move(testFile4, testMovedFile4); + } + + @Test + @DisplayName("move(\"/File 4\", \"/Directory 1/File 4x250\")") + public void testMoveShortFileToNewLongFile() throws BackendException { + String file4Name = "File " + Strings.repeat("4", 250); + String file4Cipher = "file" + Strings.repeat("4", 250); + byte[] longFilenameBytes = file4Cipher.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base32().encode(hash) + ".lng"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4 = new TestFile(aaFolder, "file4", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4", Optional.empty(), Optional.empty()); + CryptoFile cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + + TestFile testFile4ContentFile = new TestFile(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName, Optional.empty(), Optional.empty()); + + CloudFile testFile4NameFile = metadataFile(shortenedFileName); + + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, file4Name, "/Directory 1/" + file4Name, Optional.empty(), testFile4ContentFile); + + Mockito.when(cloudContentRepository.move(testFile4, testFile4ContentFile)).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.create(testFile4NameFile.getParent())).thenReturn(testFile4NameFile.getParent()); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(file4Cipher)); + return testFile4NameFile; + }); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename("File 4", dirIdRoot.getBytes())).thenReturn("file4"); + Mockito.when(fileNameCryptor.encryptFilename(file4Name, dirId1.getBytes())).thenReturn(file4Cipher); + + CloudFile targetFile = inTest.file(cryptoFolder1, file4Name); // needed due to ugly side effect + CryptoFile result = inTest.move(cryptoFile4, cryptoMovedFile4); + + Assertions.assertEquals(file4Name, result.getName()); + + Mockito.verify(cloudContentRepository).create(testFile4NameFile.getParent()); + Mockito.verify(cloudContentRepository).move(testFile4, testFile4ContentFile); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + } + + @Test + @DisplayName("move(\"/File 4x250\", \"/Directory 1/File 4x250\")") + public void testMoveLongFileToNewLongFile() throws BackendException { + String file4Name = "File " + Strings.repeat("4", 250); + String file4Cipher = "file" + Strings.repeat("4", 250); + byte[] longFilenameBytes = file4Cipher.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base32().encode(hash) + ".lng"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4ContentFileOld = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + + CryptoFile cryptoFile4Old = new CryptoFile(root, file4Name, "/" + file4Name, Optional.empty(), testFile4ContentFileOld); + + TestFile testFile4ContentFile = new TestFile(bbFolder, shortenedFileName, "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName, Optional.empty(), Optional.empty()); + CloudFile testFile4NameFile = metadataFile(shortenedFileName); + + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, file4Name, "/Directory 1/" + file4Name, Optional.empty(), testFile4ContentFile); + + Mockito.when(cloudContentRepository.move(testFile4ContentFileOld, testFile4ContentFile)).thenReturn(testFile4ContentFile); + Mockito.when(cloudContentRepository.create(testFile4NameFile.getParent())).thenReturn(testFile4NameFile.getParent()); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(file4Cipher)); + return testFile4NameFile; + }); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename(file4Name, dirIdRoot.getBytes())).thenReturn(file4Cipher); + Mockito.when(fileNameCryptor.encryptFilename(file4Name, dirId1.getBytes())).thenReturn(file4Cipher); + + CloudFile targetFile = inTest.file(cryptoFolder1, file4Name); // needed due to ugly side effect + CryptoFile result = inTest.move(cryptoFile4Old, cryptoMovedFile4); + + Assertions.assertEquals(file4Name, result.getName()); + + Mockito.verify(cloudContentRepository).create(testFile4NameFile.getParent()); + Mockito.verify(cloudContentRepository).write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).move(testFile4ContentFileOld, testFile4ContentFile); + } + + @Test + @DisplayName("move(\"/Directory 1/File 4x250\", \"/File 4\")") + public void testMoveLongFileToNewShortFile() throws BackendException { + String file4Name = "File " + Strings.repeat("4", 250); + String file4Cipher = "file" + Strings.repeat("4", 250); + byte[] longFilenameBytes = file4Cipher.getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base32().encode(hash) + ".lng"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testFile4 = new TestFile(aaFolder, "file4", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/file4", Optional.empty(), Optional.empty()); + CryptoFile cryptoFile4 = new CryptoFile(root, "File 4", "/File 4", Optional.empty(), testFile4); + + TestFile testFile4DirFile = new TestFile(bbFolder, "contents.c9r", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB/" + shortenedFileName, Optional.empty(), Optional.empty()); + CloudFile testFile4NameFile = metadataFile(shortenedFileName); + + CryptoFile cryptoMovedFile4 = new CryptoFile(cryptoFolder1, file4Name, "/Directory 1/" + file4Name, Optional.empty(), testFile4DirFile); + + Mockito.when(cloudContentRepository.file(aaFolder, "file4.c9r")).thenReturn(testFile4); + Mockito.when(cloudContentRepository.move(testFile4DirFile, testFile4)).thenReturn(testFile4); + Mockito.when(cloudContentRepository.write(Mockito.eq(testFile4NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is(file4Cipher + ".c9r")); + return testFile4NameFile; + }); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename("File 4", dirIdRoot.getBytes())).thenReturn("file4"); + Mockito.when(fileNameCryptor.encryptFilename(file4Name, dirId1.getBytes())).thenReturn(file4Cipher); + + CryptoFile result = inTest.move(cryptoMovedFile4, cryptoFile4); + Assertions.assertEquals(cryptoFile4, result); + + Mockito.verify(cloudContentRepository).move(testFile4DirFile, testFile4); + } + + @Test + @DisplayName("move(\"/Directory 1\", \"/Directory 15\")") + public void testMoveShortFolderToNewShortFolder() throws BackendException { + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testDir15DirFile = new TestFile(aaFolder, "0dir15", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/0dir15", Optional.empty(), Optional.empty()); + + CryptoFolder cryptoFolder15 = new CryptoFolder(root, "Directory 15", "/Directory 15/", testDir15DirFile); + + Mockito.when(cloudContentRepository.folder(rootFolder, "d")).thenReturn(d); + Mockito.when(cloudContentRepository.folder(d, "11")).thenReturn(bbLvl2Dir); + Mockito.when(cloudContentRepository.folder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")).thenReturn(bbFolder); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder15), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(cloudContentRepository.file(aaFolder, "0dir15")).thenReturn(testDir15DirFile); + Mockito.when(cloudContentRepository.move(cryptoFolder1.getDirFile(), testDir15DirFile)).thenReturn(testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename("Directory 15", dirIdRoot.getBytes())).thenReturn(dirId1); + + CryptoFolder result = inTest.move(cryptoFolder1, cryptoFolder15); + + Mockito.verify(cloudContentRepository).move(cryptoFolder1.getDirFile(), testDir15DirFile); + Mockito.verify(dirIdCache).evict(cryptoFolder1); + Mockito.verify(dirIdCache).evict(cryptoFolder15); + } + + @Test + @DisplayName("move(\"/Directory 1\", \"/Directory 15x200\")") + public void testMoveShortFolderToNewLongFolder() throws BackendException { + String dir15Name = "Dir " + Strings.repeat("15", 250); + String dir15Cipher = "dir" + Strings.repeat("15", 250); + byte[] longFilenameBytes = ("0" + dir15Cipher).getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base32().encode(hash) + ".lng"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testDir15DirFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + CloudFile testDir15NameFile = metadataFile(shortenedFileName); + + CryptoFolder cryptoFolder15 = new CryptoFolder(root, dir15Name, "/" + dir15Name, testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(dir15Name, dirId1.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(cloudContentRepository.file(aaFolder, "dir15.c9r", Optional.ofNullable(null))) + .thenReturn(new TestFile(aaFolder, "dir15.c9r", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir15.c9r", Optional.empty(), Optional.empty())); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder15), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(cloudContentRepository.move(cryptoFolder1.getDirFile(), testDir15DirFile)).thenReturn(testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(dir15Name, dirIdRoot.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir15NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong())).thenAnswer(invocationOnMock -> { + DataSource in = invocationOnMock.getArgument(1); + String dirContent = new BufferedReader(new InputStreamReader(in.open(context), StandardCharsets.UTF_8)).readLine(); + assertThat(dirContent, is("0" + dir15Cipher)); + return testDir15NameFile; + }); + + CryptoFolder targetFile = inTest.folder(root, dir15Name); // needed due to ugly side effect + CryptoFolder result = inTest.move(cryptoFolder1, cryptoFolder15); + Assertions.assertEquals(cryptoFolder15, result); + + Mockito.verify(cloudContentRepository).write(Mockito.eq(testDir15NameFile), Mockito.any(), Mockito.any(), Mockito.eq(true), Mockito.anyLong()); + Mockito.verify(cloudContentRepository).move(cryptoFolder1.getDirFile(), testDir15DirFile); + } + + @Test + @DisplayName("move(\"/Directory 15x200\", \"/Directory 3000\")") + public void testMoveLongFolderToNewShortFolder() throws BackendException { + String dir15Name = "Dir " + Strings.repeat("15", 250); + String dir15Cipher = "dir" + Strings.repeat("15", 250); + byte[] longFilenameBytes = ("0" + dir15Cipher).getBytes(Encodings.UTF_8); + byte[] hash = MessageDigestSupplier.SHA1.get().digest(longFilenameBytes); + String shortenedFileName = BaseEncoding.base32().encode(hash) + ".lng"; + + TestFolder bbLvl2Dir = new TestFolder(d, "11", "/d/11"); + TestFolder bbFolder = new TestFolder(bbLvl2Dir, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "/d/11/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + TestFile testDir15DirFile = new TestFile(aaFolder, shortenedFileName, "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/" + shortenedFileName, Optional.empty(), Optional.empty()); + + CryptoFolder cryptoFolder15 = new CryptoFolder(root, dir15Name, "/" + dir15Name, testDir15DirFile); + + Mockito.when(fileNameCryptor.encryptFilename(dir15Name, dirId1.getBytes())).thenReturn(dir15Cipher); + + Mockito.when(dirIdCache.put(Mockito.eq(cryptoFolder1), Mockito.any())).thenReturn(new DirIdCache.DirIdInfo(dirId1, bbFolder)); + + Mockito.when(fileNameCryptor.encryptFilename(dir15Name, dirIdRoot.getBytes())).thenReturn(dir15Cipher); + + TestFile testDir3DirFile = new TestFile(aaFolder, "dir3", "/d/00/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/dir3", Optional.empty(), Optional.empty()); + CryptoFolder cryptoFolder3 = new CryptoFolder(root, "Directory 3", "/Directory 3", testDir3DirFile); + + Mockito.when(fileNameCryptor.encryptFilename("Directory 3", dirIdRoot.getBytes())).thenReturn("dir3"); + Mockito.when(fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), "dir3", dirIdRoot.getBytes())).thenReturn("Directory 3"); + Mockito.when(fileNameCryptor.hashDirectoryId(AdditionalMatchers.not(Mockito.eq("")))).thenReturn("33DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); + + Mockito.when(cloudContentRepository.create(lvl2Dir)).thenReturn(lvl2Dir); + Mockito.when(cloudContentRepository.write(Mockito.eq(testDir3DirFile), Mockito.any(), Mockito.any(), Mockito.eq(false), Mockito.anyLong())).thenReturn(testDir3DirFile); + Mockito.when(cloudContentRepository.move(testDir15DirFile, testDir3DirFile)).thenReturn(testDir3DirFile); + + CryptoFolder targetFile = inTest.folder(root, cryptoFolder3.getName()); // needed due to ugly side effect + CryptoFolder result = inTest.move(cryptoFolder15, cryptoFolder3); + + Mockito.verify(cloudContentRepository).move(testDir15DirFile, cryptoFolder3.getDirFile()); + } + + private CloudFile metadataFile(String shortFilename) throws BackendException { + Mockito.when(cloudContentRepository.folder(rootFolder, "m")).thenReturn(m); + TestFolder firstLevelFolder = new TestFolder(m, shortFilename.substring(0, 2), "/m/" + shortFilename.substring(0, 2)); + Mockito.when(cloudContentRepository.folder(m, shortFilename.substring(0, 2))).thenReturn(firstLevelFolder); + TestFolder secondLevelFolder = new TestFolder(firstLevelFolder, shortFilename.substring(2, 4), "/m/" + shortFilename.substring(0, 2) + "/" + shortFilename.substring(2, 4)); + Mockito.when(cloudContentRepository.folder(firstLevelFolder, shortFilename.substring(2, 4))).thenReturn(secondLevelFolder); + TestFile file = new TestFile(secondLevelFolder, shortFilename, "/m/" + shortFilename.substring(0, 2) + "/" + shortFilename.substring(2, 4) + "/" + shortFilename, Optional.empty(), Optional.empty()); + Mockito.when(cloudContentRepository.file(secondLevelFolder, shortFilename)).thenReturn(file); + return file; + } + +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/crypto/RootTestFolder.java b/data/src/test/java/org/cryptomator/data/cloud/crypto/RootTestFolder.java new file mode 100644 index 000000000..e06dd943c --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/crypto/RootTestFolder.java @@ -0,0 +1,46 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; + +import java.util.Objects; + +class RootTestFolder extends TestFolder { + + private final Cloud cloud; + + public RootTestFolder(Cloud cloud) { + super(null, "", ""); + this.cloud = cloud; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + if (!super.equals(o)) + return false; + + RootTestFolder that = (RootTestFolder) o; + + return Objects.equals(cloud, that.cloud); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (cloud != null ? cloud.hashCode() : 0); + return result; + } + + @Override + public Cloud getCloud() { + return cloud; + } + + @Override + public TestFolder withCloud(Cloud cloud) { + return new RootTestFolder(cloud); + } +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/crypto/TestFile.java b/data/src/test/java/org/cryptomator/data/cloud/crypto/TestFile.java new file mode 100644 index 000000000..66cbeae95 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/crypto/TestFile.java @@ -0,0 +1,85 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.util.Optional; + +import java.util.Date; +import java.util.Objects; + +class TestFile implements CloudFile { + + private final TestFolder parent; + private final String name; + private final String path; + private final Optional size; + private final Optional modified; + + public TestFile(TestFolder 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 TestFolder getParent() { + return parent; + } + + @Override + public Optional getSize() { + return size; + } + + @Override + public Optional getModified() { + return modified; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + TestFile testFile = (TestFile) o; + + if (!Objects.equals(parent, testFile.parent)) + return false; + if (!Objects.equals(name, testFile.name)) + return false; + if (!Objects.equals(path, testFile.path)) + return false; + if (!Objects.equals(size, testFile.size)) + return false; + return Objects.equals(modified, testFile.modified); + } + + @Override + public int hashCode() { + int result = parent != null ? parent.hashCode() : 0; + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (path != null ? path.hashCode() : 0); + result = 31 * result + (size != null ? size.hashCode() : 0); + result = 31 * result + (modified != null ? modified.hashCode() : 0); + return result; + } +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/crypto/TestFolder.java b/data/src/test/java/org/cryptomator/data/cloud/crypto/TestFolder.java new file mode 100644 index 000000000..f2b16bdeb --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/crypto/TestFolder.java @@ -0,0 +1,68 @@ +package org.cryptomator.data.cloud.crypto; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; + +import java.util.Objects; + +class TestFolder implements CloudFolder { + + private final TestFolder parent; + private final String name; + private final String path; + + public TestFolder(TestFolder parent, String name, String path) { + this.parent = parent; + this.name = name; + this.path = path; + } + + @Override + public Cloud getCloud() { + return parent.getCloud(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + TestFolder that = (TestFolder) o; + + if (!Objects.equals(parent, that.parent)) + return false; + if (!Objects.equals(name, that.name)) + return false; + return Objects.equals(path, that.path); + } + + @Override + public int hashCode() { + int result = parent != null ? parent.hashCode() : 0; + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (path != null ? path.hashCode() : 0); + return result; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getPath() { + return path; + } + + @Override + public TestFolder getParent() { + return parent; + } + + @Override + public TestFolder withCloud(Cloud cloud) { + return new TestFolder(parent.withCloud(cloud), name, path); + } +} diff --git a/data/src/test/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParserTest.java b/data/src/test/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParserTest.java new file mode 100644 index 000000000..aff2053bb --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/cloud/webdav/network/PropfindResponseParserTest.java @@ -0,0 +1,149 @@ +package org.cryptomator.data.cloud.webdav.network; + +import org.cryptomator.data.cloud.webdav.RootWebDavFolder; +import org.cryptomator.data.cloud.webdav.WebDavFile; +import org.cryptomator.data.cloud.webdav.WebDavFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.List; + +import static java.util.Collections.sort; +import static org.cryptomator.data.cloud.CloudFileMatcher.cloudFile; +import static org.cryptomator.data.cloud.CloudFolderMatcher.cloudFolder; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.collection.IsIterableContainingInOrder.contains; + +@Disabled +public class PropfindResponseParserTest { + + private static final String PARENT_CLOUD_PATH = "https://webdavserver.com/User7de989b"; + private static final String PARENT_FOLDER_PATH = "/asdasdasd/d/OC"; + private static final WebDavFolder PARENT_FOLDER = new WebDavFolder(new RootWebDavFolder( // + WebDavCloud // + .aWebDavCloudCloud() // + .withUrl(PARENT_CLOUD_PATH) // + .withPassword("Bla") // + .withUsername("Julian") // + .build()), // + "OC", // + PARENT_FOLDER_PATH); // + + private static final String RESPONSE_EMPTY_DIRECTORY = "empty-directory"; + private static final String RESPONSE_ONE_DIRECTORY = "directory-one-folder"; + private static final String RESPONSE_ONE_FILE = "directory-one-file"; + private static final String RESPONSE_ONE_FILE_NO_SERVER = "directory-one-file-no-server"; + private static final String RESPONSE_ONE_FILE_AND_FOLDERS = "directory-and-file"; + private static final String RESPONSE_MAL_FORMATTED_XMLPULLPARSER_EXCEPTION = "malformatted-response-xmlpullparser"; + + private PropfindResponseParser inTest; + + @BeforeEach + public void setup() { + inTest = new PropfindResponseParser(PARENT_FOLDER); + } + + @Test + public void testEmptyResponseLeadsToEmptyCloudNodeList() throws XmlPullParserException, IOException { + List result = inTest.parse(load(RESPONSE_EMPTY_DIRECTORY)); + List nodes = processDirList(result, PARENT_FOLDER); + + assertThat(nodes, is(emptyCollectionOf(CloudNode.class))); + } + + @Test + public void testFolderResponseLeadsToFolderInCloudNodeList() throws XmlPullParserException, IOException { + List result = inTest.parse(load(RESPONSE_ONE_DIRECTORY)); + List nodes = processDirList(result, PARENT_FOLDER); + + assertThat(nodes.size(), is(1)); + assertThat(nodes, contains(cloudFolder(new WebDavFolder(PARENT_FOLDER, // + "DYNTZMMHWLW25RZHWYEDHLFWIUZZG2", // + "/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2")))); + } + + @Test + public void testFolderWithoutServerPartInHrefResponseLeadsToFolderInCloudNodeListWithCompleteUrl() throws XmlPullParserException, IOException { + List result = inTest.parse(load(RESPONSE_ONE_FILE_NO_SERVER)); + List nodes = processDirList(result, PARENT_FOLDER); + + assertThat(nodes.size(), is(1)); + assertThat(nodes, contains(cloudFolder(new WebDavFolder(PARENT_FOLDER, // + "DYNTZMMHWLW25RZHWYEDHLFWIUZZG2", // + "/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2")))); + } + + @Test + public void testFileResponseLeadsToFileInCloudNodeList() throws XmlPullParserException, IOException { + List result = inTest.parse(load(RESPONSE_ONE_FILE)); + List nodes = processDirList(result, PARENT_FOLDER); + + assertThat(nodes.size(), is(1)); + assertThat(nodes.get(0), is(cloudFile(new WebDavFile(PARENT_FOLDER, // + "0ZRGQYTW7FFHOJDJWIJYVR3M6MOME5EAR", // + "/asdasdasd/d/OC/0ZRGQYTW7FFHOJDJWIJYVR3M6MOME5EAR", // + Optional.of(36L), // + Optional.of(new Date("Thu, 30 Mar 2017 10:14:39 GMT")))))); + } + + @Test + public void testFileResponseLeadsToFileAndFoldersInCloudNodeList() throws XmlPullParserException, IOException { + WebDavFolder webDavFolder = new WebDavFolder(new RootWebDavFolder( // + WebDavCloud // + .aWebDavCloudCloud() // + .withUrl("") // + .withPassword("Bla") // + .withUsername("Julian") // + .build()), // + "", // + ""); // + + inTest = new PropfindResponseParser(webDavFolder); + + List result = inTest.parse(load(RESPONSE_ONE_FILE_AND_FOLDERS)); + List nodes = processDirList(result, webDavFolder); + + assertThat(nodes.size(), is(2)); + assertThat(nodes, // + containsInAnyOrder( // + cloudFolder(new WebDavFolder(webDavFolder, "Gelöschte Dateien")), // + cloudFile(new WebDavFile(webDavFolder, "0.txt", Optional.of(54175L), Optional.of(new Date("Thu, 18 May 2017 9:49:41 GMT")))))); + } + + @Test + public void testMallFormattedResponseLeadsToXmlPullParserException() { + Assertions.assertThrows(XmlPullParserException.class, () -> inTest.parse(load(RESPONSE_MAL_FORMATTED_XMLPULLPARSER_EXCEPTION))); + } + + private InputStream load(String resourceName) { + return getClass().getResourceAsStream("/propfind-test-request/" + resourceName + ".xml"); + } + + private List processDirList(List entryData, WebDavFolder requestedFolder) { + List result = new ArrayList<>(); + sort(entryData, ASCENDING_BY_DEPTH); + // after sorting the first entry is the parent + // because it's depth is 1 smaller than the depth + // ot the other entries, thus we skip the first entry + for (PropfindEntryData childEntry : entryData.subList(1, entryData.size())) { + result.add(childEntry.toCloudNode(requestedFolder)); + } + return result; + } + + private final Comparator ASCENDING_BY_DEPTH = (o1, o2) -> o1.getDepth() - o2.getDepth(); +} diff --git a/data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareInputStreamTest.java b/data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareInputStreamTest.java new file mode 100644 index 000000000..14ceeaff5 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareInputStreamTest.java @@ -0,0 +1,241 @@ +package org.cryptomator.data.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.io.InputStream; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class TransferredBytesAwareInputStreamTest { + + private static final int AN_INTEGER = 5837; + private static final int EOF = -1; + private static final byte[] A_BYTE_ARRAY = new byte[10]; + private static final int ANOTHER_INTEGER = 7820; + private static final int A_THIRD_INTEGER = 678923; + private static final long A_LONG = 67890L; + private static final long ANOTHER_LONG = 890123L; + + private InputStream delegate; + + private CountingTransferredBytesAwareInputStream inTest; + + @BeforeEach + public void setup() { + delegate = Mockito.mock(InputStream.class); + inTest = new CountingTransferredBytesAwareInputStream(delegate); + } + + @Test + public void testNewInstanceDidNotTransferAnything() throws IOException { + assertThat(inTest.getTransferred(), is(0L)); + } + + @Test + public void testReadDelegatesToRead() throws IOException { + when(delegate.read()).thenReturn(AN_INTEGER); + + int result = inTest.read(); + + assertThat(result, is(AN_INTEGER)); + } + + @Test + public void testReadTransfersOneByte() throws IOException { + when(delegate.read()).thenReturn(AN_INTEGER); + + inTest.read(); + + assertThat(inTest.getTransferred(), is(1L)); + } + + @Test + public void testReadWithExceptionDoesNotTransferAnything() throws IOException { + when(delegate.read()).thenThrow(new IOException()); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.read(); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + @Test + public void testReadWithEofDoesNotTransferAnything() throws IOException { + when(delegate.read()).thenReturn(EOF); + + int result = inTest.read(); + + assertThat(result, is(EOF)); + assertThat(inTest.getTransferred(), is(0L)); + } + + @Test + public void testArrayReadDelegatesToRead() throws IOException { + when(delegate.read(A_BYTE_ARRAY)).thenReturn(AN_INTEGER); + + int result = inTest.read(A_BYTE_ARRAY); + + assertThat(result, is(AN_INTEGER)); + } + + @Test + public void testArrayReadTransfersBytes() throws IOException { + when(delegate.read(A_BYTE_ARRAY)).thenReturn(AN_INTEGER); + + inTest.read(A_BYTE_ARRAY); + + assertThat(inTest.getTransferred(), is((long) AN_INTEGER)); + } + + @Test + public void testArrayReadWithExceptionDoesNotTransferAnything() throws IOException { + when(delegate.read(A_BYTE_ARRAY)).thenThrow(new IOException()); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.read(A_BYTE_ARRAY); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + @Test + public void testArrayReadWithEofDoesNotTransferAnything() throws IOException { + when(delegate.read(A_BYTE_ARRAY)).thenReturn(EOF); + + inTest.read(A_BYTE_ARRAY); + + assertThat(inTest.getTransferred(), is(0L)); + } + + @Test + public void testArrayWithRangeReadDelegatesToRead() throws IOException { + when(delegate.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER)).thenReturn(A_THIRD_INTEGER); + + int result = inTest.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + + assertThat(result, is(A_THIRD_INTEGER)); + } + + @Test + public void testArrayWithRangeReadTransfersBytes() throws IOException { + when(delegate.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER)).thenReturn(A_THIRD_INTEGER); + + inTest.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + + assertThat(inTest.getTransferred(), is((long) A_THIRD_INTEGER)); + } + + @Test + public void testArrayWithRangeReadWithExceptionDoesNotTransferAnything() throws IOException { + when(delegate.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER)).thenThrow(new IOException()); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + @Test + public void testArrayWithRangeReadWithEofDoesNotTransferAnything() throws IOException { + when(delegate.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER)).thenReturn(EOF); + + inTest.read(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + + assertThat(inTest.getTransferred(), is(0L)); + } + + @Test + public void testCloseDelegatesToClose() throws IOException { + inTest.close(); + + verify(delegate).close(); + } + + @Test + public void testAvailableDelegatesToAvailable() throws IOException { + when(delegate.available()).thenReturn(AN_INTEGER); + + int result = inTest.available(); + + assertThat(result, is(AN_INTEGER)); + } + + @Test + public void testResetThrowsIOException() { + Assertions.assertThrows(IOException.class, () -> inTest.reset()); + } + + @Test + public void testMarkSupportedReturnsFalse() { + boolean result = inTest.markSupported(); + + assertFalse(result); + } + + @Test + public void testSkipDelegatesToSkip() throws IOException { + when(delegate.skip(A_LONG)).thenReturn(ANOTHER_LONG); + + long result = inTest.skip(A_LONG); + + assertThat(result, is(ANOTHER_LONG)); + } + + @Test + public void testSkipTransfersBytes() throws IOException { + when(delegate.skip(A_LONG)).thenReturn(ANOTHER_LONG); + + inTest.skip(A_LONG); + + assertThat(inTest.getTransferred(), is(ANOTHER_LONG)); + } + + @Test + public void testSkipWithExceptionDoesNotTransferAnything() throws IOException { + when(delegate.skip(A_LONG)).thenThrow(new IOException()); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.skip(A_LONG); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + private static class CountingTransferredBytesAwareInputStream extends TransferredBytesAwareInputStream { + + private long transferred; + + public CountingTransferredBytesAwareInputStream(InputStream delegate) { + super(delegate); + } + + @Override + public void bytesTransferred(long transferred) { + this.transferred = transferred; + } + + public long getTransferred() { + return transferred; + } + + } + +} diff --git a/data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareOutputStreamTest.java b/data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareOutputStreamTest.java new file mode 100644 index 000000000..75f69f4bd --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/util/TransferredBytesAwareOutputStreamTest.java @@ -0,0 +1,150 @@ +package org.cryptomator.data.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.io.OutputStream; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +public class TransferredBytesAwareOutputStreamTest { + + private static final int AN_INTEGER = 5678; + private static final byte[] A_BYTE_ARRAY = new byte[10]; + private static final int ANOTHER_INTEGER = 8923; + + private OutputStream delegate; + private CountingTransferredBytesAwareOutputStream inTest; + + @BeforeEach + public void setup() { + delegate = Mockito.mock(OutputStream.class); + inTest = new CountingTransferredBytesAwareOutputStream(delegate); + } + + @Test + public void testNewInstanceDidNotTransferAnything() { + assertThat(inTest.getTransferred(), is(0L)); + } + + @Test + public void testIntegerWriteDelegatesToWrite() throws IOException { + inTest.write(AN_INTEGER); + + verify(delegate).write(AN_INTEGER); + } + + @Test + public void testIntegerWriteTransfersOneByte() throws IOException { + inTest.write(AN_INTEGER); + + assertThat(inTest.getTransferred(), is(1L)); + } + + @Test + public void testIntegerWriteWithExceptionDoesNotTransferAnything() throws IOException { + doThrow(new IOException()).when(delegate).write(AN_INTEGER); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.write(AN_INTEGER); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + @Test + public void testArrayWriteDelegatesToWrite() throws IOException { + inTest.write(A_BYTE_ARRAY); + + verify(delegate).write(A_BYTE_ARRAY); + } + + @Test + public void testArrayWriteTransfersBytes() throws IOException { + inTest.write(A_BYTE_ARRAY); + + assertThat(inTest.getTransferred(), is((long) A_BYTE_ARRAY.length)); + } + + @Test + public void testArrayWriteWithExceptionDoesNotTransferAnything() throws IOException { + doThrow(new IOException()).when(delegate).write(A_BYTE_ARRAY); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.write(A_BYTE_ARRAY); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + @Test + public void testArrayWithRangeWriteDelegatesToWrite() throws IOException { + inTest.write(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + + verify(delegate).write(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + } + + @Test + public void testArrayWithRangeWriteTransfersBytes() throws IOException { + inTest.write(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + + assertThat(inTest.getTransferred(), is((long) ANOTHER_INTEGER)); + } + + @Test + public void testArrayWithRangeWriteWithExceptionDoesNotTransferAnything() throws IOException { + doThrow(new IOException()).when(delegate).write(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + + Assertions.assertThrows(IOException.class, () -> { + try { + inTest.write(A_BYTE_ARRAY, AN_INTEGER, ANOTHER_INTEGER); + } finally { + assertThat(inTest.getTransferred(), is(0L)); + } + }); + } + + @Test + public void testCloseDelegatesToClose() throws IOException { + inTest.close(); + + verify(delegate).close(); + } + + @Test + public void testFlushDelegatesToFlush() throws IOException { + inTest.flush(); + + verify(delegate).flush(); + } + + private static class CountingTransferredBytesAwareOutputStream extends TransferredBytesAwareOutputStream { + + private long transferred; + + public CountingTransferredBytesAwareOutputStream(OutputStream delegate) { + super(delegate); + } + + @Override + public void bytesTransferred(long transferred) { + this.transferred = transferred; + } + + public long getTransferred() { + return transferred; + } + + } + +} diff --git a/data/src/test/resources/propfind-test-request/directory-and-file.xml b/data/src/test/resources/propfind-test-request/directory-and-file.xml new file mode 100644 index 000000000..bd29050bc --- /dev/null +++ b/data/src/test/resources/propfind-test-request/directory-and-file.xml @@ -0,0 +1,40 @@ + + + + / + + + Thu, 18 May 2017 09:51:59 GMT + 0 + + + + + HTTP/1.1 200 OK + + + + /0.txt + + + Thu, 18 May 2017 09:49:41 GMT + 54175 + + + HTTP/1.1 200 OK + + + + /Gel%c3%b6schte%20Dateien/ + + + Thu, 18 May 2017 09:51:59 GMT + 0 + + + + + HTTP/1.1 200 OK + + + diff --git a/data/src/test/resources/propfind-test-request/directory-one-file-no-server.xml b/data/src/test/resources/propfind-test-request/directory-one-file-no-server.xml new file mode 100644 index 000000000..1e1adf389 --- /dev/null +++ b/data/src/test/resources/propfind-test-request/directory-one-file-no-server.xml @@ -0,0 +1,35 @@ + + + + /User7de989b/asdasdasd/d/OC/ + + + Thu, 18 May 2017 09:51:59 GMT + 0 + + + + + HTTP/1.1 200 OK + + + + /User7de989b/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2/ + + HTTP/1.1 200 OK + + + + + Thu, 30 Mar 2017 10:14:39 GMT + + + + HTTP/1.1 404 Not Found + + + + + + + diff --git a/data/src/test/resources/propfind-test-request/directory-one-file.xml b/data/src/test/resources/propfind-test-request/directory-one-file.xml new file mode 100644 index 000000000..a8a0b67f1 --- /dev/null +++ b/data/src/test/resources/propfind-test-request/directory-one-file.xml @@ -0,0 +1,29 @@ + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/ + + + Thu, 18 May 2017 09:51:59 GMT + 0 + + + + + HTTP/1.1 200 OK + + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/0ZRGQYTW7FFHOJDJWIJYVR3M6MOME5EAR + + + HTTP/1.1 200 OK + + + 36 + Thu, 30 Mar 2017 10:14:39 GMT + + + + diff --git a/data/src/test/resources/propfind-test-request/directory-one-folder.xml b/data/src/test/resources/propfind-test-request/directory-one-folder.xml new file mode 100644 index 000000000..43f34c688 --- /dev/null +++ b/data/src/test/resources/propfind-test-request/directory-one-folder.xml @@ -0,0 +1,37 @@ + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/ + + + Thu, 18 May 2017 09:51:59 GMT + 0 + + + + + HTTP/1.1 200 OK + + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2/ + + + HTTP/1.1 200 OK + + + + + Thu, 30 Mar 2017 10:14:39 GMT + + + + HTTP/1.1 404 Not Found + + + + + + + diff --git a/data/src/test/resources/propfind-test-request/empty-directory.xml b/data/src/test/resources/propfind-test-request/empty-directory.xml new file mode 100644 index 000000000..ea08a952c --- /dev/null +++ b/data/src/test/resources/propfind-test-request/empty-directory.xml @@ -0,0 +1,16 @@ + + + + /asdasdasd/d/OC/ + + + Thu, 18 May 2017 09:51:59 GMT + 0 + + + + + HTTP/1.1 200 OK + + + diff --git a/data/src/test/resources/propfind-test-request/malformatted-response-illegalstate.xml b/data/src/test/resources/propfind-test-request/malformatted-response-illegalstate.xml new file mode 100644 index 000000000..225a9075a --- /dev/null +++ b/data/src/test/resources/propfind-test-request/malformatted-response-illegalstate.xml @@ -0,0 +1,66 @@ + + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2/ + + + HTTP/1.1 200 OK + + + + + Thu, 30 Mar 2017 10:14:39 GMT + + + + HTTP/1.1 404 Not Found + + + + + + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2/bla.txt + + + HTTP/1.1 200 OK + + + + + Thu, 30 Mar 2017 10:14:39 GMT + + + + HTTP/1.1 404 Not Found + + + + + + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/ + + + HTTP/1.1 200 OK + + + + + Thu, 30 Mar 2017 10:14:39 GMT + + + + HTTP/1.1 404 Not Found + + + + + + + diff --git a/data/src/test/resources/propfind-test-request/malformatted-response-xmlpullparser.xml b/data/src/test/resources/propfind-test-request/malformatted-response-xmlpullparser.xml new file mode 100644 index 000000000..76bad5b71 --- /dev/null +++ b/data/src/test/resources/propfind-test-request/malformatted-response-xmlpullparser.xml @@ -0,0 +1,21 @@ + + + + + https://webdavserver.com/User7de989b/asdasdasd/d/OC/DYNTZMMHWLW25RZHWYEDHLFWIUZZG2/0ZRGQYTW7FFHOJDJWIJYVR3M6MOME5EAR + + + HTTP/1.1 200 OK + + + 36 + Thu, 30 Mar 2017 10:14:39 GMT + + + + HTTP/1.1 404 Not Found + + + + + diff --git a/domain/.gitignore b/domain/.gitignore new file mode 100755 index 000000000..796b96d1c --- /dev/null +++ b/domain/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/build.gradle b/domain/build.gradle new file mode 100644 index 000000000..711ecff42 --- /dev/null +++ b/domain/build.gradle @@ -0,0 +1,73 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'de.mannodermaus.android-junit5' + +android { + defaultPublishConfig "debug" + + def globalConfiguration = rootProject.extensions.getByName("ext") + + compileSdkVersion globalConfiguration["androidCompileSdkVersion"] + buildToolsVersion globalConfiguration["androidBuildToolsVersion"] + + defaultConfig { + minSdkVersion globalConfiguration["androidMinSdkVersion"] + targetSdkVersion globalConfiguration["androidTargetSdkVersion"] + + buildConfigField 'int', 'VERSION_CODE', "${globalConfiguration["androidVersionCode"]}" + buildConfigField "String", "VERSION_NAME", "\"${globalConfiguration["androidVersionName"]}\"" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + quiet true + abortOnError false + ignoreWarnings true + } +} + +dependencies { + def dependencies = rootProject.ext.dependencies + + implementation project(':generator-api') + implementation project(':util') + + annotationProcessor project(':generator') + annotationProcessor dependencies.daggerCompiler + + compileOnly dependencies.javaxAnnotation + + api dependencies.timber + api dependencies.dagger + api dependencies.rxJava + + api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + implementation dependencies.appcompat + + api dependencies.jsonWebTokenApi + runtimeOnly dependencies.jsonWebTokenImpl + runtimeOnly(dependencies.jsonWebTokenJson) { + exclude group: 'org.json', module: 'json' //provided by Android natively + } + + // test + testImplementation dependencies.junit + testImplementation dependencies.junitApi + testRuntimeOnly dependencies.junitEngine + testImplementation dependencies.junitParams + + testImplementation dependencies.junit4 + testRuntimeOnly dependencies.junit4Engine + + testImplementation dependencies.mockito + testImplementation dependencies.hamcrest +} + +configurations { + all*.exclude group: 'com.google.android', module: 'android' +} diff --git a/domain/src/debug/java/org/cryptomator/domain/executor/BackgroundTasks.java b/domain/src/debug/java/org/cryptomator/domain/executor/BackgroundTasks.java new file mode 100644 index 000000000..ca3889a15 --- /dev/null +++ b/domain/src/debug/java/org/cryptomator/domain/executor/BackgroundTasks.java @@ -0,0 +1,93 @@ +package org.cryptomator.domain.executor; + +import java.util.Date; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import timber.log.Timber; + +public class BackgroundTasks { + + private static final Lock lock = new ReentrantLock(); + private static final Condition reachedZero = lock.newCondition(); + + private static final ObjectCounts counts = new ObjectCounts(); + + public static class Registration { + private final Class type; + private boolean unregistered = false; + + public Registration(Class type) { + this.type = type; + } + + public synchronized void unregister() { + if (unregistered) { + return; + } + + unregistered = true; + + lock.lock(); + + try { + int count = counts.removeAndGet(type); + Timber.tag("BackgroundTasks").d(caller("unregister", count, counts.count())); + if (count == 0) { + reachedZero.signalAll(); + } + } finally { + lock.unlock(); + } + } + } + + public static void awaitCompleted() { + lock.lock(); + try { + Date deadline = new Date(System.currentTimeMillis() + 60_000); + while (counts.count() > 0) { + if (!reachedZero.awaitUntil(deadline)) { + throw new RuntimeException("Timeout while waiting for idle async excecution"); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread interrupted"); + } finally { + lock.unlock(); + } + } + + public static Registration register(Class type) { + lock.lock(); + + try { + int count = counts.addAndGet(type); + Timber.tag("BackgroundTasks").d(caller("register", count, counts.count())); + } finally { + lock.unlock(); + } + + return new Registration(type); + } + + static String caller(String what, int count, int all) { + StackTraceElement caller = Thread.currentThread().getStackTrace()[4]; + return new StringBuilder() // + .append("type:") // + .append(count) // + .append(" all:") // + .append(all) // + .append(' ') // + .append(what) // + .append("@ ") // + .append(caller.getClassName()) // + .append('#') // + .append(caller.getMethodName()) // + .append(':') // + .append(caller.getLineNumber()) // + .toString(); + } +} diff --git a/domain/src/debug/java/org/cryptomator/domain/executor/ObjectCounts.java b/domain/src/debug/java/org/cryptomator/domain/executor/ObjectCounts.java new file mode 100644 index 000000000..83173a559 --- /dev/null +++ b/domain/src/debug/java/org/cryptomator/domain/executor/ObjectCounts.java @@ -0,0 +1,54 @@ +package org.cryptomator.domain.executor; + +import java.util.HashMap; +import java.util.Map; + +import timber.log.Timber; + +class ObjectCounts { + + private static final Integer ZERO = 0; + + private final Map map = new HashMap<>(); + private int count = 0; + + public Integer addAndGet(T value) { + Integer newValue = count(value) + 1; + map.put(value, newValue); + count++; + return newValue; + } + + public Integer removeAndGet(T value) { + Integer newValue = count(value) - 1; + if (newValue < 0) { + RuntimeException e = new RuntimeException("Counter of " + value + " below zero"); + e.fillInStackTrace(); + Timber.tag("AsyncExceutionMonitor").e(e); + newValue = 0; + } + map.put(value, newValue); + count--; + if (count < 0) { + RuntimeException e = new RuntimeException("Count below zero"); + e.fillInStackTrace(); + Timber.tag("AsyncExceutionMonitor").e(e); + count = 0; + } + return newValue; + } + + public Integer count() { + return count; + } + + public Integer count(T value) { + Integer result = map.get(value); + if (result == null) { + return ZERO; + } else { + return result; + } + } + +} diff --git a/domain/src/main/AndroidManifest.xml b/domain/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9321a69e9 --- /dev/null +++ b/domain/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/domain/src/main/java/org/cryptomator/domain/Cloud.java b/domain/src/main/java/org/cryptomator/domain/Cloud.java new file mode 100644 index 000000000..1ede6d259 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/Cloud.java @@ -0,0 +1,18 @@ +package org.cryptomator.domain; + +import java.io.Serializable; + +public interface Cloud extends Serializable { + + Long id(); + + CloudType type(); + + boolean configurationMatches(Cloud cloud); + + boolean predefined(); + + boolean persistent(); + + boolean requiresNetwork(); +} diff --git a/domain/src/main/java/org/cryptomator/domain/CloudFile.java b/domain/src/main/java/org/cryptomator/domain/CloudFile.java new file mode 100644 index 000000000..9fcdb7ab3 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/CloudFile.java @@ -0,0 +1,13 @@ +package org.cryptomator.domain; + +import org.cryptomator.util.Optional; + +import java.util.Date; + +public interface CloudFile extends CloudNode { + + Optional getSize(); + + Optional getModified(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/CloudFolder.java b/domain/src/main/java/org/cryptomator/domain/CloudFolder.java new file mode 100644 index 000000000..675a4dc87 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/CloudFolder.java @@ -0,0 +1,7 @@ +package org.cryptomator.domain; + +public interface CloudFolder extends CloudNode { + + CloudFolder withCloud(Cloud cloud); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/CloudNode.java b/domain/src/main/java/org/cryptomator/domain/CloudNode.java new file mode 100755 index 000000000..1f83a9456 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/CloudNode.java @@ -0,0 +1,14 @@ +package org.cryptomator.domain; + +import java.io.Serializable; + +public interface CloudNode extends Serializable { + + Cloud getCloud(); + + String getName(); + + String getPath(); + + CloudFolder getParent(); +} diff --git a/domain/src/main/java/org/cryptomator/domain/CloudType.java b/domain/src/main/java/org/cryptomator/domain/CloudType.java new file mode 100644 index 000000000..5161e4188 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/CloudType.java @@ -0,0 +1,7 @@ +package org.cryptomator.domain; + +public enum CloudType { + + DROPBOX, GOOGLE_DRIVE, ONEDRIVE, WEBDAV, LOCAL, CRYPTO + +} diff --git a/domain/src/main/java/org/cryptomator/domain/DropboxCloud.java b/domain/src/main/java/org/cryptomator/domain/DropboxCloud.java new file mode 100644 index 000000000..c3184679b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/DropboxCloud.java @@ -0,0 +1,120 @@ +package org.cryptomator.domain; + +import org.jetbrains.annotations.NotNull; + +public class DropboxCloud implements Cloud { + + private final Long id; + private final String accessToken; + private final String username; + + private DropboxCloud(Builder builder) { + this.id = builder.id; + this.accessToken = builder.accessToken; + this.username = builder.username; + } + + @Override + public Long id() { + return id; + } + + public String accessToken() { + return accessToken; + } + + public String username() { + return username; + } + + @Override + public CloudType type() { + return CloudType.DROPBOX; + } + + @Override + public boolean configurationMatches(Cloud cloud) { + return true; + } + + @Override + public boolean predefined() { + return true; + } + + @Override + public boolean persistent() { + return true; + } + + @Override + public boolean requiresNetwork() { + return true; + } + + public static Builder aDropboxCloud() { + return new Builder(); + } + + public static Builder aCopyOf(DropboxCloud dropboxCloud) { + return new Builder() // + .withId(dropboxCloud.id()) // + .withAccessToken(dropboxCloud.accessToken()) // + .withUsername(dropboxCloud.username()); + } + + @NotNull + @Override + public String toString() { + return "DROPBOX"; + } + + public static class Builder { + + private Long id; + private String accessToken; + private String username; + + private Builder() { + } + + public Builder withId(Long id) { + this.id = id; + return this; + } + + public Builder withAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public Builder withUsername(String username) { + this.username = username; + return this; + } + + public DropboxCloud build() { + return new DropboxCloud(this); + } + + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((DropboxCloud) obj); + } + + @Override + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + private boolean internalEquals(DropboxCloud obj) { + return id != null && id.equals(obj.id); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/GoogleDriveCloud.java b/domain/src/main/java/org/cryptomator/domain/GoogleDriveCloud.java new file mode 100644 index 000000000..3e6921370 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/GoogleDriveCloud.java @@ -0,0 +1,120 @@ +package org.cryptomator.domain; + +import org.jetbrains.annotations.NotNull; + +public class GoogleDriveCloud implements Cloud { + + private final Long id; + private final String accessToken; + private final String username; + + private GoogleDriveCloud(Builder builder) { + this.id = builder.id; + this.accessToken = builder.accessToken; + this.username = builder.username; + } + + @Override + public Long id() { + return id; + } + + @Override + public CloudType type() { + return CloudType.GOOGLE_DRIVE; + } + + public String accessToken() { + return accessToken; + } + + public String username() { + return username; + } + + @Override + public boolean configurationMatches(Cloud cloud) { + return true; + } + + @Override + public boolean predefined() { + return true; + } + + @Override + public boolean persistent() { + return true; + } + + @Override + public boolean requiresNetwork() { + return true; + } + + public static Builder aGoogleDriveCloud() { + return new Builder(); + } + + public static Builder aCopyOf(GoogleDriveCloud googleDriveCloud) { + return new Builder() // + .withId(googleDriveCloud.id()) // + .withAccessToken(googleDriveCloud.accessToken()) // + .withUsername(googleDriveCloud.username()); + } + + @NotNull + @Override + public String toString() { + return "GOOGLE_DRIVE"; + } + + public static class Builder { + + private Long id; + private String accessToken; + private String username; + + private Builder() { + } + + public Builder withId(Long id) { + this.id = id; + return this; + } + + public Builder withAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public Builder withUsername(String username) { + this.username = username; + return this; + } + + public GoogleDriveCloud build() { + return new GoogleDriveCloud(this); + } + + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((GoogleDriveCloud) obj); + } + + @Override + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + private boolean internalEquals(GoogleDriveCloud obj) { + return id != null && id.equals(obj.id); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/LocalStorageCloud.java b/domain/src/main/java/org/cryptomator/domain/LocalStorageCloud.java new file mode 100644 index 000000000..689b5c36d --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/LocalStorageCloud.java @@ -0,0 +1,118 @@ +package org.cryptomator.domain; + +import android.os.Build; +import android.text.TextUtils; + +import org.jetbrains.annotations.NotNull; + +public class LocalStorageCloud implements Cloud { + + private final Long id; + private final String rootUri; + + private LocalStorageCloud(Builder builder) { + this.id = builder.id; + this.rootUri = builder.rootUri; + } + + @Override + public Long id() { + return id; + } + + public String rootUri() { + return rootUri; + } + + @Override + public CloudType type() { + return CloudType.LOCAL; + } + + @Override + public boolean configurationMatches(Cloud cloud) { + return cloud instanceof LocalStorageCloud && configurationMatches((LocalStorageCloud) cloud); + } + + private boolean configurationMatches(LocalStorageCloud cloud) { + return TextUtils.equals(rootUri, cloud.rootUri); + + } + + @Override + public boolean predefined() { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP; + } + + @Override + public boolean persistent() { + return true; + } + + @Override + public boolean requiresNetwork() { + return false; + } + + public static Builder aLocalStorage() { + return new Builder(); + } + + public static Builder aCopyOf(LocalStorageCloud localStorageCloud) { + return new Builder() // + .withId(localStorageCloud.id()); + } + + @NotNull + @Override + public String toString() { + return "LOCAL"; + } + + public static class Builder { + + private Long id; + private String rootUri; + + private Builder() { + } + + public Builder withId(Long id) { + this.id = id; + return this; + } + + public Builder withRootUri(String rootUri) { + this.rootUri = rootUri; + return this; + } + + public LocalStorageCloud build() { + return new LocalStorageCloud(this); + } + + @NotNull + @Override + public String toString() { + return "LOCAL"; + } + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((LocalStorageCloud) obj); + } + + @Override + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + private boolean internalEquals(LocalStorageCloud obj) { + return id != null && id.equals(obj.id); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/OnedriveCloud.java b/domain/src/main/java/org/cryptomator/domain/OnedriveCloud.java new file mode 100644 index 000000000..a350a3e04 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/OnedriveCloud.java @@ -0,0 +1,120 @@ +package org.cryptomator.domain; + +import org.jetbrains.annotations.NotNull; + +public class OnedriveCloud implements Cloud { + + private final Long id; + private final String accessToken; + private final String username; + + private OnedriveCloud(Builder builder) { + this.id = builder.id; + this.accessToken = builder.accessToken; + this.username = builder.username; + } + + @Override + public Long id() { + return id; + } + + public String accessToken() { + return accessToken; + } + + public String username() { + return username; + } + + @Override + public CloudType type() { + return CloudType.ONEDRIVE; + } + + @Override + public boolean predefined() { + return true; + } + + @Override + public boolean persistent() { + return true; + } + + @Override + public boolean requiresNetwork() { + return true; + } + + @Override + public boolean configurationMatches(Cloud cloud) { + return true; + } + + @NotNull + @Override + public String toString() { + return "ONEDRIVE"; + } + + public static Builder aOnedriveCloud() { + return new Builder(); + } + + public static Builder aCopyOf(OnedriveCloud oneDriveCloud) { + return new Builder() // + .withId(oneDriveCloud.id()) // + .withAccessToken(oneDriveCloud.accessToken()) // + .withUsername(oneDriveCloud.username()); + } + + public static class Builder { + + private Long id; + private String accessToken; + private String username; + + private Builder() { + } + + public Builder withId(Long id) { + this.id = id; + return this; + } + + public Builder withAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public Builder withUsername(String username) { + this.username = username; + return this; + } + + public OnedriveCloud build() { + return new OnedriveCloud(this); + } + + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((OnedriveCloud) obj); + } + + @Override + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + private boolean internalEquals(OnedriveCloud obj) { + return id != null && id.equals(obj.id); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/Vault.java b/domain/src/main/java/org/cryptomator/domain/Vault.java new file mode 100644 index 000000000..6bcd36697 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/Vault.java @@ -0,0 +1,195 @@ +package org.cryptomator.domain; + +import java.io.Serializable; + +public class Vault implements Serializable { + + private static final Long NOT_SET = Long.MIN_VALUE; + + public static Builder aVault() { + return new Builder(); + } + + public static Builder aCopyOf(Vault vault) { + return new Builder() // + .withId(vault.getId()) // + .withCloud(vault.getCloud()) // + .withCloudType(vault.getCloudType()) // + .withName(vault.getName()) // + .withPath(vault.getPath()) // + .withUnlocked(vault.isUnlocked()) // + .withSavedPassword(vault.getPassword()) // + .withVersion(vault.getVersion()); + } + + private final Long id; + private final String name; + private final String path; + private final Cloud cloud; + private final CloudType cloudType; + private final boolean unlocked; + private final String password; + private final int version; + + private Vault(Builder builder) { + this.id = builder.id; + this.name = builder.name; + this.path = builder.path; + this.cloud = builder.cloud; + this.unlocked = builder.unlocked; + this.cloudType = builder.cloudType; + this.password = builder.password; + this.version = builder.version; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getPath() { + return path; + } + + public Cloud getCloud() { + return cloud; + } + + public CloudType getCloudType() { + return cloudType; + } + + public boolean isUnlocked() { + return unlocked; + } + + public String getPassword() { + return password; + } + + public int getVersion() { + return version; + } + + public static class Builder { + + private Long id = NOT_SET; + private String name; + private String path; + private Cloud cloud; + private CloudType cloudType; + private boolean unlocked; + private String password; + private int version = -1; + + private Builder() { + } + + public Builder thatIsNew() { + this.id = null; + return this; + } + + public Builder withId(Long id) { + if (id < 1) { + throw new IllegalArgumentException("id must not be smaller one"); + } + this.id = id; + return this; + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withPath(String path) { + this.path = path; + return this; + } + + public Builder withUnlocked(boolean unlocked) { + this.unlocked = unlocked; + return this; + } + + public Builder withCloud(Cloud cloud) { + this.cloud = cloud; + + if (cloud != null) { + this.cloudType = cloud.type(); + } + + return this; + } + + public Builder withCloudType(CloudType cloudType) { + this.cloudType = cloudType; + + if (cloud != null && cloud.type() != cloudType) { + throw new IllegalStateException("Cloud type must match cloud"); + } + + return this; + } + + public Builder withNamePathAndCloudFrom(CloudFolder vaultFolder) { + this.name = vaultFolder.getName(); + this.path = vaultFolder.getPath(); + this.cloud = vaultFolder.getCloud(); + this.cloudType = cloud.type(); + return this; + } + + public Builder withSavedPassword(String password) { + this.password = password; + return this; + } + + public Builder withVersion(int version) { + this.version = version; + return this; + } + + public Vault build() { + validate(); + return new Vault(this); + } + + private void validate() { + if (NOT_SET.equals(id)) { + throw new IllegalStateException("id must be set"); + } + if (name == null) { + throw new IllegalStateException("name must be set"); + } + if (path == null) { + throw new IllegalStateException("path must be set"); + } + if (cloudType == null) { + throw new IllegalStateException("cloudtype must be set"); + } + } + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((Vault) obj); + } + + private boolean internalEquals(Vault obj) { + return id != null && id.equals(obj.id); + } + + @Override + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/WebDavCloud.java b/domain/src/main/java/org/cryptomator/domain/WebDavCloud.java new file mode 100644 index 000000000..6d9a1cea9 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/WebDavCloud.java @@ -0,0 +1,150 @@ +package org.cryptomator.domain; + +import org.jetbrains.annotations.NotNull; + +public class WebDavCloud implements Cloud { + + private final Long id; + private final String url; + private final String username; + private final String password; + private final String certificate; + + private WebDavCloud(Builder builder) { + this.id = builder.id; + this.url = builder.url; + this.username = builder.username; + this.password = builder.password; + this.certificate = builder.certificate; + } + + @Override + public Long id() { + return id; + } + + @Override + public boolean configurationMatches(Cloud cloud) { + return cloud instanceof WebDavCloud && configurationMatches((WebDavCloud) cloud); + } + + private boolean configurationMatches(WebDavCloud cloud) { + return url.equals(cloud.url) && username.equals(cloud.username); + } + + @Override + public CloudType type() { + return CloudType.WEBDAV; + } + + public String password() { + return password; + } + + public String url() { + return url; + } + + public String username() { + return username; + } + + public String certificate() { + return certificate; + } + + @Override + public boolean predefined() { + return false; + } + + @Override + public boolean persistent() { + return true; + } + + @Override + public boolean requiresNetwork() { + return true; + } + + public static Builder aWebDavCloudCloud() { + return new Builder(); + } + + public static Builder aCopyOf(WebDavCloud webDavCloud) { + return new Builder() // + .withId(webDavCloud.id()) // + .withUrl(webDavCloud.url()) // + .withUsername(webDavCloud.username()) // + .withPassword(webDavCloud.password()) // + .withCertificate(webDavCloud.certificate()); + } + + @NotNull + @Override + public String toString() { + return "WEBDAV"; + } + + public static class Builder { + + private Long id; + private String password; + private String url; + private String username; + private String certificate; + + private Builder() { + } + + public Builder withId(Long id) { + this.id = id; + return this; + } + + public Builder withUsername(String username) { + this.username = username; + return this; + } + + public Builder withPassword(String password) { + this.password = password; + return this; + } + + public Builder withUrl(String url) { + this.url = url; + return this; + } + + public Builder withCertificate(String certificate) { + this.certificate = certificate; + return this; + } + + public WebDavCloud build() { + return new WebDavCloud(this); + } + + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) + return false; + if (obj == this) + return true; + return internalEquals((WebDavCloud) obj); + } + + @Override + public int hashCode() { + return id == null ? 0 : id.hashCode(); + } + + private boolean internalEquals(WebDavCloud obj) { + return id != null && id.equals(obj.id); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/di/PerView.java b/domain/src/main/java/org/cryptomator/domain/di/PerView.java new file mode 100755 index 000000000..83f4f4875 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/di/PerView.java @@ -0,0 +1,7 @@ +package org.cryptomator.domain.di; + +import javax.inject.Scope; + +@Scope +public @interface PerView { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/AlreadyExistException.java b/domain/src/main/java/org/cryptomator/domain/exception/AlreadyExistException.java new file mode 100644 index 000000000..fba7f2cd8 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/AlreadyExistException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class AlreadyExistException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/BackendException.java b/domain/src/main/java/org/cryptomator/domain/exception/BackendException.java new file mode 100644 index 000000000..fbca5a1dc --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/BackendException.java @@ -0,0 +1,21 @@ +package org.cryptomator.domain.exception; + +public abstract class BackendException extends Exception { + + public BackendException() { + super(); + } + + public BackendException(Throwable e) { + super(e); + } + + public BackendException(String message) { + super(message); + } + + public BackendException(String message, Throwable e) { + super(message, e); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/CancellationException.java b/domain/src/main/java/org/cryptomator/domain/exception/CancellationException.java new file mode 100644 index 000000000..4fe6812a5 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/CancellationException.java @@ -0,0 +1,16 @@ +package org.cryptomator.domain.exception; + +/** + * Thrown if an operation has willingly be canceld. + */ +public class CancellationException extends FatalBackendException { + + public CancellationException() { + super("Operation canceled"); + } + + public CancellationException(Throwable cause) { + super("Operation canceled", cause); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/CloudAlreadyExistsException.java b/domain/src/main/java/org/cryptomator/domain/exception/CloudAlreadyExistsException.java new file mode 100644 index 000000000..e880d8477 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/CloudAlreadyExistsException.java @@ -0,0 +1,7 @@ +package org.cryptomator.domain.exception; + +public class CloudAlreadyExistsException extends BackendException { + + public CloudAlreadyExistsException() { + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/CloudNodeAlreadyExistsException.java b/domain/src/main/java/org/cryptomator/domain/exception/CloudNodeAlreadyExistsException.java new file mode 100644 index 000000000..976fdaae8 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/CloudNodeAlreadyExistsException.java @@ -0,0 +1,8 @@ +package org.cryptomator.domain.exception; + +public class CloudNodeAlreadyExistsException extends BackendException { + + public CloudNodeAlreadyExistsException(String name) { + super(name); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/EmptyDirFileException.java b/domain/src/main/java/org/cryptomator/domain/exception/EmptyDirFileException.java new file mode 100644 index 000000000..150f796a1 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/EmptyDirFileException.java @@ -0,0 +1,21 @@ +package org.cryptomator.domain.exception; + +public class EmptyDirFileException extends BackendException { + + private final String filePath; + private final String dirName; + + public EmptyDirFileException(String dirName, String filePath) { + super(String.format("Empty dir file detected: %s", filePath)); + this.dirName = dirName; + this.filePath = filePath; + } + + public String getFilePath() { + return filePath; + } + + public String getDirName() { + return dirName; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/FatalBackendException.java b/domain/src/main/java/org/cryptomator/domain/exception/FatalBackendException.java new file mode 100644 index 000000000..ef5eb8c0b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/FatalBackendException.java @@ -0,0 +1,17 @@ +package org.cryptomator.domain.exception; + +public class FatalBackendException extends RuntimeException { + + public FatalBackendException(Throwable e) { + super(e); + } + + public FatalBackendException(String message, Throwable e) { + super(message, e); + } + + public FatalBackendException(String message) { + super(message); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/ForbiddenException.java b/domain/src/main/java/org/cryptomator/domain/exception/ForbiddenException.java new file mode 100644 index 000000000..068a1a905 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/ForbiddenException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class ForbiddenException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/MissingCryptorException.java b/domain/src/main/java/org/cryptomator/domain/exception/MissingCryptorException.java new file mode 100644 index 000000000..b230fe62e --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/MissingCryptorException.java @@ -0,0 +1,5 @@ +package org.cryptomator.domain.exception; + +public class MissingCryptorException extends RuntimeException { + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NetworkConnectionException.java b/domain/src/main/java/org/cryptomator/domain/exception/NetworkConnectionException.java new file mode 100644 index 000000000..b616757d0 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NetworkConnectionException.java @@ -0,0 +1,13 @@ +package org.cryptomator.domain.exception; + +public class NetworkConnectionException extends BackendException { + + public NetworkConnectionException() { + + } + + public NetworkConnectionException(Throwable cause) { + super(cause); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NoDirFileException.java b/domain/src/main/java/org/cryptomator/domain/exception/NoDirFileException.java new file mode 100644 index 000000000..1e8424293 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NoDirFileException.java @@ -0,0 +1,20 @@ +package org.cryptomator.domain.exception; + +public class NoDirFileException extends BackendException { + + private final String cryptoFolderName; + private final String cloudFolderPath; + + public NoDirFileException(String name, String path) { + this.cryptoFolderName = name; + this.cloudFolderPath = path; + } + + public String getCryptoFolderName() { + return cryptoFolderName; + } + + public String getCloudFolderPath() { + return cloudFolderPath; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NoSuchCloudFileException.java b/domain/src/main/java/org/cryptomator/domain/exception/NoSuchCloudFileException.java new file mode 100644 index 000000000..82fb6b746 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NoSuchCloudFileException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception; + +public class NoSuchCloudFileException extends BackendException { + + public NoSuchCloudFileException() { + } + + public NoSuchCloudFileException(String name) { + super(name); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NoSuchVaultException.java b/domain/src/main/java/org/cryptomator/domain/exception/NoSuchVaultException.java new file mode 100644 index 000000000..4cc3c38be --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NoSuchVaultException.java @@ -0,0 +1,17 @@ +package org.cryptomator.domain.exception; + +import org.cryptomator.domain.Vault; + +public class NoSuchVaultException extends BackendException { + + private final Vault vault; + + public NoSuchVaultException(Vault vault, Throwable cause) { + super(cause); + this.vault = vault; + } + + public Vault getVault() { + return vault; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NotFoundException.java b/domain/src/main/java/org/cryptomator/domain/exception/NotFoundException.java new file mode 100644 index 000000000..b82cdef61 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NotFoundException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception; + +public class NotFoundException extends BackendException { + + public NotFoundException() { + } + + public NotFoundException(String name) { + super(name); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NotImplementedException.java b/domain/src/main/java/org/cryptomator/domain/exception/NotImplementedException.java new file mode 100644 index 000000000..1495df350 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NotImplementedException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class NotImplementedException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/NotTrustableCertificateException.java b/domain/src/main/java/org/cryptomator/domain/exception/NotTrustableCertificateException.java new file mode 100644 index 000000000..2aed01201 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/NotTrustableCertificateException.java @@ -0,0 +1,14 @@ +package org.cryptomator.domain.exception; + +import java.security.cert.CertificateException; + +public class NotTrustableCertificateException extends CertificateException { + + public NotTrustableCertificateException(String message) { + super(message); + } + + public NotTrustableCertificateException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/ParentFolderDoesNotExistException.java b/domain/src/main/java/org/cryptomator/domain/exception/ParentFolderDoesNotExistException.java new file mode 100644 index 000000000..ba1304145 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/ParentFolderDoesNotExistException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class ParentFolderDoesNotExistException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/ServerNotWebdavCompatibleException.java b/domain/src/main/java/org/cryptomator/domain/exception/ServerNotWebdavCompatibleException.java new file mode 100644 index 000000000..9fd652fd7 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/ServerNotWebdavCompatibleException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class ServerNotWebdavCompatibleException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/SymLinkException.java b/domain/src/main/java/org/cryptomator/domain/exception/SymLinkException.java new file mode 100644 index 000000000..3ac2a25b4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/SymLinkException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception; + +public class SymLinkException extends BackendException { + + public SymLinkException() { + } + + public SymLinkException(String name) { + super(name); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/TypeMismatchException.java b/domain/src/main/java/org/cryptomator/domain/exception/TypeMismatchException.java new file mode 100644 index 000000000..79fd8de2c --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/TypeMismatchException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class TypeMismatchException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/UnableToDecryptWebdavPasswordException.java b/domain/src/main/java/org/cryptomator/domain/exception/UnableToDecryptWebdavPasswordException.java new file mode 100644 index 000000000..4a7a0512e --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/UnableToDecryptWebdavPasswordException.java @@ -0,0 +1,8 @@ +package org.cryptomator.domain.exception; + +public class UnableToDecryptWebdavPasswordException extends FatalBackendException { + + public UnableToDecryptWebdavPasswordException(RuntimeException exception) { + super(exception); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/UnauthorizedException.java b/domain/src/main/java/org/cryptomator/domain/exception/UnauthorizedException.java new file mode 100644 index 000000000..1d8b2bc88 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/UnauthorizedException.java @@ -0,0 +1,4 @@ +package org.cryptomator.domain.exception; + +public class UnauthorizedException extends BackendException { +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/VaultAlreadyExistException.java b/domain/src/main/java/org/cryptomator/domain/exception/VaultAlreadyExistException.java new file mode 100755 index 000000000..f78776cb3 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/VaultAlreadyExistException.java @@ -0,0 +1,5 @@ +package org.cryptomator.domain.exception; + +public class VaultAlreadyExistException extends BackendException { + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/AuthenticationException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/AuthenticationException.java new file mode 100644 index 000000000..0df773afe --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/AuthenticationException.java @@ -0,0 +1,30 @@ +package org.cryptomator.domain.exception.authentication; + +import android.content.Intent; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.util.Optional; + +public abstract class AuthenticationException extends RuntimeException { + + private final Cloud cloud; + + AuthenticationException(Cloud cloud) { + super("Authentication failed"); + this.cloud = cloud; + } + + AuthenticationException(Cloud cloud, String message) { + super(message); + this.cloud = cloud; + } + + public Cloud getCloud() { + return cloud; + } + + public Optional getRecoveryAction() { + return Optional.empty(); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/NoAuthenticationProvidedException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/NoAuthenticationProvidedException.java new file mode 100644 index 000000000..773506ac4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/NoAuthenticationProvidedException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception.authentication; + +import org.cryptomator.domain.Cloud; + +public class NoAuthenticationProvidedException extends AuthenticationException { + + public NoAuthenticationProvidedException(Cloud cloud) { + super(cloud); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/UserRecoverableAuthenticationException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/UserRecoverableAuthenticationException.java new file mode 100644 index 000000000..36b04af28 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/UserRecoverableAuthenticationException.java @@ -0,0 +1,21 @@ +package org.cryptomator.domain.exception.authentication; + +import android.content.Intent; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.util.Optional; + +public class UserRecoverableAuthenticationException extends AuthenticationException { + + private final transient Intent recoveryAction; + + public UserRecoverableAuthenticationException(Cloud cloud, Intent recoveryAction) { + super(cloud); + this.recoveryAction = recoveryAction; + } + + public Optional getRecoveryAction() { + return Optional.ofNullable(recoveryAction); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavCertificateUntrustedAuthenticationException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavCertificateUntrustedAuthenticationException.java new file mode 100644 index 000000000..1e58cb11c --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavCertificateUntrustedAuthenticationException.java @@ -0,0 +1,17 @@ +package org.cryptomator.domain.exception.authentication; + +import org.cryptomator.domain.Cloud; + +public class WebDavCertificateUntrustedAuthenticationException extends AuthenticationException { + + private final String certificate; + + public WebDavCertificateUntrustedAuthenticationException(Cloud cloud, String certificate) { + super(cloud); + this.certificate = certificate; + } + + public String getCertificate() { + return certificate; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavNotSupportedException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavNotSupportedException.java new file mode 100644 index 000000000..dd9bb44c0 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavNotSupportedException.java @@ -0,0 +1,9 @@ +package org.cryptomator.domain.exception.authentication; + +import org.cryptomator.domain.Cloud; + +public class WebDavNotSupportedException extends AuthenticationException { + public WebDavNotSupportedException(Cloud cloud) { + super(cloud, "WebDav not supported by server"); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavServerNotFoundException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavServerNotFoundException.java new file mode 100644 index 000000000..556660d5b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WebDavServerNotFoundException.java @@ -0,0 +1,9 @@ +package org.cryptomator.domain.exception.authentication; + +import org.cryptomator.domain.Cloud; + +public class WebDavServerNotFoundException extends AuthenticationException { + public WebDavServerNotFoundException(Cloud cloud) { + super(cloud); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/authentication/WrongCredentialsException.java b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WrongCredentialsException.java new file mode 100644 index 000000000..4cb5933a6 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/authentication/WrongCredentialsException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception.authentication; + +import org.cryptomator.domain.Cloud; + +public class WrongCredentialsException extends AuthenticationException { + + public WrongCredentialsException(Cloud cloud) { + super(cloud); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/license/LicenseNotValidException.java b/domain/src/main/java/org/cryptomator/domain/exception/license/LicenseNotValidException.java new file mode 100644 index 000000000..d4de0cf29 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/license/LicenseNotValidException.java @@ -0,0 +1,16 @@ +package org.cryptomator.domain.exception.license; + +import org.cryptomator.domain.exception.BackendException; + +public class LicenseNotValidException extends BackendException { + + private final String license; + + public LicenseNotValidException(final String license) { + this.license = license; + } + + public String getLicense() { + return license; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/license/NoLicenseAvailableException.java b/domain/src/main/java/org/cryptomator/domain/exception/license/NoLicenseAvailableException.java new file mode 100644 index 000000000..394c89b5c --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/license/NoLicenseAvailableException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception.license; + +import org.cryptomator.domain.exception.BackendException; + +public class NoLicenseAvailableException extends BackendException { + + public NoLicenseAvailableException() { + + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/update/GeneralUpdateErrorException.java b/domain/src/main/java/org/cryptomator/domain/exception/update/GeneralUpdateErrorException.java new file mode 100644 index 000000000..dee9870a6 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/update/GeneralUpdateErrorException.java @@ -0,0 +1,14 @@ +package org.cryptomator.domain.exception.update; + +import org.cryptomator.domain.exception.BackendException; + +public class GeneralUpdateErrorException extends BackendException { + + public GeneralUpdateErrorException(final String message) { + super(message); + } + + public GeneralUpdateErrorException(final String message, final Exception e) { + super(message, e); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/exception/update/SSLHandshakePreAndroid5UpdateCheckException.java b/domain/src/main/java/org/cryptomator/domain/exception/update/SSLHandshakePreAndroid5UpdateCheckException.java new file mode 100644 index 000000000..9cbd64da4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/exception/update/SSLHandshakePreAndroid5UpdateCheckException.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.exception.update; + +import org.cryptomator.domain.exception.BackendException; + +public class SSLHandshakePreAndroid5UpdateCheckException extends BackendException { + + public SSLHandshakePreAndroid5UpdateCheckException(final String message, javax.net.ssl.SSLHandshakeException e) { + super(message, e); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/executor/PostExecutionThread.java b/domain/src/main/java/org/cryptomator/domain/executor/PostExecutionThread.java new file mode 100644 index 000000000..19315a1db --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/executor/PostExecutionThread.java @@ -0,0 +1,15 @@ +package org.cryptomator.domain.executor; + +import io.reactivex.Scheduler; + +/** + * Thread abstraction created to change the execution context from any thread to any other thread. + * Useful to encapsulate a UI Thread for example, since some job will be done in background, an + * implementation of this interface will change context and update the UI. + * + */ +public interface PostExecutionThread { + + Scheduler getScheduler(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/executor/ThreadExecutor.java b/domain/src/main/java/org/cryptomator/domain/executor/ThreadExecutor.java new file mode 100644 index 000000000..de1806a66 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/executor/ThreadExecutor.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.executor; + +import java.util.concurrent.Executor; + +/** + * Executor implementation can be based on different frameworks or techniques of asynchronous + * execution, but every implementation will execute a use case out of the UI thread. + * + */ +public interface ThreadExecutor extends Executor { +} diff --git a/domain/src/main/java/org/cryptomator/domain/repository/CloudAuthenticationService.java b/domain/src/main/java/org/cryptomator/domain/repository/CloudAuthenticationService.java new file mode 100644 index 000000000..3427075f8 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/repository/CloudAuthenticationService.java @@ -0,0 +1,13 @@ +package org.cryptomator.domain.repository; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.BackendException; + +public interface CloudAuthenticationService { + + boolean isAuthenticated(Cloud cloud) throws BackendException; + + boolean canAuthenticate(Cloud cloud); + + Cloud updateAuthenticatedCloud(Cloud cloud); +} diff --git a/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.java b/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.java new file mode 100644 index 000000000..7ed7d1f18 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/repository/CloudContentRepository.java @@ -0,0 +1,80 @@ +package org.cryptomator.domain.repository; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.authentication.AuthenticationException; +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.OutputStream; +import java.util.List; + +/** + *

+ * An interface to retrieve the contents of a cloud. + *

+ * A CloudContentRepository will throw {@link AuthenticationException AuthenticationExceptions} + * from any operation if AuthenticationExceptions occur to allow correct handling in the UI. + */ +public interface CloudContentRepository { + + DirType root(CloudType cloud) throws BackendException; + + DirType resolve(CloudType cloud, String path) throws BackendException; + + FileType file(DirType parent, String name) throws BackendException; + + FileType file(DirType parent, String name, Optional size) throws BackendException; + + DirType folder(DirType parent, String name) throws BackendException; + + boolean exists(NodeType node) throws BackendException; + + List list(DirType folder) throws BackendException; + + /** + * Creates a cloud folder and maybe intermediate directories. + * + * @return created cloud folder (migth be different from target) + * + * @throws org.cryptomator.domain.exception.CloudNodeAlreadyExistsException If a cloud node with the same folder name already exists + */ + DirType create(DirType folder) throws BackendException; + + /** + * @return moved cloud folder (might be different from target) + * + * @throws org.cryptomator.domain.exception.CloudNodeAlreadyExistsException If a cloud node with the same target name already exists + */ + DirType move(DirType source, DirType target) throws BackendException; + + /** + * @return moved cloud file (might be different from target) + * + * @throws org.cryptomator.domain.exception.CloudNodeAlreadyExistsException If a cloud node with the same target name already exists + */ + FileType move(FileType source, FileType target) throws BackendException; + + /** + * @throws org.cryptomator.domain.exception.CloudNodeAlreadyExistsException If a cloud node with the same file name already exists + */ + FileType write(FileType file, DataSource data, ProgressAware progressAware, boolean replace, long size) throws BackendException; + + void read(FileType file, Optional encryptedTmpFile, OutputStream data, ProgressAware progressAware) throws BackendException; + + void delete(NodeType node) throws BackendException; + + String checkAuthenticationAndRetrieveCurrentAccount(CloudType cloud) throws BackendException; + + /** + * Performs a logout. After a call to this method further usage of this cloud will cause {@link org.cryptomator.cryptolib.api.AuthenticationFailedException AuthenticationFailedExceptions}. + */ + void logout(CloudType cloud) throws BackendException; +} diff --git a/domain/src/main/java/org/cryptomator/domain/repository/CloudRepository.java b/domain/src/main/java/org/cryptomator/domain/repository/CloudRepository.java new file mode 100644 index 000000000..8cd9154c4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/repository/CloudRepository.java @@ -0,0 +1,38 @@ +package org.cryptomator.domain.repository; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.usecases.vault.UnlockToken; + +import java.util.List; + +public interface CloudRepository { + + List clouds(CloudType cloudType) throws BackendException; + + List allClouds() throws BackendException; + + Cloud store(Cloud cloud) throws BackendException; + + void delete(Cloud cloud) throws BackendException; + + void create(CloudFolder location, CharSequence password) throws BackendException; + + Cloud decryptedViewOf(Vault vault) throws BackendException; + + boolean isVaultPasswordValid(Vault vault, CharSequence password) throws BackendException; + + void lock(Vault vault) throws BackendException; + + void changePassword(Vault vault, String oldPassword, String newPassword) throws BackendException; + + UnlockToken prepareUnlock(Vault vault) throws BackendException; + + Cloud unlock(UnlockToken token, CharSequence password) throws BackendException; + + Cloud unlock(Vault vault, CharSequence password) throws BackendException; + +} diff --git a/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java b/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java new file mode 100644 index 000000000..bb1ac6943 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/repository/UpdateCheckRepository.java @@ -0,0 +1,19 @@ +package org.cryptomator.domain.repository; + +import java.io.File; + +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.update.GeneralUpdateErrorException; +import org.cryptomator.domain.usecases.UpdateCheck; +import org.cryptomator.util.Optional; + +public interface UpdateCheckRepository { + + Optional getUpdateCheck(String version) throws BackendException; + + String getLicense(); + + void setLicense(String license); + + void update(File file) throws GeneralUpdateErrorException; +} diff --git a/domain/src/main/java/org/cryptomator/domain/repository/VaultRepository.java b/domain/src/main/java/org/cryptomator/domain/repository/VaultRepository.java new file mode 100644 index 000000000..5bc767590 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/repository/VaultRepository.java @@ -0,0 +1,21 @@ +package org.cryptomator.domain.repository; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.MissingCryptorException; + +import java.util.List; + +public interface VaultRepository { + + List vaults() throws BackendException; + + Vault store(Vault vault) throws BackendException; + + Long delete(Vault vault) throws BackendException; + + Vault load(Long id) throws BackendException; + + void assertUnlocked(Vault vault) throws MissingCryptorException; + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/CloudFolderRecursiveListing.java b/domain/src/main/java/org/cryptomator/domain/usecases/CloudFolderRecursiveListing.java new file mode 100644 index 000000000..e3a2bc4d5 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/CloudFolderRecursiveListing.java @@ -0,0 +1,40 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; + +import java.util.ArrayList; +import java.util.List; + +public class CloudFolderRecursiveListing { + + private final CloudFolder parent; + private final List files; + private final List folders; + + public CloudFolderRecursiveListing(CloudFolder parent) { + this.parent = parent; + this.files = new ArrayList<>(); + this.folders = new ArrayList<>(); + } + + public CloudFolder getParent() { + return parent; + } + + public List getFiles() { + return files; + } + + public List getFolders() { + return folders; + } + + public void addFile(CloudFile file) { + this.files.add(file); + } + + public void addFolders(CloudFolderRecursiveListing folder) { + this.folders.add(folder); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/CloudNodeRecursiveListing.java b/domain/src/main/java/org/cryptomator/domain/usecases/CloudNodeRecursiveListing.java new file mode 100644 index 000000000..9f70588fa --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/CloudNodeRecursiveListing.java @@ -0,0 +1,21 @@ +package org.cryptomator.domain.usecases; + +import java.util.ArrayList; +import java.util.List; + +public class CloudNodeRecursiveListing { + + private final List foldersContent; + + public CloudNodeRecursiveListing(int size) { + this.foldersContent = new ArrayList<>(size); + } + + public void addFolderContent(CloudFolderRecursiveListing cloudFolderRecursiveListing) { + foldersContent.add(cloudFolderRecursiveListing); + } + + public List getFoldersContent() { + return foldersContent; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/CopyData.java b/domain/src/main/java/org/cryptomator/domain/usecases/CopyData.java new file mode 100644 index 000000000..6cd241b87 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/CopyData.java @@ -0,0 +1,39 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +@UseCase +class CopyData { + + private static final int EOF = -1; + private final InputStream source; + private final OutputStream target; + + public CopyData(@Parameter InputStream source, @Parameter OutputStream target) { + this.source = source; + this.target = target; + } + + public void execute() throws BackendException { + try { + byte[] buffer = new byte[4096]; + int read = 0; + while (read != EOF) { + read = source.read(buffer); + if (read > 0) { + target.write(buffer, 0, read); + } + } + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java b/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java new file mode 100644 index 000000000..fc0f6e7cd --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DoLicenseCheck.java @@ -0,0 +1,81 @@ +package org.cryptomator.domain.usecases; + +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.license.LicenseNotValidException; +import org.cryptomator.domain.exception.license.NoLicenseAvailableException; +import org.cryptomator.domain.repository.UpdateCheckRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import com.google.common.io.BaseEncoding; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; + +@UseCase +public class DoLicenseCheck { + + private final UpdateCheckRepository updateCheckRepository; + private String license; + + DoLicenseCheck(final UpdateCheckRepository updateCheckRepository, @Parameter final String license) { + this.updateCheckRepository = updateCheckRepository; + this.license = license; + } + + public LicenseCheck execute() throws BackendException { + license = useLicenseOrRetrieveFromDb(license); + + try { + final Claims claims = Jwts // + .parserBuilder().setSigningKey(getPublicKey()) // + .build().parseClaimsJws(license) // + .getBody(); + + return claims::getSubject; + } catch (JwtException | FatalBackendException e) { + throw new LicenseNotValidException(license); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new FatalBackendException(e); + } + } + + private String useLicenseOrRetrieveFromDb(String license) throws NoLicenseAvailableException { + if (!license.isEmpty()) { + updateCheckRepository.setLicense(license); + } else { + license = updateCheckRepository.getLicense(); + + if (license == null) { + throw new NoLicenseAvailableException(); + } + } + + return license; + } + + private ECPublicKey getPublicKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + final byte[] publicKey = BaseEncoding // + .base64() // + .decode("MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBcnb81CfNeL3qBVFMx/yRfm1Y1yib" + // + "ajIJkV1s82AQt+mOl4+Kub64wq1OCgBVwWUlKwqgnyF39nmkoXEjakRPFngBzg2J" + // + "zo4UR0B7OYmn0uGf3K+zQfxKnNMxGVPtlzE8j9Nqz/dm2YvYLLVwvTSDQX/GaxoP" + // + "/EH84Hupw2wuU7qAaFU="); + + Key key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(publicKey)); + if (key instanceof ECPublicKey) { + return (ECPublicKey) key; + } else { + throw new FatalBackendException("Key not an EC public key."); + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/DoUpdate.java b/domain/src/main/java/org/cryptomator/domain/usecases/DoUpdate.java new file mode 100644 index 000000000..f8dfaff3b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DoUpdate.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases; + +import java.io.File; + +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.UpdateCheckRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +public class DoUpdate { + + private final UpdateCheckRepository updateCheckRepository; + private final File file; + + DoUpdate(final UpdateCheckRepository updateCheckRepository, @Parameter File file) { + this.updateCheckRepository = updateCheckRepository; + this.file = file; + } + + public void execute() throws BackendException { + updateCheckRepository.update(file); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/DoUpdateCheck.java b/domain/src/main/java/org/cryptomator/domain/usecases/DoUpdateCheck.java new file mode 100644 index 000000000..1ce94e505 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DoUpdateCheck.java @@ -0,0 +1,23 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.UpdateCheckRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; +import org.cryptomator.util.Optional; + +@UseCase +public class DoUpdateCheck { + + private final String version; + private final UpdateCheckRepository updateCheckRepository; + + DoUpdateCheck(final UpdateCheckRepository updateCheckRepository, @Parameter String version) { + this.updateCheckRepository = updateCheckRepository; + this.version = version; + } + + public Optional execute() throws BackendException { + return updateCheckRepository.getUpdateCheck(version); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/DownloadFile.java b/domain/src/main/java/org/cryptomator/domain/usecases/DownloadFile.java new file mode 100644 index 000000000..94cea38de --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DownloadFile.java @@ -0,0 +1,45 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.CloudFile; + +import java.io.OutputStream; + +public class DownloadFile { + + private final CloudFile downloadFile; + + private final OutputStream dataSink; + + private DownloadFile(Builder builder) { + this.downloadFile = builder.downloadFile; + this.dataSink = builder.dataSink; + } + + public CloudFile getDownloadFile() { + return downloadFile; + } + + public OutputStream getDataSink() { + return dataSink; + } + + public static class Builder { + + private CloudFile downloadFile; + private OutputStream dataSink; + + public Builder setDownloadFile(CloudFile downloadFile) { + this.downloadFile = downloadFile; + return this; + } + + public Builder setDataSink(OutputStream dataSink) { + this.dataSink = dataSink; + return this; + } + + public DownloadFile build() { + return new DownloadFile(this); + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/DownloadFileReplacingProgressAware.java b/domain/src/main/java/org/cryptomator/domain/usecases/DownloadFileReplacingProgressAware.java new file mode 100644 index 000000000..b70e58d6d --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/DownloadFileReplacingProgressAware.java @@ -0,0 +1,25 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.usecases.cloud.DownloadState; +import org.cryptomator.domain.usecases.cloud.Progress; + +public class DownloadFileReplacingProgressAware implements ProgressAware { + + private final CloudFile file; + private final ProgressAware delegate; + + public DownloadFileReplacingProgressAware(CloudFile file, ProgressAware delegate) { + this.file = file; + this.delegate = delegate; + } + + @Override + public void onProgress(Progress progress) { + if (progress.state() == null) { + delegate.onProgress(progress); + } else { + delegate.onProgress(progress.withState(progress.state().withFile(file))); + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/GetDecryptedCloudForVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/GetDecryptedCloudForVault.java new file mode 100755 index 000000000..e0fdad8f4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/GetDecryptedCloudForVault.java @@ -0,0 +1,25 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class GetDecryptedCloudForVault { + + private final CloudRepository cloudRepository; + private final Vault vault; + + public GetDecryptedCloudForVault(CloudRepository cloudRepository, @Parameter Vault vault) { + this.cloudRepository = cloudRepository; + this.vault = vault; + } + + public Cloud execute() throws BackendException { + return cloudRepository.decryptedViewOf(vault); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/LicenseCheck.java b/domain/src/main/java/org/cryptomator/domain/usecases/LicenseCheck.java new file mode 100644 index 000000000..5ead5fc4a --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/LicenseCheck.java @@ -0,0 +1,7 @@ +package org.cryptomator.domain.usecases; + +public interface LicenseCheck { + + String mail(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/NoOpResultHandler.java b/domain/src/main/java/org/cryptomator/domain/usecases/NoOpResultHandler.java new file mode 100644 index 000000000..df4b408c9 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/NoOpResultHandler.java @@ -0,0 +1,18 @@ +package org.cryptomator.domain.usecases; + +public abstract class NoOpResultHandler implements ResultHandler { + @Override + public void onFinished() { + // no-op + } + + @Override + public void onError(Throwable e) { + // no-op + } + + @Override + public void onSuccess(T t) { + // no-op + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/ProgressAware.java b/domain/src/main/java/org/cryptomator/domain/usecases/ProgressAware.java new file mode 100644 index 000000000..647bdd851 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/ProgressAware.java @@ -0,0 +1,13 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.ProgressState; + +public interface ProgressAware { + + ProgressAware NO_OP_PROGRESS_AWARE = progress -> { + }; + + void onProgress(Progress progress); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/ProgressAwareResultHandler.java b/domain/src/main/java/org/cryptomator/domain/usecases/ProgressAwareResultHandler.java new file mode 100644 index 000000000..9a37a743d --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/ProgressAwareResultHandler.java @@ -0,0 +1,81 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.ProgressState; + +/** + * A {@link ProgressAware} {@link ResultHandler}. + */ +public abstract class ProgressAwareResultHandler implements ResultHandler, ProgressAware { + + public static class NoOp extends ProgressAwareResultHandler { + @Override + public void onSuccess(T result) { + // no-op + } + + @Override + public void onError(Throwable e) { + // no-op + } + + @Override + public void onFinished() { + // no-op + } + + @Override + public void onProgress(Progress progress) { + // no-op + } + } + + public static ProgressAwareResultHandler from(final ResultHandler resultHandler) { + return new ProgressAwareResultHandler() { + @Override + public void onProgress(Progress progress) { + // no-op + } + + @Override + public void onSuccess(T result) { + resultHandler.onSuccess(result); + } + + @Override + public void onError(Throwable e) { + resultHandler.onError(e); + } + + @Override + public void onFinished() { + resultHandler.onFinished(); + } + }; + } + + public static ProgressAwareResultHandler from(final ResultHandler resultHandler, final ProgressAware progressAware) { + return new ProgressAwareResultHandler() { + @Override + public void onProgress(Progress progress) { + progressAware.onProgress(progress); + } + + @Override + public void onSuccess(T result) { + resultHandler.onSuccess(result); + } + + @Override + public void onError(Throwable e) { + resultHandler.onError(e); + } + + @Override + public void onFinished() { + resultHandler.onFinished(); + } + }; + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/ResultHandler.java b/domain/src/main/java/org/cryptomator/domain/usecases/ResultHandler.java new file mode 100644 index 000000000..5535c5855 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/ResultHandler.java @@ -0,0 +1,32 @@ +package org.cryptomator.domain.usecases; + +/** + * A handler for use case results. + * + * @param The type of result this handler can handle. + */ +public interface ResultHandler { + + /** + * Invoked after successful execution of a use case. + * + * @param result the use case result + */ + void onSuccess(T result); + + /** + * Invoked after failed execution of a use case. + * + * @param e the error that occured + */ + void onError(Throwable e); + + /** + *

+ * Invoked after successful and failed execution of a use case. + *

+ * This method is always invoked after onSuccess / onError. + */ + void onFinished(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/ResultRenamed.java b/domain/src/main/java/org/cryptomator/domain/usecases/ResultRenamed.java new file mode 100644 index 000000000..54081d09f --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/ResultRenamed.java @@ -0,0 +1,22 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.CloudNode; + +public class ResultRenamed { + + private final T value; + private final String oldName; + + public ResultRenamed(T value, String oldName) { + this.value = value; + this.oldName = oldName; + } + + public T value() { + return value; + } + + public String getOldName() { + return oldName; + } +} \ No newline at end of file diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/ResultWithProgress.java b/domain/src/main/java/org/cryptomator/domain/usecases/ResultWithProgress.java new file mode 100644 index 000000000..eb18b4c3e --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/ResultWithProgress.java @@ -0,0 +1,35 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.ProgressState; + +public class ResultWithProgress { + + public static ResultWithProgress progress(Progress progress) { + return new ResultWithProgress<>(null, progress); + } + + public static ResultWithProgress finalResult(T value) { + return new ResultWithProgress<>(value, Progress.completed()); + } + + public static ResultWithProgress noProgress(S state) { + return new ResultWithProgress<>(null, Progress.started(state)); + } + + private final Progress progress; + private final T value; + + private ResultWithProgress(T value, Progress progress) { + this.value = value; + this.progress = progress; + } + + public Progress progress() { + return progress; + } + + public T value() { + return value; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/ThrottlingProgressAware.java b/domain/src/main/java/org/cryptomator/domain/usecases/ThrottlingProgressAware.java new file mode 100644 index 000000000..132930429 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/ThrottlingProgressAware.java @@ -0,0 +1,37 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.ProgressState; + +import timber.log.Timber; + +import static java.lang.System.currentTimeMillis; + +public class ThrottlingProgressAware implements ProgressAware { + + private static final long THROTTLE_INTERVAL_MS = 40L; + + private final ProgressAware progressAware; + + private Progress lastProgress; + private long nextProgressDelegation = 0L; + + public ThrottlingProgressAware(ProgressAware progressAware) { + this.progressAware = progressAware; + } + + @Override + public void onProgress(Progress progress) { + if (progress.stateOrCompletionChanged(lastProgress) || // + progress.percentageChanged(lastProgress) && throttleIntervalHasPassed()) { + Timber.tag("Progress").v(progress.toString()); + nextProgressDelegation = currentTimeMillis() + THROTTLE_INTERVAL_MS; + lastProgress = progress; + progressAware.onProgress(progress); + } + } + + private boolean throttleIntervalHasPassed() { + return nextProgressDelegation < currentTimeMillis(); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/UpdateCheck.java b/domain/src/main/java/org/cryptomator/domain/usecases/UpdateCheck.java new file mode 100644 index 000000000..494b33dae --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/UpdateCheck.java @@ -0,0 +1,12 @@ +package org.cryptomator.domain.usecases; + +public interface UpdateCheck { + + String releaseNote(); + + String getVersion(); + + String getUrlApk(); + + String getUrlReleaseNote(); +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/UploadFileReplacingProgressAware.java b/domain/src/main/java/org/cryptomator/domain/usecases/UploadFileReplacingProgressAware.java new file mode 100644 index 000000000..3915ec3f4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/UploadFileReplacingProgressAware.java @@ -0,0 +1,25 @@ +package org.cryptomator.domain.usecases; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.usecases.cloud.Progress; +import org.cryptomator.domain.usecases.cloud.UploadState; + +public class UploadFileReplacingProgressAware implements ProgressAware { + + private final CloudFile file; + private final ProgressAware delegate; + + public UploadFileReplacingProgressAware(CloudFile file, ProgressAware delegate) { + this.file = file; + this.delegate = delegate; + } + + @Override + public void onProgress(Progress progress) { + if (progress.state() == null) { + delegate.onProgress(progress); + } else { + delegate.onProgress(progress.withState(progress.state().withFile(file))); + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/AddOrChangeCloudConnection.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/AddOrChangeCloudConnection.java new file mode 100644 index 000000000..829eaee3b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/AddOrChangeCloudConnection.java @@ -0,0 +1,47 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +public class AddOrChangeCloudConnection { + + private final Cloud cloud; + private final CloudRepository cloudRepository; + + public AddOrChangeCloudConnection(CloudRepository cloudRepository, // + @Parameter Cloud cloud) { + this.cloud = cloud; + this.cloudRepository = cloudRepository; + } + + public void execute() throws BackendException { + if (cloudExists(cloud)) { + throw new CloudAlreadyExistsException(); + } + + if (cloud.persistent()) { + cloudRepository.store(cloud); + } else { + throw new FatalBackendException("Can't change cloud because it's not persistent"); + } + } + + private boolean cloudExists(Cloud cloud) throws BackendException { + for (Cloud storedCloud : cloudRepository.clouds(cloud.type())) { + if (cloud.id() != null && cloud.id().equals(storedCloud.id())) { + continue; + } + if (storedCloud.configurationMatches(cloud)) { + return true; + } + } + return false; + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ByteArrayDataSource.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ByteArrayDataSource.java new file mode 100644 index 000000000..3f5c59d97 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ByteArrayDataSource.java @@ -0,0 +1,43 @@ +package org.cryptomator.domain.usecases.cloud; + +import android.content.Context; + +import org.cryptomator.util.Optional; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class ByteArrayDataSource implements DataSource { + + public static DataSource from(byte[] bytes) { + return new ByteArrayDataSource(bytes); + } + + private final byte[] bytes; + + private ByteArrayDataSource(byte[] bytes) { + this.bytes = bytes; + } + + @Override + public Optional size(Context context) { + long size = bytes.length; + return Optional.of(size); + } + + @Override + public InputStream open(Context context) throws IOException { + return new ByteArrayInputStream(bytes); + } + + @Override + public DataSource decorate(DataSource delegate) { + return delegate; + } + + @Override + public void close() throws IOException { + // do nothing because ByteArrayInputStream need no close + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareDataSource.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareDataSource.java new file mode 100644 index 000000000..91adf61f4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareDataSource.java @@ -0,0 +1,49 @@ +package org.cryptomator.domain.usecases.cloud; + +import android.content.Context; + +import org.cryptomator.domain.exception.CancellationException; +import org.cryptomator.util.Optional; + +import java.io.IOException; +import java.io.InputStream; + +public class CancelAwareDataSource implements DataSource { + + public static CancelAwareDataSource wrap(DataSource delegate, Flag cancelled) { + return new CancelAwareDataSource(delegate, cancelled); + } + + private final DataSource delegate; + private final Flag cancelled; + + private CancelAwareDataSource(DataSource delegate, Flag cancelled) { + this.delegate = delegate; + this.cancelled = cancelled; + } + + @Override + public Optional size(Context context) { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.size(context); + } + + @Override + public InputStream open(Context context) throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + return CancelAwareInputStream.wrap(delegate.open(context), cancelled); + } + + public CancelAwareDataSource decorate(DataSource delegate) { + return new CancelAwareDataSource(delegate, cancelled); + } + + @Override + public void close() throws IOException { + delegate.close(); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareInputStream.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareInputStream.java new file mode 100644 index 000000000..416021e34 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CancelAwareInputStream.java @@ -0,0 +1,95 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.exception.CancellationException; + +import java.io.IOException; +import java.io.InputStream; + +import androidx.annotation.NonNull; + +class CancelAwareInputStream extends InputStream { + + public static CancelAwareInputStream wrap(InputStream delegate, Flag cancelled) { + return new CancelAwareInputStream(delegate, cancelled); + } + + private final InputStream delegate; + private final Flag cancelled; + + private CancelAwareInputStream(InputStream delegate, Flag cancelled) { + this.delegate = delegate; + this.cancelled = cancelled; + } + + @Override + public int read() throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.read(); + } + + @Override + public long skip(long n) throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.skip(n); + } + + @Override + public int available() throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.available(); + } + + @Override + public int read(@NonNull byte[] b) throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.read(b); + } + + @Override + public int read(@NonNull byte[] b, int off, int len) throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.read(b, off, len); + } + + @Override + public void close() throws IOException { + delegate.close(); + if (cancelled.get()) { + throw new CancellationException(); + } + } + + @Override + public synchronized void reset() throws IOException { + if (cancelled.get()) { + throw new CancellationException(); + } + delegate.reset(); + } + + @Override + public synchronized void mark(int readlimit) { + if (cancelled.get()) { + throw new CancellationException(); + } + delegate.mark(readlimit); + } + + @Override + public boolean markSupported() { + if (cancelled.get()) { + throw new CancellationException(); + } + return delegate.markSupported(); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToWebDav.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToWebDav.java new file mode 100644 index 000000000..eec978c94 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ConnectToWebDav.java @@ -0,0 +1,23 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class ConnectToWebDav { + + private final CloudContentRepository cloudContentRepository; + private final WebDavCloud cloud; + + public ConnectToWebDav(CloudContentRepository cloudContentRepository, @Parameter WebDavCloud cloud) { + this.cloudContentRepository = cloudContentRepository; + this.cloud = cloud; + } + + public void execute() throws BackendException { + cloudContentRepository.checkAuthenticationAndRetrieveCurrentAccount(cloud); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CreateFolder.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CreateFolder.java new file mode 100755 index 000000000..35c6be9b1 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/CreateFolder.java @@ -0,0 +1,26 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class CreateFolder { + + private final CloudContentRepository cloudContentRepository; + private final CloudFolder parent; + private final String folderName; + + public CreateFolder(CloudContentRepository cloudContentRepository, @Parameter CloudFolder parent, @Parameter String folderName) { + this.cloudContentRepository = cloudContentRepository; + this.parent = parent; + this.folderName = folderName; + } + + public CloudFolder execute() throws BackendException { + CloudFolder toCreate = cloudContentRepository.folder(parent, folderName); + return cloudContentRepository.create(toCreate); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DataSource.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DataSource.java new file mode 100644 index 000000000..460c68b90 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DataSource.java @@ -0,0 +1,20 @@ +package org.cryptomator.domain.usecases.cloud; + +import android.content.Context; + +import org.cryptomator.util.Optional; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; + +public interface DataSource extends Serializable, Closeable { + + Optional size(Context context); + + InputStream open(Context context) throws IOException; + + DataSource decorate(DataSource delegate); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DeleteNodes.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DeleteNodes.java new file mode 100644 index 000000000..b2c557b5d --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DeleteNodes.java @@ -0,0 +1,37 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +import timber.log.Timber; + +@UseCase +class DeleteNodes { + + private final CloudContentRepository cloudContentRepository; + private final List cloudNodes; + + public DeleteNodes(CloudContentRepository cloudContentRepository, // + @Parameter List cloudNodes) { + this.cloudContentRepository = cloudContentRepository; + this.cloudNodes = cloudNodes; + } + + public List execute() throws BackendException { + for (CloudNode cloudNode : cloudNodes) { + try { + cloudContentRepository.delete(cloudNode); + } catch (NoSuchCloudFileException e) { + Timber.tag("DeleteNodes").i("Skipped node deletion: Not found"); + Timber.tag("DeleteNodes").v(e, "Skipped deletion of %s: Not found", cloudNode.getPath()); + } + } + return cloudNodes; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadFiles.java new file mode 100644 index 000000000..8b8d26c71 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadFiles.java @@ -0,0 +1,50 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.DownloadFile; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; +import org.cryptomator.util.Optional; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@UseCase +class DownloadFiles { + + private final CloudContentRepository cloudContentRepository; + private final List downloadFiles; + + public DownloadFiles(CloudContentRepository cloudContentRepository, // + @Parameter List downloadFiles) { + this.cloudContentRepository = cloudContentRepository; + this.downloadFiles = downloadFiles; + } + + public List execute(ProgressAware progressAware) throws BackendException { + List downloadedFiles = new ArrayList<>(); + for (DownloadFile file : downloadFiles) { + try { + cloudContentRepository.read(file.getDownloadFile(), Optional.empty(), file.getDataSink(), progressAware); + downloadedFiles.add(file.getDownloadFile()); + } finally { + closeQuietly(file.getDataSink()); + } + } + return downloadedFiles; + } + + private void closeQuietly(Closeable closeable) { + try { + closeable.close(); + } catch (IOException e) { + // ignore + } + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadState.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadState.java new file mode 100644 index 000000000..4ffc1ed00 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadState.java @@ -0,0 +1,36 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; + +public class DownloadState implements FileTransferState { + + private final CloudFile file; + private final boolean download; + + public static DownloadState download(CloudFile file) { + return new DownloadState(file, true); + } + + public static DownloadState decryption(CloudFile file) { + return new DownloadState(file, false); + } + + private DownloadState(CloudFile file, boolean download) { + this.download = download; + this.file = file; + } + + @Override + public CloudFile file() { + return file; + } + + public boolean isDownload() { + return download; + } + + public DownloadState withFile(CloudFile file) { + return new DownloadState(file, download); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileBasedDataSource.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileBasedDataSource.java new file mode 100644 index 000000000..bff61d924 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileBasedDataSource.java @@ -0,0 +1,43 @@ +package org.cryptomator.domain.usecases.cloud; + +import android.content.Context; + +import org.cryptomator.util.Optional; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class FileBasedDataSource implements DataSource { + + public static FileBasedDataSource from(File file) { + return new FileBasedDataSource(file); + } + + private final File file; + + private FileBasedDataSource(File file) { + this.file = file; + } + + @Override + public Optional size(Context context) { + return Optional.of(file.length()); + } + + @Override + public InputStream open(Context context) throws IOException { + return new FileInputStream(file); + } + + @Override + public DataSource decorate(DataSource delegate) { + return delegate; + } + + @Override + public void close() throws IOException { + // Do nothing + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileTransferState.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileTransferState.java new file mode 100644 index 000000000..5113ac166 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileTransferState.java @@ -0,0 +1,9 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; + +public interface FileTransferState extends ProgressState { + + CloudFile file(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/Flag.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/Flag.java new file mode 100644 index 000000000..026c38551 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/Flag.java @@ -0,0 +1,7 @@ +package org.cryptomator.domain.usecases.cloud; + +public interface Flag { + + boolean get(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetAllClouds.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetAllClouds.java new file mode 100644 index 000000000..4ecf6cddf --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetAllClouds.java @@ -0,0 +1,22 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +@UseCase +class GetAllClouds { + + private final CloudRepository cloudRepository; + + public GetAllClouds(CloudRepository cloudRepository) { + this.cloudRepository = cloudRepository; + } + + public List execute() throws BackendException { + return cloudRepository.allClouds(); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudList.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudList.java new file mode 100644 index 000000000..4aac72dc6 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudList.java @@ -0,0 +1,27 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +@UseCase +class GetCloudList { + + private final CloudContentRepository cloudContentRepository; + private final CloudFolder folder; + + public GetCloudList(CloudContentRepository cloudContentRepository, @Parameter CloudFolder folder) { + this.cloudContentRepository = cloudContentRepository; + this.folder = folder; + } + + public List execute() throws BackendException { + return cloudContentRepository.list(folder); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudListRecursive.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudListRecursive.java new file mode 100644 index 000000000..3c69e6496 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetCloudListRecursive.java @@ -0,0 +1,49 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.CloudFolderRecursiveListing; +import org.cryptomator.domain.usecases.CloudNodeRecursiveListing; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +@UseCase +class GetCloudListRecursive { + + private final CloudContentRepository cloudContentRepository; + private final List folders; + + GetCloudListRecursive(CloudContentRepository cloudContentRepository, // + @Parameter List folders) { + this.cloudContentRepository = cloudContentRepository; + this.folders = folders; + } + + public CloudNodeRecursiveListing execute() throws BackendException { + CloudNodeRecursiveListing cloudNodeRecursiveListing = new CloudNodeRecursiveListing(folders.size()); + for (CloudFolder folder : folders) { + cloudNodeRecursiveListing.addFolderContent(recursiveListing(new CloudFolderRecursiveListing(folder), folder)); + } + return cloudNodeRecursiveListing; + } + + private CloudFolderRecursiveListing recursiveListing(CloudFolderRecursiveListing cloudFolderRecursiveListing, CloudFolder folder) throws BackendException { + List children = cloudContentRepository.list(folder); + for (CloudNode child : children) { + if (child instanceof CloudFolder) { + cloudFolderRecursiveListing.addFolders(// + recursiveListing(new CloudFolderRecursiveListing((CloudFolder) child), // + (CloudFolder) child)); + } else if (child instanceof CloudFile) { + cloudFolderRecursiveListing.addFile((CloudFile) child); + } + } + return cloudFolderRecursiveListing; + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetClouds.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetClouds.java new file mode 100644 index 000000000..496927710 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetClouds.java @@ -0,0 +1,26 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +@UseCase +class GetClouds { + + private final CloudRepository cloudRepository; + private final CloudType cloudType; + + public GetClouds(CloudRepository cloudRepository, @Parameter CloudType cloudType) { + this.cloudRepository = cloudRepository; + this.cloudType = cloudType; + } + + public List execute() throws BackendException { + return cloudRepository.clouds(cloudType); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetRootFolder.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetRootFolder.java new file mode 100755 index 000000000..fece14408 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetRootFolder.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class GetRootFolder { + + private final CloudContentRepository cloudContentRepository; + private final Cloud cloud; + + public GetRootFolder(CloudContentRepository cloudContentRepository, @Parameter Cloud cloud) { + this.cloudContentRepository = cloudContentRepository; + this.cloud = cloud; + } + + public CloudFolder execute() throws BackendException { + return cloudContentRepository.root(cloud); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetUsername.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetUsername.java new file mode 100644 index 000000000..568634b28 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/GetUsername.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class GetUsername { + + private final CloudContentRepository cloudContentRepository; + private final Cloud cloud; + + public GetUsername(CloudContentRepository cloudContentRepository, // + @Parameter Cloud cloud) { + this.cloudContentRepository = cloudContentRepository; + this.cloud = cloud; + } + + public String execute() throws BackendException { + return cloudContentRepository.checkAuthenticationAndRetrieveCurrentAccount(cloud); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/LogoutCloud.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/LogoutCloud.java new file mode 100644 index 000000000..3ea926a98 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/LogoutCloud.java @@ -0,0 +1,53 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.DropboxCloud; +import org.cryptomator.domain.GoogleDriveCloud; +import org.cryptomator.domain.OnedriveCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class LogoutCloud { + + private final CloudContentRepository cloudContentRepository; + private final CloudRepository cloudRepository; + private final Cloud cloud; + + public LogoutCloud(CloudContentRepository cloudContentRepository, CloudRepository cloudRepository, @Parameter Cloud cloud) { + this.cloudContentRepository = cloudContentRepository; + this.cloudRepository = cloudRepository; + this.cloud = cloud; + } + + public Cloud execute() throws BackendException { + cloudContentRepository.logout(cloud); + return cloudRepository.store(cloudWithUsernameAndAccessTokenRemoved(cloud)); + } + + private Cloud cloudWithUsernameAndAccessTokenRemoved(Cloud cloud) { + if (cloud instanceof DropboxCloud) { + return DropboxCloud // + .aCopyOf((DropboxCloud) cloud) // + .withUsername(null) // + .withAccessToken(null) // + .build(); + } else if (cloud instanceof GoogleDriveCloud) { + return GoogleDriveCloud // + .aCopyOf((GoogleDriveCloud) cloud) // + .withUsername(null) // + .withAccessToken(null) // + .build(); + } else if (cloud instanceof OnedriveCloud) { + return OnedriveCloud // + .aCopyOf((OnedriveCloud) cloud) // + .withUsername(null) // + .withAccessToken(null) // + .build(); + } + throw new IllegalStateException("Logout not supported for cloud with type " + cloud.type()); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFiles.java new file mode 100644 index 000000000..da1bd8b9a --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFiles.java @@ -0,0 +1,34 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.ArrayList; +import java.util.List; + +@UseCase +class MoveFiles { + + private final CloudContentRepository cloudContentRepository; + private final List sourceFiles; + private final CloudFolder parent; + + MoveFiles(CloudContentRepository cloudContentRepository, @Parameter List sourceFiles, @Parameter CloudFolder parent) { + this.cloudContentRepository = cloudContentRepository; + this.sourceFiles = sourceFiles; + this.parent = parent; + } + + public List execute() throws BackendException { + List resultFiles = new ArrayList<>(); + for (CloudFile sourceFile : sourceFiles) { + CloudFile targetFile = cloudContentRepository.file(parent, sourceFile.getName()); + resultFiles.add(cloudContentRepository.move(sourceFile, targetFile)); + } + return resultFiles; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFolders.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFolders.java new file mode 100644 index 000000000..075b4efcd --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/MoveFolders.java @@ -0,0 +1,35 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import java.util.ArrayList; +import java.util.List; + +@UseCase +class MoveFolders { + + private final CloudContentRepository cloudContentRepository; + private final List sourceFolders; + private final CloudFolder parent; + + MoveFolders(CloudContentRepository cloudContentRepository, // + @Parameter List sourceFolders, // + @Parameter CloudFolder parent) { + this.cloudContentRepository = cloudContentRepository; + this.sourceFolders = sourceFolders; + this.parent = parent; + } + + public List execute() throws BackendException { + List resultFolders = new ArrayList<>(); + for (CloudFolder sourceFolder : sourceFolders) { + CloudFolder targetFolder = cloudContentRepository.folder(parent, sourceFolder.getName()); + resultFolders.add(cloudContentRepository.move(sourceFolder, targetFolder)); + } + return resultFolders; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/Progress.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/Progress.java new file mode 100644 index 000000000..86428d211 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/Progress.java @@ -0,0 +1,131 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.jetbrains.annotations.NotNull; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +public class Progress { + + private static final int MAX_VALUE = 100; + private static final int MIN_VALUE = 0; + + private final T state; + private final boolean complete; + private final int value; + + private Progress(T state, int value, boolean complete) { + this.state = state; + this.complete = complete; + this.value = min(MAX_VALUE, max(MIN_VALUE, value)); + } + + public boolean stateOrCompletionChanged(Progress other) { + return other == null || // + other.state != state || // + other.complete != complete; + } + + public boolean percentageChanged(Progress other) { + return other == null || // + other.asPercentage() != asPercentage(); + } + + public int asPercentage() { + return value; + } + + public T state() { + return state; + } + + public boolean isCompleteAndHasState() { + return complete && state != null; + } + + public boolean isOverallComplete() { + return complete && state == null; + } + + @NotNull + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + if (state != null) { + sb.append(state).append(": "); + } + if (complete) { + sb.append("complete"); + } else { + sb.append(value); + sb.append('%'); + } + return sb.toString(); + } + + public static ProgressBuilder progress(T state) { + return new ProgressBuilder<>(state); + } + + public static Progress completed() { + return progress((T) null).between(0).and(1).thatIsCompleted(); + } + + public static Progress completed(T state) { + return progress(state).between(0).and(1).thatIsCompleted(); + } + + public static Progress started(T state) { + return progress(state).between(0).and(1).withValue(0); + } + + public Progress withState(S state) { + return new Progress<>(state, value, complete); + } + + public static class ProgressBuilder { + + private final T state; + private long min = 0; + private long max = 100; + + private ProgressBuilder(T state) { + this.state = state; + } + + public ProgressBuilder between(long value) { + min = value; + if (max < min) { + max = min; + } + return this; + } + + public ProgressBuilder and(long value) { + max = value; + if (max < min) { + min = max; + } + return this; + } + + public Progress thatIsCompleted() { + return withValueAndCompleted(max, true); + } + + public Progress withValue(long value) { + return withValueAndCompleted(value, false); + } + + private Progress withValueAndCompleted(long value, boolean completed) { + if (value >= max) { + value = max; + } else if (value <= min) { + value = min; + } + return new Progress<>(state, (int) Math.round(100.0 * (value - min) / (max - min)), completed); + } + + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ProgressState.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ProgressState.java new file mode 100755 index 000000000..175e6880b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/ProgressState.java @@ -0,0 +1,5 @@ +package org.cryptomator.domain.usecases.cloud; + +public interface ProgressState { + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RemoveCloud.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RemoveCloud.java new file mode 100644 index 000000000..2a21d132d --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RemoveCloud.java @@ -0,0 +1,23 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class RemoveCloud { + + private final Cloud cloud; + private final CloudRepository cloudRepository; + + public RemoveCloud(CloudRepository cloudRepository, @Parameter Cloud cloud) { + this.cloud = cloud; + this.cloudRepository = cloudRepository; + } + + public void execute() throws BackendException { + cloudRepository.delete(cloud); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFile.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFile.java new file mode 100755 index 000000000..f324a5d0e --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFile.java @@ -0,0 +1,30 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ResultRenamed; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class RenameFile { + + private final CloudContentRepository cloudContentRepository; + private final CloudFile file; + private final String newName; + + public RenameFile(CloudContentRepository cloudContentRepository, @Parameter CloudFile file, @Parameter String newName) { + this.cloudContentRepository = cloudContentRepository; + this.file = file; + this.newName = newName; + + } + + public ResultRenamed execute() throws BackendException { + CloudFile targetFile = cloudContentRepository.file(file.getParent(), newName); + CloudFile movedFile = cloudContentRepository.move(file, targetFile); + return new ResultRenamed<>(movedFile, file.getName()); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFolder.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFolder.java new file mode 100755 index 000000000..cbf3e9a22 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/RenameFolder.java @@ -0,0 +1,30 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ResultRenamed; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class RenameFolder { + + private final CloudContentRepository cloudContentRepository; + private final CloudFolder folder; + private final String newName; + + public RenameFolder(CloudContentRepository cloudContentRepository, @Parameter CloudFolder folder, @Parameter String newName) { + this.cloudContentRepository = cloudContentRepository; + this.folder = folder; + this.newName = newName; + + } + + public ResultRenamed execute() throws BackendException { + CloudFolder targetFolder = cloudContentRepository.folder(folder.getParent(), newName); + CloudFolder movedFolder = cloudContentRepository.move(folder, targetFolder); + return new ResultRenamed<>(movedFolder, folder.getName()); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFile.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFile.java new file mode 100644 index 000000000..4b7852566 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFile.java @@ -0,0 +1,68 @@ +package org.cryptomator.domain.usecases.cloud; + +public class UploadFile { + + private final String fileName; + + private final DataSource dataSource; + + private final Boolean replacing; + + private UploadFile(Builder builder) { + this.fileName = builder.fileName; + this.dataSource = builder.dataSource; + this.replacing = builder.replacing; + } + + public String getFileName() { + return fileName; + } + + public DataSource getDataSource() { + return dataSource; + } + + public Boolean getReplacing() { + return replacing; + } + + public static Builder aCopyOf(UploadFile uploadFile) { + return new Builder() // + .withFileName(uploadFile.getFileName()) // + .withDataSource(uploadFile.getDataSource()) // + .thatIsReplacing(uploadFile.getReplacing()); + } + + public static Builder anUploadFile() { + return new Builder(); + } + + public static class Builder { + + private String fileName; + private DataSource dataSource; + private Boolean replacing; + + public Builder() { + } + + public Builder withDataSource(DataSource dataSource) { + this.dataSource = dataSource; + return this; + } + + public Builder withFileName(String fileName) { + this.fileName = fileName; + return this; + } + + public Builder thatIsReplacing(Boolean replacing) { + this.replacing = replacing; + return this; + } + + public UploadFile build() { + return new UploadFile(this); + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java new file mode 100644 index 000000000..f56c6c8a4 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java @@ -0,0 +1,158 @@ +package org.cryptomator.domain.usecases.cloud; + +import android.content.Context; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CancellationException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; +import org.cryptomator.util.Optional; + +import java.io.Closeable; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import static java.io.File.createTempFile; + +@UseCase +class UploadFiles { + + private static final int EOF = -1; + + private final Context context; + private final CloudContentRepository cloudContentRepository; + private final CloudFolder parent; + private final List files; + + private volatile boolean cancelled; + private final Flag cancelledFlag = new Flag() { + @Override + public boolean get() { + return cancelled; + } + }; + + public UploadFiles(Context context, // + CloudContentRepository cloudContentRepository, // + @Parameter CloudFolder parent, // + @Parameter List files) { + this.context = context; + this.cloudContentRepository = cloudContentRepository; + this.parent = parent; + this.files = files; + } + + public void onCancel() { + cancelled = true; + } + + public List execute(ProgressAware progressAware) throws BackendException { + cancelled = false; + try { + return upload(progressAware); + } catch (BackendException | RuntimeException e) { + if (cancelled) { + throw new CancellationException(e); + } else { + throw e; + } + } + } + + private List upload(ProgressAware progressAware) throws BackendException { + List uploadedFiles = new ArrayList<>(); + for (UploadFile file : files) { + uploadedFiles.add(upload(file, progressAware)); + } + return uploadedFiles; + } + + private CloudFile upload(UploadFile uploadFile, ProgressAware progressAware) throws BackendException { + DataSource dataSource = uploadFile.getDataSource(); + if (dataSource.size(context).isPresent()) { + return upload(uploadFile, dataSource, progressAware); + } else { + File file = copyDataToFile(dataSource); + try { + return upload(uploadFile, FileBasedDataSource.from(file), progressAware); + } finally { + file.delete(); + } + } + } + + private CloudFile upload(UploadFile uploadFile, DataSource dataSource, ProgressAware progressAware) throws BackendException { + return writeCloudFile( // + uploadFile.getFileName(), // + CancelAwareDataSource.wrap(dataSource, cancelledFlag), // + uploadFile.getReplacing(), // + progressAware); + } + + private File copyDataToFile(DataSource dataSource) { + File dir = context.getCacheDir(); + try { + File target = createTempFile("upload", "tmp", dir); + InputStream in = CancelAwareDataSource.wrap(dataSource, cancelledFlag).open(context); + OutputStream out = new FileOutputStream(target); + copy(in, out); + return target; + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private CloudFile writeCloudFile(String fileName, CancelAwareDataSource dataSource, boolean replacing, ProgressAware progressAware) throws BackendException { + Optional size = dataSource.size(context); + CloudFile source = cloudContentRepository.file(parent, fileName, size); + return cloudContentRepository.write( // + source, // + dataSource, // + progressAware, // + replacing, // + size.get()); + } + + private void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[4096]; + try { + while (copyDidNotReachEof(in, out, buffer)) { + // empty + } + } finally { + closeQuietly(in); + closeQuietly(out); + } + } + + private boolean copyDidNotReachEof(InputStream in, OutputStream out, byte[] buffer) throws IOException { + int read = in.read(buffer); + if (read == EOF) { + return false; + } else { + out.write(buffer, 0, read); + return true; + } + } + + private void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + // ignore + } + } + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadState.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadState.java new file mode 100644 index 000000000..c8c6948e5 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadState.java @@ -0,0 +1,39 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; + +public class UploadState implements FileTransferState { + + private final CloudFile file; + private final boolean upload; + + public static UploadState upload(CloudFile file) { + return new UploadState(file, true); + } + + public static UploadState encryption(CloudFile file) { + return new UploadState(file, false); + } + + private UploadState(CloudFile file, boolean upload) { + this.upload = upload; + this.file = file; + } + + @Override + public CloudFile file() { + return file; + } + + public boolean isUpload() { + return upload; + } + + public boolean isEncryption() { + return !upload; + } + + public UploadState withFile(CloudFile file) { + return new UploadState(file, upload); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/AssertUnlocked.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/AssertUnlocked.java new file mode 100644 index 000000000..b35a36ad8 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/AssertUnlocked.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class AssertUnlocked { + + private final Vault vault; + private final VaultRepository vaultRepository; + + public AssertUnlocked(VaultRepository vaultRepository, @Parameter Vault vault) { + this.vaultRepository = vaultRepository; + this.vault = vault; + } + + public void execute() throws BackendException { + vaultRepository.assertUnlocked(vault); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/ChangePassword.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/ChangePassword.java new file mode 100644 index 000000000..42d771763 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/ChangePassword.java @@ -0,0 +1,46 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.cryptolib.api.InvalidPassphraseException; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.NoSuchVaultException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import static org.cryptomator.util.ExceptionUtil.contains; + +@UseCase +class ChangePassword { + + private final CloudRepository cloudRepository; + private final Vault vault; + private final String oldPassword; + private final String newPassword; + + public ChangePassword(CloudRepository cloudRepository, // + @Parameter Vault vault, // + @Parameter String oldPassword, // + @Parameter String newPassword) { + this.cloudRepository = cloudRepository; + this.vault = vault; + this.oldPassword = oldPassword; + this.newPassword = newPassword; + } + + public void execute() throws BackendException { + try { + if (cloudRepository.isVaultPasswordValid(vault, oldPassword)) { + cloudRepository.changePassword(vault, oldPassword, newPassword); + } else { + throw new InvalidPassphraseException(); + } + } catch (BackendException e) { + if (contains(e, NoSuchCloudFileException.class)) { + throw new NoSuchVaultException(vault, e); + } + throw e; + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/CheckVaultPassword.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/CheckVaultPassword.java new file mode 100644 index 000000000..006f843ea --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/CheckVaultPassword.java @@ -0,0 +1,26 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class CheckVaultPassword { + + private final CloudRepository cloudRepository; + private final Vault vault; + private final String password; + + public CheckVaultPassword(CloudRepository cloudRepository, @Parameter Vault vault, @Parameter String password) { + this.cloudRepository = cloudRepository; + this.vault = vault; + this.password = password; + } + + public Boolean execute() throws BackendException { + return cloudRepository.isVaultPasswordValid(vault, password); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/CreateVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/CreateVault.java new file mode 100644 index 000000000..9b73b5f20 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/CreateVault.java @@ -0,0 +1,41 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import static org.cryptomator.domain.Vault.aVault; + +@UseCase +class CreateVault { + + private final CloudContentRepository cloudContentRepository; + private final CloudRepository cloudRepository; + private final VaultRepository vaultRepository; + private final CloudFolder folder; + private final String vaultName; + private final String password; + + public CreateVault(CloudContentRepository cloudContentRepository, VaultRepository vaultRepository, CloudRepository cloudRepository, @Parameter CloudFolder folder, @Parameter String vaultName, + @Parameter String password) { + this.cloudContentRepository = cloudContentRepository; + this.vaultRepository = vaultRepository; + this.cloudRepository = cloudRepository; + this.folder = folder; + this.vaultName = vaultName; + this.password = password; + } + + public Vault execute() throws BackendException { + CloudFolder vaultFolder = cloudContentRepository.folder(folder, vaultName); + vaultFolder = cloudContentRepository.create(vaultFolder); + cloudRepository.create(vaultFolder, password); + return vaultRepository.store(aVault().thatIsNew().withNamePathAndCloudFrom(vaultFolder).build()); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/DeleteVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/DeleteVault.java new file mode 100644 index 000000000..7ff6cb17b --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/DeleteVault.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class DeleteVault { + + private final VaultRepository vaultRepository; + private final Vault vault; + + public DeleteVault(VaultRepository vaultRepository, @Parameter Vault vault) { + this.vaultRepository = vaultRepository; + this.vault = vault; + } + + public Long execute() throws BackendException { + return vaultRepository.delete(vault); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/GetVaultList.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/GetVaultList.java new file mode 100644 index 000000000..2e6fe96e3 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/GetVaultList.java @@ -0,0 +1,23 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.UseCase; + +import java.util.List; + +@UseCase +class GetVaultList { + + private final VaultRepository vaultRepository; + + public GetVaultList(VaultRepository vaultRepository) { + this.vaultRepository = vaultRepository; + } + + public List execute() throws BackendException { + return vaultRepository.vaults(); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/LockVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/LockVault.java new file mode 100644 index 000000000..c95dce766 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/LockVault.java @@ -0,0 +1,25 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class LockVault { + + private final CloudRepository cloudRepository; + private final Vault vault; + + public LockVault(CloudRepository cloudRepository, @Parameter Vault vault) { + this.cloudRepository = cloudRepository; + this.vault = vault; + } + + public Vault execute() throws BackendException { + cloudRepository.lock(vault); + return Vault.aCopyOf(vault) // + .withUnlocked(false).build(); + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/PrepareUnlock.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/PrepareUnlock.java new file mode 100644 index 000000000..99f029f5e --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/PrepareUnlock.java @@ -0,0 +1,35 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.NoSuchVaultException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import static org.cryptomator.util.ExceptionUtil.contains; + +@UseCase +class PrepareUnlock { + + private final CloudRepository cloudRepository; + private final Vault vault; + + public PrepareUnlock(CloudRepository cloudRepository, @Parameter Vault vault) { + this.cloudRepository = cloudRepository; + this.vault = vault; + } + + public UnlockToken execute() throws BackendException { + try { + return cloudRepository.prepareUnlock(vault); + } catch (BackendException e) { + if (contains(e, NoSuchCloudFileException.class)) { + throw new NoSuchVaultException(vault, e); + } + throw e; + } + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/ReloadVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/ReloadVault.java new file mode 100644 index 000000000..55196adaf --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/ReloadVault.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class ReloadVault { + + private final VaultRepository vaultRepository; + private final Vault vault; + + public ReloadVault(VaultRepository vaultRepository, @Parameter Vault vault) { + this.vaultRepository = vaultRepository; + this.vault = vault; + } + + public Vault execute() throws BackendException { + return vaultRepository.load(vault.getId()); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/RemoveStoredVaultPasswords.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/RemoveStoredVaultPasswords.java new file mode 100644 index 000000000..b38f4ea69 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/RemoveStoredVaultPasswords.java @@ -0,0 +1,43 @@ +package org.cryptomator.domain.usecases.vault; + +import android.content.Context; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.UseCase; +import org.cryptomator.util.SharedPreferencesHandler; +import org.cryptomator.util.crypto.BiometricAuthCryptor; + +import static org.cryptomator.domain.Vault.aCopyOf; + +@UseCase +class RemoveStoredVaultPasswords { + + private final VaultRepository vaultRepository; + private final SharedPreferencesHandler sharedPreferencesHandler; + private final Context context; + + public RemoveStoredVaultPasswords(VaultRepository vaultRepository, // + Context context, // + SharedPreferencesHandler sharedPreferencesHandler) { + this.vaultRepository = vaultRepository; + this.context = context; + this.sharedPreferencesHandler = sharedPreferencesHandler; + } + + public void execute() throws BackendException { + BiometricAuthCryptor.recreateKey(context); + + sharedPreferencesHandler.changeUseBiometricAuthentication(false); + + for (Vault vault : vaultRepository.vaults()) { + if (vault.getPassword() != null) { + vault = aCopyOf(vault) // + .withSavedPassword(null) // + .build(); + vaultRepository.store(vault); + } + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/RenameVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/RenameVault.java new file mode 100644 index 000000000..264586bb0 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/RenameVault.java @@ -0,0 +1,56 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.exception.NoSuchVaultException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +import static org.cryptomator.domain.Vault.aCopyOf; +import static org.cryptomator.util.ExceptionUtil.contains; + +@UseCase +class RenameVault { + + private final CloudContentRepository cloudContentRepository; + private final CloudRepository cloudRepository; + private final VaultRepository vaultRepository; + private Vault vault; + private final String newVaultName; + + public RenameVault(CloudContentRepository cloudContentRepository, CloudRepository cloudRepository, VaultRepository vaultRepository, @Parameter Vault vault, @Parameter String newVaultName) { + this.cloudContentRepository = cloudContentRepository; + this.vaultRepository = vaultRepository; + this.cloudRepository = cloudRepository; + this.vault = vault; + this.newVaultName = newVaultName; + } + + public Vault execute() throws BackendException { + try { + CloudFolder vaultLocation = cloudContentRepository.resolve(vault.getCloud(), vault.getPath()); + CloudFolder vaultLocationAfterRename = cloudContentRepository.folder(vaultLocation.getParent(), newVaultName); + cloudContentRepository.move(vaultLocation, vaultLocationAfterRename); + + if (vault.isUnlocked()) { + cloudRepository.lock(vault); + vault = Vault.aCopyOf(vault) // + .withUnlocked(false).build(); + } + Vault renamedVault = aCopyOf(vault) // + .withNamePathAndCloudFrom(vaultLocationAfterRename) // + .build(); + return vaultRepository.store(renamedVault); + } catch (BackendException e) { + if (contains(e, NoSuchCloudFileException.class)) { + throw new NoSuchVaultException(vault, e); + } + throw e; + } + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/SaveVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/SaveVault.java new file mode 100644 index 000000000..40f32ff2c --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/SaveVault.java @@ -0,0 +1,24 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class SaveVault { + + private final VaultRepository vaultRepository; + private final Vault vault; + + public SaveVault(VaultRepository vaultRepository, @Parameter Vault vault) { + this.vaultRepository = vaultRepository; + this.vault = vault; + } + + public Vault execute() throws BackendException { + return vaultRepository.store(vault); + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockToken.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockToken.java new file mode 100644 index 000000000..0925bfec1 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockToken.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; + +import java.io.Serializable; + +public interface UnlockToken extends Serializable { + + Vault getVault(); + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockVault.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockVault.java new file mode 100644 index 000000000..07951dbfc --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/UnlockVault.java @@ -0,0 +1,30 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.UseCase; + +@UseCase +class UnlockVault { + + private final CloudRepository cloudRepository; + private final VaultOrUnlockToken vaultOrUnlockToken; + private final String password; + + public UnlockVault(CloudRepository cloudRepository, @Parameter VaultOrUnlockToken vaultOrUnlockToken, @Parameter String password) { + this.cloudRepository = cloudRepository; + this.vaultOrUnlockToken = vaultOrUnlockToken; + this.password = password; + } + + public Cloud execute() throws BackendException { + if (vaultOrUnlockToken.getVault().isPresent()) { + return cloudRepository.unlock(vaultOrUnlockToken.getVault().get(), password); + } else { + return cloudRepository.unlock(vaultOrUnlockToken.getUnlockToken().get(), password); + } + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/vault/VaultOrUnlockToken.java b/domain/src/main/java/org/cryptomator/domain/usecases/vault/VaultOrUnlockToken.java new file mode 100644 index 000000000..e2bbb0559 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/vault/VaultOrUnlockToken.java @@ -0,0 +1,34 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.util.Optional; + +import java.io.Serializable; + +public class VaultOrUnlockToken implements Serializable { + + public static VaultOrUnlockToken from(Vault vault) { + return new VaultOrUnlockToken(vault, null); + } + + public static VaultOrUnlockToken from(UnlockToken unlockToken) { + return new VaultOrUnlockToken(null, unlockToken); + } + + private final Vault vault; + private final UnlockToken unlockToken; + + private VaultOrUnlockToken(Vault vault, UnlockToken unlockToken) { + this.vault = vault; + this.unlockToken = unlockToken; + } + + public Optional getVault() { + return Optional.ofNullable(vault); + } + + public Optional getUnlockToken() { + return Optional.ofNullable(unlockToken); + } + +} diff --git a/domain/src/release/java/org.cryptomator.domain.executor/BackgroundTasks.java b/domain/src/release/java/org.cryptomator.domain.executor/BackgroundTasks.java new file mode 100644 index 000000000..3c33204ca --- /dev/null +++ b/domain/src/release/java/org.cryptomator.domain.executor/BackgroundTasks.java @@ -0,0 +1,15 @@ +package org.cryptomator.domain.executor; + +public class BackgroundTasks { + + public static class Registration { + public void unregister() { + // empty in production code, only used in debug / test variant + } + } + + public static Registration register(Class type) { + // empty in production code, only used in debug / test variant + return new Registration(); + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DataSourceCapturingAnswer.java b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DataSourceCapturingAnswer.java new file mode 100644 index 000000000..abf9f35a2 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DataSourceCapturingAnswer.java @@ -0,0 +1,44 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.mockito.invocation.InvocationOnMock; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +class DataSourceCapturingAnswer implements org.mockito.stubbing.Answer { + + private ByteArrayOutputStream out; + private final T result; + private final int argIndex; + + DataSourceCapturingAnswer(T result, int argIndex) { + this.result = result; + this.argIndex = argIndex; + } + + @Override + public T answer(InvocationOnMock invocation) throws Throwable { + InputStream in = ((DataSource) invocation.getArguments()[argIndex]).open(null); + out = new ByteArrayOutputStream(); + copy(in, out); + return result; + } + + private void copy(InputStream in, ByteArrayOutputStream out) { + byte[] buffer = new byte[4096]; + int read; + try { + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public byte[] toByteArray() { + return out.toByteArray(); + } + +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DeleteNodeTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DeleteNodeTest.java new file mode 100644 index 000000000..9f27f0956 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DeleteNodeTest.java @@ -0,0 +1,68 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +public class DeleteNodeTest { + + private final CloudContentRepository cloudContentRepository = mock(CloudContentRepository.class); + + public CloudNode cloudFile = Mockito.mock(CloudFile.class); + + public CloudNode cloudFolder = Mockito.mock(CloudFolder.class); + + @Test + public void testDeleteCloudFile() throws BackendException { + DeleteNodes inTest = testCandidate(singletonList(cloudFile)); + + List results = inTest.execute(); + + verify(cloudContentRepository).delete(cloudFile); + verifyNoMoreInteractions(cloudContentRepository); + assertThat(results, is(singletonList(cloudFile))); + } + + @Test + public void testDeleteCloudFolder() throws BackendException { + DeleteNodes inTest = testCandidate(singletonList(cloudFolder)); + + List results = inTest.execute(); + + verify(cloudContentRepository).delete(cloudFolder); + verifyNoMoreInteractions(cloudContentRepository); + assertThat(results, is(singletonList(cloudFolder))); + } + + @Test + public void testDeleteCloudNodes() throws BackendException { + List cloudNodes = Arrays.asList(cloudFile, cloudFolder); + + DeleteNodes inTest = testCandidate(cloudNodes); + List results = inTest.execute(); + + verify(cloudContentRepository).delete(cloudFile); + verify(cloudContentRepository).delete(cloudFolder); + verifyNoMoreInteractions(cloudContentRepository); + + assertThat(results, is(cloudNodes)); + } + + private DeleteNodes testCandidate(List cloudNodes) { + return new DeleteNodes(cloudContentRepository, cloudNodes); + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DownloadFileTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DownloadFileTest.java new file mode 100644 index 000000000..ff2377365 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/DownloadFileTest.java @@ -0,0 +1,70 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.DownloadFile; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.util.Optional; +import org.junit.jupiter.api.Test; + +import java.io.OutputStream; +import java.util.List; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +public class DownloadFileTest { + + private CloudContentRepository cloudContentRepository = mock(CloudContentRepository.class); + + private CloudFile downloadFile = mock(CloudFile.class); + + private OutputStream dataSink = mock(OutputStream.class); + + private ProgressAware progressAware = mock(ProgressAware.class); + + @Test + public void testDownloadFile() throws BackendException { + DownloadFiles inTest = testCandidate(singletonList(new DownloadFile.Builder() // + .setDownloadFile(downloadFile) // + .setDataSink(dataSink) // + .build())); + + List results = inTest.execute(progressAware); + + verify(cloudContentRepository).read(downloadFile, Optional.empty(), dataSink, progressAware); + verifyNoMoreInteractions(cloudContentRepository); + + assertThat(results, is(singletonList(downloadFile))); + } + + @Test + public void testDownloadFiles() throws BackendException { + DownloadFile file = new DownloadFile.Builder() // + .setDownloadFile(downloadFile) // + .setDataSink(dataSink) // + .build(); + List downloadFiles = asList(file, file); + DownloadFiles inTest = testCandidate(downloadFiles); + + List results = inTest.execute(progressAware); + + verify(cloudContentRepository, times(downloadFiles.size())) // + .read(downloadFile, Optional.empty(), dataSink, progressAware); + verifyNoMoreInteractions(cloudContentRepository); + + assertThat(results, is(asList(downloadFile, downloadFile))); + + } + + private DownloadFiles testCandidate(List downloadFiles) { + return new DownloadFiles(cloudContentRepository, downloadFiles); + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFileTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFileTest.java new file mode 100644 index 000000000..25bc28e89 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFileTest.java @@ -0,0 +1,78 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +public class MoveFileTest { + + private CloudContentRepository cloudContentRepository; + + private CloudFolder parent; + + private CloudFile sourceFile; + + private CloudFile targetFile; + + private CloudFile resultFile; + + @BeforeEach + public void setup() { + cloudContentRepository = Mockito.mock(CloudContentRepository.class); + parent = Mockito.mock(CloudFolder.class); + sourceFile = Mockito.mock(CloudFile.class); + targetFile = Mockito.mock(CloudFile.class); + resultFile = Mockito.mock(CloudFile.class); + } + + @Test + public void testMoveFile() throws BackendException { + MoveFiles inTest = testCandidate(singletonList(sourceFile)); + when(cloudContentRepository.file(parent, null)).thenReturn(targetFile); + when(cloudContentRepository.move(sourceFile, targetFile)).thenReturn(resultFile); + + List result = inTest.execute(); + + verify(cloudContentRepository).file(parent, null); + verify(cloudContentRepository).move(sourceFile, targetFile); + verifyNoMoreInteractions(cloudContentRepository); + assertThat(result, is(singletonList(resultFile))); + } + + @Test + public void testMoveFiles() throws BackendException { + List sourceFiles = asList(sourceFile, sourceFile); + + MoveFiles inTest = testCandidate(sourceFiles); + when(cloudContentRepository.file(parent, null)).thenReturn(targetFile); + when(cloudContentRepository.move(sourceFile, targetFile)).thenReturn(resultFile); + + List result = inTest.execute(); + + verify(cloudContentRepository, times(sourceFiles.size())).file(parent, null); + verify(cloudContentRepository, times(sourceFiles.size())).move(sourceFile, targetFile); + verifyNoMoreInteractions(cloudContentRepository); + assertThat(result, is(asList(resultFile, resultFile))); + } + + private MoveFiles testCandidate(List sourceFile) { + return new MoveFiles(cloudContentRepository, // + sourceFile, // + parent); + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFolderTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFolderTest.java new file mode 100644 index 000000000..f9a460ee7 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/MoveFolderTest.java @@ -0,0 +1,77 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.is; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +public class MoveFolderTest { + + private CloudContentRepository cloudContentRepository; + + private CloudFolder parent; + + private CloudFolder sourceFolder; + + private CloudFolder targetFolder; + + private CloudFolder resultFolder; + + @BeforeEach + public void setup() { + cloudContentRepository = Mockito.mock(CloudContentRepository.class); + parent = Mockito.mock(CloudFolder.class); + sourceFolder = Mockito.mock(CloudFolder.class); + targetFolder = Mockito.mock(CloudFolder.class); + resultFolder = Mockito.mock(CloudFolder.class); + } + + @Test + public void testMoveFolder() throws BackendException { + MoveFolders inTest = testCandidate(singletonList(sourceFolder)); + when(cloudContentRepository.folder(parent, null)).thenReturn(targetFolder); + when(cloudContentRepository.move(sourceFolder, targetFolder)).thenReturn(resultFolder); + + List result = inTest.execute(); + + verify(cloudContentRepository).folder(parent, null); + verify(cloudContentRepository).move(sourceFolder, targetFolder); + verifyNoMoreInteractions(cloudContentRepository); + MatcherAssert.assertThat(result, is(singletonList(resultFolder))); + } + + @Test + public void testMoveFolders() throws BackendException { + List sourceFiles = asList(sourceFolder, sourceFolder); + + MoveFolders inTest = testCandidate(sourceFiles); + when(cloudContentRepository.folder(parent, null)).thenReturn(targetFolder); + when(cloudContentRepository.move(sourceFolder, targetFolder)).thenReturn(resultFolder); + + List result = inTest.execute(); + + verify(cloudContentRepository, times(sourceFiles.size())).folder(parent, null); + verify(cloudContentRepository, times(sourceFiles.size())).move(sourceFolder, targetFolder); + verifyNoMoreInteractions(cloudContentRepository); + MatcherAssert.assertThat(result, is(asList(resultFolder, resultFolder))); + } + + private MoveFolders testCandidate(List sourceFolder) { + return new MoveFolders(cloudContentRepository, // + sourceFolder, // + parent); + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.java new file mode 100644 index 000000000..441355b89 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.java @@ -0,0 +1,122 @@ +package org.cryptomator.domain.usecases.cloud; + +import android.content.Context; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.util.Optional; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import static java.util.Arrays.fill; +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class UploadFileTest { + + private final Context context = mock(Context.class); + private final CloudContentRepository cloudContentRepository = mock(CloudContentRepository.class); + private final CloudFolder parent = mock(CloudFolder.class); + private final CloudFile targetFile = mock(CloudFile.class); + private final CloudFile resultFile = mock(CloudFile.class); + private final String fileName = "fileName"; + private final ProgressAware progressAware = mock(ProgressAware.class); + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testInvocationWithFileSizeDelegatesToCloudContentRepository(Boolean replacing) throws BackendException { + long fileSize = 1337; + DataSource dataSource = dataSourceWithBytes(0, fileSize, Optional.of(fileSize)); + UploadFiles inTest = testCandidate(dataSource, replacing); + when(cloudContentRepository.file(parent, fileName, Optional.of(fileSize))).thenReturn(targetFile); + when(cloudContentRepository.write(same(targetFile), any(DataSource.class), same(progressAware), eq(replacing), eq(fileSize))).thenReturn(resultFile); + + List result = inTest.execute(progressAware); + + assertThat(result.size(), is(1)); + assertThat(result.get(0), is(resultFile)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testInvocationWithoutFileSizeDelegatesToCloudContentRepository(Boolean replacing) throws BackendException, IOException { + long fileSize = 8893; + try (DataSource dataSource = dataSourceWithBytes(85, fileSize, Optional.empty())) { + UploadFiles inTest = testCandidate(dataSource, replacing); + when(cloudContentRepository.file(parent, fileName, Optional.of(fileSize))).thenReturn(targetFile); + DataSourceCapturingAnswer capturedStreamData = new DataSourceCapturingAnswer(resultFile, 1); + when(cloudContentRepository.write(same(targetFile), any(DataSource.class), same(progressAware), eq(replacing), eq(fileSize))).thenAnswer(capturedStreamData); + + List result = inTest.execute(progressAware); + + assertThat(result.size(), is(1)); + assertThat(result.get(0), is(resultFile)); + assertThat(capturedStreamData.toByteArray(), is(bytes(85, fileSize))); + } + } + + private DataSource dataSourceWithBytes(int value, long amount, final Optional size) { + if (amount > Integer.MAX_VALUE) { + throw new IllegalStateException("Can not use values > Integer.MAX_VALUE"); + } + final byte[] bytes = bytes(value, (int) amount); + return new DataSource() { + + @Override + public Optional size(Context context) { + return size; + } + + @Override + public InputStream open(Context context) throws IOException { + return new ByteArrayInputStream(bytes); + } + + @Override + public DataSource decorate(DataSource delegate) { + return delegate; + } + + @Override + public void close() throws IOException { + // do nothing + } + }; + } + + private byte[] bytes(int value, long amount) { + if (amount > Integer.MAX_VALUE) { + throw new IllegalStateException("Can not use values > Integer.MAX_VALUE"); + } + byte[] data = new byte[(int) amount]; + fill(data, (byte) value); + return data; + } + + private UploadFiles testCandidate(DataSource dataSource, Boolean replacing) { + return new UploadFiles( // + context, // + cloudContentRepository, // + parent, // + singletonList(new UploadFile.Builder() // + .withFileName(fileName) // + .withDataSource(dataSource) // + .thatIsReplacing(replacing) // + .build())); + } + +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockVaultTest.java b/domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockVaultTest.java new file mode 100644 index 000000000..557885069 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/vault/UnlockVaultTest.java @@ -0,0 +1,48 @@ +package org.cryptomator.domain.usecases.vault; + +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.repository.CloudRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.mockito.Mockito.verify; + +public class UnlockVaultTest { + + private static final String A_STRING = "89dfhsjdhfjsd"; + + private UnlockToken unlockToken; + + private Vault vault; + + private CloudRepository cloudRepository; + + private UnlockVault inTest; + + @BeforeEach + public void setup() { + unlockToken = Mockito.mock(UnlockToken.class); + vault = Mockito.mock(Vault.class); + cloudRepository = Mockito.mock(CloudRepository.class); + inTest = Mockito.mock(UnlockVault.class); + } + + @Test + public void testExecuteDelegatesToUnlockWhenInvokedWithVault() throws BackendException { + inTest = new UnlockVault(cloudRepository, VaultOrUnlockToken.from(vault), A_STRING); + inTest.execute(); + + verify(cloudRepository).unlock(vault, A_STRING); + } + + @Test + public void testExecuteDelegatesToUnlockWhenInvokedWithUnlockToken() throws BackendException { + inTest = new UnlockVault(cloudRepository, VaultOrUnlockToken.from(unlockToken), A_STRING); + inTest.execute(); + + verify(cloudRepository).unlock(unlockToken, A_STRING); + } + +} diff --git a/eclipse+formatter.xml b/eclipse+formatter.xml new file mode 100755 index 000000000..a98d7ca15 --- /dev/null +++ b/eclipse+formatter.xml @@ -0,0 +1,634 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/generator-api/.gitignore b/generator-api/.gitignore new file mode 100755 index 000000000..796b96d1c --- /dev/null +++ b/generator-api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/generator-api/build.gradle b/generator-api/build.gradle new file mode 100644 index 000000000..c032a3c85 --- /dev/null +++ b/generator-api/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +//noinspection GroovyUnusedAssignment +sourceCompatibility = 1.8 +//noinspection GroovyUnusedAssignment +targetCompatibility = 1.8 + +dependencies { + def dependencies = rootProject.ext.dependencies + + implementation dependencies.android +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Activity.java b/generator-api/src/main/java/org/cryptomator/generator/Activity.java new file mode 100644 index 000000000..3dfc56bf5 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Activity.java @@ -0,0 +1,17 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Retention(RUNTIME) +@Target(TYPE) +public @interface Activity { + + int layout() default -1; + + boolean secure() default true; + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/BottomSheet.java b/generator-api/src/main/java/org/cryptomator/generator/BottomSheet.java new file mode 100644 index 000000000..f62989147 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/BottomSheet.java @@ -0,0 +1,15 @@ +package org.cryptomator.generator; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Retention(RUNTIME) +@Target(TYPE) +public @interface BottomSheet { + + int value(); + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/BoundCallback.java b/generator-api/src/main/java/org/cryptomator/generator/BoundCallback.java new file mode 100644 index 000000000..262694e98 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/BoundCallback.java @@ -0,0 +1,38 @@ +package org.cryptomator.generator; + +import java.io.Serializable; + +public abstract class BoundCallback implements Serializable { + + private final Class declaringType; + private final Class resultType; + private final Serializable[] additionalParameters; + + protected BoundCallback(Class declaringType, Class resultType, Serializable... additionalParameters) { + this.declaringType = declaringType; + this.resultType = resultType; + this.additionalParameters = additionalParameters; + } + + public boolean acceptsNonOkResults() { + return false; + } + + public final Class getDeclaringType() { + return declaringType; + } + + public final Class getResultType() { + return resultType; + } + + public final void call(A instance, R result) { + Object[] parametersWithResult = new Object[additionalParameters.length + 1]; + parametersWithResult[0] = result; + System.arraycopy(additionalParameters, 0, parametersWithResult, 1, additionalParameters.length); + doCall(instance, parametersWithResult); + } + + protected abstract void doCall(A instance, Object[] parameters); + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Callback.java b/generator-api/src/main/java/org/cryptomator/generator/Callback.java new file mode 100644 index 000000000..f03a73dc7 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Callback.java @@ -0,0 +1,15 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Retention(RUNTIME) +@Target(METHOD) +public @interface Callback { + + boolean dispatchResultOkOnly() default true; + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Dialog.java b/generator-api/src/main/java/org/cryptomator/generator/Dialog.java new file mode 100644 index 000000000..7241e4555 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Dialog.java @@ -0,0 +1,15 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Retention(RUNTIME) +@Target(TYPE) +public @interface Dialog { + + int value(); + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Fragment.java b/generator-api/src/main/java/org/cryptomator/generator/Fragment.java new file mode 100644 index 000000000..946fb24a5 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Fragment.java @@ -0,0 +1,15 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Retention(RUNTIME) +@Target(TYPE) +public @interface Fragment { + + int value(); + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/InjectIntent.java b/generator-api/src/main/java/org/cryptomator/generator/InjectIntent.java new file mode 100644 index 000000000..d946fcb48 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/InjectIntent.java @@ -0,0 +1,12 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Retention(SOURCE) +@Target(FIELD) +public @interface InjectIntent { +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/InstanceState.java b/generator-api/src/main/java/org/cryptomator/generator/InstanceState.java new file mode 100644 index 000000000..c9e426dd1 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/InstanceState.java @@ -0,0 +1,12 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Retention(RUNTIME) +@Target(FIELD) +public @interface InstanceState { +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Intent.java b/generator-api/src/main/java/org/cryptomator/generator/Intent.java new file mode 100644 index 000000000..283d06b7d --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Intent.java @@ -0,0 +1,17 @@ +package org.cryptomator.generator; + +import android.app.Activity; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Retention(SOURCE) +@Target(TYPE) +public @interface Intent { + + Class value(); + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Optional.java b/generator-api/src/main/java/org/cryptomator/generator/Optional.java new file mode 100644 index 000000000..1ff9d196d --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Optional.java @@ -0,0 +1,13 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +@Documented +public @interface Optional { +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Parameter.java b/generator-api/src/main/java/org/cryptomator/generator/Parameter.java new file mode 100644 index 000000000..b800f3e6e --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Parameter.java @@ -0,0 +1,12 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Retention(SOURCE) +@Target(PARAMETER) +public @interface Parameter { +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/Unsubscribable.java b/generator-api/src/main/java/org/cryptomator/generator/Unsubscribable.java new file mode 100644 index 000000000..d7bc16da1 --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/Unsubscribable.java @@ -0,0 +1,7 @@ +package org.cryptomator.generator; + +public interface Unsubscribable { + + void unsubscribe(); + +} diff --git a/generator-api/src/main/java/org/cryptomator/generator/UseCase.java b/generator-api/src/main/java/org/cryptomator/generator/UseCase.java new file mode 100644 index 000000000..10c0d0f4e --- /dev/null +++ b/generator-api/src/main/java/org/cryptomator/generator/UseCase.java @@ -0,0 +1,12 @@ +package org.cryptomator.generator; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Retention(SOURCE) +@Target(TYPE) +public @interface UseCase { +} diff --git a/generator/.gitignore b/generator/.gitignore new file mode 100755 index 000000000..796b96d1c --- /dev/null +++ b/generator/.gitignore @@ -0,0 +1 @@ +/build diff --git a/generator/build.gradle b/generator/build.gradle new file mode 100644 index 000000000..3dc33c03f --- /dev/null +++ b/generator/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'java' + +//noinspection GroovyUnusedAssignment +sourceCompatibility = 1.8 +//noinspection GroovyUnusedAssignment +targetCompatibility = 1.8 + +repositories { + mavenCentral() +} + +dependencies { + def dependencies = rootProject.ext.dependencies + + implementation project(':generator-api') + + implementation dependencies.velocity + implementation dependencies.javaxAnnotation +} + +configurations { + all*.exclude group: 'com.google.android', module: 'android' +} diff --git a/generator/src/main/java/org/cryptomator/generator/ActivityProcessor.java b/generator/src/main/java/org/cryptomator/generator/ActivityProcessor.java new file mode 100644 index 000000000..92d41342d --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/ActivityProcessor.java @@ -0,0 +1,44 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.model.ActivitiesModel; +import org.cryptomator.generator.model.ActivityModel; +import org.cryptomator.generator.templates.ActivitiesTemplate; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes("org.cryptomator.generator.Activity") +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class ActivityProcessor extends BaseProcessor { + + @Override + public void process(RoundEnvironment environment) throws IOException { + ActivitiesModel.Builder activitiesModelBuilder = ActivitiesModel.builder(); + List activityAnnotatedElements = new ArrayList<>(); + for (Element element : environment.getElementsAnnotatedWith(Activity.class)) { + activityAnnotatedElements.add(element); + activitiesModelBuilder.add(new ActivityModel(utils.type((TypeElement) element))); + } + if (!activityAnnotatedElements.isEmpty()) { + generateActivities(activitiesModelBuilder.build(), activityAnnotatedElements); + } + } + + private void generateActivities(ActivitiesModel model, List activityAnnotatedElements) throws IOException { + JavaFileObject file = filer.createSourceFile(model.getJavaPackage() + '.' + model.getClassName(), activityAnnotatedElements.stream().toArray(Element[]::new)); + Writer writer = file.openWriter(); + new ActivitiesTemplate(model).render(writer); + writer.close(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/BaseProcessor.java b/generator/src/main/java/org/cryptomator/generator/BaseProcessor.java new file mode 100644 index 000000000..b7867e0c5 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/BaseProcessor.java @@ -0,0 +1,68 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.utils.Utils; + +import java.io.IOException; +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.TypeElement; +import javax.tools.Diagnostic; + +abstract class BaseProcessor extends AbstractProcessor { + + Filer filer; + Messager messager; + Utils utils; + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.filer = processingEnv.getFiler(); + this.messager = processingEnv.getMessager(); + this.utils = new Utils(messager, processingEnv.getTypeUtils(), processingEnv.getElementUtils()); + } + + @Override + public final boolean process(Set annotations, RoundEnvironment roundEnv) { + try { + try { + messager.printMessage(Diagnostic.Kind.NOTE, "Running " + getClass().getSimpleName()); + doProcess(annotations, roundEnv); + messager.printMessage(Diagnostic.Kind.NOTE, getClass().getSimpleName() + " finished"); + } catch (ProcessorException e) { + messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), e.getElement()); + } catch (IOException e) { + throw new RuntimeException("Processing failed", e); + } + } catch (RuntimeException | Error e) { + StringBuilder sb = new StringBuilder(); + sb.append(e.getClass().getSimpleName()).append(": ").append(e.getMessage()); + Throwable current = e; + do { + for (StackTraceElement stack : current.getStackTrace()) { + sb.append('\n').append('\t').append(stack.getClassName()).append(':').append(stack.getLineNumber()).append('#').append(stack.getMethodName()); + } + current = current.getCause(); + if (current != null) { + sb.append("\nCaused by ").append(current.getClass().getSimpleName()).append(": ").append(current.getMessage()); + } + } while (current != null); + messager.printMessage(Diagnostic.Kind.NOTE, getClass().getSimpleName() + " failed: " + sb.toString()); + throw e; + } + return true; + } + + private boolean doProcess(Set annotations, RoundEnvironment environment) throws IOException { + process(environment); + return true; + } + + protected abstract void process(RoundEnvironment environment) throws IOException; + +} diff --git a/generator/src/main/java/org/cryptomator/generator/CallbackProcessor.java b/generator/src/main/java/org/cryptomator/generator/CallbackProcessor.java new file mode 100644 index 000000000..eadac4822 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/CallbackProcessor.java @@ -0,0 +1,48 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.model.CallbackModel; +import org.cryptomator.generator.model.CallbacksModel; +import org.cryptomator.generator.model.CallbacksModel.CallbacksClassModel; +import org.cryptomator.generator.templates.CallbacksTemplate; +import org.cryptomator.generator.utils.Method; + +import java.io.IOException; +import java.io.Writer; + +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes("org.cryptomator.generator.Callback") +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class CallbackProcessor extends BaseProcessor { + + @Override + public void process(RoundEnvironment environment) throws IOException { + CallbacksModel callbacksModel = new CallbacksModel(); + for (Element element : environment.getElementsAnnotatedWith(Callback.class)) { + try { + CallbackModel callbackModel = new CallbackModel(new Method(utils, (ExecutableElement) element)); + callbacksModel.add(callbackModel); + } catch (ProcessorException e) { + messager.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), e.getElement()); + } + } + for (CallbacksClassModel packageWithCallbacks : callbacksModel.getCallbacksClasses()) { + generateCallbacks(packageWithCallbacks); + } + } + + private void generateCallbacks(CallbacksClassModel callbacksClassModel) throws IOException { + JavaFileObject file = filer.createSourceFile(callbacksClassModel.getJavaPackage() + '.' + callbacksClassModel.getCallbacksClassName()); + Writer writer = file.openWriter(); + new CallbacksTemplate(callbacksClassModel).render(writer); + writer.close(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/FragmentProcessor.java b/generator/src/main/java/org/cryptomator/generator/FragmentProcessor.java new file mode 100644 index 000000000..8eb902cac --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/FragmentProcessor.java @@ -0,0 +1,44 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.model.FragmentModel; +import org.cryptomator.generator.model.FragmentsModel; +import org.cryptomator.generator.templates.FragmentsTemplate; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes("org.cryptomator.generator.Fragment") +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class FragmentProcessor extends BaseProcessor { + + @Override + public void process(RoundEnvironment environment) throws IOException { + FragmentsModel.Builder fragmentsModelBuilder = FragmentsModel.builder(); + List fragmentAnnotatedElements = new ArrayList<>(); + for (Element element : environment.getElementsAnnotatedWith(Fragment.class)) { + fragmentAnnotatedElements.add(element); + fragmentsModelBuilder.add(new FragmentModel(utils.type((TypeElement) element))); + } + if (!fragmentAnnotatedElements.isEmpty()) { + generateFragments(fragmentsModelBuilder.build(), fragmentAnnotatedElements); + } + } + + private void generateFragments(FragmentsModel model, List fragmentAnnotatedElements) throws IOException { + JavaFileObject file = filer.createSourceFile(model.getJavaPackage() + '.' + model.getClassName(), fragmentAnnotatedElements.stream().toArray(Element[]::new)); + Writer writer = file.openWriter(); + new FragmentsTemplate(model).render(writer); + writer.close(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/InstanceStateProcessor.java b/generator/src/main/java/org/cryptomator/generator/InstanceStateProcessor.java new file mode 100644 index 000000000..d5e194f34 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/InstanceStateProcessor.java @@ -0,0 +1,41 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.model.InstanceStateModel; +import org.cryptomator.generator.model.InstanceStatesModel; +import org.cryptomator.generator.templates.InstanceStateTemplate; +import org.cryptomator.generator.utils.Field; + +import java.io.IOException; +import java.io.Writer; + +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.VariableElement; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes("org.cryptomator.generator.InstanceState") +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class InstanceStateProcessor extends BaseProcessor { + + @Override + public void process(RoundEnvironment environment) throws IOException { + InstanceStatesModel instanceStates = new InstanceStatesModel(); + for (Element element : environment.getElementsAnnotatedWith(InstanceState.class)) { + instanceStates.add(new Field(utils, (VariableElement) element)); + } + for (InstanceStateModel model : (Iterable) (instanceStates.instanceStates()::iterator)) { + generateInstanceStates(model); + } + } + + private void generateInstanceStates(InstanceStateModel model) throws IOException { + JavaFileObject file = filer.createSourceFile(model.getJavaPackage() + ".InstanceStates", model.elements()); + Writer writer = file.openWriter(); + new InstanceStateTemplate(model).render(writer); + writer.close(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/IntentProcessor.java b/generator/src/main/java/org/cryptomator/generator/IntentProcessor.java new file mode 100644 index 000000000..87dc3dbd9 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/IntentProcessor.java @@ -0,0 +1,66 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.model.IntentBuilderModel; +import org.cryptomator.generator.model.IntentReaderModel; +import org.cryptomator.generator.model.IntentsModel; +import org.cryptomator.generator.templates.IntentBuilderTemplate; +import org.cryptomator.generator.templates.IntentReaderTemplate; +import org.cryptomator.generator.templates.IntentsTemplate; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes("org.cryptomator.generator.Intent") +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class IntentProcessor extends BaseProcessor { + + @Override + public void process(RoundEnvironment environment) throws IOException { + IntentsModel.Builder intentsModelBuilder = IntentsModel.builder(); + List intentAnnotatedElements = new ArrayList<>(); + for (Element element : environment.getElementsAnnotatedWith(Intent.class)) { + intentAnnotatedElements.add(element); + intentsModelBuilder.add(generateIntentBuilder((TypeElement) element)); + intentsModelBuilder.add(generateIntentReader((TypeElement) element)); + } + if (!intentAnnotatedElements.isEmpty()) { + generateIntents(intentsModelBuilder.build(), intentAnnotatedElements); + } + } + + private IntentBuilderModel generateIntentBuilder(TypeElement element) throws IOException { + IntentBuilderModel model = new IntentBuilderModel(element); + JavaFileObject file = filer.createSourceFile(model.getJavaPackage() + '.' + model.getClassName(), element); + Writer writer = file.openWriter(); + new IntentBuilderTemplate(model).render(writer); + writer.close(); + return model; + } + + private IntentReaderModel generateIntentReader(TypeElement element) throws IOException { + IntentReaderModel model = new IntentReaderModel(element); + JavaFileObject file = filer.createSourceFile(model.getJavaPackage() + '.' + model.getClassName(), element); + Writer writer = file.openWriter(); + new IntentReaderTemplate(model).render(writer); + writer.close(); + return model; + } + + private void generateIntents(IntentsModel model, List intentAnnotatedElements) throws IOException { + JavaFileObject file = filer.createSourceFile(model.getJavaPackage() + '.' + model.getClassName(), intentAnnotatedElements.stream().toArray(Element[]::new)); + Writer writer = file.openWriter(); + new IntentsTemplate(model).render(writer); + writer.close(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/ProcessorException.java b/generator/src/main/java/org/cryptomator/generator/ProcessorException.java new file mode 100644 index 000000000..f87e7d486 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/ProcessorException.java @@ -0,0 +1,17 @@ +package org.cryptomator.generator; + +import javax.lang.model.element.Element; + +public class ProcessorException extends RuntimeException { + + private final Element element; + + public ProcessorException(String message, Element element) { + super("Generator: " + message); + this.element = element; + } + + public Element getElement() { + return element; + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/UseCaseProcessor.java b/generator/src/main/java/org/cryptomator/generator/UseCaseProcessor.java new file mode 100644 index 000000000..ef8b10d72 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/UseCaseProcessor.java @@ -0,0 +1,36 @@ +package org.cryptomator.generator; + +import org.cryptomator.generator.model.UseCaseModel; +import org.cryptomator.generator.templates.UseCaseTemplate; + +import java.io.IOException; +import java.io.Writer; + +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes("org.cryptomator.generator.UseCase") +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class UseCaseProcessor extends BaseProcessor { + + @Override + public void process(RoundEnvironment environment) throws IOException { + for (Element element : environment.getElementsAnnotatedWith(UseCase.class)) { + generateUseCase((TypeElement) element); + } + } + + private void generateUseCase(TypeElement element) throws IOException { + UseCaseModel model = new UseCaseModel(utils.type(element)); + JavaFileObject file = filer.createSourceFile(model.getClassName(), element); + Writer writer = file.openWriter(); + new UseCaseTemplate(model).render(writer); + writer.close(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/ActivitiesModel.java b/generator/src/main/java/org/cryptomator/generator/model/ActivitiesModel.java new file mode 100644 index 000000000..4a8187e62 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/ActivitiesModel.java @@ -0,0 +1,48 @@ +package org.cryptomator.generator.model; + +import java.util.ArrayList; +import java.util.List; + +public class ActivitiesModel { + + private final List activities; + + public static ActivitiesModel.Builder builder() { + return new Builder(); + } + + private ActivitiesModel(List activities) { + this.activities = activities; + } + + public List getActivities() { + return activities; + } + + public String getJavaPackage() { + return "org.cryptomator.presentation.ui.activity"; + } + + public String getClassName() { + return "Activities"; + } + + public static class Builder { + + private final List activities = new ArrayList<>(); + + private Builder() { + } + + public Builder add(ActivityModel activity) { + activities.add(activity); + return this; + } + + public ActivitiesModel build() { + return new ActivitiesModel(activities); + } + + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/ActivityModel.java b/generator/src/main/java/org/cryptomator/generator/model/ActivityModel.java new file mode 100644 index 000000000..c565f05aa --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/ActivityModel.java @@ -0,0 +1,131 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.InjectIntent; +import org.cryptomator.generator.ProcessorException; +import org.cryptomator.generator.utils.Field; +import org.cryptomator.generator.utils.Type; + +import java.util.List; +import java.util.Optional; + +import javax.lang.model.element.TypeElement; + +import static java.util.stream.Collectors.toList; + +public class ActivityModel { + + private final String qualifiedName; + + private final boolean hasIntentField; + private final String intentFieldName; + private final String methodInIntentsName; + + private final boolean presenterHasIntentField; + private final String presenterIntentFieldName; + + private final boolean hasPresenter; + private final String presenterFieldName; + private final String presenterQualifiedName; + + public ActivityModel(Type type) { + try { + this.qualifiedName = type.qualifiedName(); + java.util.Optional intentField = intentField(type); + hasIntentField = intentField.isPresent(); + intentFieldName = intentField.map(Field::name).orElse(null); + methodInIntentsName = intentField.map(ActivityModel::methodInIntentsName).orElse(null); + Optional presenterField = presenterField(type); + hasPresenter = presenterField.isPresent(); + presenterFieldName = presenterField.map(Field::name).orElse(null); + presenterQualifiedName = presenterField.map(Field::type).map(Type::qualifiedName).orElse(null); + java.util.Optional presenterIntentField = presenterIntentField(presenterField, intentField); + presenterHasIntentField = presenterIntentField.isPresent(); + presenterIntentFieldName = presenterIntentField.map(Field::name).orElse(null); + } catch (RuntimeException e) { + throw new ProcessorException(e.getMessage(), type.element()); + } + } + + private Optional presenterIntentField(Optional presenterField, Optional intentField) { + if (!presenterField.isPresent() || !intentField.isPresent()) { + return Optional.empty(); + } + Type presenterType = presenterField.get().type(); + List intentFields = presenterType.fields().filter(field -> field.hasAnnotation(InjectIntent.class)).collect(toList()); + if (intentFields.size() > 1) { + throw new ProcessorException("Only one field annotated with InjectIntent is allowed per Presenter", presenterType.element()); + } else if (intentFields.isEmpty()) { + return java.util.Optional.empty(); + } else { + Field presenterIntentField = intentFields.get(0); + if (!presenterIntentField.type().qualifiedName().equals(intentField.get().type().qualifiedName())) { + throw new ProcessorException("Intent field in presenter must have the same declaringType as intent field in activity", presenterIntentField.element()); + } + return java.util.Optional.of(presenterIntentField); + } + } + + private static Optional presenterField(Type type) { + return type // + .fields() // + .filter(field -> field.type().isAssignableTo("org.cryptomator.presentation.presenter.Presenter")) // + .findFirst(); + } + + private static java.util.Optional intentField(Type type) { + List intentFields = type.fields().filter(field -> field.hasAnnotation(InjectIntent.class)).collect(toList()); + if (intentFields.size() > 1) { + throw new ProcessorException("Only one field annotated with InjectIntent is allowed per Activity", type.element()); + } else if (intentFields.isEmpty()) { + return java.util.Optional.empty(); + } else { + return java.util.Optional.of(intentFields.get(0)); + } + } + + private static String methodInIntentsName(Field field) { + Type fieldType = field.type(); + String name = fieldType.simpleName(); + return Character.toLowerCase(name.charAt(0)) + name.substring(1) + "From"; + } + + private static String qualifiedName(TypeElement type) { + return type.getQualifiedName().toString(); + } + + public String getPresenterFieldName() { + return presenterFieldName; + } + + public String getPresenterQualifiedName() { + return presenterQualifiedName; + } + + public boolean isHasPresenter() { + return hasPresenter; + } + + public boolean isHasIntentField() { + return hasIntentField; + } + + public String getQualifiedName() { + return qualifiedName; + } + + public String getMethodInIntentsName() { + return methodInIntentsName; + } + + public String getIntentFieldName() { + return intentFieldName; + } + + public boolean isPresenterHasIntentField() { + return presenterHasIntentField; + } + + public String getPresenterIntentFieldName() { + return presenterIntentFieldName; + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/CallbackModel.java b/generator/src/main/java/org/cryptomator/generator/model/CallbackModel.java new file mode 100644 index 000000000..ee06f7a8e --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/CallbackModel.java @@ -0,0 +1,96 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.Callback; +import org.cryptomator.generator.ProcessorException; +import org.cryptomator.generator.utils.Method; +import org.cryptomator.generator.utils.MethodParameter; +import org.cryptomator.generator.utils.Type; + +import java.util.List; + +import static java.util.stream.Collectors.toList; + +public class CallbackModel { + + private final String callbacksClassName; + private final String name; + private final String declaringTypeName; + private final String resultTypeName; + private final List additionalParameterTypes; + private final boolean dispatchResultOkOnly; + + public CallbackModel(Method method) { + this.declaringTypeName = declaringTypeName(method); + this.dispatchResultOkOnly = method.getAnnotation(Callback.class).dispatchResultOkOnly(); + this.name = method.name(); + this.resultTypeName = resultTypeName(method); + this.additionalParameterTypes = additionalParameterTypes(method); + this.callbacksClassName = callbacksClassName(method); + } + + private String declaringTypeName(Method method) { + if (method.declaringType().isAssignableTo("android.app.Activity") || method.declaringType().isAssignableTo("android.app.Fragment")) { + throw new ProcessorException("@Callback is not allowed in subtypes of Activity or Fragment", method.element()); + } + return method.declaringType().qualifiedName(); + } + + private String callbacksClassName(Method method) { + if (resultTypeName.endsWith(".ActivityResult")) { + return method.declaringType().packageName() + ".ActivityResultCallbacks"; + } else if (resultTypeName.endsWith(".PermissionsResult")) { + return method.declaringType().packageName() + ".PermissionsResultCallbacks"; + } else { + return method.declaringType().packageName() + ".SerializableResultCallbacks"; + } + } + + private String resultTypeName(Method method) { + if (method.parameters().count() < 1) { + throw new ProcessorException("Callback method must have at least one parameter", method.element()); + } + String result = method.parameters().findFirst().get().getType().qualifiedName(); + if (result.equals("org.cryptomator.presentation.workflow.ActivityResult") // + || result.equals("org.cryptomator.presentation.workflow.PermissionsResult") // + || result.equals("org.cryptomator.presentation.workflow.SerializableResult")) { + return result; + } + throw new ProcessorException("Type of first parameter of callback method must be either ActivityResult, PermissionsResult or SerializableResult", method.element()); + } + + private List additionalParameterTypes(Method method) { + return method.parameters().skip(1) // + .peek(this::assertIsSerializable).map(MethodParameter::getType) // + .map(Type::qualifiedName).collect(toList()); + } + + private void assertIsSerializable(MethodParameter methodParameter) { + if (!methodParameter.getType().isAssignableTo("java.io.Serializable")) { + throw new ProcessorException("Parameters of callback method must be Serializable", methodParameter.element()); + } + } + + public String getCallbacksClassName() { + return callbacksClassName; + } + + public String getName() { + return name; + } + + public String getDeclaringTypeName() { + return declaringTypeName; + } + + public String getResultTypeName() { + return resultTypeName; + } + + public List getAdditionalParameterTypes() { + return additionalParameterTypes; + } + + public boolean isDispatchResultOkOnly() { + return dispatchResultOkOnly; + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/CallbacksModel.java b/generator/src/main/java/org/cryptomator/generator/model/CallbacksModel.java new file mode 100644 index 000000000..0b8e43982 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/CallbacksModel.java @@ -0,0 +1,51 @@ +package org.cryptomator.generator.model; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toList; + +public class CallbacksModel { + + private final List callbacks = new ArrayList<>(); + + public void add(CallbackModel callback) { + callbacks.add(callback); + } + + public Collection getCallbacksClasses() { + return callbacks.stream().collect(Collectors.groupingBy(CallbackModel::getCallbacksClassName)).entrySet().stream().map(CallbacksClassModel::new).collect(toList()); + } + + public static class CallbacksClassModel { + + private final String callbacksClassName; + private final String javaPackage; + private final List callbacks; + + public CallbacksClassModel(Map.Entry> entry) { + String qualifiedCallbacksClassName = entry.getKey(); + int lastDot = qualifiedCallbacksClassName.lastIndexOf('.'); + this.javaPackage = qualifiedCallbacksClassName.substring(0, lastDot); + this.callbacksClassName = qualifiedCallbacksClassName.substring(lastDot + 1); + this.callbacks = entry.getValue(); + } + + public String getCallbacksClassName() { + return callbacksClassName; + } + + public String getJavaPackage() { + return javaPackage; + } + + public List getCallbacks() { + return callbacks; + } + + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/FragmentModel.java b/generator/src/main/java/org/cryptomator/generator/model/FragmentModel.java new file mode 100644 index 000000000..b7f55631d --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/FragmentModel.java @@ -0,0 +1,48 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.ProcessorException; +import org.cryptomator.generator.utils.Field; +import org.cryptomator.generator.utils.Type; + +import java.util.Optional; + +public class FragmentModel { + + private final String qualifiedName; + + private final boolean hasPresenter; + private final String presenterFieldName; + private final String presenterQualifiedName; + + public FragmentModel(Type type) { + try { + this.qualifiedName = type.qualifiedName(); + Optional presenterField = presenterField(type); + hasPresenter = presenterField.isPresent(); + presenterFieldName = presenterField.map(Field::name).orElse(null); + presenterQualifiedName = presenterField.map(Field::type).map(Type::qualifiedName).orElse(null); + } catch (RuntimeException e) { + throw new ProcessorException(e.getMessage(), type.element()); + } + } + + private static Optional presenterField(Type type) { + return type.fields().filter(field -> field.type() != null && field.type().isAssignableTo("org.cryptomator.presentation.presenter.Presenter")).findFirst(); + } + + public String getQualifiedName() { + return qualifiedName; + } + + public String getPresenterFieldName() { + return presenterFieldName; + } + + public String getPresenterQualifiedName() { + return presenterQualifiedName; + } + + public boolean isHasPresenter() { + return hasPresenter; + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/FragmentsModel.java b/generator/src/main/java/org/cryptomator/generator/model/FragmentsModel.java new file mode 100644 index 000000000..7ca076f6d --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/FragmentsModel.java @@ -0,0 +1,48 @@ +package org.cryptomator.generator.model; + +import java.util.ArrayList; +import java.util.List; + +public class FragmentsModel { + + private final List fragments; + + public static FragmentsModel.Builder builder() { + return new Builder(); + } + + private FragmentsModel(List fragments) { + this.fragments = fragments; + } + + public List getFragments() { + return fragments; + } + + public String getJavaPackage() { + return "org.cryptomator.presentation.ui.fragment"; + } + + public String getClassName() { + return "Fragments"; + } + + public static class Builder { + + private final List fragments = new ArrayList<>(); + + private Builder() { + } + + public Builder add(FragmentModel fragment) { + fragments.add(fragment); + return this; + } + + public FragmentsModel build() { + return new FragmentsModel(fragments); + } + + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/InstanceStateModel.java b/generator/src/main/java/org/cryptomator/generator/model/InstanceStateModel.java new file mode 100644 index 000000000..2e0f3f535 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/InstanceStateModel.java @@ -0,0 +1,147 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.ProcessorException; +import org.cryptomator.generator.utils.Field; +import org.cryptomator.generator.utils.Type; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.lang.model.element.Element; + +public class InstanceStateModel { + + private final String javaPackage; + private final Map types = new HashMap<>(); + + public InstanceStateModel(String javaPackage) { + this.javaPackage = javaPackage; + } + + public void add(Field field) { + Type type = field.declaringType(); + if (!types.containsKey(type)) { + types.put(type, new InstanceStateType(type)); + } + InstanceStateField instanceStateField = new InstanceStateField(field); + types.get(type).add(instanceStateField); + } + + public Collection getTypes() { + return types.values(); + } + + public Element[] elements() { + return types.values().stream() // + .flatMap(type -> type.fields.stream()) // + .map(InstanceStateField::element) // + .toArray(Element[]::new); + } + + public String getJavaPackage() { + return javaPackage; + } + + public static class InstanceStateType { + + private final List fields = new ArrayList<>(); + private final String qualifiedName; + + public InstanceStateType(Type type) { + this.qualifiedName = type.qualifiedName(); + } + + public void add(InstanceStateField field) { + fields.add(field); + } + + public String getQualifiedName() { + return qualifiedName; + } + + public List getFields() { + return fields; + } + } + + public static class InstanceStateField { + + private static int nextBundleKey = 0; + + private final Field field; + private final String name; + private final String qualifiedType; + private final String putMethod; + private final String getMethod; + private final String bundleKey; + private final boolean castRequired; + + public InstanceStateField(Field field) { + this.field = field; + if (field.isPrivate()) { + throw new ProcessorException("Field annotated with @InstanceState must not be private", field.element()); + } + this.qualifiedType = field.type().qualifiedName(); + this.name = field.name(); + this.putMethod = determinePutMethod(field.type()); + this.getMethod = determineGetMethod(field.type()); + this.bundleKey = "InstanceState$" + nextBundleKey++; + this.castRequired = "getSerializable".equals(getMethod); + } + + private String determinePutMethod(Type type) { + return determineMethod(type, "put"); + } + + private String determineGetMethod(Type type) { + return determineMethod(type, "get"); + } + + private String determineMethod(Type type, String prefix) { + if (type.primitiveType().isPresent()) { + String name = type.primitiveType().get().simpleName(); + return prefix + Character.toUpperCase(name.charAt(0)) + name.substring(1); + } else if (type.isAssignableTo("java.lang.String")) { + return prefix + "String"; + } else if (type.isAssignableTo("java.io.Serializable")) { + return prefix + "Serializable"; + } else if (type.isAssignableTo("android.os.Parcelable")) { + return prefix + "Parcelable"; + } + throw new ProcessorException("Unsupported type. InstanceState must be a primitive type, String or Serializable", field.element()); + } + + public String getGetMethod() { + return getMethod; + } + + public boolean isCastRequired() { + return castRequired; + } + + public String getName() { + return name; + } + + public String getQualifiedType() { + return qualifiedType; + } + + public String getPutMethod() { + return putMethod; + } + + public String getBundleKey() { + return bundleKey; + } + + public Element element() { + return field.element(); + } + + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/InstanceStatesModel.java b/generator/src/main/java/org/cryptomator/generator/model/InstanceStatesModel.java new file mode 100644 index 000000000..41703b5b6 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/InstanceStatesModel.java @@ -0,0 +1,25 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.utils.Field; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +public class InstanceStatesModel { + + private final Map instanceStatesByPackage = new HashMap<>(); + + public void add(Field field) { + String packageName = field.declaringType().packageName(); + if (!instanceStatesByPackage.containsKey(packageName)) { + instanceStatesByPackage.put(packageName, new InstanceStateModel(packageName)); + } + instanceStatesByPackage.get(packageName).add(field); + } + + public Stream instanceStates() { + return instanceStatesByPackage.values().stream(); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/IntentBuilderModel.java b/generator/src/main/java/org/cryptomator/generator/model/IntentBuilderModel.java new file mode 100644 index 000000000..e0c468cbf --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/IntentBuilderModel.java @@ -0,0 +1,141 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; + +import static java.lang.Character.toLowerCase; +import static java.util.stream.Collectors.toList; + +public class IntentBuilderModel { + + private final String javaPackage; + private final String className; + private final String targetActivity; + private final String targetActivitySimpleName; + private final String buildMethodName; + private final List parameters; + + public IntentBuilderModel(TypeElement type) { + this.javaPackage = javaPackage(type); + this.className = className(type); + this.targetActivity = targetActivity(type); + this.targetActivitySimpleName = targetActivitySimpleName(targetActivity); + this.parameters = parameters(type); + this.buildMethodName = buildMethodName(type); + } + + private static String javaPackage(TypeElement type) { + String qualifiedName = type.getQualifiedName().toString(); + int lastDot = qualifiedName.lastIndexOf('.'); + return qualifiedName.substring(0, lastDot); + } + + private static String className(TypeElement type) { + String qualifiedName = type.getQualifiedName().toString(); + int lastDot = qualifiedName.lastIndexOf('.'); + return qualifiedName.substring(lastDot + 1) + "Builder"; + } + + private static String targetActivity(TypeElement type) { + return type.getAnnotationMirrors().stream().filter(is(Intent.class)).findFirst().get().getElementValues().entrySet().stream().map(entry -> (Map.Entry) entry) + .filter(entry -> "value".equals(entry.getKey().getSimpleName().toString())).map(Map.Entry::getValue).map(AnnotationValue::getValue).map(DeclaredType.class::cast).map(DeclaredType::asElement) + .map(TypeElement.class::cast).findFirst().get().getQualifiedName().toString(); + } + + private static Predicate is(Class type) { + return mirror -> { + TypeElement typeElement = (TypeElement) mirror.getAnnotationType().asElement(); + String qualifiedName = typeElement.getQualifiedName().toString(); + return qualifiedName.equals(type.getName()); + }; + } + + private static String targetActivitySimpleName(String targetActivity) { + int lastDot = targetActivity.lastIndexOf('.'); + String name = targetActivity.substring(lastDot + 1); + return toLowerCase(name.charAt(0)) + name.substring(1); + } + + private static List parameters(TypeElement type) { + return type.getEnclosedElements().stream().filter(ExecutableElement.class::isInstance).map(ExecutableElement.class::cast).map(ParameterModel::new).collect(toList()); + } + + private static String buildMethodName(TypeElement type) { + String qualifiedName = type.getQualifiedName().toString(); + int lastDot = qualifiedName.lastIndexOf('.'); + String name = qualifiedName.substring(lastDot + 1); + return toLowerCase(name.charAt(0)) + name.substring(1); + } + + public String getJavaPackage() { + return javaPackage; + } + + public String getClassName() { + return className; + } + + public Iterable getParameters() { + return parameters; + } + + public String getTargetActivity() { + return targetActivity; + } + + public String getTargetActivitySimpleName() { + return targetActivitySimpleName; + } + + public String getBuildMethodName() { + return buildMethodName; + } + + public static class ParameterModel { + + private final String nameWithFirstCharUppercase; + private final String name; + private final String type; + private final boolean required; + + private ParameterModel(ExecutableElement element) { + this.name = element.getSimpleName().toString(); + this.nameWithFirstCharUppercase = Character.toUpperCase(name.charAt(0)) + name.substring(1); + this.type = type(element); + this.required = element.getAnnotation(Optional.class) == null; + } + + private static String type(ExecutableElement element) { + DeclaredType declaredType = (DeclaredType) element.getReturnType(); + TypeElement type = (TypeElement) declaredType.asElement(); + return type.getQualifiedName().toString(); + } + + public String getNameWithFirstCharUppercase() { + return nameWithFirstCharUppercase; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public boolean isRequired() { + return required; + } + + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/IntentReaderModel.java b/generator/src/main/java/org/cryptomator/generator/model/IntentReaderModel.java new file mode 100644 index 000000000..62c59abf4 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/IntentReaderModel.java @@ -0,0 +1,128 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; + +import static java.util.stream.Collectors.toList; + +public class IntentReaderModel { + + private final String javaPackage; + private final String className; + private final String targetActivity; + private final String intentInterface; + private final String readMethodName; + private final List parameters; + + public IntentReaderModel(TypeElement type) { + this.intentInterface = type.getQualifiedName().toString(); + this.readMethodName = readMethodName(type); + this.javaPackage = javaPackage(type); + this.className = className(type); + this.targetActivity = targetActivity(type); + this.parameters = parameters(type); + } + + private static String javaPackage(TypeElement type) { + String qualifiedName = type.getQualifiedName().toString(); + int lastDot = qualifiedName.lastIndexOf('.'); + return qualifiedName.substring(0, lastDot); + } + + private static String className(TypeElement type) { + String qualifiedName = type.getQualifiedName().toString(); + int lastDot = qualifiedName.lastIndexOf('.'); + return qualifiedName.substring(lastDot + 1) + "Reader"; + } + + private static String targetActivity(TypeElement type) { + return type.getAnnotationMirrors().stream().filter(is(Intent.class)).findFirst().get().getElementValues().entrySet().stream().map(entry -> (Map.Entry) entry) + .filter(entry -> "value".equals(entry.getKey().getSimpleName().toString())).map(Map.Entry::getValue).map(AnnotationValue::getValue).map(DeclaredType.class::cast).map(DeclaredType::asElement) + .map(TypeElement.class::cast).findFirst().get().getQualifiedName().toString(); + } + + private static Predicate is(Class type) { + return mirror -> { + TypeElement typeElement = (TypeElement) mirror.getAnnotationType().asElement(); + String qualifiedName = typeElement.getQualifiedName().toString(); + return qualifiedName.equals(type.getName()); + }; + } + + private static List parameters(TypeElement type) { + return type.getEnclosedElements().stream().filter(ExecutableElement.class::isInstance).map(ExecutableElement.class::cast).map(ParameterModel::new).collect(toList()); + } + + private static String readMethodName(TypeElement type) { + String qualifiedName = type.getQualifiedName().toString(); + int lastDot = qualifiedName.lastIndexOf('.'); + String name = qualifiedName.substring(lastDot + 1); + return Character.toLowerCase(name.charAt(0)) + name.substring(1) + "From"; + } + + public String getJavaPackage() { + return javaPackage; + } + + public String getIntentInterface() { + return intentInterface; + } + + public String getClassName() { + return className; + } + + public Iterable getParameters() { + return parameters; + } + + public String getTargetActivity() { + return targetActivity; + } + + public String getReadMethodName() { + return readMethodName; + } + + public static class ParameterModel { + + private final String name; + private final String type; + private final boolean required; + + private ParameterModel(ExecutableElement element) { + this.name = element.getSimpleName().toString(); + this.type = type(element); + this.required = element.getAnnotation(Optional.class) == null; + } + + private static String type(ExecutableElement element) { + DeclaredType declaredType = (DeclaredType) element.getReturnType(); + TypeElement type = (TypeElement) declaredType.asElement(); + return type.getQualifiedName().toString(); + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public boolean isRequired() { + return required; + } + + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/IntentsModel.java b/generator/src/main/java/org/cryptomator/generator/model/IntentsModel.java new file mode 100644 index 000000000..13fe91291 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/IntentsModel.java @@ -0,0 +1,60 @@ +package org.cryptomator.generator.model; + +import java.util.ArrayList; +import java.util.List; + +public class IntentsModel { + + private final List builders; + private final List readers; + + public static IntentsModel.Builder builder() { + return new Builder(); + } + + private IntentsModel(List builders, List readers) { + this.builders = builders; + this.readers = readers; + } + + public List getBuilders() { + return builders; + } + + public List getReaders() { + return readers; + } + + public String getJavaPackage() { + return "org.cryptomator.presentation.intent"; + } + + public String getClassName() { + return "Intents"; + } + + public static class Builder { + + private final List builders = new ArrayList<>(); + private final List readers = new ArrayList<>(); + + private Builder() { + } + + public Builder add(IntentBuilderModel builder) { + builders.add(builder); + return this; + } + + public Builder add(IntentReaderModel reader) { + readers.add(reader); + return this; + } + + public IntentsModel build() { + return new IntentsModel(builders, readers); + } + + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/model/UseCaseModel.java b/generator/src/main/java/org/cryptomator/generator/model/UseCaseModel.java new file mode 100644 index 000000000..85dfcd426 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/model/UseCaseModel.java @@ -0,0 +1,263 @@ +package org.cryptomator.generator.model; + +import org.cryptomator.generator.Optional; +import org.cryptomator.generator.Parameter; +import org.cryptomator.generator.ProcessorException; +import org.cryptomator.generator.utils.Method; +import org.cryptomator.generator.utils.MethodParameter; +import org.cryptomator.generator.utils.Type; + +import java.util.Collection; + +import static java.util.stream.Collectors.toList; + +public class UseCaseModel { + + private final String simpleName; + private final String className; + private final String packageName; + private final String implClassName; + private final Collection injected; + private final Collection injectedAndParams; + private final Collection parameters; + private final boolean resultIsVoid; + private final String resultClassName; + private final String subscriberResultClassName; + private final boolean hasParameters; + private final boolean hasProgressAware; + private final boolean hasCancelHandler; + private final String progressStateName; + + public UseCaseModel(Type type) { + Method executeMethod = checkExecuteMethod(type); + Method constructor = checkConstructor(type); + + this.simpleName = simpleName(type); + this.className = className(type); + this.packageName = type.packageName(); + this.implClassName = type.qualifiedName(); + this.injected = injected(constructor); + this.parameters = parameters(constructor); + this.injectedAndParams = injectedAndParams(constructor); + this.resultIsVoid = executeMethod.returnsVoid(); + this.resultClassName = resultClassName(executeMethod); + this.subscriberResultClassName = subscriberResultClassName(executeMethod); + this.hasParameters = !parameters.isEmpty(); + this.hasProgressAware = executeMethod.parameters().count() == 1; + this.hasCancelHandler = hasCancelHandler(type); + this.progressStateName = progressStateName(executeMethod); + } + + private boolean hasCancelHandler(Type type) { + return type.methods().filter(method -> "onCancel".equals(method.name())).findFirst().isPresent(); + } + + private String subscriberResultClassName(Method executeMethod) { + if (executeMethod.returnsVoid()) { + return "Object"; + } else { + return executeMethod.getSourceCodeRepresentationOfType(); + } + } + + private String resultClassName(Method executeMethod) { + if (executeMethod.returnsVoid()) { + return "Void"; + } else { + return executeMethod.getSourceCodeRepresentationOfType(); + } + } + + private String progressStateName(Method executeMethod) { + if (executeMethod.parameters().count() == 1) { + return executeMethod.parameters().findFirst().get().getTypeArgument(0).qualifiedName(); + } else { + return null; + } + } + + private Method checkExecuteMethod(Type type) { + java.util.Optional executeMethod = type.methods().filter(method -> "execute".equals(method.name())).findFirst(); + if (!executeMethod.isPresent()) { + throw new ProcessorException("UseCase must define exactly and only one public method 'execute'", type.element()); + } + boolean paramsInvalid = true; + if (executeMethod.get().parameters().count() == 0) { + paramsInvalid = false; + } else if (executeMethod.get().parameters().count() == 1) { + if (executeMethod.get().parameters().findFirst().get().getType().isAssignableFrom("org.cryptomator.domain.usecases.ProgressAware")) { + paramsInvalid = false; + } + } + if (paramsInvalid) { + throw new ProcessorException("'execute' method must not have parameters or a single parameter of declaringType ProgressAware", type.element()); + } + return executeMethod.get(); + } + + private Method checkConstructor(Type type) { + if (type.constructors().count() != 1) { + throw new ProcessorException("UseCase must define exactly only one constructor", type.element()); + } + return type.constructors().findFirst().get(); + } + + private Collection injected(Method constructor) { + return constructor.parameters() // + .filter(parameter -> !parameter.hasAnnotation(Parameter.class)) // + .map(InjectedModel::new).collect(toList()); + } + + private Collection parameters(Method constructor) { + return constructor.parameters() // + .filter(parameter -> parameter.hasAnnotation(Parameter.class)) // + .map(ParameterModel::new).collect(toList()); + } + + private Collection injectedAndParams(Method constructor) { + return constructor.parameters().map(parameter -> { + if (parameter.hasAnnotation(Parameter.class)) { + return new ParameterModel(parameter); + } else { + return new InjectedModel(parameter); + } + }).collect(toList()); + } + + private String simpleName(Type type) { + return type.simpleName() + "UseCase"; + } + + private String className(Type type) { + return type.packageName() + "." + simpleName(type); + } + + public String getClassName() { + return className; + } + + public String getSimpleName() { + return simpleName; + } + + public String getPackageName() { + return packageName; + } + + public String getImplClassName() { + return implClassName; + } + + public Collection getInjected() { + return injected; + } + + public Collection getParameters() { + return parameters; + } + + public String getResultClassName() { + return resultClassName; + } + + public boolean isResultIsVoid() { + return resultIsVoid; + } + + public Collection getInjectedAndParams() { + return injectedAndParams; + } + + public boolean isHasParameters() { + return hasParameters; + } + + public boolean isHasProgressAware() { + return hasProgressAware; + } + + public String getProgressStateName() { + return progressStateName; + } + + public boolean isHasCancelHandler() { + return hasCancelHandler; + } + + public String getSubscriberResultClassName() { + return subscriberResultClassName; + } + + public static class InjectedModel { + + private final String type; + private final String lowerCaseName; + private final String methodName; + + public InjectedModel(MethodParameter parameter) { + this.type = parameter.getSourceCodeRepresentationOfType(); + this.methodName = parameter.getName(); + this.lowerCaseName = Character.toLowerCase(methodName.charAt(0)) + methodName.substring(1); + } + + public String getType() { + return type; + } + + public String getLowerCaseName() { + return lowerCaseName; + } + + public String getMethodName() { + return methodName; + } + + public boolean isParameter() { + return false; + } + + } + + public static class ParameterModel { + + private final String type; + private final String lowerCaseName; + private final String upperCaseName; + private final String methodName; + private final boolean optional; + + public ParameterModel(MethodParameter parameter) { + this.type = parameter.getSourceCodeRepresentationOfType(); + this.methodName = parameter.getName(); + this.lowerCaseName = Character.toLowerCase(methodName.charAt(0)) + methodName.substring(1); + this.upperCaseName = Character.toUpperCase(lowerCaseName.charAt(0)) + lowerCaseName.substring(1); + this.optional = parameter.hasAnnotation(Optional.class); + } + + public boolean isOptional() { + return optional; + } + + public String getType() { + return type; + } + + public String getLowerCaseName() { + return lowerCaseName; + } + + public String getUpperCaseName() { + return upperCaseName; + } + + public String getMethodName() { + return methodName; + } + + public boolean isParameter() { + return true; + } + + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/ActivitiesTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/ActivitiesTemplate.java new file mode 100644 index 000000000..586a8d960 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/ActivitiesTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.ActivitiesModel; + +public class ActivitiesTemplate extends Template { + + public ActivitiesTemplate(ActivitiesModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/CallbacksTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/CallbacksTemplate.java new file mode 100644 index 000000000..46cdb788d --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/CallbacksTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.CallbacksModel.CallbacksClassModel; + +public class CallbacksTemplate extends Template { + + public CallbacksTemplate(CallbacksClassModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/FragmentsTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/FragmentsTemplate.java new file mode 100644 index 000000000..ec7edec69 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/FragmentsTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.FragmentsModel; + +public class FragmentsTemplate extends Template { + + public FragmentsTemplate(FragmentsModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/InstanceStateTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/InstanceStateTemplate.java new file mode 100644 index 000000000..24b581846 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/InstanceStateTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.InstanceStateModel; + +public class InstanceStateTemplate extends Template { + + public InstanceStateTemplate(InstanceStateModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/IntentBuilderTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/IntentBuilderTemplate.java new file mode 100644 index 000000000..5bcc2ad44 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/IntentBuilderTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.IntentBuilderModel; + +public class IntentBuilderTemplate extends Template { + + public IntentBuilderTemplate(IntentBuilderModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/IntentReaderTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/IntentReaderTemplate.java new file mode 100644 index 000000000..923ec4c02 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/IntentReaderTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.IntentReaderModel; + +public class IntentReaderTemplate extends Template { + + public IntentReaderTemplate(IntentReaderModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/IntentsTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/IntentsTemplate.java new file mode 100644 index 000000000..dea7dddfc --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/IntentsTemplate.java @@ -0,0 +1,11 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.IntentsModel; + +public class IntentsTemplate extends Template { + + public IntentsTemplate(IntentsModel model) { + super(model); + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/Template.java b/generator/src/main/java/org/cryptomator/generator/templates/Template.java new file mode 100644 index 000000000..10a9b1a75 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/Template.java @@ -0,0 +1,56 @@ +package org.cryptomator.generator.templates; + +import org.apache.velocity.VelocityContext; +import org.apache.velocity.app.Velocity; + +import java.io.Writer; +import java.lang.reflect.Modifier; + +import static java.lang.Character.toLowerCase; +import static java.util.Arrays.stream; +import static org.apache.velocity.app.Velocity.mergeTemplate; + +abstract class Template { + + static { + try { + Velocity.setProperty("resource.loader", "classpath"); + Velocity.setProperty("classpath.resource.loader.class", "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader"); + Velocity.init(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private final String name; + private final VelocityContext context = new VelocityContext(); + + Template(T model) { + this.name = "/templates/" + getClass().getSimpleName() + ".vm"; + stream(model.getClass().getDeclaredMethods()).filter(method -> Modifier.isPublic(method.getModifiers())).filter(method -> method.getParameterCount() == 0).forEach(method -> { + try { + this.context.put(methodToPropertyName(method.getName()), method.invoke(model)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private String methodToPropertyName(String method) { + if (method.startsWith("get") || method.startsWith("set")) { + method = method.substring(3); + } else if (method.startsWith("is")) { + method = method.substring(2); + } + return toLowerCase(method.charAt(0)) + method.substring(1); + } + + public void render(Writer writer) { + try { + mergeTemplate(name, "UTF-8", context, writer); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/templates/UseCaseTemplate.java b/generator/src/main/java/org/cryptomator/generator/templates/UseCaseTemplate.java new file mode 100644 index 000000000..e99f0280f --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/templates/UseCaseTemplate.java @@ -0,0 +1,10 @@ +package org.cryptomator.generator.templates; + +import org.cryptomator.generator.model.UseCaseModel; + +public class UseCaseTemplate extends Template { + + public UseCaseTemplate(UseCaseModel model) { + super(model); + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/utils/Field.java b/generator/src/main/java/org/cryptomator/generator/utils/Field.java new file mode 100644 index 000000000..06cd79fe7 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/utils/Field.java @@ -0,0 +1,51 @@ +package org.cryptomator.generator.utils; + +import java.lang.annotation.Annotation; + +import javax.lang.model.element.Element; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; + +public class Field { + + private final Utils utils; + private final VariableElement delegate; + + public Field(Utils utils, VariableElement variableElement) { + this.utils = utils; + this.delegate = variableElement; + } + + public Element element() { + return delegate; + } + + public String name() { + return delegate.getSimpleName().toString(); + } + + public boolean isStatic() { + return delegate.getModifiers().contains(Modifier.STATIC); + } + + public boolean isPackagePrivate() { + return !delegate.getModifiers().contains(Modifier.PRIVATE) && !delegate.getModifiers().contains(Modifier.PUBLIC) && !delegate.getModifiers().contains(Modifier.PROTECTED); + } + + public Type declaringType() { + return new Type(utils, (TypeElement) delegate.getEnclosingElement()); + } + + public Type type() { + return new Type(utils, delegate.asType()); + } + + public boolean hasAnnotation(Class annotationType) { + return delegate.getAnnotation(annotationType) != null; + } + + public boolean isPrivate() { + return delegate.getModifiers().contains(Modifier.PRIVATE); + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/utils/Method.java b/generator/src/main/java/org/cryptomator/generator/utils/Method.java new file mode 100644 index 000000000..4966517ff --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/utils/Method.java @@ -0,0 +1,78 @@ +package org.cryptomator.generator.utils; + +import java.lang.annotation.Annotation; +import java.util.stream.Stream; + +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeKind; + +public class Method { + + private final Utils utils; + private final ExecutableElement delegate; + + public Method(Utils utils, ExecutableElement executableElement) { + this.utils = utils; + if (!isRegularMethod(executableElement) && !isConstructor(executableElement)) { + throw new IllegalArgumentException(executableElement + " not a method or constructor"); + } + this.delegate = executableElement; + } + + public Element element() { + return delegate; + } + + public String name() { + return delegate.getSimpleName().toString(); + } + + public static boolean isRegularMethod(ExecutableElement executableElement) { + String name = executableElement.getSimpleName().toString(); + return !"".equals(name) && !name.startsWith("<"); + } + + public static boolean isConstructor(ExecutableElement executableElement) { + String name = executableElement.getSimpleName().toString(); + return name.equals(""); + } + + public boolean isStatic() { + return delegate.getModifiers().contains(Modifier.STATIC); + } + + public boolean isPackagePrivate() { + return !delegate.getModifiers().contains(Modifier.PRIVATE) && !delegate.getModifiers().contains(Modifier.PUBLIC) && !delegate.getModifiers().contains(Modifier.PROTECTED); + } + + public boolean returnsVoid() { + return delegate.getReturnType().getKind() == TypeKind.VOID; + } + + public Type returnType() { + return new Type(utils, delegate.getReturnType()); + } + + public String getSourceCodeRepresentationOfType() { + return delegate.getReturnType().toString(); + } + + public Stream parameters() { + return delegate.getParameters().stream().map(variableElement -> new MethodParameter(utils, variableElement)); + } + + public boolean hasAnnotation(Class annotationType) { + return delegate.getAnnotation(annotationType) != null; + } + + public T getAnnotation(Class annotationType) { + return delegate.getAnnotation(annotationType); + } + + public Type declaringType() { + return new Type(utils, (TypeElement) delegate.getEnclosingElement()); + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/utils/MethodParameter.java b/generator/src/main/java/org/cryptomator/generator/utils/MethodParameter.java new file mode 100644 index 000000000..becca10a4 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/utils/MethodParameter.java @@ -0,0 +1,54 @@ +package org.cryptomator.generator.utils; + +import org.cryptomator.generator.ProcessorException; + +import java.lang.annotation.Annotation; +import java.util.List; + +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; + +public class MethodParameter { + + private final Utils utils; + private final VariableElement delegate; + + public MethodParameter(Utils utils, VariableElement variableElement) { + this.utils = utils; + this.delegate = variableElement; + } + + public String getName() { + return delegate.getSimpleName().toString(); + } + + public Type getTypeArgument(int index) { + List typeArguments = ((DeclaredType) delegate.asType()).getTypeArguments(); + TypeMirror mirror; + try { + mirror = typeArguments.get(index); + } catch (IndexOutOfBoundsException e) { + throw new ProcessorException("Required declaringType parameter is missing", delegate); + } + return new Type(utils, (TypeElement) ((DeclaredType) mirror).asElement()); + } + + public Type getType() { + return new Type(utils, (TypeElement) ((DeclaredType) delegate.asType()).asElement()); + } + + public String getSourceCodeRepresentationOfType() { + return delegate.asType().toString(); + } + + public boolean hasAnnotation(Class annotationType) { + return delegate.getAnnotation(annotationType) != null; + } + + public Element element() { + return delegate; + } +} diff --git a/generator/src/main/java/org/cryptomator/generator/utils/Type.java b/generator/src/main/java/org/cryptomator/generator/utils/Type.java new file mode 100644 index 000000000..61f115d41 --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/utils/Type.java @@ -0,0 +1,186 @@ +package org.cryptomator.generator.utils; + +import java.util.Optional; +import java.util.stream.Stream; + +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.PrimitiveType; +import javax.lang.model.type.TypeMirror; + +import static javax.lang.model.element.ElementKind.FIELD; +import static javax.lang.model.type.TypeKind.ARRAY; +import static javax.lang.model.type.TypeKind.NONE; + +public class Type { + + private final TypeMirror mirror; + private final Optional element; + private final Utils utils; + + public Utils getUtils() { + return utils; + } + + public Type(Utils utils, TypeMirror mirror) { + if (utils == null) { + throw new IllegalArgumentException("utils must not be null"); + } + if (mirror == null) { + throw new IllegalArgumentException("mirror must not be null"); + } + this.utils = utils; + this.mirror = mirror; + if (mirror instanceof DeclaredType) { + this.element = Optional.of((TypeElement) ((DeclaredType) mirror).asElement()); + } else { + this.element = Optional.empty(); + } + } + + public Type(Utils utils, TypeElement element) { + if (utils == null) { + throw new IllegalArgumentException("utils must not be null"); + } + if (element == null) { + throw new IllegalArgumentException("element must not be null"); + } + this.utils = utils; + this.mirror = utils.types.erasure(element.asType()); + this.element = Optional.of(element); + } + + public TypeElement element() { + return element.get(); + } + + public String qualifiedName() { + + return mirror.toString(); + } + + public String simpleName() { + if (element.isPresent()) { + return element.get().getSimpleName().toString(); + } else { + return qualifiedName(); + } + } + + public String packageName() { + return utils.elements.getPackageOf(element()).getQualifiedName().toString(); + } + + public boolean isAssignableFrom(String other) { + return new Type(utils, utils.elements.getTypeElement(other)).isAssignableTo(this); + } + + public boolean isAssignableTo(String other) { + return isAssignableTo(new Type(utils, utils.elements.getTypeElement(other))); + } + + private boolean isAssignableTo(Type other) { + return utils.types.isAssignable(mirror, other.mirror); + } + + public Stream constructors() { + return element // + .map(type -> type.getEnclosedElements().stream() // + .filter(ExecutableElement.class::isInstance) // + .map(ExecutableElement.class::cast) // + .filter(Method::isConstructor) // + .map(executableElement -> new Method(utils, executableElement))) + .orElse(Stream.empty()); // + } + + public Stream methods() { + return element // + .map(type -> type.getEnclosedElements().stream() // + .filter(ExecutableElement.class::isInstance) // + .map(ExecutableElement.class::cast) // + .filter(Method::isRegularMethod) // + .map(executableElement -> new Method(utils, executableElement))) + .orElse(Stream.empty()); // + } + + public Stream fields() { + return element // + .map(type -> type.getEnclosedElements().stream() // + .filter(VariableElement.class::isInstance) // + .map(VariableElement.class::cast) // + .filter(variable -> variable.getKind() == FIELD) // + .map(variableElement -> new Field(utils, variableElement))) + .orElse(Stream.empty()); // + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + return internalEquals((Type) obj); + } + + private boolean internalEquals(Type o) { + return utils.types.isSameType(mirror, o.mirror); + } + + @Override + public int hashCode() { + return mirror.hashCode(); + } + + public Optional enclosingType() { + if (mirror instanceof DeclaredType) { + return Optional.ofNullable(((DeclaredType) mirror).getEnclosingType()) // + .filter(mirror -> mirror.getKind() == NONE) // + .map(mirror -> new Type(utils, mirror)); + } else { + return Optional.empty(); + } + } + + public boolean isClass() { + return element.map(type -> type.getKind().isClass()).orElse(false); + } + + private boolean isPrimitive() { + return mirror.getKind().isPrimitive(); + } + + public boolean isPrimitiveWrapper() { + return unboxed().isPresent(); + } + + public Optional primitiveType() { + if (isPrimitive()) { + return Optional.of(this); + } else { + return unboxed(); + } + } + + private Optional unboxed() { + try { + return Optional.of(new Type(utils, utils.types.unboxedType(mirror))); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + public Optional boxed() { + if (isPrimitive()) { + return Optional.of(new Type(utils, utils.types.boxedClass((PrimitiveType) mirror))); + } else { + return Optional.empty(); + } + } + + public boolean isArray() { + return mirror.getKind() == ARRAY; + } + +} diff --git a/generator/src/main/java/org/cryptomator/generator/utils/Utils.java b/generator/src/main/java/org/cryptomator/generator/utils/Utils.java new file mode 100755 index 000000000..164e7311c --- /dev/null +++ b/generator/src/main/java/org/cryptomator/generator/utils/Utils.java @@ -0,0 +1,29 @@ +package org.cryptomator.generator.utils; + +import javax.annotation.processing.Messager; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; + +public class Utils { + + final Messager messager; + final Types types; + final Elements elements; + + public Utils(Messager messager, Types types, Elements elements) { + this.messager = messager; + this.types = types; + this.elements = elements; + } + + public Type type(TypeMirror mirror) { + return new Type(this, mirror); + } + + public Type type(TypeElement element) { + return new Type(this, element); + } + +} diff --git a/generator/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/generator/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100755 index 000000000..39f167923 --- /dev/null +++ b/generator/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1,6 @@ +org.cryptomator.generator.IntentProcessor +org.cryptomator.generator.UseCaseProcessor +org.cryptomator.generator.ActivityProcessor +org.cryptomator.generator.FragmentProcessor +org.cryptomator.generator.CallbackProcessor +org.cryptomator.generator.InstanceStateProcessor \ No newline at end of file diff --git a/generator/src/main/resources/templates/ActivitiesTemplate.vm b/generator/src/main/resources/templates/ActivitiesTemplate.vm new file mode 100644 index 000000000..e37a0d278 --- /dev/null +++ b/generator/src/main/resources/templates/ActivitiesTemplate.vm @@ -0,0 +1,55 @@ +package $javaPackage; + +/** + * Provides methods to read and build {@link android.content.Intent Intents}. + */ +@javax.annotation.Generated("org.cryptomator.generator.ActivityProcessor") +class $className { + + public static void inject(org.cryptomator.presentation.di.component.ActivityComponent component, org.cryptomator.presentation.ui.activity.BaseActivity activity) { +#foreach( $activity in $activities ) + if (activity instanceof $activity.qualifiedName) { + component.inject(($activity.qualifiedName)activity); + } else +#end + { + throw new java.lang.IllegalStateException("Failed to inject activity of type " + activity.getClass().getName()); + } + } + + public static org.cryptomator.presentation.presenter.Presenter initializePresenter(org.cryptomator.presentation.ui.activity.BaseActivity activity) { +#foreach( $activity in $activities ) + if (activity instanceof $activity.qualifiedName) { + #if ($activity.hasPresenter) + $activity.qualifiedName castActivity = ($activity.qualifiedName)activity; + ${activity.presenterQualifiedName} presenter = castActivity.${activity.presenterFieldName}; + presenter.setView(castActivity); + #if ($activity.presenterHasIntentField) + presenter.${activity.presenterIntentFieldName} = castActivity.${activity.intentFieldName}; + #end + return presenter; + #else + return null; + #end + } else +#end + { + throw new java.lang.IllegalStateException("Failed to initialize presenter for " + activity.getClass().getName()); + } + } + + public static void setIntent(org.cryptomator.presentation.ui.activity.BaseActivity activity) { +#foreach( $activity in $activities ) + #if ($activity.hasIntentField) + if (activity instanceof $activity.qualifiedName) { + $activity.qualifiedName castActivity = ($activity.qualifiedName)activity; + castActivity.${activity.intentFieldName} = org.cryptomator.presentation.intent.Intents.${activity.methodInIntentsName}(castActivity); + } else + #end +#end + {} + } + + private ${className}() {} + +} diff --git a/generator/src/main/resources/templates/CallbacksTemplate.vm b/generator/src/main/resources/templates/CallbacksTemplate.vm new file mode 100644 index 000000000..f70c84a3d --- /dev/null +++ b/generator/src/main/resources/templates/CallbacksTemplate.vm @@ -0,0 +1,38 @@ +package ${javaPackage}; + +@javax.annotation.Generated("org.cryptomator.generator.CallbackProcessor") +class ${callbacksClassName} { + +#foreach( $callback in ${callbacks} ) + public static org.cryptomator.generator.BoundCallback<${callback.declaringTypeName},${callback.resultTypeName}> ${callback.name}( + #foreach( $paramType in ${callback.additionalParameterTypes} ) + ${paramType} param${foreach.count}#if( $foreach.hasNext ),#end + #end + ) { + return new org.cryptomator.generator.BoundCallback<${callback.declaringTypeName},${callback.resultTypeName}>( + ${callback.declaringTypeName}.class, + ${callback.resultTypeName}.class + #foreach( $paramType in ${callback.additionalParameterTypes} ) + ,param${foreach.count} + #end + ) { + #if( !$callback.dispatchResultOkOnly ) + public boolean acceptsNonOkResults() { + return true; + } + #end + public void doCall(${callback.declaringTypeName} instance, Object[] parameters) { + instance.${callback.name}( + (${callback.resultTypeName})parameters[0] + #foreach( $paramType in ${callback.additionalParameterTypes} ) + ,(${paramType})parameters[${foreach.count}] + #end + ); + } + }; + } +#end + + private ${callbacksClassName}() {} + +} diff --git a/generator/src/main/resources/templates/FragmentsTemplate.vm b/generator/src/main/resources/templates/FragmentsTemplate.vm new file mode 100644 index 000000000..6ef315944 --- /dev/null +++ b/generator/src/main/resources/templates/FragmentsTemplate.vm @@ -0,0 +1,39 @@ +package $javaPackage; + +/** + * Provides utility methods used in BaseFragment. + */ +@javax.annotation.Generated("org.cryptomator.generator.FragmentProcessor") +class $className { + + public static void inject(org.cryptomator.presentation.di.component.ActivityComponent component, org.cryptomator.presentation.ui.fragment.BaseFragment fragment) { +#foreach( $fragment in $fragments ) + if (fragment instanceof $fragment.qualifiedName) { + component.inject(($fragment.qualifiedName)fragment); + } else +#end + { + throw new java.lang.IllegalStateException("Failed to inject fragment of type " + fragment.getClass().getName()); + } + } + + public static org.cryptomator.presentation.presenter.Presenter initializePresenter(org.cryptomator.presentation.ui.fragment.BaseFragment fragment) { +#foreach( $fragment in $fragments ) + if (fragment instanceof $fragment.qualifiedName) { + #if ($fragment.hasPresenter) + $fragment.qualifiedName castFragment = ($fragment.qualifiedName)fragment; + ${fragment.presenterQualifiedName} presenter = castFragment.${fragment.presenterFieldName}; + return presenter; + #else + return null; + #end + } else +#end + { + throw new java.lang.IllegalStateException("Failed to initialize presenter for " + fragment.getClass().getName()); + } + } + + private ${className}() {} + +} diff --git a/generator/src/main/resources/templates/InstanceStateTemplate.vm b/generator/src/main/resources/templates/InstanceStateTemplate.vm new file mode 100644 index 000000000..7b30f34a1 --- /dev/null +++ b/generator/src/main/resources/templates/InstanceStateTemplate.vm @@ -0,0 +1,45 @@ +package $javaPackage; + +/** + * Provides methods to save and restore values of {@link org.cryptomator.generator.InstanceState} annotated classes using a Bundle. + */ +@javax.annotation.Generated("org.cryptomator.generator.InstanceStateProcessor") +public class InstanceStates { + + private static final int SAVE = 0; + private static final int RESTORE = 1; + + public static void save(Object object, android.os.Bundle bundle) { + dispatch(object, bundle, SAVE); + } + + public static void restore(Object object, android.os.Bundle bundle) { + dispatch(object, bundle, RESTORE); + } + + public static void dispatch(Object object, android.os.Bundle bundle, int action) { + #foreach( $type in $types ) + if (object instanceof ${type.qualifiedName}) { + if (action == SAVE) save((${type.qualifiedName})object, bundle); + else restore((${type.qualifiedName})object, bundle); + } + #end + } + + #foreach( $type in $types ) + private static void save(${type.qualifiedName} instance, android.os.Bundle bundle) { + #foreach( $field in $type.fields ) + bundle.${field.putMethod}("${field.bundleKey}", instance.${field.name}); + #end + } + + private static void restore(${type.qualifiedName} instance, android.os.Bundle bundle) { + #foreach( $field in $type.fields ) + instance.${field.name} = #if( $field.castRequired )(${field.qualifiedType})#{end}bundle.${field.getMethod}("${field.bundleKey}"); + #end + } + #end + + private InstanceStates() {} + +} diff --git a/generator/src/main/resources/templates/IntentBuilderTemplate.vm b/generator/src/main/resources/templates/IntentBuilderTemplate.vm new file mode 100644 index 000000000..814e0de73 --- /dev/null +++ b/generator/src/main/resources/templates/IntentBuilderTemplate.vm @@ -0,0 +1,73 @@ +package $javaPackage; + +/** + * Builds {@link android.content.Intent Intents} to invoke {@link ${targetActivity}}. + */ +@javax.annotation.Generated("org.cryptomator.generator.IntentProcessor") +public class $className implements org.cryptomator.presentation.intent.IntentBuilder { + + private boolean preventGoingBackInHistory; +#foreach( $parameter in $parameters ) + private $parameter.type $parameter.name; +#end + + $className() {} + + /** + * Prevents that the user can go to fragments displayed before by setting to + * ACTIVITY_NEW_TASK and ACTIVITY_CLEAR_TASK flags on the created {@code Intent}. + * + * @see android.content.Intent.FLAG_ACTIVITY_NEW_TASK + * @see android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK + */ + public $className preventGoingBackInHistory() { + this.preventGoingBackInHistory = true; + return this; + } + +#foreach( $parameter in $parameters ) + /** + * Sets the value for the extra $parameter.name on the created {@code Intent}. + */ + public $className with${parameter.nameWithFirstCharUppercase}($parameter.type $parameter.name) { + this.$parameter.name = $parameter.name; + return this; + } +#end + + /** + * Create the {@code Intent} and then invoke {@link android.content.Context\#startActivity(android.content.Intent)} on the context. + * + * @throws IllegalStateException if some required extras or the context were not set + */ + public void startActivity(org.cryptomator.presentation.presenter.ContextHolder contextHolder) { + contextHolder.context().startActivity(build(contextHolder)); + } + + /** + * @return an {@code Intent} with all values set before + * @throws IllegalStateException if some required extras or the context were not set + */ + public android.content.Intent build(org.cryptomator.presentation.presenter.ContextHolder contextHolder) { + validate(); + android.content.Intent intent = new android.content.Intent(contextHolder.context(), ${targetActivity}.class); + if (preventGoingBackInHistory) { + intent.setFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK | android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK); + } +#foreach( $parameter in $parameters ) + intent.putExtra("$parameter.name", this.$parameter.name); +#end + return intent; + } + + private void validate() { +#foreach( $parameter in $parameters ) +#if ($parameter.required) + if ($parameter.name == null) { + throw new IllegalStateException("Parameter $parameter.name is required for $targetActivitySimpleName"); + } +#end +#end + } + +} diff --git a/generator/src/main/resources/templates/IntentReaderTemplate.vm b/generator/src/main/resources/templates/IntentReaderTemplate.vm new file mode 100644 index 000000000..228c917b4 --- /dev/null +++ b/generator/src/main/resources/templates/IntentReaderTemplate.vm @@ -0,0 +1,47 @@ +package $javaPackage; + +/** + * Provides extra data of {@link android.content.Intent Intents} from {@link ${targetActivity}}. + */ +@javax.annotation.Generated("org.cryptomator.generator.IntentProcessor") +class $className implements $intentInterface { + +#foreach( $parameter in $parameters ) + private $parameter.type $parameter.name; +#end + + public $className(${targetActivity} activity) { + android.content.Intent intent = activity.getIntent(); +#foreach( $parameter in $parameters ) + this.${parameter.name} = readExtra(intent, "$parameter.name", $parameter.required, ${parameter.type}.class); +#end + } + + private static T readExtra(android.content.Intent intent, String name, boolean required, Class type) { + android.os.Bundle extras = intent.getExtras(); + Object value = null; + if (extras != null) { + value = extras.get(name); + } + if (value == null) { + if (required) { + throw new IllegalStateException("Missing value for required extra " + name); + } + return null; + } else if (!type.isInstance(value)) { + throw new IllegalStateException("Invalid type of extra " + name + ". Expected " + type.getName() + " but got " + value.getClass().getName()); + } + return type.cast(value); + } + +#foreach( $parameter in $parameters ) + /** + * @return the value of the extra $parameter.name + */ + public $parameter.type ${parameter.name}() { + return this.$parameter.name; + } + +#end + +} diff --git a/generator/src/main/resources/templates/IntentsTemplate.vm b/generator/src/main/resources/templates/IntentsTemplate.vm new file mode 100644 index 000000000..0046152e8 --- /dev/null +++ b/generator/src/main/resources/templates/IntentsTemplate.vm @@ -0,0 +1,32 @@ +package $javaPackage; + +/** + * Provides methods to read and build {@link android.content.Intent Intents}. + */ +@javax.annotation.Generated("org.cryptomator.generator.IntentProcessor") +public class $className { + +#foreach( $builder in $builders ) + /** + * @return a builder which can be used to create {@code Intent}s to invoke {@link ${builder.targetActivity}} + */ + public static ${builder.javaPackage}.${builder.className} ${builder.buildMethodName}() { + return new ${builder.javaPackage}.${builder.className}(); + } + +#end + +#foreach( $reader in $readers ) + /** + * @param context a {@link $reader.targetActivity} to read {@code Intent} data from + * @return a {@link ${reader.javaPackage}.${reader.className} ${reader.className}} holding extra values from the intent of the given {@code Activity} + */ + public static ${reader.javaPackage}.${reader.className} ${reader.readMethodName}($reader.targetActivity context) { + return new ${reader.javaPackage}.${reader.className}(context); + } + +#end + + private ${className}() {} + +} diff --git a/generator/src/main/resources/templates/UseCaseTemplate.vm b/generator/src/main/resources/templates/UseCaseTemplate.vm new file mode 100644 index 000000000..0ecea5972 --- /dev/null +++ b/generator/src/main/resources/templates/UseCaseTemplate.vm @@ -0,0 +1,290 @@ +package $packageName; + +import java.util.concurrent.atomic.AtomicInteger; + +@javax.annotation.Generated("org.cryptomator.generator.UseCaseProcessor") +@org.cryptomator.domain.di.PerView +public class $simpleName implements org.cryptomator.generator.Unsubscribable { + + private static final AtomicInteger EXECUTION_ID = new AtomicInteger((int)System.currentTimeMillis() & 0x7fffffff); + + private final org.cryptomator.domain.executor.PostExecutionThread postExecutionThread; + private final org.cryptomator.domain.executor.ThreadExecutor threadExecutor; + +#foreach( $anInjected in $injected ) + private final $anInjected.type $anInjected.lowerCaseName; +#end + + private $implClassName impl; + private org.cryptomator.domain.executor.BackgroundTasks.Registration registration; + private io.reactivex.disposables.Disposable disposable = io.reactivex.internal.disposables.EmptyDisposable.INSTANCE; + + @javax.inject.Inject + public ${simpleName}( + org.cryptomator.domain.executor.ThreadExecutor threadExecutor, + org.cryptomator.domain.executor.PostExecutionThread postExecutionThread +#foreach( $anInjected in $injected ) + ,$anInjected.type $anInjected.lowerCaseName +#end ) { + this.threadExecutor = threadExecutor; + this.postExecutionThread = postExecutionThread; +#foreach( $anInjected in $injected ) + this.$anInjected.lowerCaseName = $anInjected.lowerCaseName; +#end + } + + @Override + public void unsubscribe() { + if (disposable != null && !disposable.isDisposed()) { + registration.unregister(); + disposable.dispose(); + disposable = null; +#if ( $hasCancelHandler ) + cancel(); +#end + impl = null; + } + } + +#if ( $hasCancelHandler ) + public void cancel() { + $implClassName local = impl; + if (local != null) { + local.onCancel(); + } + } +#end + +#foreach( $parameter in $parameters ) + public Launcher with${parameter.upperCaseName}($parameter.type $parameter.lowerCaseName) { + return new Launcher().and${parameter.upperCaseName}($parameter.lowerCaseName); + } +#end + + +#if ( $hasParameters ) + public class Launcher { + + private Launcher() {} + +#foreach( $parameter in $parameters ) + private $parameter.type $parameter.lowerCaseName; +#end + +#foreach( $parameter in $parameters ) + public Launcher and${parameter.upperCaseName}($parameter.type $parameter.lowerCaseName) { + this.$parameter.lowerCaseName = $parameter.lowerCaseName; + return this; + } +#end + +#if ( $hasProgressAware ) + public void run(final org.cryptomator.domain.usecases.ProgressAwareResultHandler<$resultClassName,$progressStateName> resultHandler) { +#else + public void run(final org.cryptomator.domain.usecases.ResultHandler<$resultClassName> resultHandler) { +#end + if(registration != null) { + registration.unregister(); + } + registration = org.cryptomator.domain.executor.BackgroundTasks.register(${simpleName}.class); + validate(); + impl = new ${implClassName}( +#foreach ( $injectedOrParam in $injectedAndParams ) + #if ( $injectedOrParam.parameter ) + this.$injectedOrParam.lowerCaseName + #elseif ( $injectedOrParam.progressAware) + null + #else + ${simpleName}.this.$injectedOrParam.lowerCaseName + #end + #if( $foreach.hasNext ),#end +#end + ); +#if ( $hasProgressAware ) + final io.reactivex.subscribers.DisposableSubscriber> subscriber = new io.reactivex.subscribers.DisposableSubscriber>(){ + @Override + public void onComplete() { + resultHandler.onFinished(); + } + @Override + public void onError(Throwable e) { + resultHandler.onError(e); + resultHandler.onFinished(); + } + @Override + public void onNext(org.cryptomator.domain.usecases.ResultWithProgress<$resultClassName,$progressStateName> result) { + resultHandler.onProgress(result.progress()); + if (result.value() != null) { + resultHandler.onSuccess(result.value()); + } + } + }; + ${simpleName}.this.disposable = subscriber; + io.reactivex.Flowable.fromPublisher(new org.reactivestreams.Publisher>(){ + public void subscribe(final org.reactivestreams.Subscriber> subscriber) { + org.cryptomator.domain.usecases.ProgressAware<$progressStateName> progressAware = new org.cryptomator.domain.usecases.ThrottlingProgressAware<$progressStateName>(new org.cryptomator.domain.usecases.ProgressAware<$progressStateName>() { + @Override + public void onProgress(org.cryptomator.domain.usecases.cloud.Progress<$progressStateName> progress) { + subscriber.onNext(org.cryptomator.domain.usecases.ResultWithProgress.<$resultClassName,$progressStateName>progress(progress)); + } + }); + + final int id = EXECUTION_ID.getAndIncrement(); + try { + #if ($resultIsVoid) + timber.log.Timber.tag("${simpleName}").d("started %x", id); + impl.execute(progressAware); + subscriber.onNext(org.cryptomator.domain.usecases.ResultWithProgress.<$resultClassName,$progressStateName>finalResult(null)); + timber.log.Timber.tag("${simpleName}").d("finished %x", id); + subscriber.onComplete(); + registration.unregister(); + #else + timber.log.Timber.tag("${simpleName}").d("started %x", id); + subscriber.onNext(org.cryptomator.domain.usecases.ResultWithProgress.<$resultClassName,$progressStateName>finalResult(impl.execute(progressAware))); + timber.log.Timber.tag("${simpleName}").d("finished %x", id); + subscriber.onComplete(); + registration.unregister(); + #end + } catch (Throwable e) { + timber.log.Timber.tag("${simpleName}").d("failed %x", id); + subscriber.onError(e); + registration.unregister(); + } + } +#else + final io.reactivex.subscribers.DisposableSubscriber<$subscriberResultClassName> subscriber = new io.reactivex.subscribers.DisposableSubscriber<$subscriberResultClassName>(){ + @Override + public void onComplete() { + resultHandler.onFinished(); + registration.unregister(); + } + @Override + public void onError(Throwable e) { + resultHandler.onError(e); + resultHandler.onFinished(); + registration.unregister(); + } + @Override + public void onNext($subscriberResultClassName result) { + #if ($resultIsVoid) + resultHandler.onSuccess(null); + #else + resultHandler.onSuccess(result); + #end + } + }; + ${simpleName}.this.disposable = subscriber; + io.reactivex.Flowable.fromCallable(new java.util.concurrent.Callable<$subscriberResultClassName>(){ + public $subscriberResultClassName call() throws Exception { + final int id = EXECUTION_ID.getAndIncrement(); + boolean failed = true; + try { + #if ($resultIsVoid) + timber.log.Timber.tag("${simpleName}").d("started %x", id); + impl.execute(); + failed = false; + timber.log.Timber.tag("${simpleName}").d("finished %x", id); + return new Object(); + #else + timber.log.Timber.tag("${simpleName}").d("started %x", id); + $resultClassName result = impl.execute(); + failed = false; + timber.log.Timber.tag("${simpleName}").d("finished %x", id); + return result; + #end + } finally { + if (failed) { + timber.log.Timber.tag("${simpleName}").d("failed %x", id); + } + } + } +#end + }) + .subscribeOn(io.reactivex.schedulers.Schedulers.from(threadExecutor)) + .onBackpressureLatest() + .observeOn(postExecutionThread.getScheduler()) + .subscribe(subscriber); + } + + private void validate() { +#foreach( $parameter in $parameters ) + #if (!$parameter.optional) + if ($parameter.lowerCaseName == null) { + throw new IllegalStateException("$parameter.lowerCaseName is required"); + } + #end +#end + } + } +#else ## $hasParameters + +public void run(final org.cryptomator.domain.usecases.ResultHandler<$resultClassName> resultHandler) { + if(registration != null) { + registration.unregister(); + } + registration = org.cryptomator.domain.executor.BackgroundTasks.register(${simpleName}.class); + impl = new ${implClassName}( +#foreach ( $anInjected in $injected ) + this.$anInjected.lowerCaseName + #if( $foreach.hasNext ),#end +#end + ); + final io.reactivex.subscribers.DisposableSubscriber<$subscriberResultClassName> subscriber = new io.reactivex.subscribers.DisposableSubscriber<$subscriberResultClassName>(){ + @Override + public void onComplete() { + resultHandler.onFinished(); + registration.unregister(); + } + @Override + public void onError(Throwable e) { + resultHandler.onError(e); + resultHandler.onFinished(); + registration.unregister(); + } + @Override + public void onNext($subscriberResultClassName result) { + #if ($resultIsVoid) + resultHandler.onSuccess(null); + #else + resultHandler.onSuccess(result); + #end + } + }; + this.disposable = subscriber; + io.reactivex.Flowable.fromCallable(new java.util.concurrent.Callable<$subscriberResultClassName>(){ + public $subscriberResultClassName call() throws Exception { + final int id = EXECUTION_ID.getAndIncrement(); + boolean failed = true; +#if ($resultIsVoid) + timber.log.Timber.tag("${simpleName}").d("started %x", id); + try { + impl.execute(); + failed = false; + } finally { + timber.log.Timber.tag("${simpleName}").d("finished %x", id); + } + return new Object(); +#else + timber.log.Timber.tag("${simpleName}").d("started %x", id); + try { + $resultClassName result = impl.execute(); + failed = false; + return result; + } finally { + if (failed) { + timber.log.Timber.tag("${simpleName}").d("failed %x", id); + } else { + timber.log.Timber.tag("${simpleName}").d("finished %x", id); + } + } +#end + } + }) + .subscribeOn(io.reactivex.schedulers.Schedulers.from(threadExecutor)) + .observeOn(postExecutionThread.getScheduler()) + .subscribe(subscriber); + } + +#end ## $hasParameters + +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..913b5c507 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +#Enable daemon +org.gradle.daemon=true +# Try and findout the best heap size for your project build. +org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# Modularise your project and enable parallel build +org.gradle.parallel=true +# Enable configure on demand. +org.gradle.configureondemand=true +kotlin.incremental=true +kotlin.setJvmTargetFromAndroidCompileOptions=true +android.useAndroidX=true +android.enableJetifier=true +android.enableD8=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 zcmafaW0a=B^559DjdyHo$F^PVt zzd|cWgMz^T0YO0lQ8%TE1O06v|NZl~LH{LLQ58WtNjWhFP#}eWVO&eiP!jmdp!%24 z{&z-MK{-h=QDqf+S+Pgi=_wg$I{F28X*%lJ>A7Yl#$}fMhymMu?R9TEB?#6@|Q^e^AHhxcRL$z1gsc`-Q`3j+eYAd<4@z^{+?JM8bmu zSVlrVZ5-)SzLn&LU9GhXYG{{I+u(+6ES+tAtQUanYC0^6kWkks8cG;C&r1KGs)Cq}WZSd3k1c?lkzwLySimkP5z)T2Ox3pNs;PdQ=8JPDkT7#0L!cV? zzn${PZs;o7UjcCVd&DCDpFJvjI=h(KDmdByJuDYXQ|G@u4^Kf?7YkE67fWM97kj6F z973tGtv!k$k{<>jd~D&c(x5hVbJa`bILdy(00%lY5}HZ2N>)a|))3UZ&fUa5@uB`H z+LrYm@~t?g`9~@dFzW5l>=p0hG%rv0>(S}jEzqQg6-jImG%Pr%HPtqIV_Ym6yRydW z4L+)NhcyYp*g#vLH{1lK-hQQSScfvNiNx|?nSn-?cc8}-9~Z_0oxlr~(b^EiD`Mx< zlOLK)MH?nl4dD|hx!jBCIku-lI(&v~bCU#!L7d0{)h z;k4y^X+=#XarKzK*)lv0d6?kE1< zmCG^yDYrSwrKIn04tG)>>10%+ zEKzs$S*Zrl+GeE55f)QjY$ zD5hi~J17k;4VSF_`{lPFwf^Qroqg%kqM+Pdn%h#oOPIsOIwu?JR717atg~!)*CgXk zERAW?c}(66rnI+LqM^l7BW|9dH~5g1(_w$;+AAzSYlqop*=u5}=g^e0xjlWy0cUIT7{Fs2Xqx*8% zW71JB%hk%aV-wjNE0*$;E-S9hRx5|`L2JXxz4TX3nf8fMAn|523ssV;2&145zh{$V z#4lt)vL2%DCZUgDSq>)ei2I`*aeNXHXL1TB zC8I4!uq=YYVjAdcCjcf4XgK2_$y5mgsCdcn2U!VPljXHco>+%`)6W=gzJk0$e%m$xWUCs&Ju-nUJjyQ04QF_moED2(y6q4l+~fo845xm zE5Esx?~o#$;rzpCUk2^2$c3EBRNY?wO(F3Pb+<;qfq;JhMFuSYSxiMejBQ+l8(C-- zz?Xufw@7{qvh$;QM0*9tiO$nW(L>83egxc=1@=9Z3)G^+*JX-z92F((wYiK>f;6 zkc&L6k4Ua~FFp`x7EF;ef{hb*n8kx#LU|6{5n=A55R4Ik#sX{-nuQ}m7e<{pXq~8#$`~6| zi{+MIgsBRR-o{>)CE8t0Bq$|SF`M0$$7-{JqwFI1)M^!GMwq5RAWMP!o6G~%EG>$S zYDS?ux;VHhRSm*b^^JukYPVb?t0O%^&s(E7Rb#TnsWGS2#FdTRj_SR~YGjkaRFDI=d)+bw$rD;_!7&P2WEmn zIqdERAbL&7`iA^d?8thJ{(=)v>DgTF7rK-rck({PpYY$7uNY$9-Z< ze4=??I#p;$*+-Tm!q8z}k^%-gTm59^3$*ByyroqUe02Dne4?Fc%JlO>*f9Zj{++!^ zBz0FxuS&7X52o6-^CYq>jkXa?EEIfh?xdBPAkgpWpb9Tam^SXoFb3IRfLwanWfskJ zIbfU-rJ1zPmOV)|%;&NSWIEbbwj}5DIuN}!m7v4($I{Rh@<~-sK{fT|Wh?<|;)-Z; zwP{t@{uTsmnO@5ZY82lzwl4jeZ*zsZ7w%a+VtQXkigW$zN$QZnKw4F`RG`=@eWowO zFJ6RC4e>Y7Nu*J?E1*4*U0x^>GK$>O1S~gkA)`wU2isq^0nDb`);Q(FY<8V6^2R%= zDY}j+?mSj{bz2>F;^6S=OLqiHBy~7h4VVscgR#GILP!zkn68S^c04ZL3e$lnSU_(F zZm3e`1~?eu1>ys#R6>Gu$`rWZJG&#dsZ?^)4)v(?{NPt+_^Ak>Ap6828Cv^B84fa4 z_`l$0SSqkBU}`f*H#<14a)khT1Z5Z8;=ga^45{l8y*m|3Z60vgb^3TnuUKaa+zP;m zS`za@C#Y;-LOm&pW||G!wzr+}T~Q9v4U4ufu*fLJC=PajN?zN=?v^8TY}wrEeUygdgwr z7szml+(Bar;w*c^!5txLGKWZftqbZP`o;Kr1)zI}0Kb8yr?p6ZivtYL_KA<+9)XFE z=pLS5U&476PKY2aKEZh}%|Vb%!us(^qf)bKdF7x_v|Qz8lO7Ro>;#mxG0gqMaTudL zi2W!_#3@INslT}1DFJ`TsPvRBBGsODklX0`p-M6Mrgn~6&fF`kdj4K0I$<2Hp(YIA z)fFdgR&=qTl#sEFj6IHzEr1sYM6 zNfi!V!biByA&vAnZd;e_UfGg_={}Tj0MRt3SG%BQYnX$jndLG6>ssgIV{T3#=;RI% zE}b!9z#fek19#&nFgC->@!IJ*Fe8K$ZOLmg|6(g}ccsSBpc`)3;Ar8;3_k`FQ#N9&1tm>c|2mzG!!uWvelm zJj|oDZ6-m(^|dn3em(BF&3n12=hdtlb@%!vGuL*h`CXF?^=IHU%Q8;g8vABm=U!vX zT%Ma6gpKQC2c;@wH+A{)q+?dAuhetSxBDui+Z;S~6%oQq*IwSMu-UhMDy{pP z-#GB-a0`0+cJ%dZ7v0)3zfW$eV>w*mgU4Cma{P$DY3|w364n$B%cf()fZ;`VIiK_O zQ|q|(55+F$H(?opzr%r)BJLy6M&7Oq8KCsh`pA5^ohB@CDlMKoDVo5gO&{0k)R0b(UOfd>-(GZGeF}y?QI_T+GzdY$G{l!l% zHyToqa-x&X4;^(-56Lg$?(KYkgJn9W=w##)&CECqIxLe@+)2RhO*-Inpb7zd8txFG6mY8E?N8JP!kRt_7-&X{5P?$LAbafb$+hkA*_MfarZxf zXLpXmndnV3ubbXe*SYsx=eeuBKcDZI0bg&LL-a8f9>T(?VyrpC6;T{)Z{&|D5a`Aa zjP&lP)D)^YYWHbjYB6ArVs+4xvrUd1@f;;>*l zZH``*BxW+>Dd$be{`<&GN(w+m3B?~3Jjz}gB8^|!>pyZo;#0SOqWem%xeltYZ}KxOp&dS=bg|4 zY-^F~fv8v}u<7kvaZH`M$fBeltAglH@-SQres30fHC%9spF8Ld%4mjZJDeGNJR8+* zl&3Yo$|JYr2zi9deF2jzEC) zl+?io*GUGRp;^z+4?8gOFA>n;h%TJC#-st7#r&-JVeFM57P7rn{&k*z@+Y5 zc2sui8(gFATezp|Te|1-Q*e|Xi+__8bh$>%3|xNc2kAwTM!;;|KF6cS)X3SaO8^z8 zs5jV(s(4_NhWBSSJ}qUzjuYMKlkjbJS!7_)wwVsK^qDzHx1u*sC@C1ERqC#l%a zk>z>m@sZK{#GmsB_NkEM$$q@kBrgq%=NRBhL#hjDQHrI7(XPgFvP&~ZBJ@r58nLme zK4tD}Nz6xrbvbD6DaDC9E_82T{(WRQBpFc+Zb&W~jHf1MiBEqd57}Tpo8tOXj@LcF zwN8L-s}UO8%6piEtTrj@4bLH!mGpl5mH(UJR1r9bBOrSt0tSJDQ9oIjcW#elyMAxl7W^V(>8M~ss0^>OKvf{&oUG@uW{f^PtV#JDOx^APQKm& z{*Ysrz&ugt4PBUX@KERQbycxP%D+ApR%6jCx7%1RG2YpIa0~tqS6Xw6k#UN$b`^l6d$!I z*>%#Eg=n#VqWnW~MurJLK|hOQPTSy7G@29g@|g;mXC%MF1O7IAS8J^Q6D&Ra!h^+L&(IBYg2WWzZjT-rUsJMFh@E)g)YPW_)W9GF3 zMZz4RK;qcjpnat&J;|MShuPc4qAc)A| zVB?h~3TX+k#Cmry90=kdDoPYbhzs#z96}#M=Q0nC{`s{3ZLU)c(mqQQX;l~1$nf^c zFRQ~}0_!cM2;Pr6q_(>VqoW0;9=ZW)KSgV-c_-XdzEapeLySavTs5-PBsl-n3l;1jD z9^$^xR_QKDUYoeqva|O-+8@+e??(pRg@V|=WtkY!_IwTN~ z9Rd&##eWt_1w$7LL1$-ETciKFyHnNPjd9hHzgJh$J(D@3oYz}}jVNPjH!viX0g|Y9 zDD`Zjd6+o+dbAbUA( zEqA9mSoX5p|9sDVaRBFx_8)Ra4HD#xDB(fa4O8_J2`h#j17tSZOd3%}q8*176Y#ak zC?V8Ol<*X{Q?9j{Ys4Bc#sq!H;^HU$&F_`q2%`^=9DP9YV-A!ZeQ@#p=#ArloIgUH%Y-s>G!%V3aoXaY=f<UBrJTN+*8_lMX$yC=Vq+ zrjLn-pO%+VIvb~>k%`$^aJ1SevcPUo;V{CUqF>>+$c(MXxU12mxqyFAP>ki{5#;Q0 zx7Hh2zZdZzoxPY^YqI*Vgr)ip0xnpQJ+~R*UyFi9RbFd?<_l8GH@}gGmdB)~V7vHg z>Cjy78TQTDwh~+$u$|K3if-^4uY^|JQ+rLVX=u7~bLY29{lr>jWV7QCO5D0I>_1?; zx>*PxE4|wC?#;!#cK|6ivMzJ({k3bT_L3dHY#h7M!ChyTT`P#%3b=k}P(;QYTdrbe z+e{f@we?3$66%02q8p3;^th;9@y2vqt@LRz!DO(WMIk?#Pba85D!n=Ao$5NW0QVgS zoW)fa45>RkjU?H2SZ^#``zs6dG@QWj;MO4k6tIp8ZPminF`rY31dzv^e-3W`ZgN#7 z)N^%Rx?jX&?!5v`hb0-$22Fl&UBV?~cV*{hPG6%ml{k;m+a-D^XOF6DxPd$3;2VVY zT)E%m#ZrF=D=84$l}71DK3Vq^?N4``cdWn3 zqV=mX1(s`eCCj~#Nw4XMGW9tK>$?=cd$ule0Ir8UYzhi?%_u0S?c&j7)-~4LdolkgP^CUeE<2`3m)I^b ztV`K0k$OS^-GK0M0cNTLR22Y_eeT{<;G(+51Xx}b6f!kD&E4; z&Op8;?O<4D$t8PB4#=cWV9Q*i4U+8Bjlj!y4`j)^RNU#<5La6|fa4wLD!b6?RrBsF z@R8Nc^aO8ty7qzlOLRL|RUC-Bt-9>-g`2;@jfNhWAYciF{df9$n#a~28+x~@x0IWM zld=J%YjoKm%6Ea>iF){z#|~fo_w#=&&HRogJmXJDjCp&##oVvMn9iB~gyBlNO3B5f zXgp_1I~^`A0z_~oAa_YBbNZbDsnxLTy0@kkH!=(xt8|{$y<+|(wSZW7@)#|fs_?gU5-o%vpsQPRjIxq;AED^oG%4S%`WR}2(*!84Pe8Jw(snJ zq~#T7+m|w#acH1o%e<+f;!C|*&_!lL*^zRS`;E}AHh%cj1yR&3Grv&0I9k9v0*w8^ zXHEyRyCB`pDBRAxl;ockOh6$|7i$kzCBW$}wGUc|2bo3`x*7>B@eI=-7lKvI)P=gQ zf_GuA+36kQb$&{ZH)6o^x}wS}S^d&Xmftj%nIU=>&j@0?z8V3PLb1JXgHLq)^cTvB zFO6(yj1fl1Bap^}?hh<>j?Jv>RJdK{YpGjHxnY%d8x>A{k+(18J|R}%mAqq9Uzm8^Us#Ir_q^w9-S?W07YRD`w%D(n;|8N%_^RO`zp4 z@`zMAs>*x0keyE)$dJ8hR37_&MsSUMlGC*=7|wUehhKO)C85qoU}j>VVklO^TxK?! zO!RG~y4lv#W=Jr%B#sqc;HjhN={wx761vA3_$S>{j+r?{5=n3le|WLJ(2y_r>{)F_ z=v8Eo&xFR~wkw5v-{+9^JQukxf8*CXDWX*ZzjPVDc>S72uxAcY+(jtg3ns_5R zRYl2pz`B)h+e=|7SfiAAP;A zk0tR)3u1qy0{+?bQOa17SpBRZ5LRHz(TQ@L0%n5xJ21ri>^X420II1?5^FN3&bV?( zCeA)d9!3FAhep;p3?wLPs`>b5Cd}N!;}y`Hq3ppDs0+><{2ey0yq8o7m-4|oaMsWf zsLrG*aMh91drd-_QdX6t&I}t2!`-7$DCR`W2yoV%bcugue)@!SXM}fJOfG(bQQh++ zjAtF~zO#pFz})d8h)1=uhigDuFy`n*sbxZ$BA^Bt=Jdm}_KB6sCvY(T!MQnqO;TJs zVD{*F(FW=+v`6t^6{z<3-fx#|Ze~#h+ymBL^^GKS%Ve<)sP^<4*y_Y${06eD zH_n?Ani5Gs4&1z)UCL-uBvq(8)i!E@T_*0Sp5{Ddlpgke^_$gukJc_f9e=0Rfpta@ ze5~~aJBNK&OJSw!(rDRAHV0d+eW#1?PFbr==uG-$_fu8`!DWqQD~ef-Gx*ZmZx33_ zb0+I(0!hIK>r9_S5A*UwgRBKSd6!ieiYJHRigU@cogJ~FvJHY^DSysg)ac=7#wDBf zNLl!E$AiUMZC%%i5@g$WsN+sMSoUADKZ}-Pb`{7{S>3U%ry~?GVX!BDar2dJHLY|g zTJRo#Bs|u#8ke<3ohL2EFI*n6adobnYG?F3-#7eZZQO{#rmM8*PFycBR^UZKJWr(a z8cex$DPOx_PL^TO<%+f^L6#tdB8S^y#+fb|acQfD(9WgA+cb15L+LUdHKv)wE6={i zX^iY3N#U7QahohDP{g`IHS?D00eJC9DIx0V&nq!1T* z4$Bb?trvEG9JixrrNRKcjX)?KWR#Y(dh#re_<y*=5!J+-Wwb*D>jKXgr5L8_b6pvSAn3RIvI5oj!XF^m?otNA=t^dg z#V=L0@W)n?4Y@}49}YxQS=v5GsIF3%Cp#fFYm0Bm<}ey& zOfWB^vS8ye?n;%yD%NF8DvOpZqlB++#4KnUj>3%*S(c#yACIU>TyBG!GQl7{b8j#V z;lS})mrRtT!IRh2B-*T58%9;!X}W^mg;K&fb7?2#JH>JpCZV5jbDfOgOlc@wNLfHN z8O92GeBRjCP6Q9^Euw-*i&Wu=$>$;8Cktx52b{&Y^Ise-R1gTKRB9m0*Gze>$k?$N zua_0Hmbcj8qQy{ZyJ%`6v6F+yBGm>chZxCGpeL@os+v&5LON7;$tb~MQAbSZKG$k z8w`Mzn=cX4Hf~09q8_|3C7KnoM1^ZGU}#=vn1?1^Kc-eWv4x^T<|i9bCu;+lTQKr- zRwbRK!&XrWRoO7Kw!$zNQb#cJ1`iugR(f_vgmu!O)6tFH-0fOSBk6$^y+R07&&B!(V#ZV)CX42( zTC(jF&b@xu40fyb1=_2;Q|uPso&Gv9OSM1HR{iGPi@JUvmYM;rkv#JiJZ5-EFA%Lu zf;wAmbyclUM*D7>^nPatbGr%2aR5j55qSR$hR`c?d+z z`qko8Yn%vg)p=H`1o?=b9K0%Blx62gSy)q*8jWPyFmtA2a+E??&P~mT@cBdCsvFw4 zg{xaEyVZ|laq!sqN}mWq^*89$e6%sb6Thof;ml_G#Q6_0-zwf80?O}D0;La25A0C+ z3)w-xesp6?LlzF4V%yA9Ryl_Kq*wMk4eu&)Tqe#tmQJtwq`gI^7FXpToum5HP3@;N zpe4Y!wv5uMHUu`zbdtLys5)(l^C(hFKJ(T)z*PC>7f6ZRR1C#ao;R&_8&&a3)JLh* zOFKz5#F)hJqVAvcR#1)*AWPGmlEKw$sQd)YWdAs_W-ojA?Lm#wCd}uF0^X=?AA#ki zWG6oDQZJ5Tvifdz4xKWfK&_s`V*bM7SVc^=w7-m}jW6U1lQEv_JsW6W(| zkKf>qn^G!EWn~|7{G-&t0C6C%4)N{WRK_PM>4sW8^dDkFM|p&*aBuN%fg(I z^M-49vnMd%=04N95VO+?d#el>LEo^tvnQsMop70lNqq@%cTlht?e+B5L1L9R4R(_6 z!3dCLeGXb+_LiACNiqa^nOELJj%q&F^S+XbmdP}`KAep%TDop{Pz;UDc#P&LtMPgH zy+)P1jdgZQUuwLhV<89V{3*=Iu?u#v;v)LtxoOwV(}0UD@$NCzd=id{UuDdedeEp| z`%Q|Y<6T?kI)P|8c!K0Za&jxPhMSS!T`wlQNlkE(2B*>m{D#`hYYD>cgvsKrlcOcs7;SnVCeBiK6Wfho@*Ym9 zr0zNfrr}0%aOkHd)d%V^OFMI~MJp+Vg-^1HPru3Wvac@-QjLX9Dx}FL(l>Z;CkSvC zOR1MK%T1Edv2(b9$ttz!E7{x4{+uSVGz`uH&)gG`$)Vv0^E#b&JSZp#V)b6~$RWwe zzC3FzI`&`EDK@aKfeqQ4M(IEzDd~DS>GB$~ip2n!S%6sR&7QQ*=Mr(v*v-&07CO%# zMBTaD8-EgW#C6qFPPG1Ph^|0AFs;I+s|+A@WU}%@WbPI$S0+qFR^$gim+Fejs2f!$ z@Xdlb_K1BI;iiOUj`j+gOD%mjq^S~J0cZZwuqfzNH9}|(vvI6VO+9ZDA_(=EAo;( zKKzm`k!s!_sYCGOm)93Skaz+GF7eY@Ra8J$C)`X)`aPKym?7D^SI}Mnef4C@SgIEB z>nONSFl$qd;0gSZhNcRlq9VVHPkbakHlZ1gJ1y9W+@!V$TLpdsbKR-VwZrsSM^wLr zL9ob&JG)QDTaf&R^cnm5T5#*J3(pSpjM5~S1 z@V#E2syvK6wb?&h?{E)CoI~9uA(hST7hx4_6M(7!|BW3TR_9Q zLS{+uPoNgw(aK^?=1rFcDO?xPEk5Sm=|pW%-G2O>YWS^(RT)5EQ2GSl75`b}vRcD2 z|HX(x0#Qv+07*O|vMIV(0?KGjOny#Wa~C8Q(kF^IR8u|hyyfwD&>4lW=)Pa311caC zUk3aLCkAFkcidp@C%vNVLNUa#1ZnA~ZCLrLNp1b8(ndgB(0zy{Mw2M@QXXC{hTxr7 zbipeHI-U$#Kr>H4}+cu$#2fG6DgyWgq{O#8aa)4PoJ^;1z7b6t&zt zPei^>F1%8pcB#1`z`?f0EAe8A2C|}TRhzs*-vN^jf(XNoPN!tONWG=abD^=Lm9D?4 zbq4b(in{eZehKC0lF}`*7CTzAvu(K!eAwDNC#MlL2~&gyFKkhMIF=32gMFLvKsbLY z1d$)VSzc^K&!k#2Q?(f>pXn){C+g?vhQ0ijV^Z}p5#BGrGb%6n>IH-)SA$O)*z3lJ z1rtFlovL`cC*RaVG!p!4qMB+-f5j^1)ALf4Z;2X&ul&L!?`9Vdp@d(%(>O=7ZBV;l z?bbmyPen>!P{TJhSYPmLs759b1Ni1`d$0?&>OhxxqaU|}-?Z2c+}jgZ&vCSaCivx| z-&1gw2Lr<;U-_xzlg}Fa_3NE?o}R-ZRX->__}L$%2ySyiPegbnM{UuADqwDR{C2oS zPuo88%DNfl4xBogn((9j{;*YGE0>2YoL?LrH=o^SaAcgO39Ew|vZ0tyOXb509#6{7 z0<}CptRX5(Z4*}8CqCgpT@HY3Q)CvRz_YE;nf6ZFwEje^;Hkj0b1ESI*8Z@(RQrW4 z35D5;S73>-W$S@|+M~A(vYvX(yvLN(35THo!yT=vw@d(=q8m+sJyZMB7T&>QJ=jkwQVQ07*Am^T980rldC)j}}zf!gq7_z4dZ zHwHB94%D-EB<-^W@9;u|(=X33c(G>q;Tfq1F~-Lltp|+uwVzg?e$M96ndY{Lcou%w zWRkjeE`G*i)Bm*|_7bi+=MPm8by_};`=pG!DSGBP6y}zvV^+#BYx{<>p0DO{j@)(S zxcE`o+gZf8EPv1g3E1c3LIbw+`rO3N+Auz}vn~)cCm^DlEi#|Az$b z2}Pqf#=rxd!W*6HijC|u-4b~jtuQS>7uu{>wm)PY6^S5eo=?M>;tK`=DKXuArZvaU zHk(G??qjKYS9G6Du)#fn+ob=}C1Hj9d?V$_=J41ljM$CaA^xh^XrV-jzi7TR-{{9V zZZI0;aQ9YNEc`q=Xvz;@q$eqL<}+L(>HR$JA4mB6~g*YRSnpo zTofY;u7F~{1Pl=pdsDQx8Gg#|@BdoWo~J~j%DfVlT~JaC)he>he6`C`&@@#?;e(9( zgKcmoidHU$;pi{;VXyE~4>0{kJ>K3Uy6`s*1S--*mM&NY)*eOyy!7?9&osK*AQ~vi z{4qIQs)s#eN6j&0S()cD&aCtV;r>ykvAzd4O-fG^4Bmx2A2U7-kZR5{Qp-R^i4H2yfwC7?9(r3=?oH(~JR4=QMls>auMv*>^^!$}{}R z;#(gP+O;kn4G|totqZGdB~`9yzShMze{+$$?9%LJi>4YIsaPMwiJ{`gocu0U}$Q$vI5oeyKrgzz>!gI+XFt!#n z7vs9Pn`{{5w-@}FJZn?!%EQV!PdA3hw%Xa2#-;X4*B4?`WM;4@bj`R-yoAs_t4!!` zEaY5OrYi`3u3rXdY$2jZdZvufgFwVna?!>#t#DKAD2;U zqpqktqJ)8EPY*w~yj7r~#bNk|PDM>ZS?5F7T5aPFVZrqeX~5_1*zTQ%;xUHe#li?s zJ*5XZVERVfRjwX^s=0<%nXhULK+MdibMjzt%J7#fuh?NXyJ^pqpfG$PFmG!h*opyi zmMONjJY#%dkdRHm$l!DLeBm#_0YCq|x17c1fYJ#5YMpsjrFKyU=y>g5QcTgbDm28X zYL1RK)sn1@XtkGR;tNb}(kg#9L=jNSbJizqAgV-TtK2#?LZXrCIz({ zO^R|`ZDu(d@E7vE}df5`a zNIQRp&mDFbgyDKtyl@J|GcR9!h+_a$za$fnO5Ai9{)d7m@?@qk(RjHwXD}JbKRn|u z=Hy^z2vZ<1Mf{5ihhi9Y9GEG74Wvka;%G61WB*y7;&L>k99;IEH;d8-IR6KV{~(LZ zN7@V~f)+yg7&K~uLvG9MAY+{o+|JX?yf7h9FT%7ZrW7!RekjwgAA4jU$U#>_!ZC|c zA9%tc9nq|>2N1rg9uw-Qc89V}I5Y`vuJ(y`Ibc_?D>lPF0>d_mB@~pU`~)uWP48cT@fTxkWSw{aR!`K{v)v zpN?vQZZNPgs3ki9h{An4&Cap-c5sJ!LVLtRd=GOZ^bUpyDZHm6T|t#218}ZA zx*=~9PO>5IGaBD^XX-_2t7?7@WN7VfI^^#Csdz9&{1r z9y<9R?BT~-V8+W3kzWWQ^)ZSI+R zt^Lg`iN$Z~a27)sC_03jrD-%@{ArCPY#Pc*u|j7rE%}jF$LvO4vyvAw3bdL_mg&ei zXys_i=Q!UoF^Xp6^2h5o&%cQ@@)$J4l`AG09G6Uj<~A~!xG>KjKSyTX)zH*EdHMK0 zo;AV-D+bqWhtD-!^+`$*P0B`HokilLd1EuuwhJ?%3wJ~VXIjIE3tj653PExvIVhE& zFMYsI(OX-Q&W$}9gad^PUGuKElCvXxU_s*kx%dH)Bi&$*Q(+9j>(Q>7K1A#|8 zY!G!p0kW29rP*BNHe_wH49bF{K7tymi}Q!Vc_Ox2XjwtpM2SYo7n>?_sB=$c8O5^? z6as!fE9B48FcE`(ruNXP%rAZlDXrFTC7^aoXEX41k)tIq)6kJ*(sr$xVqsh_m3^?? zOR#{GJIr6E0Sz{-( z-R?4asj|!GVl0SEagNH-t|{s06Q3eG{kZOoPHL&Hs0gUkPc&SMY=&{C0&HDI)EHx9 zm#ySWluxwp+b~+K#VG%21%F65tyrt9RTPR$eG0afer6D`M zTW=y!@y6yi#I5V#!I|8IqU=@IfZo!@9*P+f{yLxGu$1MZ%xRY(gRQ2qH@9eMK0`Z> zgO`4DHfFEN8@m@dxYuljsmVv}c4SID+8{kr>d_dLzF$g>urGy9g+=`xAfTkVtz56G zrKNsP$yrDyP=kIqPN9~rVmC-wH672NF7xU>~j5M06Xr&>UJBmOV z%7Ie2d=K=u^D`~i3(U7x?n=h!SCSD1`aFe-sY<*oh+=;B>UVFBOHsF=(Xr(Cai{dL z4S7Y>PHdfG9Iav5FtKzx&UCgg)|DRLvq7!0*9VD`e6``Pgc z1O!qSaNeBBZnDXClh(Dq@XAk?Bd6+_rsFt`5(E+V2c)!Mx4X z47X+QCB4B7$B=Fw1Z1vnHg;x9oDV1YQJAR6Q3}_}BXTFg$A$E!oGG%`Rc()-Ysc%w za(yEn0fw~AaEFr}Rxi;if?Gv)&g~21UzXU9osI9{rNfH$gPTTk#^B|irEc<8W+|9$ zc~R${X2)N!npz1DFVa%nEW)cgPq`MSs)_I*Xwo<+ZK-2^hD(Mc8rF1+2v7&qV;5SET-ygMLNFsb~#u+LpD$uLR1o!ha67gPV5Q{v#PZK5X zUT4aZ{o}&*q7rs)v%*fDTl%}VFX?Oi{i+oKVUBqbi8w#FI%_5;6`?(yc&(Fed4Quy8xsswG+o&R zO1#lUiA%!}61s3jR7;+iO$;1YN;_*yUnJK=$PT_}Q%&0T@2i$ zwGC@ZE^A62YeOS9DU9me5#`(wv24fK=C)N$>!!6V#6rX3xiHehfdvwWJ>_fwz9l)o`Vw9yi z0p5BgvIM5o_ zgo-xaAkS_mya8FXo1Ke4;U*7TGSfm0!fb4{E5Ar8T3p!Z@4;FYT8m=d`C@4-LM121 z?6W@9d@52vxUT-6K_;1!SE%FZHcm0U$SsC%QB zxkTrfH;#Y7OYPy!nt|k^Lgz}uYudos9wI^8x>Y{fTzv9gfTVXN2xH`;Er=rTeAO1x znaaJOR-I)qwD4z%&dDjY)@s`LLSd#FoD!?NY~9#wQRTHpD7Vyyq?tKUHKv6^VE93U zt_&ePH+LM-+9w-_9rvc|>B!oT>_L59nipM-@ITy|x=P%Ezu@Y?N!?jpwP%lm;0V5p z?-$)m84(|7vxV<6f%rK3!(R7>^!EuvA&j@jdTI+5S1E{(a*wvsV}_)HDR&8iuc#>+ zMr^2z*@GTnfDW-QS38OJPR3h6U&mA;vA6Pr)MoT7%NvA`%a&JPi|K8NP$b1QY#WdMt8-CDA zyL0UXNpZ?x=tj~LeM0wk<0Dlvn$rtjd$36`+mlf6;Q}K2{%?%EQ+#FJy6v5cS+Q-~ ztk||Iwr$(CZQHi38QZF;lFFBNt+mg2*V_AhzkM<8#>E_S^xj8%T5tXTytD6f)vePG z^B0Ne-*6Pqg+rVW?%FGHLhl^ycQM-dhNCr)tGC|XyES*NK%*4AnZ!V+Zu?x zV2a82fs8?o?X} zjC1`&uo1Ti*gaP@E43NageV^$Xue3%es2pOrLdgznZ!_a{*`tfA+vnUv;^Ebi3cc$?-kh76PqA zMpL!y(V=4BGPQSU)78q~N}_@xY5S>BavY3Sez-+%b*m0v*tOz6zub9%*~%-B)lb}t zy1UgzupFgf?XyMa+j}Yu>102tP$^S9f7;b7N&8?_lYG$okIC`h2QCT_)HxG1V4Uv{xdA4k3-FVY)d}`cmkePsLScG&~@wE?ix2<(G7h zQ7&jBQ}Kx9mm<0frw#BDYR7_HvY7En#z?&*FurzdDNdfF znCL1U3#iO`BnfPyM@>;#m2Lw9cGn;(5*QN9$zd4P68ji$X?^=qHraP~Nk@JX6}S>2 zhJz4MVTib`OlEAqt!UYobU0-0r*`=03)&q7ubQXrt|t?^U^Z#MEZV?VEin3Nv1~?U zuwwSeR10BrNZ@*h7M)aTxG`D(By$(ZP#UmBGf}duX zhx;7y1x@j2t5sS#QjbEPIj95hV8*7uF6c}~NBl5|hgbB(}M3vnt zu_^>@s*Bd>w;{6v53iF5q7Em>8n&m&MXL#ilSzuC6HTzzi-V#lWoX zBOSBYm|ti@bXb9HZ~}=dlV+F?nYo3?YaV2=N@AI5T5LWWZzwvnFa%w%C<$wBkc@&3 zyUE^8xu<=k!KX<}XJYo8L5NLySP)cF392GK97(ylPS+&b}$M$Y+1VDrJa`GG7+%ToAsh z5NEB9oVv>as?i7f^o>0XCd%2wIaNRyejlFws`bXG$Mhmb6S&shdZKo;p&~b4wv$ z?2ZoM$la+_?cynm&~jEi6bnD;zSx<0BuCSDHGSssT7Qctf`0U!GDwG=+^|-a5%8Ty z&Q!%m%geLjBT*#}t zv1wDzuC)_WK1E|H?NZ&-xr5OX(ukXMYM~_2c;K}219agkgBte_#f+b9Al8XjL-p}1 z8deBZFjplH85+Fa5Q$MbL>AfKPxj?6Bib2pevGxIGAG=vr;IuuC%sq9x{g4L$?Bw+ zvoo`E)3#bpJ{Ij>Yn0I>R&&5B$&M|r&zxh+q>*QPaxi2{lp?omkCo~7ibow#@{0P> z&XBocU8KAP3hNPKEMksQ^90zB1&&b1Me>?maT}4xv7QHA@Nbvt-iWy7+yPFa9G0DP zP82ooqy_ku{UPv$YF0kFrrx3L=FI|AjG7*(paRLM0k1J>3oPxU0Zd+4&vIMW>h4O5G zej2N$(e|2Re z@8xQ|uUvbA8QVXGjZ{Uiolxb7c7C^nW`P(m*Jkqn)qdI0xTa#fcK7SLp)<86(c`A3 zFNB4y#NHe$wYc7V)|=uiW8gS{1WMaJhDj4xYhld;zJip&uJ{Jg3R`n+jywDc*=>bW zEqw(_+j%8LMRrH~+M*$V$xn9x9P&zt^evq$P`aSf-51`ZOKm(35OEUMlO^$>%@b?a z>qXny!8eV7cI)cb0lu+dwzGH(Drx1-g+uDX;Oy$cs+gz~?LWif;#!+IvPR6fa&@Gj zwz!Vw9@-Jm1QtYT?I@JQf%`=$^I%0NK9CJ75gA}ff@?I*xUD7!x*qcyTX5X+pS zAVy4{51-dHKs*OroaTy;U?zpFS;bKV7wb}8v+Q#z<^$%NXN(_hG}*9E_DhrRd7Jqp zr}2jKH{avzrpXj?cW{17{kgKql+R(Ew55YiKK7=8nkzp7Sx<956tRa(|yvHlW zNO7|;GvR(1q}GrTY@uC&ow0me|8wE(PzOd}Y=T+Ih8@c2&~6(nzQrK??I7DbOguA9GUoz3ASU%BFCc8LBsslu|nl>q8Ag(jA9vkQ`q2amJ5FfA7GoCdsLW znuok(diRhuN+)A&`rH{$(HXWyG2TLXhVDo4xu?}k2cH7QsoS>sPV)ylb45Zt&_+1& zT)Yzh#FHRZ-z_Q^8~IZ+G~+qSw-D<{0NZ5!J1%rAc`B23T98TMh9ylkzdk^O?W`@C??Z5U9#vi0d<(`?9fQvNN^ji;&r}geU zSbKR5Mv$&u8d|iB^qiLaZQ#@)%kx1N;Og8Js>HQD3W4~pI(l>KiHpAv&-Ev45z(vYK<>p6 z6#pU(@rUu{i9UngMhU&FI5yeRub4#u=9H+N>L@t}djC(Schr;gc90n%)qH{$l0L4T z;=R%r>CuxH!O@+eBR`rBLrT0vnP^sJ^+qE^C8ZY0-@te3SjnJ)d(~HcnQw@`|qAp|Trrs^E*n zY1!(LgVJfL?@N+u{*!Q97N{Uu)ZvaN>hsM~J?*Qvqv;sLnXHjKrtG&x)7tk?8%AHI zo5eI#`qV1{HmUf-Fucg1xn?Kw;(!%pdQ)ai43J3NP4{%x1D zI0#GZh8tjRy+2{m$HyI(iEwK30a4I36cSht3MM85UqccyUq6$j5K>|w$O3>`Ds;`0736+M@q(9$(`C6QZQ-vAKjIXKR(NAH88 zwfM6_nGWlhpy!_o56^BU``%TQ%tD4hs2^<2pLypjAZ;W9xAQRfF_;T9W-uidv{`B z{)0udL1~tMg}a!hzVM0a_$RbuQk|EG&(z*{nZXD3hf;BJe4YxX8pKX7VaIjjDP%sk zU5iOkhzZ&%?A@YfaJ8l&H;it@;u>AIB`TkglVuy>h;vjtq~o`5NfvR!ZfL8qS#LL` zD!nYHGzZ|}BcCf8s>b=5nZRYV{)KK#7$I06s<;RyYC3<~`mob_t2IfR*dkFJyL?FU zvuo-EE4U(-le)zdgtW#AVA~zjx*^80kd3A#?vI63pLnW2{j*=#UG}ISD>=ZGA$H&` z?Nd8&11*4`%MQlM64wfK`{O*ad5}vk4{Gy}F98xIAsmjp*9P=a^yBHBjF2*Iibo2H zGJAMFDjZcVd%6bZ`dz;I@F55VCn{~RKUqD#V_d{gc|Z|`RstPw$>Wu+;SY%yf1rI=>51Oolm>cnjOWHm?ydcgGs_kPUu=?ZKtQS> zKtLS-v$OMWXO>B%Z4LFUgw4MqA?60o{}-^6tf(c0{Y3|yF##+)RoXYVY-lyPhgn{1 z>}yF0Ab}D#1*746QAj5c%66>7CCWs8O7_d&=Ktu!SK(m}StvvBT1$8QP3O2a*^BNA z)HPhmIi*((2`?w}IE6Fo-SwzI_F~OC7OR}guyY!bOQfpNRg3iMvsFPYb9-;dT6T%R zhLwIjgiE^-9_4F3eMHZ3LI%bbOmWVe{SONpujQ;3C+58=Be4@yJK>3&@O>YaSdrevAdCLMe_tL zl8@F}{Oc!aXO5!t!|`I zdC`k$5z9Yf%RYJp2|k*DK1W@AN23W%SD0EdUV^6~6bPp_HZi0@dku_^N--oZv}wZA zH?Bf`knx%oKB36^L;P%|pf#}Tp(icw=0(2N4aL_Ea=9DMtF})2ay68V{*KfE{O=xL zf}tcfCL|D$6g&_R;r~1m{+)sutQPKzVv6Zw(%8w&4aeiy(qct1x38kiqgk!0^^X3IzI2ia zxI|Q)qJNEf{=I$RnS0`SGMVg~>kHQB@~&iT7+eR!Ilo1ZrDc3TVW)CvFFjHK4K}Kh z)dxbw7X%-9Ol&Y4NQE~bX6z+BGOEIIfJ~KfD}f4spk(m62#u%k<+iD^`AqIhWxtKGIm)l$7=L`=VU0Bz3-cLvy&xdHDe-_d3%*C|Q&&_-n;B`87X zDBt3O?Wo-Hg6*i?f`G}5zvM?OzQjkB8uJhzj3N;TM5dSM$C@~gGU7nt-XX_W(p0IA6$~^cP*IAnA<=@HVqNz=Dp#Rcj9_6*8o|*^YseK_4d&mBY*Y&q z8gtl;(5%~3Ehpz)bLX%)7|h4tAwx}1+8CBtu9f5%^SE<&4%~9EVn4*_!r}+{^2;} zwz}#@Iw?&|8F2LdXUIjh@kg3QH69tqxR_FzA;zVpY=E zcHnWh(3j3UXeD=4m_@)Ea4m#r?axC&X%#wC8FpJPDYR~@65T?pXuWdPzEqXP>|L`S zKYFF0I~%I>SFWF|&sDsRdXf$-TVGSoWTx7>7mtCVUrQNVjZ#;Krobgh76tiP*0(5A zs#<7EJ#J`Xhp*IXB+p5{b&X3GXi#b*u~peAD9vr0*Vd&mvMY^zxTD=e(`}ybDt=BC(4q)CIdp>aK z0c?i@vFWjcbK>oH&V_1m_EuZ;KjZSiW^i30U` zGLK{%1o9TGm8@gy+Rl=-5&z`~Un@l*2ne3e9B+>wKyxuoUa1qhf?-Pi= zZLCD-b7*(ybv6uh4b`s&Ol3hX2ZE<}N@iC+h&{J5U|U{u$XK0AJz)!TSX6lrkG?ris;y{s zv`B5Rq(~G58?KlDZ!o9q5t%^E4`+=ku_h@~w**@jHV-+cBW-`H9HS@o?YUUkKJ;AeCMz^f@FgrRi@?NvO3|J zBM^>4Z}}!vzNum!R~o0)rszHG(eeq!#C^wggTgne^2xc9nIanR$pH1*O;V>3&#PNa z7yoo?%T(?m-x_ow+M0Bk!@ow>A=skt&~xK=a(GEGIWo4AW09{U%(;CYLiQIY$bl3M zxC_FGKY%J`&oTS{R8MHVe{vghGEshWi!(EK*DWmoOv|(Ff#(bZ-<~{rc|a%}Q4-;w z{2gca97m~Nj@Nl{d)P`J__#Zgvc@)q_(yfrF2yHs6RU8UXxcU(T257}E#E_A}%2_IW?%O+7v((|iQ{H<|$S7w?;7J;iwD>xbZc$=l*(bzRXc~edIirlU0T&0E_EXfS5%yA zs0y|Sp&i`0zf;VLN=%hmo9!aoLGP<*Z7E8GT}%)cLFs(KHScNBco(uTubbxCOD_%P zD7XlHivrSWLth7jf4QR9`jFNk-7i%v4*4fC*A=;$Dm@Z^OK|rAw>*CI%E z3%14h-)|Q%_$wi9=p!;+cQ*N1(47<49TyB&B*bm_m$rs+*ztWStR~>b zE@V06;x19Y_A85N;R+?e?zMTIqdB1R8>(!4_S!Fh={DGqYvA0e-P~2DaRpCYf4$-Q z*&}6D!N_@s`$W(|!DOv%>R0n;?#(HgaI$KpHYpnbj~I5eeI(u4CS7OJajF%iKz)*V zt@8=9)tD1ML_CrdXQ81bETBeW!IEy7mu4*bnU--kK;KfgZ>oO>f)Sz~UK1AW#ZQ_ic&!ce~@(m2HT@xEh5u%{t}EOn8ET#*U~PfiIh2QgpT z%gJU6!sR2rA94u@xj3%Q`n@d}^iMH#X>&Bax+f4cG7E{g{vlJQ!f9T5wA6T`CgB%6 z-9aRjn$BmH=)}?xWm9bf`Yj-f;%XKRp@&7?L^k?OT_oZXASIqbQ#eztkW=tmRF$~% z6(&9wJuC-BlGrR*(LQKx8}jaE5t`aaz#Xb;(TBK98RJBjiqbZFyRNTOPA;fG$;~e` zsd6SBii3^(1Y`6^#>kJ77xF{PAfDkyevgox`qW`nz1F`&w*DH5Oh1idOTLES>DToi z8Qs4|?%#%>yuQO1#{R!-+2AOFznWo)e3~_D!nhoDgjovB%A8< zt%c^KlBL$cDPu!Cc`NLc_8>f?)!FGV7yudL$bKj!h;eOGkd;P~sr6>r6TlO{Wp1%xep8r1W{`<4am^(U} z+nCDP{Z*I?IGBE&*KjiaR}dpvM{ZFMW%P5Ft)u$FD373r2|cNsz%b0uk1T+mQI@4& zFF*~xDxDRew1Bol-*q>F{Xw8BUO;>|0KXf`lv7IUh%GgeLUzR|_r(TXZTbfXFE0oc zmGMwzNFgkdg><=+3MnncRD^O`m=SxJ6?}NZ8BR)=ag^b4Eiu<_bN&i0wUaCGi60W6 z%iMl&`h8G)y`gfrVw$={cZ)H4KSQO`UV#!@@cDx*hChXJB7zY18EsIo1)tw0k+8u; zg(6qLysbxVbLFbkYqKbEuc3KxTE+%j5&k>zHB8_FuDcOO3}FS|eTxoUh2~|Bh?pD| zsmg(EtMh`@s;`(r!%^xxDt(5wawK+*jLl>_Z3shaB~vdkJ!V3RnShluzmwn7>PHai z3avc`)jZSAvTVC6{2~^CaX49GXMtd|sbi*swkgoyLr=&yp!ASd^mIC^D;a|<=3pSt zM&0u%#%DGzlF4JpMDs~#kU;UCtyW+d3JwNiu`Uc7Yi6%2gfvP_pz8I{Q<#25DjM_D z(>8yI^s@_tG@c=cPoZImW1CO~`>l>rs=i4BFMZT`vq5bMOe!H@8q@sEZX<-kiY&@u3g1YFc zc@)@OF;K-JjI(eLs~hy8qOa9H1zb!3GslI!nH2DhP=p*NLHeh^9WF?4Iakt+b( z-4!;Q-8c|AX>t+5I64EKpDj4l2x*!_REy9L_9F~i{)1?o#Ws{YG#*}lg_zktt#ZlN zmoNsGm7$AXLink`GWtY*TZEH!J9Qv+A1y|@>?&(pb(6XW#ZF*}x*{60%wnt{n8Icp zq-Kb($kh6v_voqvA`8rq!cgyu;GaWZ>C2t6G5wk! zcKTlw=>KX3ldU}a1%XESW71))Z=HW%sMj2znJ;fdN${00DGGO}d+QsTQ=f;BeZ`eC~0-*|gn$9G#`#0YbT(>O(k&!?2jI z&oi9&3n6Vz<4RGR}h*1ggr#&0f%Op(6{h>EEVFNJ0C>I~~SmvqG+{RXDrexBz zw;bR@$Wi`HQ3e*eU@Cr-4Z7g`1R}>3-Qej(#Dmy|CuFc{Pg83Jv(pOMs$t(9vVJQJ zXqn2Ol^MW;DXq!qM$55vZ{JRqg!Q1^Qdn&FIug%O3=PUr~Q`UJuZ zc`_bE6i^Cp_(fka&A)MsPukiMyjG$((zE$!u>wyAe`gf-1Qf}WFfi1Y{^ zdCTTrxqpQE#2BYWEBnTr)u-qGSVRMV7HTC(x zb(0FjYH~nW07F|{@oy)rlK6CCCgyX?cB;19Z(bCP5>lwN0UBF}Ia|L0$oGHl-oSTZ zr;(u7nDjSA03v~XoF@ULya8|dzH<2G=n9A)AIkQKF0mn?!BU(ipengAE}6r`CE!jd z=EcX8exgDZZQ~~fgxR-2yF;l|kAfnjhz|i_o~cYRdhnE~1yZ{s zG!kZJ<-OVnO{s3bOJK<)`O;rk>=^Sj3M76Nqkj<_@Jjw~iOkWUCL+*Z?+_Jvdb!0cUBy=(5W9H-r4I zxAFts>~r)B>KXdQANyaeKvFheZMgoq4EVV0|^NR@>ea* zh%<78{}wsdL|9N1!jCN-)wH4SDhl$MN^f_3&qo?>Bz#?c{ne*P1+1 z!a`(2Bxy`S^(cw^dv{$cT^wEQ5;+MBctgPfM9kIQGFUKI#>ZfW9(8~Ey-8`OR_XoT zflW^mFO?AwFWx9mW2-@LrY~I1{dlX~jBMt!3?5goHeg#o0lKgQ+eZcIheq@A&dD}GY&1c%hsgo?z zH>-hNgF?Jk*F0UOZ*bs+MXO(dLZ|jzKu5xV1v#!RD+jRrHdQ z>>b){U(I@i6~4kZXn$rk?8j(eVKYJ2&k7Uc`u01>B&G@c`P#t#x@>Q$N$1aT514fK zA_H8j)UKen{k^ehe%nbTw}<JV6xN_|| z(bd-%aL}b z3VITE`N~@WlS+cV>C9TU;YfsU3;`+@hJSbG6aGvis{Gs%2K|($)(_VfpHB|DG8Nje+0tCNW%_cu3hk0F)~{-% zW{2xSu@)Xnc`Dc%AOH)+LT97ImFR*WekSnJ3OYIs#ijP4TD`K&7NZKsfZ;76k@VD3py?pSw~~r^VV$Z zuUl9lF4H2(Qga0EP_==vQ@f!FLC+Y74*s`Ogq|^!?RRt&9e9A&?Tdu=8SOva$dqgYU$zkKD3m>I=`nhx-+M;-leZgt z8TeyQFy`jtUg4Ih^JCUcq+g_qs?LXSxF#t+?1Jsr8c1PB#V+f6aOx@;ThTIR4AyF5 z3m$Rq(6R}U2S}~Bn^M0P&Aaux%D@ijl0kCCF48t)+Y`u>g?|ibOAJoQGML@;tn{%3IEMaD(@`{7ByXQ`PmDeK*;W?| zI8%%P8%9)9{9DL-zKbDQ*%@Cl>Q)_M6vCs~5rb(oTD%vH@o?Gk?UoRD=C-M|w~&vb z{n-B9>t0EORXd-VfYC>sNv5vOF_Wo5V)(Oa%<~f|EU7=npanpVX^SxPW;C!hMf#kq z*vGNI-!9&y!|>Zj0V<~)zDu=JqlQu+ii387D-_U>WI_`3pDuHg{%N5yzU zEulPN)%3&{PX|hv*rc&NKe(bJLhH=GPuLk5pSo9J(M9J3v)FxCo65T%9x<)x+&4Rr2#nu2?~Glz|{28OV6 z)H^`XkUL|MG-$XE=M4*fIPmeR2wFWd>5o*)(gG^Y>!P4(f z68RkX0cRBOFc@`W-IA(q@p@m>*2q-`LfujOJ8-h$OgHte;KY4vZKTxO95;wh#2ZDL zKi8aHkz2l54lZd81t`yY$Tq_Q2_JZ1d(65apMg}vqwx=ceNOWjFB)6m3Q!edw2<{O z4J6+Un(E8jxs-L-K_XM_VWahy zE+9fm_ZaxjNi{fI_AqLKqhc4IkqQ4`Ut$=0L)nzlQw^%i?bP~znsbMY3f}*nPWqQZ zz_CQDpZ?Npn_pEr`~SX1`OoSkS;bmzQ69y|W_4bH3&U3F7EBlx+t%2R02VRJ01cfX zo$$^ObDHK%bHQaOcMpCq@@Jp8!OLYVQO+itW1ZxlkmoG#3FmD4b61mZjn4H|pSmYi2YE;I#@jtq8Mhjdgl!6({gUsQA>IRXb#AyWVt7b=(HWGUj;wd!S+q z4S+H|y<$yPrrrTqQHsa}H`#eJFV2H5Dd2FqFMA%mwd`4hMK4722|78d(XV}rz^-GV(k zqsQ>JWy~cg_hbp0=~V3&TnniMQ}t#INg!o2lN#H4_gx8Tn~Gu&*ZF8#kkM*5gvPu^ zw?!M^05{7q&uthxOn?%#%RA_%y~1IWly7&_-sV!D=Kw3DP+W)>YYRiAqw^d7vG_Q%v;tRbE1pOBHc)c&_5=@wo4CJTJ1DeZErEvP5J(kc^GnGYX z|LqQjTkM{^gO2cO#-(g!7^di@$J0ibC(vsnVkHt3osnWL8?-;R1BW40q5Tmu_9L-s z7fNF5fiuS-%B%F$;D97N-I@!~c+J>nv%mzQ5vs?1MgR@XD*Gv`A{s8 z5Cr>z5j?|sb>n=c*xSKHpdy667QZT?$j^Doa%#m4ggM@4t5Oe%iW z@w~j_B>GJJkO+6dVHD#CkbC(=VMN8nDkz%44SK62N(ZM#AsNz1KW~3(i=)O;q5JrK z?vAVuL}Rme)OGQuLn8{3+V352UvEBV^>|-TAAa1l-T)oiYYD&}Kyxw73shz?Bn})7 z_a_CIPYK(zMp(i+tRLjy4dV#CBf3s@bdmwXo`Y)dRq9r9-c@^2S*YoNOmAX%@OYJOXs zT*->in!8Ca_$W8zMBb04@|Y)|>WZ)-QGO&S7Zga1(1#VR&)X+MD{LEPc%EJCXIMtr z1X@}oNU;_(dfQ_|kI-iUSTKiVzcy+zr72kq)TIp(GkgVyd%{8@^)$%G)pA@^Mfj71FG%d?sf(2Vm>k%X^RS`}v0LmwIQ7!_7cy$Q8pT?X1VWecA_W68u==HbrU& z@&L6pM0@8ZHL?k{6+&ewAj%grb6y@0$3oamTvXsjGmPL_$~OpIyIq%b$(uI1VKo zk_@{r>1p84UK3}B>@d?xUZ}dJk>uEd+-QhwFQ`U?rA=jj+$w8sD#{492P}~R#%z%0 z5dlltiAaiPKv9fhjmuy{*m!C22$;>#85EduvdSrFES{QO$bHpa7E@&{bWb@<7VhTF zXCFS_wB>7*MjJ3$_i4^A2XfF2t7`LOr3B@??OOUk=4fKkaHne4RhI~Lm$JrHfUU*h zgD9G66;_F?3>0W{pW2A^DR7Bq`ZUiSc${S8EM>%gFIqAw0du4~kU#vuCb=$I_PQv? zZfEY7X6c{jJZ@nF&T>4oyy(Zr_XqnMq)ZtGPASbr?IhZOnL|JKY()`eo=P5UK9(P-@ zOJKFogtk|pscVD+#$7KZs^K5l4gC}*CTd0neZ8L(^&1*bPrCp23%{VNp`4Ld*)Fly z)b|zb*bCzp?&X3_=qLT&0J+=p01&}9*xbk~^hd^@mV!Ha`1H+M&60QH2c|!Ty`RepK|H|Moc5MquD z=&$Ne3%WX+|7?iiR8=7*LW9O3{O%Z6U6`VekeF8lGr5vd)rsZu@X#5!^G1;nV60cz zW?9%HgD}1G{E(YvcLcIMQR65BP50)a;WI*tjRzL7diqRqh$3>OK{06VyC=pj6OiardshTnYfve5U>Tln@y{DC99f!B4> zCrZa$B;IjDrg}*D5l=CrW|wdzENw{q?oIj!Px^7DnqAsU7_=AzXxoA;4(YvN5^9ag zwEd4-HOlO~R0~zk>!4|_Z&&q}agLD`Nx!%9RLC#7fK=w06e zOK<>|#@|e2zjwZ5aB>DJ%#P>k4s0+xHJs@jROvoDQfSoE84l8{9y%5^POiP+?yq0> z7+Ymbld(s-4p5vykK@g<{X*!DZt1QWXKGmj${`@_R~=a!qPzB357nWW^KmhV!^G3i zsYN{2_@gtzsZH*FY!}}vNDnqq>kc(+7wK}M4V*O!M&GQ|uj>+8!Q8Ja+j3f*MzwcI z^s4FXGC=LZ?il4D+Y^f89wh!d7EU-5dZ}}>_PO}jXRQ@q^CjK-{KVnmFd_f&IDKmx zZ5;PDLF%_O);<4t`WSMN;Ec^;I#wU?Z?_R|Jg`#wbq;UM#50f@7F?b7ySi-$C-N;% zqXowTcT@=|@~*a)dkZ836R=H+m6|fynm#0Y{KVyYU=_*NHO1{=Eo{^L@wWr7 zjz9GOu8Fd&v}a4d+}@J^9=!dJRsCO@=>K6UCM)Xv6};tb)M#{(k!i}_0Rjq z2kb7wPcNgov%%q#(1cLykjrxAg)By+3QueBR>Wsep&rWQHq1wE!JP+L;q+mXts{j@ zOY@t9BFmofApO0k@iBFPeKsV3X=|=_t65QyohXMSfMRr7Jyf8~ogPVmJwbr@`nmml zov*NCf;*mT(5s4K=~xtYy8SzE66W#tW4X#RnN%<8FGCT{z#jRKy@Cy|!yR`7dsJ}R z!eZzPCF+^b0qwg(mE=M#V;Ud9)2QL~ z-r-2%0dbya)%ui_>e6>O3-}4+Q!D+MU-9HL2tH)O`cMC1^=rA=q$Pcc;Zel@@ss|K zH*WMdS^O`5Uv1qNTMhM(=;qjhaJ|ZC41i2!kt4;JGlXQ$tvvF8Oa^C@(q6(&6B^l) zNG{GaX?`qROHwL-F1WZDEF;C6Inuv~1&ZuP3j53547P38tr|iPH#3&hN*g0R^H;#) znft`cw0+^Lwe{!^kQat+xjf_$SZ05OD6~U`6njelvd+4pLZU(0ykS5&S$)u?gm!;} z+gJ8g12b1D4^2HH!?AHFAjDAP^q)Juw|hZfIv{3Ryn%4B^-rqIF2 zeWk^za4fq#@;re{z4_O|Zj&Zn{2WsyI^1%NW=2qA^iMH>u>@;GAYI>Bk~u0wWQrz* zdEf)7_pSYMg;_9^qrCzvv{FZYwgXK}6e6ceOH+i&+O=x&{7aRI(oz3NHc;UAxMJE2 zDb0QeNpm$TDcshGWs!Zy!shR$lC_Yh-PkQ`{V~z!AvUoRr&BAGS#_*ZygwI2-)6+a zq|?A;+-7f0Dk4uuht z6sWPGl&Q$bev1b6%aheld88yMmBp2j=z*egn1aAWd?zN=yEtRDGRW&nmv#%OQwuJ; zqKZ`L4DsqJwU{&2V9f>2`1QP7U}`6)$qxTNEi`4xn!HzIY?hDnnJZw+mFnVSry=bLH7ar+M(e9h?GiwnOM?9ZJcTJ08)T1-+J#cr&uHhXkiJ~}&(}wvzCo33 zLd_<%rRFQ3d5fzKYQy41<`HKk#$yn$Q+Fx-?{3h72XZrr*uN!5QjRon-qZh9-uZ$rWEKZ z!dJMP`hprNS{pzqO`Qhx`oXGd{4Uy0&RDwJ`hqLw4v5k#MOjvyt}IkLW{nNau8~XM z&XKeoVYreO=$E%z^WMd>J%tCdJx5-h+8tiawu2;s& zD7l`HV!v@vcX*qM(}KvZ#%0VBIbd)NClLBu-m2Scx1H`jyLYce;2z;;eo;ckYlU53 z9JcQS+CvCwj*yxM+e*1Vk6}+qIik2VzvUuJyWyO}piM1rEk%IvS;dsXOIR!#9S;G@ zPcz^%QTf9D<2~VA5L@Z@FGQqwyx~Mc-QFzT4Em?7u`OU!PB=MD8jx%J{<`tH$Kcxz zjIvb$x|`s!-^^Zw{hGV>rg&zb;=m?XYAU0LFw+uyp8v@Y)zmjj&Ib7Y1@r4`cfrS%cVxJiw`;*BwIU*6QVsBBL;~nw4`ZFqs z1YSgLVy=rvA&GQB4MDG+j^)X1N=T;Ty2lE-`zrg(dNq?=Q`nCM*o8~A2V~UPArX<| zF;e$5B0hPSo56=ePVy{nah#?e-Yi3g*z6iYJ#BFJ-5f0KlQ-PRiuGwe29fyk1T6>& zeo2lvb%h9Vzi&^QcVNp}J!x&ubtw5fKa|n2XSMlg#=G*6F|;p)%SpN~l8BaMREDQN z-c9O}?%U1p-ej%hzIDB!W_{`9lS}_U==fdYpAil1E3MQOFW^u#B)Cs zTE3|YB0bKpXuDKR9z&{4gNO3VHDLB!xxPES+)yaJxo<|}&bl`F21};xsQnc!*FPZA zSct2IU3gEu@WQKmY-vA5>MV?7W|{$rAEj4<8`*i)<%fj*gDz2=ApqZ&MP&0UmO1?q!GN=di+n(#bB_mHa z(H-rIOJqamMfwB%?di!TrN=x~0jOJtvb0e9uu$ZCVj(gJyK}Fa5F2S?VE30P{#n3eMy!-v7e8viCooW9cfQx%xyPNL*eDKL zB=X@jxulpkLfnar7D2EeP*0L7c9urDz{XdV;@tO;u`7DlN7#~ zAKA~uM2u8_<5FLkd}OzD9K zO5&hbK8yakUXn8r*H9RE zO9Gsipa2()=&x=1mnQtNP#4m%GXThu8Ccqx*qb;S{5}>bU*V5{SY~(Hb={cyTeaTM zMEaKedtJf^NnJrwQ^Bd57vSlJ3l@$^0QpX@_1>h^+js8QVpwOiIMOiSC_>3@dt*&| zV?0jRdlgn|FIYam0s)a@5?0kf7A|GD|dRnP1=B!{ldr;N5s)}MJ=i4XEqlC}w)LEJ}7f9~c!?It(s zu>b=YBlFRi(H-%8A!@Vr{mndRJ z_jx*?BQpK>qh`2+3cBJhx;>yXPjv>dQ0m+nd4nl(L;GmF-?XzlMK zP(Xeyh7mFlP#=J%i~L{o)*sG7H5g~bnL2Hn3y!!r5YiYRzgNTvgL<(*g5IB*gcajK z86X3LoW*5heFmkIQ-I_@I_7b!Xq#O;IzOv(TK#(4gd)rmCbv5YfA4koRfLydaIXUU z8(q?)EWy!sjsn-oyUC&uwJqEXdlM}#tmD~*Ztav=mTQyrw0^F=1I5lj*}GSQTQOW{ z=O12;?fJfXxy`)ItiDB@0sk43AZo_sRn*jc#S|(2*%tH84d|UTYN!O4R(G6-CM}84 zpiyYJ^wl|w@!*t)dwn0XJv2kuHgbfNL$U6)O-k*~7pQ?y=sQJdKk5x`1>PEAxjIWn z{H$)fZH4S}%?xzAy1om0^`Q$^?QEL}*ZVQK)NLgmnJ`(we z21c23X1&=^>k;UF-}7}@nzUf5HSLUcOYW&gsqUrj7%d$)+d8ZWwTZq)tOgc%fz95+ zl%sdl)|l|jXfqIcjKTFrX74Rbq1}osA~fXPSPE?XO=__@`7k4Taa!sHE8v-zfx(AM zXT_(7u;&_?4ZIh%45x>p!(I&xV|IE**qbqCRGD5aqLpCRvrNy@uT?iYo-FPpu`t}J zSTZ}MDrud+`#^14r`A%UoMvN;raizytxMBV$~~y3i0#m}0F}Dj_fBIz+)1RWdnctP z>^O^vd0E+jS+$V~*`mZWER~L^q?i-6RPxxufWdrW=%prbCYT{5>Vgu%vPB)~NN*2L zB?xQg2K@+Xy=sPh$%10LH!39p&SJG+3^i*lFLn=uY8Io6AXRZf;p~v@1(hWsFzeKzx99_{w>r;cypkPVJCKtLGK>?-K0GE zGH>$g?u`)U_%0|f#!;+E>?v>qghuBwYZxZ*Q*EE|P|__G+OzC-Z+}CS(XK^t!TMoT zc+QU|1C_PGiVp&_^wMxfmMAuJDQ%1p4O|x5DljN6+MJiO%8s{^ts8$uh5`N~qK46c`3WY#hRH$QI@*i1OB7qBIN*S2gK#uVd{ zik+wwQ{D)g{XTGjKV1m#kYhmK#?uy)g@idi&^8mX)Ms`^=hQGY)j|LuFr8SJGZjr| zzZf{hxYg)-I^G|*#dT9Jj)+wMfz-l7ixjmwHK9L4aPdXyD-QCW!2|Jn(<3$pq-BM; zs(6}egHAL?8l?f}2FJSkP`N%hdAeBiD{3qVlghzJe5s9ZUMd`;KURm_eFaK?d&+TyC88v zCv2R(Qg~0VS?+p+l1e(aVq`($>|0b{{tPNbi} zaZDffTZ7N|t2D5DBv~aX#X+yGagWs1JRsqbr4L8a`B`m) z1p9?T`|*8ZXHS7YD8{P1Dk`EGM`2Yjsy0=7M&U6^VO30`Gx!ZkUoqmc3oUbd&)V*iD08>dk=#G!*cs~^tOw^s8YQqYJ z!5=-4ZB7rW4mQF&YZw>T_in-c9`0NqQ_5Q}fq|)%HECgBd5KIo`miEcJ>~a1e2B@) zL_rqoQ;1MowD34e6#_U+>D`WcnG5<2Q6cnt4Iv@NC$*M+i3!c?6hqPJLsB|SJ~xo! zm>!N;b0E{RX{d*in3&0w!cmB&TBNEjhxdg!fo+}iGE*BWV%x*46rT@+cXU;leofWy zxst{S8m!_#hIhbV7wfWN#th8OI5EUr3IR_GOIzBgGW1u4J*TQxtT7PXp#U#EagTV* zehVkBFF06`@5bh!t%L)-)`p|d7D|^kED7fsht#SN7*3`MKZX};Jh0~nCREL_BGqNR zxpJ4`V{%>CAqEE#Dt95u=;Un8wLhrac$fao`XlNsOH%&Ey2tK&vAcriS1kXnntDuttcN{%YJz@!$T zD&v6ZQ>zS1`o!qT=JK-Y+^i~bZkVJpN8%<4>HbuG($h9LP;{3DJF_Jcl8CA5M~<3s^!$Sg62zLEnJtZ z0`)jwK75Il6)9XLf(64~`778D6-#Ie1IR2Ffu+_Oty%$8u+bP$?803V5W6%(+iZzp zp5<&sBV&%CJcXUIATUakP1czt$&0x$lyoLH!ueNaIpvtO z*eCijxOv^-D?JaLzH<3yhOfDENi@q#4w(#tl-19(&Yc2K%S8Y&r{3~-)P17sC1{rQ zOy>IZ6%814_UoEi+w9a4XyGXF66{rgE~UT)oT4x zg9oIx@|{KL#VpTyE=6WK@Sbd9RKEEY)5W{-%0F^6(QMuT$RQRZ&yqfyF*Z$f8>{iT zq(;UzB-Ltv;VHvh4y%YvG^UEkvpe9ugiT97ErbY0ErCEOWs4J=kflA!*Q}gMbEP`N zY#L`x9a?E)*~B~t+7c8eR}VY`t}J;EWuJ-6&}SHnNZ8i0PZT^ahA@@HXk?c0{)6rC zP}I}_KK7MjXqn1E19gOwWvJ3i9>FNxN67o?lZy4H?n}%j|Dq$p%TFLUPJBD;R|*0O z3pLw^?*$9Ax!xy<&fO@;E2w$9nMez{5JdFO^q)B0OmGwkxxaDsEU+5C#g+?Ln-Vg@ z-=z4O*#*VJa*nujGnGfK#?`a|xfZsuiO+R}7y(d60@!WUIEUt>K+KTI&I z9YQ6#hVCo}0^*>yr-#Lisq6R?uI=Ms!J7}qm@B}Zu zp%f-~1Cf!-5S0xXl`oqq&fS=tt0`%dDWI&6pW(s zJXtYiY&~t>k5I0RK3sN;#8?#xO+*FeK#=C^%{Y>{k{~bXz%(H;)V5)DZRk~(_d0b6 zV!x54fwkl`1y;%U;n|E#^Vx(RGnuN|T$oJ^R%ZmI{8(9>U-K^QpDcT?Bb@|J0NAfvHtL#wP ziYupr2E5=_KS{U@;kyW7oy*+UTOiF*e+EhYqVcV^wx~5}49tBNSUHLH1=x}6L2Fl^4X4633$k!ZHZTL50Vq+a5+ z<}uglXQ<{x&6ey)-lq6;4KLHbR)_;Oo^FodsYSw3M-)FbLaBcPI=-ao+|))T2ksKb z{c%Fu`HR1dqNw8%>e0>HI2E_zNH1$+4RWfk}p-h(W@)7LC zwVnUO17y+~kw35CxVtokT44iF$l8XxYuetp)1Br${@lb(Q^e|q*5%7JNxp5B{r<09 z-~8o#rI1(Qb9FhW-igcsC6npf5j`-v!nCrAcVx5+S&_V2D>MOWp6cV$~Olhp2`F^Td{WV`2k4J`djb#M>5D#k&5XkMu*FiO(uP{SNX@(=)|Wm`@b> z_D<~{ip6@uyd7e3Rn+qM80@}Cl35~^)7XN?D{=B-4@gO4mY%`z!kMIZizhGtCH-*7 z{a%uB4usaUoJwbkVVj%8o!K^>W=(ZzRDA&kISY?`^0YHKe!()(*w@{w7o5lHd3(Us zUm-K=z&rEbOe$ackQ3XH=An;Qyug2g&vqf;zsRBldxA+=vNGoM$Zo9yT?Bn?`Hkiq z&h@Ss--~+=YOe@~JlC`CdSHy zcO`;bgMASYi6`WSw#Z|A;wQgH@>+I3OT6(*JgZZ_XQ!LrBJfVW2RK%#02|@V|H4&8DqslU6Zj(x!tM{h zRawG+Vy63_8gP#G!Eq>qKf(C&!^G$01~baLLk#)ov-Pqx~Du>%LHMv?=WBx2p2eV zbj5fjTBhwo&zeD=l1*o}Zs%SMxEi9yokhbHhY4N!XV?t8}?!?42E-B^Rh&ABFxovs*HeQ5{{*)SrnJ%e{){Z_#JH+jvwF7>Jo zE+qzWrugBwVOZou~oFa(wc7?`wNde>~HcC@>fA^o>ll?~aj-e|Ju z+iJzZg0y1@eQ4}rm`+@hH(|=gW^;>n>ydn!8%B4t7WL)R-D>mMw<7Wz6>ulFnM7QA ze2HEqaE4O6jpVq&ol3O$46r+DW@%glD8Kp*tFY#8oiSyMi#yEpVIw3#t?pXG?+H>v z$pUwT@0ri)_Bt+H(^uzp6qx!P(AdAI_Q?b`>0J?aAKTPt>73uL2(WXws9+T|%U)Jq zP?Oy;y6?{%J>}?ZmfcnyIQHh_jL;oD$`U#!v@Bf{5%^F`UiOX%)<0DqQ^nqA5Ac!< z1DPO5C>W0%m?MN*x(k>lDT4W3;tPi=&yM#Wjwc5IFNiLkQf`7GN+J*MbB4q~HVePM zeDj8YyA*btY&n!M9$tuOxG0)2um))hsVsY+(p~JnDaT7x(s2If0H_iRSju7!z7p|8 zzI`NV!1hHWX3m)?t68k6yNKvop{Z>kl)f5GV(~1InT4%9IxqhDX-rgj)Y|NYq_NTlZgz-)=Y$=x9L7|k0=m@6WQ<4&r=BX@pW25NtCI+N{e&`RGSpR zeb^`@FHm5?pWseZ6V08{R(ki}--13S2op~9Kzz;#cPgL}Tmrqd+gs(fJLTCM8#&|S z^L+7PbAhltJDyyxAVxqf(2h!RGC3$;hX@YNz@&JRw!m5?Q)|-tZ8u0D$4we+QytG^ zj0U_@+N|OJlBHdWPN!K={a$R1Zi{2%5QD}s&s-Xn1tY1cwh)8VW z$pjq>8sj4)?76EJs6bA0E&pfr^Vq`&Xc;Tl2T!fm+MV%!H|i0o;7A=zE?dl)-Iz#P zSY7QRV`qRc6b&rON`BValC01zSLQpVemH5y%FxK8m^PeNN(Hf1(%C}KPfC*L?Nm!nMW0@J3(J=mYq3DPk;TMs%h`-amWbc%7{1Lg3$ z^e=btuqch-lydbtLvazh+fx?87Q7!YRT(=-Vx;hO)?o@f1($e5B?JB9jcRd;zM;iE zu?3EqyK`@_5Smr#^a`C#M>sRwq2^|ym)X*r;0v6AM`Zz1aK94@9Ti)Lixun2N!e-A z>w#}xPxVd9AfaF$XTTff?+#D(xwOpjZj9-&SU%7Z-E2-VF-n#xnPeQH*67J=j>TL# z<v}>AiTXrQ(fYa%82%qlH=L z6Fg8@r4p+BeTZ!5cZlu$iR?EJpYuTx>cJ~{{B7KODY#o*2seq=p2U0Rh;3mX^9sza zk^R_l7jzL5BXWlrVkhh!+LQ-Nc0I`6l1mWkp~inn)HQWqMTWl4G-TBLglR~n&6J?4 z7J)IO{wkrtT!Csntw3H$Mnj>@;QbrxC&Shqn^VVu$Ls*_c~TTY~fri6fO-=eJsC*8(3(H zSyO>=B;G`qA398OvCHRvf3mabrPZaaLhn*+jeA`qI!gP&i8Zs!*bBqMXDJpSZG$N) zx0rDLvcO>EoqCTR)|n7eOp-jmd>`#w`6`;+9+hihW2WnKVPQ20LR94h+(p)R$Y!Q zj_3ZEY+e@NH0f6VjLND)sh+Cvfo3CpcXw?`$@a^@CyLrAKIpjL8G z`;cDLqvK=ER)$q)+6vMKlxn!!SzWl>Ib9Ys9L)L0IWr*Ox;Rk#(Dpqf;wapY_EYL8 zKFrV)Q8BBKO4$r2hON%g=r@lPE;kBUVYVG`uxx~QI>9>MCXw_5vnmDsm|^KRny929 zeKx>F(LDs#K4FGU*k3~GX`A!)l8&|tyan-rBHBm6XaB5hc5sGKWwibAD7&3M-gh1n z2?eI7E2u{(^z#W~wU~dHSfy|m)%PY454NBxED)y-T3AO`CLQxklcC1I@Y`v4~SEI#Cm> z-cjqK6I?mypZapi$ZK;y&G+|#D=woItrajg69VRD+Fu8*UxG6KdfFmFLE}HvBJ~Y) zC&c-hr~;H2Idnsz7_F~MKpBZldh)>itc1AL0>4knbVy#%pUB&9vqL1Kg*^aU`k#(p z=A%lur(|$GWSqILaWZ#2xj(&lheSiA|N6DOG?A|$!aYM)?oME6ngnfLw0CA79WA+y zhUeLbMw*VB?drVE_D~3DWVaD>8x?_q>f!6;)i3@W<=kBZBSE=uIU60SW)qct?AdM zXgti8&O=}QNd|u%Fpxr172Kc`sX^@fm>Fxl8fbFalJYci_GGoIzU*~U*I!QLz? z4NYk^=JXBS*Uph@51da-v;%?))cB^(ps}y8yChu7CzyC9SX{jAq13zdnqRHRvc{ha zcPmgCUqAJ^1RChMCCz;ZN*ap{JPoE<1#8nNObDbAt6Jr}Crq#xGkK@w2mLhIUecvy z#?s~?J()H*?w9K`_;S+8TNVkHSk}#yvn+|~jcB|he}OY(zH|7%EK%-Tq=)18730)v zM3f|=oFugXq3Lqn={L!wx|u(ycZf(Te11c3?^8~aF; zNMC)gi?nQ#S$s{46yImv_7@4_qu|XXEza~);h&cr*~dO@#$LtKZa@@r$8PD^jz{D6 zk~5;IJBuQjsKk+8i0wzLJ2=toMw4@rw7(|6`7*e|V(5-#ZzRirtkXBO1oshQ&0>z&HAtSF8+871e|ni4gLs#`3v7gnG#^F zDv!w100_HwtU}B2T!+v_YDR@-9VmoGW+a76oo4yy)o`MY(a^GcIvXW+4)t{lK}I-& zl-C=(w_1Z}tsSFjFd z3iZjkO6xnjLV3!EE?ex9rb1Zxm)O-CnWPat4vw08!GtcQ3lHD+ySRB*3zQu-at$rj zzBn`S?5h=JlLXX8)~Jp%1~YS6>M8c-Mv~E%s7_RcvIYjc-ia`3r>dvjxZ6=?6=#OM zfsv}?hGnMMdi9C`J9+g)5`M9+S79ug=!xE_XcHdWnIRr&hq$!X7aX5kJV8Q(6Lq?|AE8N2H z37j{DPDY^Jw!J>~>Mwaja$g%q1sYfH4bUJFOR`x=pZQ@O(-4b#5=_Vm(0xe!LW>YF zO4w`2C|Cu%^C9q9B>NjFD{+qt)cY3~(09ma%mp3%cjFsj0_93oVHC3)AsbBPuQNBO z`+zffU~AgGrE0K{NVR}@oxB4&XWt&pJ-mq!JLhFWbnXf~H%uU?6N zWJ7oa@``Vi$pMWM#7N9=sX1%Y+1qTGnr_G&h3YfnkHPKG}p>i{fAG+(klE z(g~u_rJXF48l1D?;;>e}Ra{P$>{o`jR_!s{hV1Wk`vURz`W2c$-#r9GM7jgs2>um~ zouGlCm92rOiLITzf`jgl`v2qYw^!Lh0YwFHO1|3Krp8ztE}?#2+>c)yQlNw%5e6w5 zIm9BKZN5Q9b!tX`Zo$0RD~B)VscWp(FR|!a!{|Q$={;ZWl%10vBzfgWn}WBe!%cug z^G%;J-L4<6&aCKx@@(Grsf}dh8fuGT+TmhhA)_16uB!t{HIAK!B-7fJLe9fsF)4G- zf>(~ⅅ8zCNKueM5c!$)^mKpZNR!eIlFST57ePGQcqCqedAQ3UaUEzpjM--5V4YO zY22VxQm%$2NDnwfK+jkz=i2>NjAM6&P1DdcO<*Xs1-lzdXWn#LGSxwhPH7N%D8-zCgpFWt@`LgNYI+Fh^~nSiQmwH0^>E>*O$47MqfQza@Ce z1wBw;igLc#V2@y-*~Hp?jA1)+MYYyAt|DV_8RQCrRY@sAviO}wv;3gFdO>TE(=9o? z=S(r=0oT`w24=ihA=~iFV5z$ZG74?rmYn#eanx(!Hkxcr$*^KRFJKYYB&l6$WVsJ^ z-Iz#HYmE)Da@&seqG1fXsTER#adA&OrD2-T(z}Cwby|mQf{0v*v3hq~pzF`U`jenT z=XHXeB|fa?Ws$+9ADO0rco{#~+`VM?IXg7N>M0w1fyW1iiKTA@p$y zSiAJ%-Mg{m>&S4r#Tw@?@7ck}#oFo-iZJCWc`hw_J$=rw?omE{^tc59ftd`xq?jzf zo0bFUI=$>O!45{!c4?0KsJmZ#$vuYpZLo_O^oHTmmLMm0J_a{Nn`q5tG1m=0ecv$T z5H7r0DZGl6be@aJ+;26EGw9JENj0oJ5K0=^f-yBW2I0jqVIU};NBp*gF7_KlQnhB6 z##d$H({^HXj@il`*4^kC42&3)(A|tuhs;LygA-EWFSqpe+%#?6HG6}mE215Z4mjO2 zY2^?5$<8&k`O~#~sSc5Fy`5hg5#e{kG>SAbTxCh{y32fHkNryU_c0_6h&$zbWc63T z7|r?X7_H!9XK!HfZ+r?FvBQ$x{HTGS=1VN<>Ss-7M3z|vQG|N}Frv{h-q623@Jz*@ ziXlZIpAuY^RPlu&=nO)pFhML5=ut~&zWDSsn%>mv)!P1|^M!d5AwmSPIckoY|0u9I zTDAzG*U&5SPf+@c_tE_I!~Npfi$?gX(kn=zZd|tUZ_ez(xP+)xS!8=k(<{9@<+EUx zYQgZhjn(0qA#?~Q+EA9oh_Jx5PMfE3#KIh#*cFIFQGi)-40NHbJO&%ZvL|LAqU=Rw zf?Vr4qkUcKtLr^g-6*N-tfk+v8@#Lpl~SgKyH!+m9?T8B>WDWK22;!i5&_N=%f{__ z-LHb`v-LvKqTJZCx~z|Yg;U_f)VZu~q7trb%C6fOKs#eJosw&b$nmwGwP;Bz`=zK4 z>U3;}T_ptP)w=vJaL8EhW;J#SHA;fr13f=r#{o)`dRMOs-T;lp&Toi@u^oB_^pw=P zp#8Geo2?@!h2EYHY?L;ayT}-Df0?TeUCe8Cto{W0_a>!7Gxmi5G-nIIS;X{flm2De z{SjFG%knZoVa;mtHR_`*6)KEf=dvOT3OgT7C7&-4P#4X^B%VI&_57cBbli()(%zZC?Y0b;?5!f22UleQ=9h4_LkcA!Xsqx@q{ko&tvP_V@7epFs}AIpM{g??PA>U(sk$Gum>2Eu zD{Oy{$OF%~?B6>ixQeK9I}!$O0!T3#Ir8MW)j2V*qyJ z8Bg17L`rg^B_#rkny-=<3fr}Y42+x0@q6POk$H^*p3~Dc@5uYTQ$pfaRnIT}Wxb;- zl!@kkZkS=l)&=y|21veY8yz$t-&7ecA)TR|=51BKh(@n|d$EN>18)9kSQ|GqP?aeM ztXd9C&Md$PPF*FVs*GhoHM2L@D$(Qf%%x zwQBUt!jM~GgwluBcwkgwQ!249uPkNz3u@LSYZgmpHgX|P#8!iKk^vSKZ;?)KE$92d z2U>y}VWJ0&zjrIqddM3dz-nU%>bL&KU%SA|LiiUU7Ka|c=jF|vQ1V)Jz`JZe*j<5U6~RVuBEVJoY~ z&GE+F$f>4lN=X4-|9v*5O*Os>>r87u z!_1NSV?_X&HeFR1fOFb8_P)4lybJ6?1BWK`Tv2;4t|x1<#@17UO|hLGnrB%nu)fDk zfstJ4{X4^Y<8Lj<}g2^kksSefQTMuTo?tJLCh zC~>CR#a0hADw!_Vg*5fJwV{~S(j8)~sn>Oyt(ud2$1YfGck77}xN@3U_#T`q)f9!2 zf>Ia;Gwp2_C>WokU%(z2ec8z94pZyhaK+e>3a9sj^-&*V494;p9-xk+u1Jn#N_&xs z59OI2w=PuTErv|aNcK*>3l^W*p3}fjXJjJAXtBA#%B(-0--s;1U#f8gFYW!JL+iVG zV0SSx5w8eVgE?3Sg@eQv)=x<+-JgpVixZQNaZr}3b8sVyVs$@ndkF5FYKka@b+YAh z#nq_gzlIDKEs_i}H4f)(VQ!FSB}j>5znkVD&W0bOA{UZ7h!(FXrBbtdGA|PE1db>s z$!X)WY)u#7P8>^7Pjjj-kXNBuJX3(pJVetTZRNOnR5|RT5D>xmwxhAn)9KF3J05J; z-Mfb~dc?LUGqozC2p!1VjRqUwwDBnJhOua3vCCB-%ykW_ohSe?$R#dz%@Gym-8-RA zjMa_SJSzIl8{9dV+&63e9$4;{=1}w2=l+_j_Dtt@<(SYMbV-18&%F@Zl7F_5! z@xwJ0wiDdO%{}j9PW1(t+8P7Ud79yjY>x>aZYWJL_NI?bI6Y02`;@?qPz_PRqz(7v``20`- z033Dy|4;y6di|>cz|P-z|6c&3f&g^OAt8aN0Zd&0yZ>dq2aFCsE<~Ucf$v{sL=*++ zBxFSa2lfA+Y%U@B&3D=&CBO&u`#*nNc|PCY7XO<}MnG0VR764XrHtrb5zwC*2F!Lp zE<~Vj0;z!S-|3M4DFxuQ=`ShTf28<9p!81(0hFbGNqF%0gg*orez9!qt8e%o@Yfl@ zhvY}{@3&f??}7<`p>FyU;7?VkKbh8_=csozU=|fH&szgZ{=NDCylQ>EH^x5!K3~-V z)_2Y>0uJ`Z0Pb58y`RL+&n@m9tJ)O<%q#&u#DAIt+-rRt0eSe1MTtMl@W)H$b3D)@ z*A-1bUgZI)>HdcI4&W>P4W5{-j=s5p5`cbQ+{(g0+RDnz!TR^mxSLu_y#SDVKrj8i zA^hi6>jMGM;`$9Vfb-Yf!47b)Ow`2OKtNB=z|Kxa$5O}WPo;(Dc^`q(7X8kkeFyO8 z{XOq^07=u|7*P2`m;>PIFf=i80MKUxsN{d2cX0M+REsE*20+WQ79T9&cqT>=I_U% z{=8~^Isg(Nzo~`4iQfIb_#CVCD>#5h>=-Z#5dH}WxYzn%0)GAm6L2WdUdP=0_h>7f z(jh&7%1i(ZOn+}D8$iGK4Vs{pmHl_w4Qm-46H9>4^{3dz^DZDh+dw)6Xd@CpQNK$j z{CU;-cmpK=egplZ3y3%y=sEnCJ^eYVKXzV8H2_r*fJ*%*B;a1_lOpt6)IT1IAK2eB z{rie|uDJUrbgfUE>~C>@RO|m5ex55F{=~Bb4Cucp{ok7Yf9V}QuZ`#Gc|WaqsQlK- zKaV)iMRR__&Ak2Z=IM9R9g5$WM4u{a^C-7uX*!myEym z#_#p^T!P~#Dx$%^K>Y_nj_3J*E_LwJ60-5Xu=LkJAwcP@|0;a&+|+ZX`Jbj9P5;T% z|KOc}4*#4o{U?09`9Hz`Xo-I!P=9XfIrr*MQ}y=$!qgv?_J38^bNb4kM&_OVg^_=Eu-qG5U(fw0KMgH){C8pazq~51rN97hf#20-7=aK0)N|UM H-+%o-(+5aQ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e9c0019c9 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Apr 18 12:59:33 CEST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..9d82f7891 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..8a0b282aa --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/intellij+formatter.xml b/intellij+formatter.xml new file mode 100644 index 000000000..06b20272a --- /dev/null +++ b/intellij+formatter.xml @@ -0,0 +1,109 @@ + + + + + diff --git a/msa-auth-for-android b/msa-auth-for-android new file mode 160000 index 000000000..eca34e843 --- /dev/null +++ b/msa-auth-for-android @@ -0,0 +1 @@ +Subproject commit eca34e843a1ca2d7953f0d7c22efe72572ce7dc1 diff --git a/presentation/.gitignore b/presentation/.gitignore new file mode 100644 index 000000000..67e07b8fe --- /dev/null +++ b/presentation/.gitignore @@ -0,0 +1,2 @@ +/build +/release diff --git a/presentation/build.gradle b/presentation/build.gradle new file mode 100644 index 000000000..8eacd80d8 --- /dev/null +++ b/presentation/build.gradle @@ -0,0 +1,194 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'de.mannodermaus.android-junit5' + +android { + signingConfigs { + debug { + keyAlias 'androiddebugkey' + keyPassword 'android' + storeFile file('debug.keystore') + storePassword 'android' + } + } + + def globalConfiguration = rootProject.extensions.getByName("ext") + + compileSdkVersion globalConfiguration["androidCompileSdkVersion"] + buildToolsVersion globalConfiguration["androidBuildToolsVersion"] + + defaultConfig { + minSdkVersion globalConfiguration["androidMinSdkVersion"] + targetSdkVersion globalConfiguration["androidTargetSdkVersion"] + + applicationId globalConfiguration["androidApplicationId"] + versionCode globalConfiguration["androidVersionCode"] + versionName globalConfiguration["androidVersionName"] + + multiDexEnabled true + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + + buildConfigField "String", "DROPBOX_API_KEY", "\"" + getApiKey('DROPBOX_API_KEY') + "\"" + manifestPlaceholders = [DROPBOX_API_KEY_DB: getApiKey('DROPBOX_API_KEY_DB')] + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + quiet true + abortOnError false + ignoreWarnings true + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + + debug { + signingConfig signingConfigs.debug + testCoverageEnabled false + } + } + + flavorDimensions "version" + + productFlavors { + playstore { + dimension "version" + } + + license { + dimension "version" + } + } + + packagingOptions { + exclude 'META-INF/jersey-module-version' + exclude 'META-INF/DEPENDENCIES' + } +} + +dependencies { + def dependencies = rootProject.ext.dependencies + + // custom code generators + kapt project(':generator') + implementation project(':generator-api') + implementation project(':util') + implementation project(':domain') + implementation project(':data') + + // dagger + kapt dependencies.daggerCompiler + implementation dependencies.dagger + // ui + implementation dependencies.appcompat + implementation dependencies.recyclerView + implementation dependencies.design + implementation project(':subsampling-image-view') + implementation dependencies.recyclerViewFastScroll + + // android x + implementation dependencies.androidxCore + implementation dependencies.androidxFragment + implementation dependencies.androidxViewpager + implementation dependencies.androidxSwiperefresh + implementation dependencies.androidxPreference + implementation dependencies.androidxBiometric + + // cloud + implementation dependencies.dropbox + implementation dependencies.msgraph + implementation(dependencies.googleApiServicesDrive) { + exclude module: 'guava-jdk5' + exclude module: 'httpclient' + } + implementation(dependencies.googleApiClientAndroid) { + exclude module: 'guava-jdk5' + exclude module: 'httpclient' + } + + // rest + implementation dependencies.rxJava + implementation dependencies.rxAndroid + compileOnly dependencies.javaxAnnotation + implementation dependencies.zxcvbn + implementation dependencies.rxBinding + + api dependencies.jsonWebTokenApi + runtimeOnly dependencies.jsonWebTokenImpl + runtimeOnly(dependencies.jsonWebTokenJson) { + exclude group: 'org.json', module: 'json' //provided by Android natively + } + + // multidex + implementation dependencies.multidex + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + // test + androidTestImplementation dependencies.androidAnnotations + androidTestImplementation dependencies.design + + androidTestImplementation(dependencies.espresso) { + exclude group: 'com.android.support', module: 'support-annotations' + exclude group: 'com.google.code.findbugs' + } + androidTestImplementation(dependencies.runner) { + exclude group: 'com.android.support', module: 'support-annotations' + } + + androidTestImplementation(dependencies.rules) { + exclude group: 'com.android.support', module: 'support-annotations' + } + + androidTestImplementation(dependencies.contribution) { + exclude group: 'com.android.support', module: 'support-annotations' + exclude group: 'com.android.support', module: 'appcompat-v7' + exclude group: 'com.android.support', module: 'support-v4' + exclude module: 'recyclerview-v7' + exclude group: 'com.google.code.findbugs' + } + + androidTestImplementation(dependencies.uiAutomator) { + exclude group: 'com.android.support' + } + + androidTestImplementation dependencies.runner + androidTestImplementation dependencies.androidAnnotations + + + testImplementation dependencies.junit + testImplementation dependencies.junitApi + testRuntimeOnly dependencies.junitEngine + testImplementation dependencies.junitParams + + testImplementation dependencies.junit4 + testRuntimeOnly dependencies.junit4Engine + + testImplementation dependencies.mockito + testImplementation dependencies.mockitoInline + testImplementation dependencies.hamcrest +} + +configurations { + all*.exclude group: 'com.google.android', module: 'android' + all*.exclude group: 'com.google.guava', module: 'listenablefuture' +} + +androidExtensions { + experimental = true +} + +static def getApiKey(key) { + Properties props = new Properties() + props.load(new FileInputStream(new File('secrets.properties'))) + return props[key] +} diff --git a/presentation/debug.keystore b/presentation/debug.keystore new file mode 100755 index 0000000000000000000000000000000000000000..2fd498ed057693ace2c666485f557d411efa9153 GIT binary patch literal 1261 zcmezO_TO6u1_mY|W&~sY#JrTE{LGY;)TGk%?9@u2c=+Ly#h-y{_82rV?J(eDnj9X~?Td02b7$KWN>yito1AcPk+(ChyB<`oJ=bT-Bz5Kki*JYU92EGmCyzzc z)~e=be4@;`mwwXEt?wJVPi!{I&S>AIlJa2J=H9NLJq*4};@Q94{QjrQrKod<^uJYA zQ)(tmf9U;J!(qYyeGd~RO!?;Q&eOR$%&sb3Tj9hm>F&FGBn!+{4*uTlv(=fq!2O5H z%b;zpFP&pA8d|TM3Avn+}`}=H4B$Z4D;VhZRT=*_A#R1nd-WDp_qzKwy!)t z2PlOk&-yZ_O}lH7IbXmd{Tr+8=h-Nnm@Bd&>1nKVrM6F|9;@N^#CWN?=W*wjXs>2}?**QM&$?eK@)3Vx^Z&rX-~&pay<e;DQ;xF4r6 zKfhaJ-o6iiyq12HI2v8IZI@tw!oPUSGo6LPDY;9$D+^j&&$26t%t=_=oj>2IW&gr8 zcX=7ITg-Ep1z)Uu(e&i6lGz^BFW0m!Jgw$$w>-b0;|W_;j=$6@&ePNG`7pXLpUbna z5_>2R_oMW~%I`wS{!JS8FPWGb z8PJ1}8R#zminqnN?@o!Ho{?X@0v)*}SI;!~+lTD(c(c%L|2-ou_rn4w{;$5e z?{?U24!QZ?dOIfsU6eM+SDpUoYQ@A|PK&iTQ%|waZ+wkh;%y1BDWE=|WtLT`GY=tQG~X0yJz1ugPg_o^iK_Q7h^tUjv) M(kJV@+ukn$0MXtWr~m)} literal 0 HcmV?d00001 diff --git a/presentation/lint.xml b/presentation/lint.xml new file mode 100644 index 000000000..7cb371ecf --- /dev/null +++ b/presentation/lint.xml @@ -0,0 +1,4 @@ + + + + diff --git a/presentation/proguard-rules.pro b/presentation/proguard-rules.pro new file mode 100644 index 000000000..8702417da --- /dev/null +++ b/presentation/proguard-rules.pro @@ -0,0 +1,81 @@ +-useuniqueclassmembernames + +# greenDAO 3, http://greenrobot.org/greendao/documentation/technical-faq +-keepclassmembers class * extends org.greenrobot.greendao.AbstractDao { + public static java.lang.String TABLENAME; +} +-keep class **$Properties {*;} +-dontwarn org.greenrobot.greendao.database.** +-dontwarn net.sqlcipher.database.** +-dontwarn rx.** + +# RxJava, https://github.com/artem-zinnatullin/RxJavaProGuardRules/blob/master/rxjava-proguard-rules/proguard-rules.txt +-dontwarn sun.misc.** +-keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* { + long producerIndex; + long consumerIndex; +} +-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef { + rx.internal.util.atomic.LinkedQueueNode producerNode; +} +-keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueConsumerNodeRef { + rx.internal.util.atomic.LinkedQueueNode consumerNode; +} + +# Google API Client, https://github.com/google/google-api-java-client/blob/dev/google-api-client-assembly/proguard-google-api-client.txt +-keepattributes Signature,RuntimeVisibleAnnotations,AnnotationDefault +-keepclassmembers class * { + @com.google.api.client.util.Key ; +} +-dontwarn com.google.api.client.extensions.android.** +-dontwarn com.google.api.client.googleapis.extensions.android.** +-dontwarn com.google.api.client.googleapis.testing.TestUtils +-dontwarn com.google.android.gms.** + +# okhttp3 +-dontwarn okhttp3.** +-dontwarn okio.** + +# Others +-dontwarn org.slf4j.** +-dontwarn com.dropbox.core.** +-dontwarn com.fernandocejas.frodo.core.** +-dontwarn com.google.errorprone.annotations.** +-dontwarn com.google.common.util.concurrent.FuturesGetChecked** +-keepclassmembers class com.microsoft.graph.http.GraphServiceException { + int mResponseCode; +} +-keep class com.nulabinc.zxcvbn.** + +# https://stackoverflow.com/a/47555897/1759462 +-dontwarn afu.org.checkerframework.** +-dontwarn org.checkerframework.** + +# https://github.com/microsoftgraph/msgraph-sdk-java/issues/258#issue-452030712 +-keep class com.microsoft.** { *; } +-keep class com.microsoft.** +-keep interface com.microsoft.** { *; } +-keepclasseswithmembernames class com.microsoft.** { *; } + +-keep class com.sun.** { *; } +-keep class com.sun.** +-keep interface com.sun.** { *; } + +# https://github.com/jwtk/jjwt +-keepattributes InnerClasses + +-keep class io.jsonwebtoken.** { *; } +-keepnames class io.jsonwebtoken.* { *; } +-keepnames interface io.jsonwebtoken.* { *; } + +-keep class org.bouncycastle.** { *; } +-keepnames class org.bouncycastle.** { *; } +-dontwarn org.bouncycastle.** + +-keep class android.net.http.** { *; } +-keep interface org.apache.** { *; } +-keep enum org.apache.** { *; } +-keep class org.apache.** { *; } +-keep class org.apache.commons.** { *; } +-keep class org.apache.http.** { *; } +-keep class org.apache.harmony.** {*;} diff --git a/presentation/src/.gitignore b/presentation/src/.gitignore new file mode 100755 index 000000000..c6b8b0014 --- /dev/null +++ b/presentation/src/.gitignore @@ -0,0 +1 @@ +/generated \ No newline at end of file diff --git a/presentation/src/androidTest/AndroidManifest.xml b/presentation/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..60efe01b4 --- /dev/null +++ b/presentation/src/androidTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/CloudContentRepositoryBlackboxTest.java b/presentation/src/androidTest/java/org/cryptomator/presentation/CloudContentRepositoryBlackboxTest.java new file mode 100644 index 000000000..6a20f8fbb --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/CloudContentRepositoryBlackboxTest.java @@ -0,0 +1,426 @@ +package org.cryptomator.presentation; + +import androidx.test.rule.ActivityTestRule; + +import org.cryptomator.data.cloud.local.file.RootLocalFolder; +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.cloud.ByteArrayDataSource; +import org.cryptomator.presentation.di.component.ApplicationComponent; +import org.cryptomator.presentation.testCloud.CryptoTestCloud; +import org.cryptomator.presentation.testCloud.DropboxTestCloud; +import org.cryptomator.presentation.testCloud.GoogledriveTestCloud; +import org.cryptomator.presentation.testCloud.LocalStorageTestCloud; +import org.cryptomator.presentation.testCloud.LocalTestCloud; +import org.cryptomator.presentation.testCloud.OnedriveTestCloud; +import org.cryptomator.presentation.testCloud.TestCloud; +import org.cryptomator.presentation.testCloud.WebdavTestCloud; +import org.cryptomator.presentation.ui.activity.SplashActivity; +import org.cryptomator.util.Optional; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.ByteArrayOutputStream; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import static androidx.test.InstrumentationRegistry.getTargetContext; +import static org.cryptomator.domain.usecases.ProgressAware.NO_OP_PROGRESS_AWARE; +import static org.cryptomator.presentation.CloudNodeMatchers.aFile; +import static org.cryptomator.presentation.CloudNodeMatchers.folder; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.collection.IsEmptyCollection.emptyCollectionOf; + +@RunWith(Parameterized.class) +public class CloudContentRepositoryBlackboxTest { + + private static final byte[] DIGITS_ONE_TO_TEN_AS_BYTES = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + private static final byte[] DIGITS_SEVEN_TO_ONE_AS_BYTES = new byte[] {7, 6, 5, 4, 3, 2, 1}; + + private static Cloud cloud; + private static TestCloud inTestCloud; + private static boolean setupCloudCompleted = false; + + private CloudContentRepository inTest; + private CloudFolder root; + + @Rule + public final ActivityTestRule activityTestRule = new ActivityTestRule<>(SplashActivity.class); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Parameterized.Parameters(name = "{0}") + public static TestCloud[] data() { + return new TestCloud[] { // + new LocalStorageTestCloud(), // + new LocalTestCloud(), // + new WebdavTestCloud(getTargetContext()), // + new DropboxTestCloud(getTargetContext()), // + new GoogledriveTestCloud(), // + new OnedriveTestCloud(getTargetContext()), // + new CryptoTestCloud()}; + } + + public CloudContentRepositoryBlackboxTest(TestCloud testCloud) { + if (inTestCloud != null && inTestCloud != testCloud) { + setupCloudCompleted = false; + } + + inTestCloud = testCloud; + } + + @Before + public void setup() throws BackendException { + + ApplicationComponent appComponent = ((CryptomatorApp) activityTestRule // + .getActivity() // + .getApplication()) // + .getComponent(); + + if (!setupCloudCompleted) { + if (inTestCloud instanceof CryptoTestCloud) { + // FIXME 343 @julian just for testcase local cloud + Cloud testCloud = appComponent.cloudRepository().clouds(CloudType.LOCAL).get(0); + CloudFolder rootFolder = new RootLocalFolder((LocalStorageCloud) testCloud); + cloud = ((CryptoTestCloud) inTestCloud).getInstance(appComponent, testCloud, rootFolder); + } else { + cloud = inTestCloud.getInstance(appComponent); + } + + setupCloudCompleted = true; + } + + inTest = appComponent.cloudContentRepository(); + root = inTest.create(inTest.resolve(cloud, UUID.randomUUID().toString())); + } + + @Test + public void testListEmptyDirectory() throws BackendException { + assertThat(listingOf(root), is(emptyCollectionOf(CloudNode.class))); + } + + @Test + public void testListDirectory() throws BackendException { + createParentsAndWrite("a", DIGITS_SEVEN_TO_ONE_AS_BYTES); + createParentsAndWrite("b.dat", DIGITS_ONE_TO_TEN_AS_BYTES); + createParentsAndWrite("empty.txt", new byte[0]); + inTest.create(inTest.folder(root, "b")); + inTest.create(inTest.folder(root, "c")); + + assertThat(listingOf(root), containsInAnyOrder( // + aFile().withName("a").withSize(DIGITS_SEVEN_TO_ONE_AS_BYTES.length), // + aFile().withName("b.dat").withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length), // + aFile().withName("empty.txt").withSize(0L), // + folder("b"), // + folder("c"))); + } + + @Test + public void testCreateDirectory() throws BackendException { + CloudFolder created = inTest.folder(root, "created"); + created = inTest.create(created); + + assertThat(listingOf(created), is(emptyCollectionOf(CloudNode.class))); + assertThat(listingOf(root), containsInAnyOrder(folder("created"))); + } + + @Test + public void testDeleteDirectory() throws BackendException { + inTest.create(inTest.folder(root, "created")); + inTest.delete(inTest.folder(root, "created")); + + assertThat(inTest.exists(inTest.folder(root, "created")), is(false)); + assertThat(listingOf(root), is(emptyCollectionOf(CloudNode.class))); + } + + @Test + @SuppressWarnings("deprecation") + public void testUploadFile() throws BackendException { + Date start = new Date(); + CloudFile file = createParentsAndWrite("file", DIGITS_ONE_TO_TEN_AS_BYTES); + + assertThat(file, is(aFile() // + .withName("file") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length) // + .withModifiedIn(start, new Date()))); + assertThat(listingOf(root), // + containsInAnyOrder(aFile() // + .withName("file") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + } + + @Test + @SuppressWarnings("deprecation") + public void testReplaceFile() throws BackendException { + createParentsAndWrite("file", DIGITS_ONE_TO_TEN_AS_BYTES); + Date start = new Date(); + CloudFile file = createParentsAndWriteOrReplace("file", DIGITS_SEVEN_TO_ONE_AS_BYTES); + + assertThat(file, is(aFile() // + .withName("file") // + .withSize(DIGITS_SEVEN_TO_ONE_AS_BYTES.length) // + .withModifiedIn(start, new Date()))); + assertThat(listingOf(root), // + containsInAnyOrder(aFile() // + .withName("file") // + .withSize(DIGITS_SEVEN_TO_ONE_AS_BYTES.length))); + } + + @Test + public void testUploadExistingFileWithoutReplaceFlag() throws BackendException { + createParentsAndWrite("file", DIGITS_ONE_TO_TEN_AS_BYTES); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("CloudNode already exists and replace is false"); + + createParentsAndWrite("file", DIGITS_ONE_TO_TEN_AS_BYTES); + } + + @Test + public void testDownloadFile() throws BackendException { + createParentsAndWrite("file", DIGITS_ONE_TO_TEN_AS_BYTES); + + assertThat(read(inTest.file(root, "file")), is(DIGITS_ONE_TO_TEN_AS_BYTES)); + } + + @Test + public void testDeleteFile() throws BackendException { + createParentsAndWrite("file", DIGITS_ONE_TO_TEN_AS_BYTES); + inTest.delete(inTest.file(root, "file")); + + assertThat(inTest.exists(inTest.file(root, "file")), is(false)); + assertThat(listingOf(root), is(emptyCollectionOf(CloudNode.class))); + } + + @Test + @SuppressWarnings("deprecation") + public void testRenameDirectory() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder target = inTest.folder(root, "newName"); + + target = inTest.move(directory, target); + + assertThat(listingOf(target), containsInAnyOrder(aFile() // + .withName("file") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + } + + @Test + public void testRenameDirectoryToExistingDirectory() throws BackendException { + CloudFolder directory = inTest.folder(root, "directory"); + directory = inTest.create(directory); + CloudFolder target = inTest.folder(root, "newName"); + target = inTest.create(target); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("newName"); + + inTest.move(directory, target); + } + + @Test + @SuppressWarnings("deprecation") + public void testRenameDirectoryToExistingFile() throws BackendException { + CloudFolder directory = inTest.folder(root, "directory"); + directory = inTest.create(directory); + CloudFolder target = inTest.folder(root, "newName"); + createParentsAndWrite("newName", new byte[0]); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("newName"); + + inTest.move(directory, target); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveDirectory() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFolder target = inTest.folder(newParent, "directory"); + + target = inTest.move(directory, target); + + assertThat(listingOf(target), containsInAnyOrder(aFile() // + .withName("file") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveDirectoryToExistingDirectory() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFolder target = inTest.folder(newParent, "directory"); + target = inTest.create(target); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("directory"); + + inTest.move(directory, target); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveDirectoryToExistingFile() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFolder target = inTest.folder(newParent, "directory"); + createParentsAndWrite("newParent/directory", new byte[0]); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("directory"); + + inTest.move(directory, target); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveAndRenameDirectory() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFolder target = inTest.folder(newParent, "newName"); + + target = inTest.move(directory, target); + + assertThat(listingOf(target), containsInAnyOrder(aFile() // + .withName("file") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveAndRenameDirectoryToExistingDirectory() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFolder target = inTest.folder(newParent, "newName"); + target = inTest.create(target); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("newName"); + + inTest.move(directory, target); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveAndRenameDirectoryToExistingFile() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder directory = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFolder target = inTest.folder(newParent, "newName"); + createParentsAndWrite("newParent/newName", new byte[0]); + + thrown.expect(CloudNodeAlreadyExistsException.class); + thrown.expectMessage("newName"); + + inTest.move(directory, target); + } + + @Test + @SuppressWarnings("deprecation") + public void testRenameFile() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFile target = inTest.file(file.getParent(), "newName"); + + target = inTest.move(file, target); + + assertThat(listingOf(file.getParent()), containsInAnyOrder(aFile() // + .withName("newName") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + assertThat(read(target), is(DIGITS_ONE_TO_TEN_AS_BYTES)); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveFile() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder oldParent = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFile target = inTest.file(newParent, "file"); + + target = inTest.move(file, target); + + assertThat(listingOf(oldParent), is(emptyCollectionOf(CloudNode.class))); + assertThat(listingOf(newParent), containsInAnyOrder(aFile() // + .withName("file") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + assertThat(read(target), is(DIGITS_ONE_TO_TEN_AS_BYTES)); + } + + @Test + @SuppressWarnings("deprecation") + public void testMoveAndRenameFile() throws BackendException { + CloudFile file = createParentsAndWrite("directory/file", DIGITS_ONE_TO_TEN_AS_BYTES); + CloudFolder oldParent = file.getParent(); + CloudFolder newParent = inTest.create(inTest.folder(root, "newParent")); + CloudFile target = inTest.file(newParent, "newName"); + + target = inTest.move(file, target); + + assertThat(listingOf(oldParent), is(emptyCollectionOf(CloudNode.class))); + assertThat(listingOf(newParent), containsInAnyOrder(aFile() // + .withName("newName") // + .withSize(DIGITS_ONE_TO_TEN_AS_BYTES.length))); + assertThat(read(target), is(DIGITS_ONE_TO_TEN_AS_BYTES)); + } + + @After + public void teardown() throws BackendException { + if (inTest != null && root != null) { + inTest.delete(root); + } + } + + private List listingOf(CloudFolder testRoot) throws BackendException { + return inTest.list(testRoot); + } + + private byte[] read(CloudFile file) throws BackendException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + inTest.read(file, Optional.empty(), out, NO_OP_PROGRESS_AWARE); + return out.toByteArray(); + } + + private CloudFile createParentsAndWrite(String path, byte[] data) throws BackendException { + return createParentsAndWriteOrReplaceImpl(path, data, false); + } + + private CloudFile createParentsAndWriteOrReplace(String path, byte[] data) throws BackendException { + return createParentsAndWriteOrReplaceImpl(path, data, true); + } + + private CloudFile createParentsAndWriteOrReplaceImpl(String path, byte[] data, boolean repalce) throws BackendException { + path = root.getName() + "/" + path; + String pathToParent = path.substring(0, path.lastIndexOf('/') + 1); + String name = path.substring(path.lastIndexOf('/') + 1); + CloudFolder parent = inTest.resolve(cloud, pathToParent); + if (!inTest.exists(parent)) { + parent = inTest.create(parent); + } + CloudFile file = inTest.file(parent, name, Optional.of(new Long(data.length))); + return inTest.write(file, ByteArrayDataSource.from(data), NO_OP_PROGRESS_AWARE, repalce, data.length); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/CloudNodeMatchers.java b/presentation/src/androidTest/java/org/cryptomator/presentation/CloudNodeMatchers.java new file mode 100644 index 000000000..79dce26ae --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/CloudNodeMatchers.java @@ -0,0 +1,126 @@ +package org.cryptomator.presentation; + +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.util.Optional; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import java.util.Date; + +class CloudNodeMatchers { + + public static Matcher aFile(final String name) { + return (Matcher) new TypeSafeDiagnosingMatcher() { + @Override + protected boolean matchesSafely(CloudFile file, Description description) { + if (name.equals(file.getName())) { + return true; + } else { + description.appendText("aFile with name '").appendText(file.getName()).appendText("'"); + return false; + } + } + + @Override + public void describeTo(Description description) { + description.appendText("aFile with name '").appendText(name).appendText("'"); + } + }; + } + + public static FileMatcher aFile() { + return new FileMatcher(); + } + + public static class FileMatcher extends TypeSafeDiagnosingMatcher { + + private String nameToCheck; + private Optional sizeToCheck; + + private Date minModifiedToCheck; + private Date maxModifiedToCheck; + + private FileMatcher() { + super(CloudFile.class); + } + + public FileMatcher withName(String name) { + this.nameToCheck = name; + return this; + } + + public FileMatcher withSize(int size) { + return withSize(Long.valueOf(size)); + } + + public FileMatcher withSize(Long size) { + this.sizeToCheck = Optional.ofNullable(size); + return this; + } + + public FileMatcher withModifiedIn(Date minModified, Date maxModified) { + this.minModifiedToCheck = minModified; + this.maxModifiedToCheck = maxModified; + return this; + } + + @Override + public void describeTo(Description description) { + description.appendText("a file"); + if (nameToCheck != null) { + description.appendText(" with name ").appendText(nameToCheck); + } + if (sizeToCheck != null) { + description.appendText(" with size ").appendValue(sizeToCheck); + } + if (minModifiedToCheck != null) { + description.appendText(" with modified in [").appendValue(minModifiedToCheck).appendText(",").appendValue(maxModifiedToCheck).appendText("]"); + } + } + + @Override + protected boolean matchesSafely(CloudNode cloudNode, Description description) { + CloudFile cloudFile = (CloudFile) cloudNode; + boolean match = true; + description.appendText("a file"); + if (nameToCheck != null && !nameToCheck.equals(cloudFile.getName())) { + description.appendText(" with name ").appendText(cloudFile.getName()); + match = false; + } + if (sizeToCheck != null && !sizeToCheck.equals(cloudFile.getSize())) { + description.appendText(" with size ").appendValue(cloudFile.getSize()); + match = false; + } + if (minModifiedToCheck != null && dateInRange(minModifiedToCheck, maxModifiedToCheck, cloudFile.getModified())) { + description.appendText(" with modified ").appendValue(cloudFile.getModified()); + } + return match; + } + + private boolean dateInRange(Date min, Date max, Optional modified) { + return modified.isPresent() && !modified.get().before(min) && !modified.get().after(max); + } + } + + public static Matcher folder(final String name) { + return (Matcher) new TypeSafeDiagnosingMatcher() { + @Override + protected boolean matchesSafely(CloudFolder file, org.hamcrest.Description description) { + if (name.equals(file.getName())) { + return true; + } else { + description.appendText("folder with name '").appendText(file.getName()).appendText("'"); + return false; + } + } + + @Override + public void describeTo(org.hamcrest.Description description) { + description.appendText("folder with name '").appendText(name).appendText("'"); + } + }; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/logging/LogRotatorTest.java b/presentation/src/androidTest/java/org/cryptomator/presentation/logging/LogRotatorTest.java new file mode 100644 index 000000000..ce673f04d --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/logging/LogRotatorTest.java @@ -0,0 +1,57 @@ +package org.cryptomator.presentation.logging; + +import android.content.Context; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +import static java.lang.Thread.sleep; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertThat; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class LogRotatorTest { + + private final LogRotator inTest; + private final Context context; + + public LogRotatorTest() { + this.context = InstrumentationRegistry.getTargetContext(); + this.inTest = new LogRotator(context); + } + + @Test + public void testRotationLeadsToChangedFile() throws InterruptedException { + int indexBefore = indexOfNewestLogfile(Logfiles.logfiles(context)); + byte[] testData = new byte[(int) Logfiles.ROTATION_FILE_SIZE]; + + inTest.log(Arrays.toString(testData)); + sleep(500); + inTest.log("Hello World!"); + + int indexAfter = indexOfNewestLogfile(Logfiles.logfiles(context)); + assertThat(indexBefore, not(indexAfter)); + } + + private int indexOfNewestLogfile(List logfiles) { + int index = 0; + long newestLastModified = 0L; + for (int i = 0; i < logfiles.size(); i++) { + long lastModified = logfiles.get(i).lastModified(); + if (lastModified > newestLastModified) { + index = i; + newestLastModified = lastModified; + } + } + return index; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/CryptoTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/CryptoTestCloud.java new file mode 100644 index 000000000..de0a93b3d --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/CryptoTestCloud.java @@ -0,0 +1,71 @@ +package org.cryptomator.presentation.testCloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.presentation.di.component.ApplicationComponent; + +import static org.cryptomator.domain.Vault.aVault; + +public class CryptoTestCloud extends TestCloud { + + private final static String VAULT_PASSWORD = "password"; + private final static String VAULT_NAME = "testVault"; + + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + throw new IllegalStateException(); + } + + public Cloud getInstance(ApplicationComponent appComponent, Cloud testCloud, CloudFolder rootFolder) { + try { + CloudFolder vaultFolder = appComponent // + .cloudContentRepository() // + .folder(rootFolder, VAULT_NAME); + + Vault vault = aVault() // + .thatIsNew() // + .withCloud(testCloud) // + .withNamePathAndCloudFrom(vaultFolder) // + .build(); + + cleanup(appComponent, vault, vaultFolder); + + vaultFolder = appComponent.cloudContentRepository().create(vaultFolder); + appComponent.cloudRepository().create(vaultFolder, VAULT_PASSWORD); + vault = appComponent.vaultRepository().store(vault); + + return appComponent.cloudRepository().unlock(vault, VAULT_PASSWORD); + } catch (BackendException e) { + throw new AssertionError(e); + } + } + + private void cleanup(ApplicationComponent appComponent, Vault vault, CloudFolder vaultFolder) { + try { + appComponent.cloudContentRepository().delete(vaultFolder); + } catch (BackendException | FatalBackendException e) { + } + + try { + appComponent.vaultRepository().vaults().forEach(vaultInRepo -> { + if (vaultInRepo.getName().equals(vault.getName()) // + && vaultInRepo.getPath().equals(vault.getPath())) { + try { + appComponent.vaultRepository().delete(vaultInRepo); + } catch (BackendException e) { + throw new AssertionError(e); + } + } + }); + } catch (FatalBackendException | BackendException e) { + } + } + + @Override + public String toString() { + return "CryptoTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/DropboxTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/DropboxTestCloud.java new file mode 100644 index 000000000..2062adc33 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/DropboxTestCloud.java @@ -0,0 +1,30 @@ +package org.cryptomator.presentation.testCloud; + +import android.content.Context; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.presentation.di.component.ApplicationComponent; +import org.cryptomator.util.crypto.CredentialCryptor; + +public class DropboxTestCloud extends TestCloud { + private final Context context; + + public DropboxTestCloud(Context context) { + this.context = context; + } + + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + return WebDavCloud.aWebDavCloudCloud() // + .withUrl("https://webdav.mc.gmx.net") // + .withUsername("jraufelder@gmx.de") // + .withPassword(CredentialCryptor.getInstance(context).encrypt("mG7!3B3Mx")) // + .build(); + } + + @Override + public String toString() { + return "DropboxTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/GoogledriveTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/GoogledriveTestCloud.java new file mode 100644 index 000000000..974e52cf7 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/GoogledriveTestCloud.java @@ -0,0 +1,85 @@ +package org.cryptomator.presentation.testCloud; + +import androidx.test.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; + +import junit.framework.AssertionFailedError; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.GoogleDriveCloud; +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.di.component.ApplicationComponent; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.cryptomator.presentation.ui.TestUtil.GOOGLE_DRIVE; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.withRecyclerView; +import static org.cryptomator.presentation.ui.activity.CloudsOperationsTest.checkLoginResult; +import static org.cryptomator.presentation.ui.activity.CloudsOperationsTest.openCloudServices; + +public class GoogledriveTestCloud extends TestCloud { + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + login(); + return GoogleDriveCloud.aGoogleDriveCloud() // + .withUsername("geselthyn@googlemail.com") // + .withAccessToken("geselthyn@googlemail.com") // + .build(); + } + + public void login() { + UiDevice device = UiDevice.getInstance(getInstrumentation()); + + openCloudServices(device); + + if (alreadyLoggedIn()) { + return; + } + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(GOOGLE_DRIVE, click())); + + try { + device // + .findObject(new UiSelector().resourceId("android:id/text1")) // + .click(); + + device // + .findObject(new UiSelector().resourceId("android:id/button1")) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("GoogleDrive login failed"); + } + + device.waitForIdle(); + + checkLoginResult("Google Drive", GOOGLE_DRIVE); + } + + private boolean alreadyLoggedIn() { + try { + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(GOOGLE_DRIVE, R.id.tv_cloud_name)) // + .check(matches(withText(InstrumentationRegistry // + .getTargetContext() // + .getString(R.string.screen_cloud_settings_sign_out_from_cloud) + " Google Drive"))); + } catch (AssertionFailedError e) { + return false; + } + + return true; + + } + + @Override + public String toString() { + return "GoogledriveTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalStorageTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalStorageTestCloud.java new file mode 100644 index 000000000..348c2ca17 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalStorageTestCloud.java @@ -0,0 +1,62 @@ +package org.cryptomator.presentation.testCloud; + +import androidx.test.uiautomator.UiDevice; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.di.component.ApplicationComponent; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.TestUtil.LOCAL; +import static org.cryptomator.presentation.ui.TestUtil.chooseSdCard; +import static org.cryptomator.presentation.ui.activity.CloudsOperationsTest.openCloudServices; +import static org.cryptomator.presentation.ui.activity.LoginLocalClouds.chooseFolder; + +public class LocalStorageTestCloud extends TestCloud { + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + login(); + try { + return appComponent.cloudRepository() // + .clouds(CloudType.LOCAL).stream() // + .map(LocalStorageCloud.class::cast) // + .filter(cloud -> cloud.rootUri() != null) // + .findFirst() // + .get(); + } catch (BackendException e) { + throw new RuntimeException(e); + } + } + + private void login() { + UiDevice device = UiDevice.getInstance(getInstrumentation()); + + openCloudServices(device); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(LOCAL, click())); + awaitCompleted(); + + onView(withId(R.id.floating_action_button)) // + .perform(click()); + awaitCompleted(); + + chooseSdCard(device); + awaitCompleted(); + + chooseFolder(device); + } + + @Override + public String toString() { + return "LocalStorageTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalTestCloud.java new file mode 100644 index 000000000..22e10bd50 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/LocalTestCloud.java @@ -0,0 +1,27 @@ +package org.cryptomator.presentation.testCloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.LocalStorageCloud; +import org.cryptomator.presentation.di.component.ApplicationComponent; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +public class LocalTestCloud extends TestCloud { + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + getInstrumentation() // + .getUiAutomation() // + .executeShellCommand("pm grant " + "org.cryptomator" + " android.permission.READ_EXTERNAL_STORAGE"); + + getInstrumentation() // + .getUiAutomation() // + .executeShellCommand("pm grant " + "org.cryptomator" + " android.permission.WRITE_EXTERNAL_STORAGE"); + + return LocalStorageCloud.aLocalStorage().build(); + } + + @Override + public String toString() { + return "LocalTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/OnedriveTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/OnedriveTestCloud.java new file mode 100644 index 000000000..fe5edd143 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/OnedriveTestCloud.java @@ -0,0 +1,45 @@ +package org.cryptomator.presentation.testCloud; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.OnedriveCloud; +import org.cryptomator.presentation.di.component.ApplicationComponent; + +public class OnedriveTestCloud extends TestCloud { + private final Context context; + + public OnedriveTestCloud(Context context) { + this.context = context; + } + + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + SharedPreferences sharedPreferences = context.getSharedPreferences("com.microsoft.live", 0); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString("refresh_token", "OAQABAAAAAABHh4kmS_aKT5XrjzxRAtHzMkxzFfIzutF8Q04IwuU77Vmp5-ErO6muS0-watCiLjvPG" + // + "-QICK6PwzSsJZApKZPIyTgDxJuElkhM9j9Caa-NGXKx3JPZx4Tk5X3zhmSWebZdQu2TM1N" + // + "RrKWLl2k2m3Zj7xr1zEsjcr4tUjjrZl_W6EIglAIoi-DPOwznIEDWAzWCJeUY76CIpywax" + // + "RTsbcZXD3u2321kIGaL-bnGLa_z5IzUbal0PcYfPNYPXXTuv8eMyl4L9Tls1tSEseTHgCu" + // + "X3OZU2owiGQk6ycDAeyrbRaPoUOA78GYuaJGUXRmhAeH1WBccUzABdrZmAY1dAt4yu2eV7" + // + "70RzrDQhbLCPV3u3x-7xEtvsM8w0W7096VBuu2-MXvaWuDccnCHo_PK8ketpow19_llBI9" + // + "fx7yBnIU-HCkKuvOCKcvVq3Bv9r312bwAoWHdOrxsKrNK8aLoR317O9Cxjpr7q-YI3NSJJ" + // + "veTK_vn2uE0e6gppGOYSmpJIvoW82ZVngpptW0jsp6rOsnkSg2yfKKpIPN-n0U1njVlYf8" + // + "cwsS99tx8NDdCPS6MTkVmcdKRJ4dhMuuWdEm4E_hZRnnj2Pya3APxjL2eqyAR1-54TDLF-" + // + "-L-jIJUS4bVpC_RBQn4fxcrX4s-ddo6I0ejfdC07mU_Np9p66VJ_3_Yokt64fFw-zzaGpn" + // + "QEzRMxtJp5G40MAelYwxLhDIc-syw91JEoSSqfGJYHETKExPlnQOw-rqLGPFbIF6OKFNqO" + // + "XtVLWKZFxIEf-EFQbhq5igZz8DZ2n-cRxC3HWi_x18tVSAA"); + editor.commit(); + + return OnedriveCloud.aOnedriveCloud() // + .withUsername("info@cryptomator.org") // + .withAccessToken("authenticated") // + .build(); + } + + @Override + public String toString() { + return "OnedriveTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/TestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/TestCloud.java new file mode 100644 index 000000000..ac90ac71d --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/TestCloud.java @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.testCloud; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.presentation.di.component.ApplicationComponent; + +public abstract class TestCloud { + public abstract Cloud getInstance(ApplicationComponent appComponent); +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/WebdavTestCloud.java b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/WebdavTestCloud.java new file mode 100644 index 000000000..0345acdd5 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/testCloud/WebdavTestCloud.java @@ -0,0 +1,30 @@ +package org.cryptomator.presentation.testCloud; + +import android.content.Context; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.WebDavCloud; +import org.cryptomator.presentation.di.component.ApplicationComponent; +import org.cryptomator.util.crypto.CredentialCryptor; + +public class WebdavTestCloud extends TestCloud { + private final Context context; + + public WebdavTestCloud(Context context) { + this.context = context; + } + + @Override + public Cloud getInstance(ApplicationComponent appComponent) { + return WebDavCloud.aWebDavCloudCloud() // + .withUrl("https://webdav.mc.gmx.net") // + .withUsername("jraufelder@gmx.de") // + .withPassword(CredentialCryptor.getInstance(context).encrypt("mG7!3B3Mx")) // + .build(); + } + + @Override + public String toString() { + return "WebdavTestCloud"; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/RecyclerViewMatcher.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/RecyclerViewMatcher.java new file mode 100644 index 000000000..ec493e14c --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/RecyclerViewMatcher.java @@ -0,0 +1,64 @@ +package org.cryptomator.presentation.ui; + +import android.content.res.Resources; +import android.view.View; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +import androidx.recyclerview.widget.RecyclerView; + +/** + * Created by dannyroa on 5/10/15. + */ +public class RecyclerViewMatcher { + private final int recyclerViewId; + + public RecyclerViewMatcher(int recyclerViewId) { + this.recyclerViewId = recyclerViewId; + } + + public Matcher atPositionOnView(final int position, final int targetViewId) { + + return new TypeSafeMatcher() { + Resources resources = null; + View childView; + + public void describeTo(Description description) { + String idDescription = Integer.toString(recyclerViewId); + if (this.resources != null) { + try { + idDescription = this.resources.getResourceName(recyclerViewId); + } catch (Resources.NotFoundException var4) { + idDescription = String.format("%s (resource name not found)", recyclerViewId); + } + } + + description.appendText("with id: " + idDescription); + } + + public boolean matchesSafely(View view) { + + this.resources = view.getResources(); + + if (childView == null) { + RecyclerView recyclerView = view.getRootView().findViewById(recyclerViewId); + if (recyclerView != null && recyclerView.getId() == recyclerViewId) { + childView = recyclerView.findViewHolderForAdapterPosition(position).itemView; + } else { + return false; + } + } + + if (targetViewId == -1) { + return view == childView; + } else { + View targetView = childView.findViewById(targetViewId); + return view == targetView; + } + + } + }; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/TestUtil.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/TestUtil.java new file mode 100644 index 000000000..a65823f69 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/TestUtil.java @@ -0,0 +1,178 @@ +package org.cryptomator.presentation.ui; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.CloudNode; +import org.cryptomator.domain.CloudType; +import org.cryptomator.domain.Vault; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.di.component.ApplicationComponent; +import org.hamcrest.Matchers; + +import java.util.List; + +import androidx.test.espresso.ViewInteraction; +import androidx.test.rule.ActivityTestRule; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.withDecorView; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.core.Is.is; + +public class TestUtil { + public static final int DROPBOX = 0; + public static final int GOOGLE_DRIVE = 1; + public static final int ONEDRIVE = 2; + public static final int WEBDAV = 3; + public static final int LOCAL = 4; + + private static final String SD_CARD_REGEX = "(?i)SD[- ]*(CARD|KARTE)"; + + public static void isToastDisplayed(String message, ActivityTestRule activityTestRule) { + onView(withText(message)) // + .inRoot(withDecorView(not(is(activityTestRule.getActivity().getWindow().getDecorView())))) // + .check(matches(isDisplayed())); + } + + public static void openMenu(UiDevice device) { + try { + final UiSelector toolbar = new UiSelector() // + .resourceId("com.android.documentsui:id/toolbar"); + + device // + .findObject(toolbar.childSelector(new UiSelector().index(0))) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Menu not found"); + } + } + + public static void chooseSdCard(UiDevice device) { + try { + if (!sdCardAlreadySelected()) { + openMenu(device); + + device // + .findObject(new UiSelector().textMatches(SD_CARD_REGEX)) // + .click(); + } + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Menu not found"); + } + } + + private static boolean sdCardAlreadySelected() { + ViewInteraction textView = onView(allOf(withText("SDCARD"), withParent(withId(R.id.toolbar)), isDisplayed())); + return textView.check(matches(withText("SDCARD"))) != null; + } + + public static void openSettings(UiDevice device) { + awaitCompleted(); + + waitForIdle(device); + + openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); + + waitForIdle(device); + + onView(allOf( // + withId(R.id.title), // + withText(R.string.snack_bar_action_title_settings))) // + .perform(click()); + + awaitCompleted(); + + } + + public static void waitForIdle(UiDevice uiDevice) { + uiDevice.waitForIdle(); + } + + public static void addFolderInCloud(ApplicationComponent appComponent, String path, CloudType cloudType) { + try { + CloudFolder vaultFolder = (CloudFolder) getNode(appComponent, getEncryptedCloud(appComponent, cloudType), path); + + if (!appComponent.cloudContentRepository().exists(vaultFolder)) { + assertThat(appComponent.cloudContentRepository().create(vaultFolder), Matchers.is(notNullValue())); + } + } catch (BackendException e) { + throw new AssertionError("Error while adding testVault"); + } + } + + public static void removeFolderInCloud(ApplicationComponent appComponent, String path, CloudType cloudType) { + try { + CloudFolder vaultFolder = (CloudFolder) getNode(appComponent, getEncryptedCloud(appComponent, cloudType), path); + + if (appComponent.cloudContentRepository().exists(vaultFolder)) { + appComponent.cloudContentRepository().delete(vaultFolder); + } + } catch (BackendException e) { + throw new AssertionError("Error while removing testVault"); + } + } + + private static Cloud getEncryptedCloud(ApplicationComponent appComponent, CloudType cloudType) throws BackendException { + Cloud cloud; + if (cloudType.equals(CloudType.LOCAL)) { + cloud = appComponent.cloudRepository().clouds(cloudType).get(1); + } else { + cloud = appComponent.cloudRepository().clouds(cloudType).get(0); + } + + return cloud; + } + + private static CloudNode getNode(ApplicationComponent appComponent, Cloud cloud, String path) throws BackendException { + return appComponent.cloudContentRepository().resolve(cloud, path); + } + + public static void removeFolderInVault(ApplicationComponent appComponent, String name, CloudType cloudType) { + try { + Cloud decryptedCloud = getDecryptedCloud(appComponent, cloudType); + CloudFolder root = appComponent.cloudContentRepository().root(decryptedCloud); + CloudFolder folder = appComponent.cloudContentRepository().folder(root, name); + appComponent.cloudContentRepository().delete(folder); + } catch (BackendException e) { + throw new AssertionError(e); + } + } + + public static void addFolderInVaultsRoot(ApplicationComponent appComponent, String name, CloudType cloudType) { + try { + Cloud decryptedCloud = getDecryptedCloud(appComponent, cloudType); + CloudFolder root = appComponent.cloudContentRepository().root(decryptedCloud); + CloudFolder folder = appComponent.cloudContentRepository().folder(root, name); + assertThat(appComponent.cloudContentRepository().create(folder), is(notNullValue())); + } catch (BackendException e) { + throw new AssertionError(e); + } + } + + private static Cloud getDecryptedCloud(ApplicationComponent appComponent, CloudType cloudType) throws BackendException { + List vaults = appComponent.vaultRepository().vaults(); + for (Vault vault : vaults) { + if (vault.getCloudType().equals(cloudType)) { + return appComponent.cloudRepository().decryptedViewOf(vault); + } + } + + throw new AssertionError("Cloud for vault not found"); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/BasicNodeOperationsUtil.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/BasicNodeOperationsUtil.java new file mode 100644 index 000000000..4bd00dd5f --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/BasicNodeOperationsUtil.java @@ -0,0 +1,57 @@ +package org.cryptomator.presentation.ui.activity; + +import androidx.test.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; + +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.ui.RecyclerViewMatcher; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static java.lang.String.format; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.hamcrest.Matchers.allOf; + +public class BasicNodeOperationsUtil { + + static void openSettings(UiDevice device, int nodePosition) { + awaitCompleted(); + + waitForIdle(device); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(nodePosition, R.id.settings)) // + .perform(click()); + + awaitCompleted(); + } + + static void waitForIdle(UiDevice uiDevice) { + uiDevice.waitForIdle(); + } + + static void checkEmptyFolderHint() { + awaitCompleted(); + + onView(allOf( // + withId(R.id.tv_empty_folder_hint), // + withText(R.string.screen_file_browser_msg_empty_folder))) // + .check(matches(withText(R.string.screen_file_browser_msg_empty_folder))); + } + + static void checkFileOrFolderAlreadyExistsErrorMessage(String nodeName) { + onView(withId(R.id.tv_error)) // + .check(matches(withText(format( // + InstrumentationRegistry // + .getTargetContext() // + .getString(R.string.error_file_or_folder_exists), // + nodeName)))); + } + + public static RecyclerViewMatcher withRecyclerView(final int recyclerViewId) { + return new RecyclerViewMatcher(recyclerViewId); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/CloudsOperationsTest.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/CloudsOperationsTest.java new file mode 100644 index 000000000..8d3deb436 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/CloudsOperationsTest.java @@ -0,0 +1,249 @@ +package org.cryptomator.presentation.ui.activity; + +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import androidx.test.InstrumentationRegistry; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.rule.ActivityTestRule; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiScrollable; +import androidx.test.uiautomator.UiSelector; + +import org.cryptomator.presentation.R; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; +import static androidx.test.espresso.matcher.ViewMatchers.withChild; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static java.lang.Thread.sleep; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.TestUtil.DROPBOX; +import static org.cryptomator.presentation.ui.TestUtil.GOOGLE_DRIVE; +import static org.cryptomator.presentation.ui.TestUtil.LOCAL; +import static org.cryptomator.presentation.ui.TestUtil.ONEDRIVE; +import static org.cryptomator.presentation.ui.TestUtil.WEBDAV; +import static org.cryptomator.presentation.ui.TestUtil.openSettings; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.withRecyclerView; +import static org.hamcrest.core.AllOf.allOf; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class CloudsOperationsTest { + + @Rule + public ActivityTestRule activityTestRule // + = new ActivityTestRule<>(SplashActivity.class); + + private final UiDevice device = UiDevice.getInstance(getInstrumentation()); + + @Test + public void test01EnableDebugModeLeadsToDebugMode() { + openSettings(device); + + try { + new UiScrollable(new UiSelector().scrollable(true)).scrollToEnd(10); + + awaitCompleted(); + + onView(withChild(withText(R.string.screen_settings_debug_mode_label))) // + .perform(click()); + + awaitCompleted(); + + onView(withId(android.R.id.button1)) // + .perform(click()); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Scrolling down failed"); + } + } + + @Test + public void test02LoginDropboxCloudLeadsToLoggedInDropboxCloud() { + openCloudServices(device); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(DROPBOX, click())); + + try { + device // + .findObject(new UiSelector().resourceId("android:id/button_once")) // + .click(); + + device.waitForIdle(); + + device // + .findObject(new UiSelector().text("Email")) // + .setText(""); + + device // + .findObject(new UiSelector().text("Password")) // + .setText(""); + + device // + .findObject(new UiSelector().description("Sign in")) // + .click(); + + device.waitForIdle(); + + device // + .findObject(new UiSelector().description("Allow")) // + .click(); + + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Dropbox login failed"); + } + + device.waitForIdle(); + + checkLoginResult("Dropbox", DROPBOX); + } + + @Test + public void test03LoginGoogleDriveCloudLeadsToLoggedInGoogleDriveCloud() { + openCloudServices(device); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(GOOGLE_DRIVE, click())); + + try { + device // + .findObject(new UiSelector().resourceId("android:id/text1")) // + .click(); + + device // + .findObject(new UiSelector().resourceId("android:id/button1")) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("GoogleDrive login failed"); + } + + device.waitForIdle(); + + checkLoginResult("Google Drive", GOOGLE_DRIVE); + } + + @Test + public void test04LoginOneDriveLeadsToLoggedInOneDriveCloud() { + openCloudServices(device); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(ONEDRIVE, click())); + + try { + try { + sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + device // + .findObject(new UiSelector().resourceId("i0116")) // + .setText(""); + + device // + .findObject(new UiSelector().resourceId("idSIButton9")) // + .click(); + + device.waitForWindowUpdate(null, 500); + + device // + .findObject(new UiSelector().resourceId("i0118")) // + .setText(""); + + device.waitForWindowUpdate(null, 500); + + device // + .findObject(new UiSelector().resourceId("idSIButton9")) // + .click(); + + try { + device // + .findObject(new UiSelector().resourceId("idSIButton9")) // + .click(); + } catch (UiObjectNotFoundException e) { + // Do nothing because second click is normaly not necessary + } + } catch (UiObjectNotFoundException e) { + throw new AssertionError("OneDrive login failed"); + } + + awaitCompleted(); + + checkLoginResult("OneDrive", ONEDRIVE); + } + + @Test + public void test05LoginWebdavCloudLeadsToLoggedInWebdavCloud() { + openCloudServices(device); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(WEBDAV, click())); + + LoginWebdavClouds.loginWebdavClouds(activityTestRule.getActivity()); + } + + @Test + public void test06LoginLocalCloudLeadsToLoggedInLocalCloud() { + openCloudServices(device); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(LOCAL, click())); + + LoginLocalClouds.loginLocalClouds(device); + } + + public static void openCloudServices(UiDevice device) { + openSettings(device); + + ViewInteraction recyclerView = onView(allOf(withId(R.id.recycler_view), childAtPosition(withId(android.R.id.list_container), 0))); + recyclerView.perform(RecyclerViewActions.actionOnItemAtPosition(3, click())); + + awaitCompleted(); + } + + public static void checkLoginResult(String cloudName, int cloudPosition) { + String displayText = InstrumentationRegistry // + .getTargetContext() // + .getString(R.string.screen_cloud_settings_sign_out_from_cloud) + " " + cloudName; + + UiObject signOutText = UiDevice.getInstance(getInstrumentation()).findObject(new UiSelector().text(displayText)); + signOutText.waitForExists(15000); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(cloudPosition, R.id.cloudName)) // + .check(matches(withText(displayText))); + } + + private static Matcher childAtPosition(final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) // + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FileOperationsTest.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FileOperationsTest.java new file mode 100644 index 000000000..6b3294446 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FileOperationsTest.java @@ -0,0 +1,670 @@ +package org.cryptomator.presentation.ui.activity; + +import android.content.Context; + +import androidx.test.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; + +import org.cryptomator.domain.CloudType; +import org.cryptomator.presentation.CryptomatorApp; +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.di.component.ApplicationComponent; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.junit.runners.Parameterized; + +import java.util.Arrays; + +import static android.R.id.button1; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.action.ViewActions.swipeDown; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static java.lang.String.format; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.TestUtil.DROPBOX; +import static org.cryptomator.presentation.ui.TestUtil.GOOGLE_DRIVE; +import static org.cryptomator.presentation.ui.TestUtil.LOCAL; +import static org.cryptomator.presentation.ui.TestUtil.ONEDRIVE; +import static org.cryptomator.presentation.ui.TestUtil.WEBDAV; +import static org.cryptomator.presentation.ui.TestUtil.addFolderInVaultsRoot; +import static org.cryptomator.presentation.ui.TestUtil.chooseSdCard; +import static org.cryptomator.presentation.ui.TestUtil.isToastDisplayed; +import static org.cryptomator.presentation.ui.TestUtil.removeFolderInVault; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.checkEmptyFolderHint; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.checkFileOrFolderAlreadyExistsErrorMessage; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.openSettings; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.withRecyclerView; +import static org.cryptomator.presentation.ui.activity.FolderOperationsTest.openFolder; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assume.assumeThat; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@RunWith(Parameterized.class) +public class FileOperationsTest { + + private final UiDevice device = UiDevice.getInstance(getInstrumentation()); + private final Context context = InstrumentationRegistry.getTargetContext(); + + @Rule + public ActivityTestRule activityTestRule // + = new ActivityTestRule<>(SplashActivity.class); + + private String packageName; + private final Integer cloudId; + + @Parameterized.Parameters(name = "{1}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{DROPBOX, "DROPBOX"}, {GOOGLE_DRIVE, "GOOGLE_DRIVE"}, {ONEDRIVE, "ONEDRIVE"}, {WEBDAV, "WEBDAV"}, {LOCAL, "LOCAL"}}); + } + + public FileOperationsTest(Integer cloudId, String cloudName) { + this.cloudId = cloudId; + } + + @Test + public void test00UploadFileWithCancelPressedWhileUploadingLeadsToNoNewFileInVault() { + assumeThat(cloudId, is(not(LOCAL))); + + packageName = activityTestRule.getActivity().getPackageName(); + + String nodeName = "foo.pdf"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + uploadFile(nodeName); + + onView(withId(android.R.id.button3)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(swipeDown()); + + awaitCompleted(); + + try { + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(1, R.id.cloudFileText)) // + .check(matches(withText(nodeName))); + + throw new AssertionError("Canceling the upload should not lead to new cloud node"); + } catch (NullPointerException e) { + // do nothing + } + + pressBack(); + } + + @Test + public void test01UploadFileLeadsToNewFileInVault() { + packageName = activityTestRule.getActivity().getPackageName(); + String nodeName = "lala.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + uploadFile(nodeName); + + device.waitForWindowUpdate(packageName, 15000); + + checkFileDisplayText(nodeName, 1); + + pressBack(); + } + + @Test + public void test02UploadAlreadyExistingFileAndCancelLeadsToNoNewFileInVault() { + String nodeName = "lala.png"; + String subString; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + // subString = subtextFromFile(); + + uploadFile(nodeName); + + device.waitForWindowUpdate(packageName, 500); + + onView(withText(R.string.dialog_button_cancel)) // + .perform(click()); + + awaitCompleted(); + + // assertThat(subtextFromFile(), equalTo(subString)); + + pressBack(); + } + + @Test + public void test03UploadAlreadyExistingFileAndReplaceLeadsToUpdatedFileInVault() { + String nodeName = "lala.png"; + String subString; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + awaitCompleted(); + + subString = subtextFromFile(); + + uploadFile(nodeName); + + device.waitForWindowUpdate(packageName, 500); + + onView(withText(R.string.dialog_existing_file_positive_button)) // + .perform(click()); + + awaitCompleted(); + + // assertThat(subtextFromFile(), not(equalTo(subString))); + + pressBack(); + } + + @Test + public void test04OpenFileLeadsToOpenFile() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFile(1); + + device.waitForWindowUpdate(null, 25000); + + device.pressBack(); + + awaitCompleted(); + + pressBack(); + } + + @Test + public void test05RenameFileLeadsToFileWithNewName() { + String fileName = "foo.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + renameFileTo(fileName, 1); + + checkFileDisplayText(fileName, 1); + + pressBack(); + } + + @Test + public void test06RenameFileToExistingFolderLeadsToNothingChanged() { + String fileName = "testFolder"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + renameFileTo(fileName, 1); + + checkFileOrFolderAlreadyExistsErrorMessage(fileName); + + onView(withId(android.R.id.button2)) // + .perform(click()); + + checkFileDisplayText("foo.png", 1); + + pressBack(); + } + + @Test + public void test07MoveFileLeadsToFileWithNewLocation() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openSettings(device, 1); + + openMoveFile(); + + openFolder(0); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + openFolder(0); + + checkFileDisplayText("foo.png", 0); + + pressBack(); + pressBack(); + } + + @Test + public void test08MoveWithExistingFileLeadsToNoNewFile() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + openSettings(device, 0); + + openMoveFile(); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + isToastDisplayed( // + format( // + InstrumentationRegistry // + .getTargetContext() // + .getString(R.string.error_file_or_folder_exists), // + "foo.png"), // + activityTestRule); + + pressBack(); + pressBack(); + pressBack(); + pressBack(); + } + + @Test + public void test09MoveWithExistingFolderLeadsToNoNewFile() { + ApplicationComponent appComponent = ((CryptomatorApp) activityTestRule.getActivity().getApplication()).getComponent(); + + String moveFileNameAndTmpFolder = "foo.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + addFolderInVaultsRoot(appComponent, moveFileNameAndTmpFolder, CloudType.values()[cloudId]); + + awaitCompleted(); + + refreshCloudNodes(); + + openFolder(1); + + openSettings(device, 0); + + openMoveFile(); + + pressBack(); + + awaitCompleted(); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + isToastDisplayed( // + InstrumentationRegistry // + .getTargetContext() // + .getString(R.string.error_file_or_folder_exists), // + activityTestRule); + + removeFolderInVault(appComponent, moveFileNameAndTmpFolder, CloudType.values()[cloudId]); + + pressBack(); + pressBack(); + pressBack(); + } + + @Test + public void test10ShareFileWithCryptomatorLeadsToNewFileInVault() { + String newFileName = "fooBar.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + shareFile(newFileName, cloudId, 0); + + isToastDisplayed(context.getString(R.string.screen_share_files_msg_success), activityTestRule); + + refreshCloudNodes(); + + checkFileDisplayText(newFileName, 1); + + pressBack(); + pressBack(); + } + + @Test + public void test11ShareExistingFileAndReplaceWithCryptomatorLeadsToUpdatedFileInVault() { + String newFileName = "fooBar.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + shareFile(newFileName, cloudId, 0); + + onView(withId(android.R.id.button1)) // + .perform(click()); + + awaitCompleted(); + + isToastDisplayed(context.getString(R.string.screen_share_files_msg_success), activityTestRule); + + refreshCloudNodes(); + + checkFileDisplayText(newFileName, 1); // check before and compare with after upload + + pressBack(); + pressBack(); + } + + @Test + public void test12ShareExistingFileWithoutReplaceWithCryptomatorLeadsToNotUpdatedFileInVault() { + String newFileName = "fooBar.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + shareFile(newFileName, cloudId, 0); + + onView(withId(android.R.id.button3)) // + .perform(click()); + + pressBack(); + pressBack(); + pressBack(); + } + + @Test + public void test13RenameFileToExistingFileLeadsToNothingChanged() { + String fileName = "fooBar.png"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + renameFileTo(fileName, 0); + + checkFileOrFolderAlreadyExistsErrorMessage(fileName); + + onView(withId(android.R.id.button2)) // + .perform(click()); + + awaitCompleted(); + + checkFileDisplayText("foo.png", 0); + + pressBack(); + pressBack(); + } + + @Test + public void test14DeleteFileLeadsToRemovedFile() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + awaitCompleted(); + + openFolder(0); + + deleteNodeOnPosition(0); + awaitCompleted(); + deleteNodeOnPosition(0); + + checkEmptyFolderHint(); + + pressBack(); + + deleteTestFolder(); + + pressBack(); + } + + private void deleteNodeOnPosition(int position) { + openSettings(device, position); + + onView(withId(R.id.delete_file)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(android.R.id.button1)) // + .perform(click()); + } + + private void refreshCloudNodes() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(swipeDown()); + + awaitCompleted(); + } + + private void shareFile(String newFilename, int vaultPosition, int filePosition) { + awaitCompleted(); + + openSettings(device, filePosition); + + openEncryptWithCryptomator(); + + chooseShareLocation(vaultPosition); + + checkPathInSharingScreen("/testFolder", vaultPosition); + + onView(withId(R.id.fileName)) // + .perform(replaceText(newFilename), closeSoftKeyboard()); + + onView(withId(R.id.saveFiles)) // + .perform(click()); + + awaitCompleted(); + } + + private void chooseShareLocation(int nodePosition) { + onView(withRecyclerView(R.id.locationsRecyclerView) // + .atPositionOnView(nodePosition, R.id.vaultName)) // + .perform(click()); + + device.waitForWindowUpdate(packageName, 300); + + onView(withRecyclerView(R.id.locationsRecyclerView) // + .atPositionOnView(nodePosition, R.id.chooseFolderLocation)) // + .perform(click()); + + openFolder(0); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + awaitCompleted(); + } + + private void checkPathInSharingScreen(String path, int nodePosition) { + onView(withRecyclerView(R.id.locationsRecyclerView) // + .atPositionOnView(nodePosition, R.id.chosenLocation)) // + .check(matches(withText(path))); + } + + private void deleteTestFolder() { + awaitCompleted(); + + openSettings(device, 0); + + onView(withId(R.id.delete_folder)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(android.R.id.button1)) // + .perform(click()); + + awaitCompleted(); + + checkEmptyFolderHint(); + } + + private void uploadFile(String nodeName) { + awaitCompleted(); + + onView(withId(R.id.floatingActionButton)) // + .perform(click()); + + onView(withId(R.id.upload_files)) // + .perform(click()); + + chooseSdCard(device); + + try { + device // + .findObject(new UiSelector().text(nodeName)) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Image " + nodeName + " not available"); + } + } + + private void renameFileTo(String name, int nodePosition) { + awaitCompleted(); + + openSettings(device, nodePosition); + + onView(withId(R.id.rename_file)) // + .perform(click()); + + onView(withId(R.id.et_rename)) // + .perform(replaceText(name), closeSoftKeyboard()); + + onView(allOf( // + withId(button1), // + withText(R.string.dialog_rename_node_positive_button))) // + .perform(click()); + + awaitCompleted(); + } + + private void checkFileDisplayText(String assertNodeText, int nodePosition) { + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(nodePosition, R.id.cloudFileText)) // + .check(matches(withText(assertNodeText))); + } + + private void openMoveFile() { + awaitCompleted(); + + onView(withId(R.id.move_file)) // + .perform(click()); + + awaitCompleted(); + } + + static void isPermissionShown(UiDevice device) { + if (!device // + .findObject(new UiSelector().text("ALLOW")) // + .waitForExists(1000L)) { + throw new AssertionError("View with text not found!"); + } + } + + static void grantPermission(UiDevice device) { + try { + device // + .findObject(new UiSelector().text("ALLOW")) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Permission not found"); + } + } + + private void openEncryptWithCryptomator() { + openShareFile(); + try { + device // + .findObject(new UiSelector().text(context.getString(R.string.share_with_label))) // + .waitForExists(30000L); + device // + .findObject(new UiSelector().text(context.getString(R.string.share_with_label))) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Share with Cryptomator not available"); + } + + awaitCompleted(); + } + + private void openShareFile() { + awaitCompleted(); + + onView(withId(R.id.share_file)) // + .perform(click()); + + awaitCompleted(); + } + + private String subtextFromFile() { + try { + final UiSelector docList = new UiSelector() // + .resourceId("org.cryptomator:id/cloudFileContent"); + + return device // + .findObject(docList.childSelector(new UiSelector(). // + resourceId("org.cryptomator:id/cloudFileSubText") // + .index(0))) // + .getText(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Folder 0 not found"); + } + } + + static void openFile(int nodePosition) { + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(nodePosition, R.id.cloudFileText)) // + .perform(click()); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FolderOperationsTest.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FolderOperationsTest.java new file mode 100644 index 000000000..618ebae37 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/FolderOperationsTest.java @@ -0,0 +1,314 @@ +package org.cryptomator.presentation.ui.activity; + +import android.content.Context; + +import androidx.test.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import androidx.test.uiautomator.UiDevice; + +import org.cryptomator.presentation.R; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.junit.runners.Parameterized; + +import java.util.Arrays; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.TestUtil.DROPBOX; +import static org.cryptomator.presentation.ui.TestUtil.GOOGLE_DRIVE; +import static org.cryptomator.presentation.ui.TestUtil.LOCAL; +import static org.cryptomator.presentation.ui.TestUtil.ONEDRIVE; +import static org.cryptomator.presentation.ui.TestUtil.WEBDAV; +import static org.cryptomator.presentation.ui.TestUtil.isToastDisplayed; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.checkEmptyFolderHint; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.checkFileOrFolderAlreadyExistsErrorMessage; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.openSettings; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.withRecyclerView; +import static org.hamcrest.Matchers.allOf; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@RunWith(Parameterized.class) +public class FolderOperationsTest { + @Rule + public ActivityTestRule activityTestRule // + = new ActivityTestRule<>(SplashActivity.class); + + private final UiDevice device = UiDevice.getInstance(getInstrumentation()); + private final Context context = InstrumentationRegistry.getTargetContext(); + + private final Integer cloudId; + + @Parameterized.Parameters(name = "{1}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{DROPBOX, "DROPBOX"}, {GOOGLE_DRIVE, "GOOGLE_DRIVE"}, {ONEDRIVE, "ONEDRIVE"}, {WEBDAV, "WEBDAV"}, {LOCAL, "LOCAL"}}); + } + + public FolderOperationsTest(Integer cloudId, String cloudName) { + this.cloudId = cloudId; + } + + @Test + public void test00CreateFolderLeadsToFolderInVault() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + String folderName = "testFolder"; + createFolder(folderName); + + checkFolderCreationResult(folderName, "/0/testVault", 0); + pressBack(); + + folderName = "testFolder1"; + createFolder(folderName); + + checkFolderCreationResult(folderName, "/0/testVault", 1); + + pressBack(); + pressBack(); + } + + @Test + public void test01CreateExistingFolderLeadsToNoNewFolderInVault() { + String folderName = "testFolder"; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + awaitCompleted(); + + createFolder(folderName); + + checkFileOrFolderAlreadyExistsErrorMessage(folderName); + + onView(withId(android.R.id.button2)) // + .perform(click()); + + awaitCompleted(); + + pressBack(); + } + + @Test + public void test02OpenFolderLeadsToOpenFolder() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + checkEmptyFolderHint(); + + pressBack(); + pressBack(); + } + + @Test + public void test03RenameFolderLeadsToFolderWithNewName() { + String newFolderName = "testFolder2"; + int nodePosition = 1; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + renameFolderTo(newFolderName, nodePosition); + + checkFolderDisplayText(newFolderName, nodePosition); + + pressBack(); + } + + @Test + public void test04RenameFolderToAlreadyExistFolderLeadsToSameFolderName() { + String newFolderName = "testFolder"; + int nodePosition = 1; + + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + renameFolderTo(newFolderName, nodePosition); + + checkFileOrFolderAlreadyExistsErrorMessage(newFolderName); + + onView(withId(android.R.id.button2)) // + .perform(click()); + + awaitCompleted(); + + pressBack(); + } + + @Test + public void test05MoveFolderLeadsToFolderWithNewLocation() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openSettings(device, 1); + + openMoveFolder(); + + openFolder(0); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + openFolder(0); + + checkFolderDisplayText("testFolder2", 0); + + openFolder(0); + + checkEmptyFolderHint(); + + pressBack(); + pressBack(); + pressBack(); + } + + @Test + public void test06MoveFolderToAlreadyExistingFolderLeadsToErrorMessage() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openSettings(device, 0); + + openMoveFolder(); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + awaitCompleted(); + + isToastDisplayed( // + context.getString(R.string.error_file_or_folder_exists), // + activityTestRule); + + pressBack(); + pressBack(); + } + + @Test + public void test07DeleteFolderLeadsToRemovedFolder() { + awaitCompleted(); + + onView(withId(R.id.recyclerView)) // + .perform(actionOnItemAtPosition(cloudId, click())); + + openFolder(0); + + openSettings(device, 0); + + onView(withId(R.id.delete_folder)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(android.R.id.button1)) // + .perform(click()); + + awaitCompleted(); + + checkEmptyFolderHint(); + + pressBack(); + pressBack(); + } + + private void openMoveFolder() { + onView(withId(R.id.move_folder)) // + .perform(click()); + } + + private void createFolder(String name) { + awaitCompleted(); + + onView(withId(R.id.floatingActionButton)) // + .perform(click()); + + onView(withId(R.id.create_new_folder)) // + .perform(click()); + + onView(withId(R.id.et_folder_name)) // + .perform(replaceText(name), closeSoftKeyboard()); + + onView(allOf( // + withId(android.R.id.button1), // + withText(R.string.screen_enter_vault_name_button_text))) // + .perform(click()); + + awaitCompleted(); + } + + private void checkFolderCreationResult(String folderName, String path, int position) { + checkFolderDisplayText(folderName, position); + + openSettings(device, position); + + onView(allOf( // + withId(R.id.tv_folder_path), // + withText(path))) // + .check(matches(withText(path))); + + awaitCompleted(); + } + + private void renameFolderTo(String name, int nodePosition) { + awaitCompleted(); + + openSettings(device, nodePosition); + + onView(withId(R.id.change_cloud)) // + .perform(click()); + + onView(withId(R.id.et_rename)) // + .perform(replaceText(name), closeSoftKeyboard()); + + onView(allOf( // + withId(android.R.id.button1), // + withText(R.string.dialog_rename_node_positive_button))) // + .perform(click()); + + awaitCompleted(); + } + + private void checkFolderDisplayText(String assertNodeText, int nodePosition) { + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(nodePosition, R.id.cloudFolderText)) // + .check(matches(withText(assertNodeText))); + } + + static void openFolder(int nodePosition) { + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(nodePosition, R.id.cloudFolderText)) // + .perform(click()); + + awaitCompleted(); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginLocalClouds.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginLocalClouds.java new file mode 100644 index 000000000..ba9fc221b --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginLocalClouds.java @@ -0,0 +1,80 @@ +package org.cryptomator.presentation.ui.activity; + +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; + +import org.cryptomator.presentation.R; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.TestUtil.chooseSdCard; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.withRecyclerView; + +public class LoginLocalClouds { + + private static UiDevice device; + + public static void loginLocalClouds(UiDevice uiDevice) { + device = uiDevice; + String folderName = "0"; + createNewStorageAccessCloudCloud(folderName); + awaitCompleted(); + checkResult(folderName); + } + + private static void createNewStorageAccessCloudCloud(String folderName) { + onView(withId(R.id.floating_action_button)) // + .perform(click()); + + awaitCompleted(); + + chooseSdCard(device); + + awaitCompleted(); + + openFolder0(); + + awaitCompleted(); + + chooseFolder(device); + } + + private static void openFolder0() { + try { + final UiSelector docList = new UiSelector() // + .resourceId("com.android.documentsui:id/container_directory") // + .childSelector( // + new UiSelector() // + .resourceId("com.android.documentsui:id/dir_list")); + + device // + .findObject(docList.childSelector(new UiSelector().text("0"))) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Folder 0 not found"); + } + } + + public static void chooseFolder(UiDevice device) { + try { + + final UiSelector docList = new UiSelector() // + .resourceId("com.android.documentsui:id/container_save"); + + device // + .findObject(docList.childSelector(new UiSelector().resourceId("android:id/button1"))) // + .click(); + } catch (UiObjectNotFoundException e) { + throw new AssertionError("Folder 0 not found"); + } + } + + private static void checkResult(String folderName) { + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(0, R.id.cloudText)) // + .perform(click()); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginWebdavClouds.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginWebdavClouds.java new file mode 100644 index 000000000..1290bcb2b --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/LoginWebdavClouds.java @@ -0,0 +1,167 @@ +package org.cryptomator.presentation.ui.activity; + +import org.cryptomator.presentation.R; + +import java.util.Collections; +import java.util.List; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.pressBack; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.activity.LoginWebdavClouds.WebdavCloudCredentials.notSpecialWebdavClouds; +import static org.hamcrest.Matchers.allOf; + +class LoginWebdavClouds { + private static final String localUrl = "192.168.0.108"; + + enum WebdavCloudCredentials { + GMX("https://webdav.mc.gmx.net/", "webdav.mc.gmx.net", "jraufelder@gmx.de", "mG7!3B3Mx"), // + /* + * FREENET("https://webmail.freenet.de/webdav", "webmail.freenet.de", "milestone@freenet.de", "rF7!3B3Et") + * + * , // + * /* + * AUTHENTICATION_FAIL("https://webdav.mc.gmx.net/", "webdav.mc.gmx.net", "bla@bla.de", "bla"), // + * SELF_SIGNED_HTTPS("https://" + localUrl + "/webdav", localUrl + "/webdav", "bla@bla.de", "bla"), // + * REDIRECT_TO_HTTPS("http://" + localUrl + "/webdav", localUrl + "/webdav", "bla@bla.de", "bla"), // + * REDIRECT_TO_URL("https://" + localUrl + "/bar/baz", localUrl + "/bar/baz", "bla@bla.de", "bla") + */; + + private final String url; + private final String displayUrl; + private final String username; + private final String password; + + WebdavCloudCredentials(String url, String displayUrl, String username, String password) { + this.url = url; + this.displayUrl = displayUrl; + this.username = username; + this.password = password; + } + + static List notSpecialWebdavClouds() { + return Collections.singletonList(GMX/* , FREENET */); + } + } + + static void loginWebdavClouds(SplashActivity activity) { + loginStandardWebdavClouds(); + + /* + * loginAuthenticationFailWebdavCloud(activity); + * + * loginSelfSignedWebdavCloud(); + * + * loginRedirectToHttpsWebdavCloud(); + * + * loginRedirectToUrlWebdavCloud(); + */ + } + + private static void loginStandardWebdavClouds() { + for (WebdavCloudCredentials webdavCloudCredential : notSpecialWebdavClouds()) { + createNewCloud(); + + enterCredentials(webdavCloudCredential); + + startLoginProcess(); + + awaitCompleted(); + + checkResult(webdavCloudCredential); + } + + pressBack(); + } + + private static void createNewCloud() { + onView(withId(R.id.floating_action_button)) // + .perform(click()); + } + + /* + * private static void loginAuthenticationFailWebdavCloud(SplashActivity activity) { + * createNewCloud(); + * enterCredentials(AUTHENTICATION_FAIL); + * startLoginProcess(); + * + * onView(withText(R.string.error_authentication_failed)) // + * .inRoot(withDecorView(not(is(activity.getWindow().getDecorView())))) // + * .check(matches(isDisplayed())); + * + * onView(allOf( // + * withId(R.id.et_url_port), // + * withText(AUTHENTICATION_FAIL.url))); + * + * onView(allOf( // + * withId(R.id.et_user), // + * withText(AUTHENTICATION_FAIL.username))); + * + * onView(allOf( // + * withId(R.id.et_password), // + * withText(AUTHENTICATION_FAIL.password))); + * + * pressBack(); + * } + * + * private static void loginSelfSignedWebdavCloud() { + * createNewCloud(); + * enterCredentials(SELF_SIGNED_HTTPS); + * startLoginProcess(); + * clickOk(); + * startLoginProcess(); + * checkResult(SELF_SIGNED_HTTPS); + * } + * + * private static void loginRedirectToHttpsWebdavCloud() { + * createNewCloud(); + * enterCredentials(REDIRECT_TO_HTTPS); + * startLoginProcess(); + * clickOk(); + * clickOk(); + * startLoginProcess(); + * checkResult(REDIRECT_TO_HTTPS); + * } + * + * private static void loginRedirectToUrlWebdavCloud() { + * createNewCloud(); + * enterCredentials(REDIRECT_TO_URL); + * startLoginProcess(); + * clickOk(); + * startLoginProcess(); + * } + */ + + private static void startLoginProcess() { + onView(withId(R.id.createCloudButton)) // + .perform(click()); + } + + private static void enterCredentials(WebdavCloudCredentials webdavCloudCredential) { + onView(withId(R.id.urlPortEditText)) // + .perform(replaceText(webdavCloudCredential.url)); + + onView(withId(R.id.userNameEditText)) // + .perform(replaceText(webdavCloudCredential.username)); + + onView(withId(R.id.passwordEditText)) // + .perform( // + replaceText(webdavCloudCredential.password), // + closeSoftKeyboard()); + } + + private static void checkResult(WebdavCloudCredentials webdavCloudCredential) { + onView(allOf( // + withId(R.id.cloudText), // + withText(webdavCloudCredential.displayUrl))); + + onView(allOf( // + withId(R.id.cloudSubText), // + withText(webdavCloudCredential.username + " • "))); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/VaultsOperationsTest.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/VaultsOperationsTest.java new file mode 100644 index 000000000..74e9d139e --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/VaultsOperationsTest.java @@ -0,0 +1,412 @@ +package org.cryptomator.presentation.ui.activity; + +import android.content.Context; + +import androidx.test.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiSelector; + +import org.cryptomator.domain.CloudType; +import org.cryptomator.presentation.CryptomatorApp; +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.di.component.ApplicationComponent; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.junit.runners.Parameterized; + +import java.util.Arrays; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.pressBack; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.cryptomator.domain.executor.BackgroundTasks.awaitCompleted; +import static org.cryptomator.presentation.ui.TestUtil.DROPBOX; +import static org.cryptomator.presentation.ui.TestUtil.GOOGLE_DRIVE; +import static org.cryptomator.presentation.ui.TestUtil.LOCAL; +import static org.cryptomator.presentation.ui.TestUtil.ONEDRIVE; +import static org.cryptomator.presentation.ui.TestUtil.WEBDAV; +import static org.cryptomator.presentation.ui.TestUtil.isToastDisplayed; +import static org.cryptomator.presentation.ui.TestUtil.removeFolderInCloud; +import static org.cryptomator.presentation.ui.TestUtil.waitForIdle; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.checkEmptyFolderHint; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.checkFileOrFolderAlreadyExistsErrorMessage; +import static org.cryptomator.presentation.ui.activity.BasicNodeOperationsUtil.withRecyclerView; +import static org.cryptomator.presentation.ui.activity.FileOperationsTest.grantPermission; +import static org.cryptomator.presentation.ui.activity.FileOperationsTest.isPermissionShown; +import static org.cryptomator.presentation.ui.activity.FileOperationsTest.openFile; +import static org.cryptomator.presentation.ui.activity.FolderOperationsTest.openFolder; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +@RunWith(Parameterized.class) +public class VaultsOperationsTest { + + @Rule + public final ActivityTestRule activityTestRule = new ActivityTestRule<>(SplashActivity.class); + + private final UiDevice device = UiDevice.getInstance(getInstrumentation()); + private final Context context = InstrumentationRegistry.getTargetContext(); + + private final Integer cloudId; + + @Parameterized.Parameters(name = "{1}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{DROPBOX, "DROPBOX"}, {GOOGLE_DRIVE, "GOOGLE_DRIVE"}, {ONEDRIVE, "ONEDRIVE"}, {WEBDAV, "WEBDAV"}, {LOCAL, "LOCAL"}}); + } + + public VaultsOperationsTest(Integer cloudId, String cloudName) { + this.cloudId = cloudId; + } + + @Test + public void test00CreateNewVaultsLeadsToNewVaults() { + ApplicationComponent appComponent = ((CryptomatorApp) activityTestRule.getActivity().getApplication()).getComponent(); + String path = "0/tempVault/"; + + awaitCompleted(); + + // Permission problem + if (cloudId != LOCAL) { + removeFolderInCloud(appComponent, path, CloudType.values()[cloudId]); + removeFolderInCloud(appComponent, "0/testVault/", CloudType.values()[cloudId]); + removeFolderInCloud(appComponent, "0/testLoggedInVault/", CloudType.values()[cloudId]); + } + + onView(withId(R.id.fab_vault)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(R.id.create_new_vault)) // + .perform(click()); + + createVault(cloudId, appComponent, path); + + awaitCompleted(); + + unlockVault("tempVault", cloudId); + + awaitCompleted(); + + checkEmptyFolderHint(); + + pressBack(); + + } + + @Test + public void test01RenameLoggedInVaultToAlreadyExistingNameLeadsToNothingChanged() { + String vaultName = "tempVault"; + + renameVault(cloudId, vaultName); + + checkFileOrFolderAlreadyExistsErrorMessage(vaultName); + + onView(withId(android.R.id.button2)) // + .perform(click()); + + checkVault(cloudId, vaultName); + } + + @Test + public void test02ChangeLoggedInVaultPasswordLeadsToNewPassword() { + String oldPassword = "tempVault"; + String newPassword = "foo"; + + changePassword(cloudId, oldPassword, newPassword); + + isToastDisplayed(context.getString(R.string.screen_vault_list_change_password_successful), activityTestRule); + + } + + @Test + public void test03RenameLoggedInVaultToNewNameLeadsToNewName() { + String vaultName = "testLoggedInVault"; + + renameVault(cloudId, vaultName); + + checkVault(cloudId, vaultName); + + } + + @Test + public void test04RenameLoggedOutVaultToAlreadyExistingNameLeadsToNothingChanged() { + String vaultName = "testLoggedInVault"; + + renameVault(cloudId, vaultName); + + checkFileOrFolderAlreadyExistsErrorMessage(vaultName); + + onView(withId(android.R.id.button2)) // + .perform(click()); + } + + @Test + public void test05RenameLoggedOutVaultToNewNameLeadsToNewName() { + String vaultName = "testVault"; + + renameVault(cloudId, vaultName); + + checkVault(cloudId, vaultName); + } + + @Test + public void test06ChangeLoggedOutVaultPasswordLeadsToNewPassword() { + String oldPassword = "foo"; + String newPassword = "testVault"; + + changePassword(cloudId, oldPassword, newPassword); + + isToastDisplayed(context.getString(R.string.screen_vault_list_change_password_successful), activityTestRule); + } + + @Test + public void test07DeleteVaultsLeadsToDeletedVaults() { + waitForIdle(device); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(cloudId, R.id.settings)) // + .perform(click()); + + waitForIdle(device); + + onView(withId(R.id.delete_vault)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(android.R.id.button1)) // + .perform(click()); + + onView(withId(R.id.tv_vault_creation_hint)); + } + + @Test + public void test08addExistingVaultsLeadsToAddedVaults() { + waitForIdle(device); + + onView(withId(R.id.fab_vault)) // + .perform(click()); + + waitForIdle(device); + + onView(withId(R.id.add_existing_vault)) // + .perform(click()); + + openVault(cloudId); + + awaitCompleted(); + + unlockVault("testVault", cloudId); + + awaitCompleted(); + + checkEmptyFolderHint(); + + pressBack(); + } + + private void renameVault(int position, String vaultName) { + awaitCompleted(); + + waitForIdle(device); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(position, R.id.settings)) // + .perform(click()); + + waitForIdle(device); + + onView(withId(R.id.et_rename)) // + .perform(click()); + + awaitCompleted(); + + waitForIdle(device); + + onView(withId(R.id.et_rename)) // + .perform(replaceText("tempVault"), closeSoftKeyboard()); + + onView(withId(R.id.et_rename)) // + .perform(replaceText(vaultName), closeSoftKeyboard()); + + onView(withId(android.R.id.button1)) // + .perform(click()); + + awaitCompleted(); + } + + private void createVault(int vaultPosition, ApplicationComponent appComponent, String path) { + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(vaultPosition, R.id.cloud)) // + .perform(click()); + + String vaultnameAndPassword = "tempVault"; + + awaitCompleted(); + + switch (vaultPosition) { + case WEBDAV: + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(0, R.id.cloudText)) // + .perform(click()); + break; + case LOCAL: + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(0, R.id.cloudText)) // + .perform(click()); + + awaitCompleted(); + + isPermissionShown(device); + grantPermission(device); + + removeFolderInCloud(appComponent, path, CloudType.LOCAL); + removeFolderInCloud(appComponent, "0/testVault/", CloudType.LOCAL); + removeFolderInCloud(appComponent, "0/testLoggedInVault/", CloudType.LOCAL); + + break; + } + + awaitCompleted(); + + onView(withId(R.id.vaultNameEditText)) // + .perform(replaceText(vaultnameAndPassword), closeSoftKeyboard()); + + awaitCompleted(); + + onView(withId(R.id.createVaultButton)) // + .perform(click()); + + awaitCompleted(); + + openFolder(0); + + onView(withId(R.id.chooseLocationButton)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(R.id.passwordEditText)) // + .perform(replaceText(vaultnameAndPassword)); + + onView(withId(R.id.passwordRetypedEditText)) // + .perform(replaceText(vaultnameAndPassword), closeSoftKeyboard()); + + awaitCompleted(); + + onView(withId(R.id.createVaultButton)) // + .perform(click()); + } + + private void openVault(int vaultPosition) { + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(vaultPosition, R.id.cloud)) // + .perform(click()); + + awaitCompleted(); + + switch (vaultPosition) { + case WEBDAV: + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(0, R.id.cloudText)) // + .perform(click()); + break; + case LOCAL: + awaitCompleted(); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(0, R.id.cloudText)) // + .perform(click()); + + break; + } + + awaitCompleted(); + + openFolder(0); + awaitCompleted(); + + openFolder(0); + awaitCompleted(); + + openFile(1); + } + + private void unlockVault(String vaultPassword, int vaultPosition) { + waitForIdle(device); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(vaultPosition, R.id.vaultName)) // + .perform(click()); + + device // + .findObject(new UiSelector().text("Password")) // + .waitForExists(30000L); + + onView(withId(R.id.et_password)) // + .perform(replaceText(vaultPassword), closeSoftKeyboard()); + onView(withId(android.R.id.button1)) // + .perform(click()); + + awaitCompleted(); + } + + private void changePassword(int position, String oldPassword, String newPassword) { + awaitCompleted(); + + waitForIdle(device); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(position, R.id.settings)) // + .perform(click()); + + waitForIdle(device); + + onView(withId(R.id.change_password)) // + .perform(click()); + + awaitCompleted(); + + onView(withId(R.id.et_old_password)) // + .perform(replaceText(oldPassword)); + + onView(withId(R.id.et_new_password)) // + .perform(replaceText(newPassword)); + + onView(withId(R.id.et_new_retype_password)) // + .perform(replaceText(newPassword), closeSoftKeyboard()); + + onView(withId(android.R.id.button1)) // + .perform(click()); + + awaitCompleted(); + } + + private void checkVault(int position, String name) { + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(position, R.id.vaultName)) // + .check(matches(withText(name))); + + onView(withRecyclerView(R.id.recyclerView) // + .atPositionOnView(position, R.id.vaultPath)) // + .check(matches(withText("/0/" + name))); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/FailureListener.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/FailureListener.java new file mode 100644 index 000000000..3e37f342f --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/FailureListener.java @@ -0,0 +1,21 @@ +package org.cryptomator.presentation.ui.activity.suite; + +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; +import org.junit.runner.notification.RunNotifier; + +class FailureListener extends RunListener { + + private final RunNotifier runNotifier; + + FailureListener(RunNotifier runNotifier) { + super(); + this.runNotifier = runNotifier; + } + + @Override + public void testFailure(Failure failure) throws Exception { + super.testFailure(failure); + this.runNotifier.pleaseStop(); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/StopOnFailureSuite.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/StopOnFailureSuite.java new file mode 100644 index 000000000..75e236f2e --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/StopOnFailureSuite.java @@ -0,0 +1,22 @@ +package org.cryptomator.presentation.ui.activity.suite; + +import org.junit.runner.notification.RunNotifier; +import org.junit.runners.Suite; +import org.junit.runners.model.InitializationError; + +public class StopOnFailureSuite extends Suite { + + public StopOnFailureSuite(Class klass, Class[] suiteClasses) throws InitializationError { + super(klass, suiteClasses); + } + + public StopOnFailureSuite(Class klass) throws InitializationError { + super(klass, klass.getAnnotation(Suite.SuiteClasses.class).value()); + } + + @Override + public void run(RunNotifier runNotifier) { + runNotifier.addListener(new FailureListener(runNotifier)); + super.run(runNotifier); + } +} diff --git a/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/UiTestSuite.java b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/UiTestSuite.java new file mode 100644 index 000000000..8f3abc478 --- /dev/null +++ b/presentation/src/androidTest/java/org/cryptomator/presentation/ui/activity/suite/UiTestSuite.java @@ -0,0 +1,20 @@ +package org.cryptomator.presentation.ui.activity.suite; + +import org.cryptomator.presentation.ui.activity.CloudsOperationsTest; +import org.cryptomator.presentation.ui.activity.FileOperationsTest; +import org.cryptomator.presentation.ui.activity.FolderOperationsTest; +import org.cryptomator.presentation.ui.activity.VaultsOperationsTest; +import org.junit.Ignore; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; + +@Ignore +@RunWith(StopOnFailureSuite.class) +@Suite.SuiteClasses({ // + CloudsOperationsTest.class, // + VaultsOperationsTest.class, // + FolderOperationsTest.class, // + FileOperationsTest.class // +}) +public class UiTestSuite { +} diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1e0c1af0d --- /dev/null +++ b/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/java/org/cryptomator/presentation/AutoPhotoUploadReceiver.kt b/presentation/src/main/java/org/cryptomator/presentation/AutoPhotoUploadReceiver.kt new file mode 100644 index 000000000..213a72cf5 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/AutoPhotoUploadReceiver.kt @@ -0,0 +1,30 @@ +package org.cryptomator.presentation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.file.MimeTypeMap_Factory +import org.cryptomator.util.file.MimeTypes +import timber.log.Timber + +class AutoPhotoUploadReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!SharedPreferencesHandler(context).usePhotoUpload()) { + return + } + + intent.data?.let { uri -> + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.moveToFirst() + val imagePath = cursor?.getString(cursor.getColumnIndex("_data")) + cursor?.close() + imagePath?.let { + val fileUtil = FileUtil(context, MimeTypes(MimeTypeMap_Factory.newInstance())) + fileUtil.addImageToAutoUploads(it) + Timber.tag("AutoPhotoUploadReceiver").i(String.format("Added file to UploadList %s", it)) + } + } ?: Timber.tag("AutoPhotoUploadReceiver").i("No data in receiving intent") + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/BootAwareReceiver.kt b/presentation/src/main/java/org/cryptomator/presentation/BootAwareReceiver.kt new file mode 100644 index 000000000..b0124eb2d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/BootAwareReceiver.kt @@ -0,0 +1,28 @@ +package org.cryptomator.presentation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import org.cryptomator.presentation.service.CryptorsService +import org.cryptomator.presentation.service.PhotoContentJob +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber + +class BootAwareReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when { + intent.action.equals(Intent.ACTION_SHUTDOWN, ignoreCase = true) -> { + Timber.tag("BootAwareReceiver").i("Starting shutting down CryptorsService") + context.stopService(CryptorsService.lockAllIntent(context)) + } + intent.action.equals(Intent.ACTION_BOOT_COMPLETED, ignoreCase = true) -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && SharedPreferencesHandler(context).usePhotoUpload()) { + Timber.tag("BootAwareReceiver").i("Starting AutoUploadJobScheduler") + PhotoContentJob.scheduleJob(context) + } + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/CacheCleanupTask.kt b/presentation/src/main/java/org/cryptomator/presentation/CacheCleanupTask.kt new file mode 100644 index 000000000..24f508c9c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/CacheCleanupTask.kt @@ -0,0 +1,12 @@ +package org.cryptomator.presentation + +import android.os.AsyncTask +import org.cryptomator.presentation.util.FileUtil + +class CacheCleanupTask(private val fileUtil: FileUtil) : AsyncTask() { + + override fun doInBackground(vararg params: Void) { + fileUtil.cleanup() + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt new file mode 100644 index 000000000..acd019cc0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -0,0 +1,186 @@ +package org.cryptomator.presentation + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.IBinder +import androidx.appcompat.app.AppCompatDelegate +import androidx.multidex.MultiDexApplication +import io.reactivex.plugins.RxJavaPlugins +import org.cryptomator.data.cloud.crypto.Cryptors +import org.cryptomator.data.cloud.crypto.CryptorsModule +import org.cryptomator.data.repository.RepositoryModule +import org.cryptomator.presentation.di.HasComponent +import org.cryptomator.presentation.di.component.ApplicationComponent +import org.cryptomator.presentation.di.component.DaggerApplicationComponent +import org.cryptomator.presentation.di.module.ApplicationModule +import org.cryptomator.presentation.di.module.ThreadModule +import org.cryptomator.presentation.logging.CrashLogging.Companion.setup +import org.cryptomator.presentation.logging.DebugLogger +import org.cryptomator.presentation.logging.ReleaseLogger +import org.cryptomator.presentation.service.AutoUploadService +import org.cryptomator.presentation.service.CryptorsService +import org.cryptomator.util.NoOpActivityLifecycleCallbacks +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger + +class CryptomatorApp : MultiDexApplication(), HasComponent { + + private val appCryptors = Cryptors.Delegating() + private lateinit var applicationComponent: ApplicationComponent + + @Volatile + private var cryptoServiceBinder: CryptorsService.Binder? = null + + @Volatile + private lateinit var autoUploadServiceBinder: AutoUploadService.Binder + + override fun onCreate() { + super.onCreate() + setupLogging() + + val flavor = if (BuildConfig.FLAVOR == "license") "License Edition" else "Google Play Edition" + Timber.tag("App").i("Cryptomator v%s (%d) \"%s\" started on android %s / API%d using a %s", // + BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, flavor, // + Build.VERSION.RELEASE, Build.VERSION.SDK_INT, // + Build.MODEL) + Timber.tag("App").d("appId %s", BuildConfig.APPLICATION_ID) + + launchServices() + initializeInjector() + registerActivityLifecycleCallbacks(serviceNotifier) + AppCompatDelegate.setDefaultNightMode(SharedPreferencesHandler(applicationContext()).screenStyleMode) + cleanupCache() + + RxJavaPlugins.setErrorHandler { e: Throwable? -> Timber.tag("CryptomatorApp").e(e, "BaseErrorHandler detected a problem") } + } + + private fun launchServices() { + try { + startCryptorsService() + } catch (e: IllegalStateException) { + Timber.tag("App").e(e, "Failed to launch cryptors service") + } + try { + startAutoUploadService() + } catch (e: IllegalStateException) { + Timber.tag("App").e(e, "Failed to launch auto upload service") + } + } + + private fun startCryptorsService() { + bindService(Intent(this, CryptorsService::class.java), object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Timber.tag("App").i("Cryptors service connected") + cryptoServiceBinder = service as CryptorsService.Binder + cryptoServiceBinder?.let { + appCryptors.setDelegate(it.cryptors()) + it.setFileUtil(applicationComponent.fileUtil()) + } + updateService() + } + + override fun onServiceDisconnected(name: ComponentName) { + Timber.tag("App").i("Cryptors service disconnected") + cryptoServiceBinder = null + appCryptors.removeDelegate() + } + }, BIND_AUTO_CREATE) + } + + private fun startAutoUploadService() { + bindService(Intent(this, AutoUploadService::class.java), object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Timber.tag("App").i("Auto upload service connected") + autoUploadServiceBinder = service as AutoUploadService.Binder + autoUploadServiceBinder.init( // + applicationComponent.cloudContentRepository(), // + applicationComponent.fileUtil(), // + applicationComponent.contentResolverUtil(), // + Companion.applicationContext) + } + + override fun onServiceDisconnected(name: ComponentName) { + Timber.tag("App").i("Auto upload service disconnected") + } + }, BIND_AUTO_CREATE) + } + + private fun setupLogging() { + setupLoggingFramework() + setup() + } + + private fun initializeInjector() { + applicationComponent = DaggerApplicationComponent.builder() // + .applicationModule(ApplicationModule(this)) // + .threadModule(ThreadModule()) // + .repositoryModule(RepositoryModule()) // + .cryptorsModule(CryptorsModule(appCryptors)) // + .build() + } + + private fun cleanupCache() { + CacheCleanupTask(applicationComponent.fileUtil()).execute() + } + + private fun setupLoggingFramework() { + if (BuildConfig.DEBUG) { + Timber.plant(DebugLogger()) + } + Timber.plant(ReleaseLogger(Companion.applicationContext)) + } + + override fun getComponent(): ApplicationComponent { + return applicationComponent + } + + private val resumedActivities = AtomicInteger(0) + private val serviceNotifier: ActivityLifecycleCallbacks = object : NoOpActivityLifecycleCallbacks() { + override fun onActivityResumed(activity: Activity) { + updateService(resumedActivities.incrementAndGet()) + } + + override fun onActivityPaused(activity: Activity) { + updateService(resumedActivities.decrementAndGet()) + } + } + + private fun updateService(resumedCount: Int = resumedActivities.get()) { + val localServiceBinder = cryptoServiceBinder + if (localServiceBinder == null) { + startCryptorsService() + } else { + localServiceBinder.appInForeground(resumedCount > 0) + } + } + + fun allVaultsLocked(): Boolean { + return appCryptors.isEmpty + } + + fun suspendLock() { + val localServiceBinder = cryptoServiceBinder + localServiceBinder?.suspendLock() + } + + fun unSuspendLock() { + val localServiceBinder = cryptoServiceBinder + localServiceBinder?.unSuspendLock() + } + + companion object { + private lateinit var applicationContext: Context + fun applicationContext(): Context { + return applicationContext + } + } + + init { + Companion.applicationContext = this + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/UIThread.kt b/presentation/src/main/java/org/cryptomator/presentation/UIThread.kt new file mode 100644 index 000000000..5f4b6d69d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/UIThread.kt @@ -0,0 +1,18 @@ +package org.cryptomator.presentation + +import io.reactivex.Scheduler +import io.reactivex.android.schedulers.AndroidSchedulers +import org.cryptomator.domain.executor.PostExecutionThread +import javax.inject.Inject +import javax.inject.Singleton + +/** + * MainThread (UI Thread) implementation based on a [io.reactivex.Scheduler] + * which will execute actions on the Android UI thread + */ +@Singleton +class UIThread @Inject constructor() : PostExecutionThread { + override fun getScheduler(): Scheduler { + return AndroidSchedulers.mainThread() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/HasComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/HasComponent.java new file mode 100755 index 000000000..2edc3955c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/di/HasComponent.java @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.di; + +/** + * Interface representing a contract for clients that contains a component for dependency injection. + */ +public interface HasComponent { + C getComponent(); +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java new file mode 100644 index 000000000..db438ffd1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ActivityComponent.java @@ -0,0 +1,117 @@ +package org.cryptomator.presentation.di.component; + +import android.app.Activity; + +import org.cryptomator.domain.di.PerView; +import org.cryptomator.presentation.di.module.ActivityModule; +import org.cryptomator.presentation.ui.activity.AuthenticateCloudActivity; +import org.cryptomator.presentation.ui.activity.AutoUploadChooseVaultActivity; +import org.cryptomator.presentation.ui.activity.BrowseFilesActivity; +import org.cryptomator.presentation.ui.activity.ChooseCloudServiceActivity; +import org.cryptomator.presentation.ui.activity.CloudConnectionListActivity; +import org.cryptomator.presentation.ui.activity.CloudSettingsActivity; +import org.cryptomator.presentation.ui.activity.CreateVaultActivity; +import org.cryptomator.presentation.ui.activity.EmptyDirIdFileInfoActivity; +import org.cryptomator.presentation.ui.activity.BiometricAuthSettingsActivity; +import org.cryptomator.presentation.ui.activity.ImagePreviewActivity; +import org.cryptomator.presentation.ui.activity.LicensesActivity; +import org.cryptomator.presentation.ui.activity.SetPasswordActivity; +import org.cryptomator.presentation.ui.activity.SettingsActivity; +import org.cryptomator.presentation.ui.activity.SharedFilesActivity; +import org.cryptomator.presentation.ui.activity.SplashActivity; +import org.cryptomator.presentation.ui.activity.TextEditorActivity; +import org.cryptomator.presentation.ui.activity.LicenseCheckActivity; +import org.cryptomator.presentation.ui.activity.VaultListActivity; +import org.cryptomator.presentation.ui.activity.WebDavAddOrChangeActivity; +import org.cryptomator.presentation.ui.fragment.AutoUploadChooseVaultFragment; +import org.cryptomator.presentation.ui.fragment.BrowseFilesFragment; +import org.cryptomator.presentation.ui.fragment.ChooseCloudServiceFragment; +import org.cryptomator.presentation.ui.fragment.CloudConnectionListFragment; +import org.cryptomator.presentation.ui.fragment.CloudSettingsFragment; +import org.cryptomator.presentation.ui.fragment.EmptyDirIdFileInfoFragment; +import org.cryptomator.presentation.ui.fragment.BiometricAuthSettingsFragment; +import org.cryptomator.presentation.ui.fragment.ImagePreviewFragment; +import org.cryptomator.presentation.ui.fragment.SetPasswordFragment; +import org.cryptomator.presentation.ui.fragment.SharedFilesFragment; +import org.cryptomator.presentation.ui.fragment.TextEditorFragment; +import org.cryptomator.presentation.ui.fragment.VaultListFragment; +import org.cryptomator.presentation.ui.fragment.WebDavAddOrChangeFragment; +import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow; +import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow; + +import dagger.Component; + +@PerView +@Component(dependencies = ApplicationComponent.class, modules = ActivityModule.class) +public interface ActivityComponent { + + Activity activity(); + + void inject(SplashActivity splashActivity); + + void inject(VaultListActivity vaultListActivity); + + void inject(SetPasswordActivity setPasswordActivity); + + void inject(CreateVaultActivity createVaultActivity); + + void inject(CloudSettingsActivity cloudSettingsActivity); + + void inject(BrowseFilesActivity browseFilesActivity); + + void inject(ChooseCloudServiceActivity chooseCloudServiceActivity); + + void inject(SettingsActivity settingsActivity); + + void inject(LicensesActivity licensesActivity); + + void inject(VaultListFragment vaultListFragment); + + void inject(SetPasswordFragment setPasswordFragment); + + void inject(CloudSettingsFragment cloudSettingsFragment); + + void inject(BrowseFilesFragment browseFilesFragment); + + void inject(ChooseCloudServiceFragment chooseCloudServiceFragment); + + void inject(SharedFilesActivity sharedFilesActivity); + + void inject(SharedFilesFragment sharedFilesFragment); + + void inject(AddExistingVaultWorkflow addExistingVaultWorkflow); + + void inject(CreateNewVaultWorkflow createNewVaultWorkflow); + + void inject(WebDavAddOrChangeActivity webDavAddOrChangeActivity); + + void inject(WebDavAddOrChangeFragment webdavAddOrChangeFragment); + + void inject(CloudConnectionListFragment webDavConnectionListFragment); + + void inject(CloudConnectionListActivity cloudConnectionListActivity); + + void inject(EmptyDirIdFileInfoActivity emptyDirIdFileInfoActivity); + + void inject(EmptyDirIdFileInfoFragment emptyDirIdFileInfoFragment); + + void inject(BiometricAuthSettingsActivity biometricAuthSettingsActivity); + + void inject(BiometricAuthSettingsFragment biometricAuthSettingsFragment); + + void inject(TextEditorActivity textEditorActivity); + + void inject(TextEditorFragment textEditorFragment); + + void inject(AuthenticateCloudActivity authenticateCloudActivity); + + void inject(ImagePreviewActivity imagePreviewActivity); + + void inject(ImagePreviewFragment imagePreviewFragment); + + void inject(AutoUploadChooseVaultActivity autoUploadChooseVaultActivity); + + void inject(AutoUploadChooseVaultFragment autoUploadChooseVaultFragment); + + void inject(LicenseCheckActivity licenseCheckActivity); +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java new file mode 100644 index 000000000..19b3c96c9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java @@ -0,0 +1,47 @@ +package org.cryptomator.presentation.di.component; + +import android.content.Context; + +import org.cryptomator.data.cloud.crypto.CryptorsModule; +import org.cryptomator.data.repository.RepositoryModule; +import org.cryptomator.data.util.NetworkConnectionCheck; +import org.cryptomator.domain.executor.PostExecutionThread; +import org.cryptomator.domain.executor.ThreadExecutor; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.repository.UpdateCheckRepository; +import org.cryptomator.domain.repository.VaultRepository; +import org.cryptomator.presentation.di.module.ApplicationModule; +import org.cryptomator.presentation.di.module.ThreadModule; +import org.cryptomator.presentation.util.ContentResolverUtil; +import org.cryptomator.presentation.util.FileUtil; + +import javax.inject.Singleton; + +import dagger.Component; + +@Singleton +@Component(modules = {ApplicationModule.class, ThreadModule.class, RepositoryModule.class, CryptorsModule.class}) +public interface ApplicationComponent { + + Context context(); + + ThreadExecutor threadExecutor(); + + PostExecutionThread postExecutionThread(); + + VaultRepository vaultRepository(); + + CloudContentRepository cloudContentRepository(); + + CloudRepository cloudRepository(); + + UpdateCheckRepository updateCheckRepository(); + + FileUtil fileUtil(); + + ContentResolverUtil contentResolverUtil(); + + NetworkConnectionCheck networkConnectionCheck(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/module/ActivityModule.java b/presentation/src/main/java/org/cryptomator/presentation/di/module/ActivityModule.java new file mode 100644 index 000000000..3c88c6289 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/di/module/ActivityModule.java @@ -0,0 +1,24 @@ + +package org.cryptomator.presentation.di.module; + +import android.app.Activity; + +import org.cryptomator.domain.di.PerView; + +import dagger.Module; +import dagger.Provides; + +@Module +public class ActivityModule { + private final Activity activity; + + public ActivityModule(Activity activity) { + this.activity = activity; + } + + @Provides + @PerView + Activity activity() { + return this.activity; + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/module/ApplicationModule.java b/presentation/src/main/java/org/cryptomator/presentation/di/module/ApplicationModule.java new file mode 100644 index 000000000..b2441af59 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/di/module/ApplicationModule.java @@ -0,0 +1,26 @@ +package org.cryptomator.presentation.di.module; + +import android.content.Context; + +import org.cryptomator.presentation.CryptomatorApp; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class ApplicationModule { + + private final CryptomatorApp application; + + public ApplicationModule(CryptomatorApp application) { + this.application = application; + } + + @Provides + @Singleton + Context provideApplicationContext() { + return application; + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/di/module/ThreadModule.java b/presentation/src/main/java/org/cryptomator/presentation/di/module/ThreadModule.java new file mode 100644 index 000000000..47a4816f0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/di/module/ThreadModule.java @@ -0,0 +1,27 @@ +package org.cryptomator.presentation.di.module; + +import org.cryptomator.data.executor.JobExecutor; +import org.cryptomator.domain.executor.PostExecutionThread; +import org.cryptomator.domain.executor.ThreadExecutor; +import org.cryptomator.presentation.UIThread; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class ThreadModule { + + @Provides + @Singleton + ThreadExecutor provideThreadExecutor(JobExecutor jobExecutor) { + return jobExecutor; + } + + @Provides + @Singleton + PostExecutionThread providePostExecutionThread(UIThread uiThread) { + return uiThread; + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/CancellationExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/CancellationExceptionHandler.kt new file mode 100644 index 000000000..dd36ccb9b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/CancellationExceptionHandler.kt @@ -0,0 +1,21 @@ +package org.cryptomator.presentation.exception + +import org.cryptomator.domain.exception.CancellationException +import org.cryptomator.presentation.ui.activity.view.View +import org.cryptomator.util.ExceptionUtil +import timber.log.Timber + +class CancellationExceptionHandler : ExceptionHandler() { + + public override fun supports(e: Throwable): Boolean { + return ExceptionUtil.contains(e, CancellationException::class.java) + } + + override fun log(e: Throwable) { + Timber.tag("ExceptionHandler").v(e, "Ignored CancellationException") + } + + override fun doHandle(view: View, e: Throwable) { + // completely ignore + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/DefaultExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/DefaultExceptionHandler.kt new file mode 100644 index 000000000..e0caebbdc --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/DefaultExceptionHandler.kt @@ -0,0 +1,27 @@ +package org.cryptomator.presentation.exception + +import android.content.Context +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.R +import org.cryptomator.presentation.ui.activity.view.View +import timber.log.Timber +import javax.inject.Inject + +@PerView +class DefaultExceptionHandler @Inject constructor(context: Context) : ExceptionHandler() { + + private val defaultMessage: String = context.getString(R.string.error_generic) + + override fun supports(e: Throwable): Boolean { + return true + } + + override fun log(e: Throwable) { + Timber.tag("ExceptionHandler").e(e) + } + + override fun doHandle(view: View, e: Throwable) { + view.showError(defaultMessage) + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandler.kt new file mode 100644 index 000000000..4122a4759 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandler.kt @@ -0,0 +1,24 @@ +package org.cryptomator.presentation.exception + +import org.cryptomator.presentation.ui.activity.view.View +import timber.log.Timber + +abstract class ExceptionHandler { + + protected abstract fun supports(e: Throwable): Boolean + protected abstract fun doHandle(view: View, e: Throwable) + + fun handle(view: View, e: Throwable): Boolean { + return if (supports(e)) { + log(e) + doHandle(view, e) + true + } else { + false + } + } + + protected open fun log(e: Throwable) { + Timber.tag("ExceptionHandler").d(e) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandlers.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandlers.kt new file mode 100644 index 000000000..33d72964f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/ExceptionHandlers.kt @@ -0,0 +1,80 @@ +package org.cryptomator.presentation.exception + +import android.content.ActivityNotFoundException +import android.content.Context +import org.cryptomator.cryptolib.api.InvalidPassphraseException +import org.cryptomator.cryptolib.api.UnsupportedVaultFormatException +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.exception.* +import org.cryptomator.domain.exception.authentication.AuthenticationException +import org.cryptomator.domain.exception.license.LicenseNotValidException +import org.cryptomator.domain.exception.license.NoLicenseAvailableException +import org.cryptomator.domain.exception.update.GeneralUpdateErrorException +import org.cryptomator.domain.exception.update.SSLHandshakePreAndroid5UpdateCheckException +import org.cryptomator.presentation.R +import org.cryptomator.presentation.ui.activity.view.View +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +@PerView +class ExceptionHandlers @Inject constructor(private val context: Context, defaultExceptionHandler: DefaultExceptionHandler) : Iterable { + + private val exceptionHandlers: MutableList = ArrayList() + private val defaultExceptionHandler: ExceptionHandler + + private fun setupHandlers() { + staticHandler(AuthenticationException::class.java, R.string.error_authentication_failed) + staticHandler(NetworkConnectionException::class.java, R.string.error_no_network_connection) + staticHandler(InvalidPassphraseException::class.java, R.string.error_invalid_passphrase) + staticHandler(CloudNodeAlreadyExistsException::class.java, R.string.error_file_or_folder_exists) + staticHandler(UnsupportedVaultFormatException::class.java, R.string.error_vault_version_not_supported) + staticHandler(VaultAlreadyExistException::class.java, R.string.error_vault_already_exists) + staticHandler(ActivityNotFoundException::class.java, R.string.error_activity_not_found) + staticHandler(CloudAlreadyExistsException::class.java, R.string.error_cloud_already_exists) + staticHandler(NoSuchCloudFileException::class.java, R.string.error_no_such_file) + staticHandler(IllegalFileNameException::class.java, R.string.error_export_illegal_file_name) + staticHandler(UnableToDecryptWebdavPasswordException::class.java, R.string.error_failed_to_decrypt_webdav_password) + staticHandler(LicenseNotValidException::class.java, R.string.dialog_enter_license_not_valid_content) + staticHandler(NoLicenseAvailableException::class.java, R.string.dialog_enter_license_no_content) + staticHandler(GeneralUpdateErrorException::class.java, R.string.error_general_update) + staticHandler(SSLHandshakePreAndroid5UpdateCheckException::class.java, R.string.error_general_update) + exceptionHandlers.add(MissingCryptorExceptionHandler()) + exceptionHandlers.add(CancellationExceptionHandler()) + exceptionHandlers.add(NoSuchVaultExceptionHandler()) + exceptionHandlers.add(PermissionNotGrantedExceptionHandler()) + } + + fun handle(view: View, e: Throwable) { + Timber.tag("ExceptionHandler").d(e, "Unexpected error") + for (mapping in this) { + if (mapping.handle(view, e)) { + return + } + } + defaultExceptionHandler.handle(view, e) + } + + private fun staticHandler(type: Class, messageId: Int) { + staticHandler(type, context.getString(messageId)) + } + + private fun staticHandler(type: Class, message: String) { + exceptionHandlers.add(object : MessageExceptionHandler(type) { + override fun toMessage(e: T?): String { + return if (e?.message?.isNotEmpty() == true) { + String.format(message, e.message) + } else message + } + }) + } + + override fun iterator(): MutableIterator { + return Collections.unmodifiableCollection(exceptionHandlers).iterator() + } + + init { + this.defaultExceptionHandler = defaultExceptionHandler + setupHandlers() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/IllegalFileNameException.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/IllegalFileNameException.kt new file mode 100644 index 000000000..905a7e006 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/IllegalFileNameException.kt @@ -0,0 +1,3 @@ +package org.cryptomator.presentation.exception + +class IllegalFileNameException : Exception() diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/MessageExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/MessageExceptionHandler.kt new file mode 100644 index 000000000..36fb97323 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/MessageExceptionHandler.kt @@ -0,0 +1,17 @@ +package org.cryptomator.presentation.exception + +import org.cryptomator.presentation.ui.activity.view.View + +internal abstract class MessageExceptionHandler(private val type: Class) : ExceptionHandler() { + + override fun supports(e: Throwable): Boolean { + return type.isInstance(e) + } + + override fun doHandle(view: View, e: Throwable) { + view.showError(toMessage(type.cast(e))) + } + + abstract fun toMessage(e: T?): String + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/MissingCryptorExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/MissingCryptorExceptionHandler.kt new file mode 100644 index 000000000..3d7945799 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/MissingCryptorExceptionHandler.kt @@ -0,0 +1,21 @@ +package org.cryptomator.presentation.exception + +import org.cryptomator.domain.exception.MissingCryptorException +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.ui.activity.view.View +import org.cryptomator.util.ExceptionUtil + +class MissingCryptorExceptionHandler : ExceptionHandler() { + + public override fun supports(e: Throwable): Boolean { + return ExceptionUtil.contains(e, MissingCryptorException::class.java) + } + + public override fun doHandle(view: View, e: Throwable) { + view.showMessage(R.string.error_vault_has_been_locked) + Intents.vaultListIntent() // + .preventGoingBackInHistory() // + .startActivity(view) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/NoSuchVaultExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/NoSuchVaultExceptionHandler.kt new file mode 100644 index 000000000..65eede96d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/NoSuchVaultExceptionHandler.kt @@ -0,0 +1,18 @@ +package org.cryptomator.presentation.exception + +import org.cryptomator.domain.exception.NoSuchVaultException +import org.cryptomator.presentation.ui.activity.view.View +import org.cryptomator.presentation.ui.dialog.VaultNotFoundDialog.Companion.withContext +import org.cryptomator.util.ExceptionUtil + +class NoSuchVaultExceptionHandler : ExceptionHandler() { + override fun supports(e: Throwable): Boolean { + return ExceptionUtil.contains(e, NoSuchVaultException::class.java) + } + + override fun doHandle(view: View, e: Throwable) { + view.closeDialog() + val vault = ExceptionUtil.extract(e, NoSuchVaultException::class.java).get().vault + withContext(view.activity()).show(vault) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedException.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedException.kt new file mode 100644 index 000000000..45541991d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedException.kt @@ -0,0 +1,3 @@ +package org.cryptomator.presentation.exception + +class PermissionNotGrantedException(val snackbarText: Int) : RuntimeException() diff --git a/presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedExceptionHandler.kt b/presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedExceptionHandler.kt new file mode 100644 index 000000000..3a3800346 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/exception/PermissionNotGrantedExceptionHandler.kt @@ -0,0 +1,17 @@ +package org.cryptomator.presentation.exception + +import org.cryptomator.presentation.ui.activity.view.View +import org.cryptomator.presentation.ui.snackbar.AppSettingsAction +import org.cryptomator.util.ExceptionUtil + +class PermissionNotGrantedExceptionHandler : ExceptionHandler() { + + override fun supports(e: Throwable): Boolean { + return ExceptionUtil.contains(e, PermissionNotGrantedException::class.java) + } + + override fun doHandle(view: View, e: Throwable) { + val extract = ExceptionUtil.extract(e, PermissionNotGrantedException::class.java).get() + view.showSnackbar(extract.snackbarText, AppSettingsAction(view.context())) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/AuthenticateCloudIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/AuthenticateCloudIntent.java new file mode 100644 index 000000000..499b885c2 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/AuthenticateCloudIntent.java @@ -0,0 +1,20 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.domain.exception.authentication.AuthenticationException; +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; +import org.cryptomator.presentation.model.CloudModel; +import org.cryptomator.presentation.ui.activity.AuthenticateCloudActivity; + +@Intent(AuthenticateCloudActivity.class) +public interface AuthenticateCloudIntent { + + CloudModel cloud(); + + @Optional + AuthenticationException error(); + + @Optional + android.content.Intent recoveryAction(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java new file mode 100644 index 000000000..a38712995 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/BrowseFilesIntent.java @@ -0,0 +1,19 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; +import org.cryptomator.presentation.model.CloudFolderModel; +import org.cryptomator.presentation.ui.activity.BrowseFilesActivity; + +@Intent(BrowseFilesActivity.class) +public interface BrowseFilesIntent { + + CloudFolderModel folder(); + + @Optional + String title(); + + @Optional + ChooseCloudNodeSettings chooseCloudNodeSettings(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudNodeSettings.java b/presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudNodeSettings.java new file mode 100644 index 000000000..721d8a9e0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudNodeSettings.java @@ -0,0 +1,199 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.presentation.model.CloudFolderModel; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import androidx.annotation.Nullable; + +import static org.cryptomator.presentation.intent.ChooseCloudNodeSettings.SelectionMode.FILES_ONLY; +import static org.cryptomator.presentation.intent.ChooseCloudNodeSettings.SelectionMode.FOLDERS_ONLY; + +public class ChooseCloudNodeSettings implements Serializable { + + private static final Pattern ANY_NAME = Pattern.compile(".*"); + private static final Pattern NO_NAME = Pattern.compile(""); + public static final int NO_ICON = -1; + + private final String extraTitle; + private final String extraText; + private final String buttonText; + + private final SelectionMode selectionMode; + private final Pattern namePattern; + private final Pattern excludeFoldersContainingNamePattern; + private final int extraToolbarIcon; + private final NavigationMode navigationMode; + private final List excludeFolders; + private final List excludeFolderContainingNames; + + private ChooseCloudNodeSettings(Builder builder) { + this.extraTitle = builder.extraTitle; + this.extraText = builder.extraText; + this.buttonText = builder.buttonText; + this.namePattern = builder.namePattern; + this.selectionMode = builder.selectionMode; + this.excludeFolderContainingNames = builder.excludeFolderContainingNames; + this.excludeFoldersContainingNamePattern = builder.excludeFoldersContainingNamePattern; + this.excludeFolders = builder.excludeFolders; + this.extraToolbarIcon = builder.extraToolbarIcon; + this.navigationMode = builder.navigationMode; + } + + @Nullable + public String extraTitle() { + return extraTitle; + } + + public String extraText() { + return extraText; + } + + @Nullable + public String buttonText() { + return buttonText; + } + + public Pattern namePattern() { + return namePattern; + } + + public List getExcludeFolderContainingNames() { + return excludeFolderContainingNames; + } + + public Pattern excludeFoldersContainingNamePattern() { + return excludeFoldersContainingNamePattern; + } + + public boolean excludeFolder(CloudFolderModel cloudFolder) { + return excludeFolders != null && excludeFolders.contains(cloudFolder); + } + + public int extraToolbarIcon() { + return extraToolbarIcon; + } + + public SelectionMode selectionMode() { + return selectionMode; + } + + public NavigationMode navigationMode() { + return navigationMode; + } + + public static Builder chooseCloudNodeSettings() { + return new Builder(); + } + + public static class Builder { + + private String extraTitle; + private String extraText; + private String buttonText; + private SelectionMode selectionMode; + private Pattern namePattern = ANY_NAME; + private final Pattern excludeFoldersContainingNamePattern = NO_NAME; + private int extraToolbarIcon = NO_ICON; + private NavigationMode navigationMode = NavigationMode.BROWSE_FILES; + private List excludeFolders; + private List excludeFolderContainingNames = new ArrayList<>(); + + public Builder withExtraTitle(String extraTitle) { + if (extraTitle == null) { + throw new IllegalArgumentException(); + } + this.extraTitle = extraTitle; + return this; + } + + public Builder withExtraText(String extraText) { + if (extraText == null) { + throw new IllegalArgumentException(); + } + this.extraText = extraText; + return this; + } + + public Builder withButtonText(String buttonText) { + if (buttonText == null) { + throw new IllegalArgumentException(); + } + this.buttonText = buttonText; + return this; + } + + public Builder withExtraToolbarIcon(int extraToolbarIcon) { + this.extraToolbarIcon = extraToolbarIcon; + return this; + } + + public Builder withNavigationMode(NavigationMode navigationMode) { + this.navigationMode = navigationMode; + return this; + } + + public Builder selectingFilesWithNameOnly(String name) { + this.selectionMode = FILES_ONLY; + this.namePattern = Pattern.compile(Pattern.quote(name)); + return this; + } + + public Builder selectingFoldersNotContaining(List names) { + this.selectionMode = FOLDERS_ONLY; + this.excludeFolderContainingNames = names; + return this; + } + + public Builder excludingFolder(List cloudFolders) { + this.excludeFolders = cloudFolders; + return this; + } + + public Builder selectingFolders() { + this.selectionMode = FOLDERS_ONLY; + return this; + } + + public ChooseCloudNodeSettings build() { + validate(); + return new ChooseCloudNodeSettings(this); + } + + private void validate() { + if (selectionMode == null) { + throw new IllegalStateException("selectionMode is required"); + } + } + + } + + public enum SelectionMode { + FILES_ONLY(true, true), FOLDERS_ONLY(false, true); + + private final boolean allowsFolders; + private final boolean allowsFiles; + + SelectionMode(boolean allowsFiles, boolean allowsFolders) { + this.allowsFiles = allowsFiles; + this.allowsFolders = allowsFolders; + } + + public boolean allowsFolders() { + return allowsFolders; + } + + public boolean allowsFiles() { + return allowsFiles; + } + + } + + public enum NavigationMode { + BROWSE_FILES, MOVE_CLOUD_NODE, SELECT_ITEMS + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudServiceIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudServiceIntent.java new file mode 100644 index 000000000..dee34d757 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/ChooseCloudServiceIntent.java @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.ChooseCloudServiceActivity; + +@Intent(ChooseCloudServiceActivity.class) +public interface ChooseCloudServiceIntent { + + String CHOSEN_CLOUD_SERVICE = "chosenCloudService"; + + String subtitle(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/CloudConnectionListIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/CloudConnectionListIntent.java new file mode 100644 index 000000000..1d208e804 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/CloudConnectionListIntent.java @@ -0,0 +1,16 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.model.CloudTypeModel; +import org.cryptomator.presentation.ui.activity.CloudConnectionListActivity; + +@Intent(CloudConnectionListActivity.class) +public interface CloudConnectionListIntent { + + CloudTypeModel cloudType(); + + String dialogTitle(); + + Boolean finishOnCloudItemClick(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/CloudSettingsIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/CloudSettingsIntent.java new file mode 100644 index 000000000..4533c5996 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/CloudSettingsIntent.java @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.CloudSettingsActivity; + +@Intent(CloudSettingsActivity.class) +public interface CloudSettingsIntent { +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/CreateVaultIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/CreateVaultIntent.java new file mode 100644 index 000000000..3aed617af --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/CreateVaultIntent.java @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.CreateVaultActivity; + +@Intent(CreateVaultActivity.class) +public interface CreateVaultIntent { + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/EmptyDirIdFileInfoIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/EmptyDirIdFileInfoIntent.java new file mode 100644 index 000000000..e5ec6bd5d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/EmptyDirIdFileInfoIntent.java @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.EmptyDirIdFileInfoActivity; + +@Intent(EmptyDirIdFileInfoActivity.class) +public interface EmptyDirIdFileInfoIntent { + + String dirName(); + + String dirFilePath(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/ImagePreviewIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/ImagePreviewIntent.java new file mode 100644 index 000000000..cf03597b8 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/ImagePreviewIntent.java @@ -0,0 +1,11 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.ImagePreviewActivity; + +@Intent(ImagePreviewActivity.class) +public interface ImagePreviewIntent { + + String withImagePreviewFiles(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/IntentBuilder.java b/presentation/src/main/java/org/cryptomator/presentation/intent/IntentBuilder.java new file mode 100644 index 000000000..57a02bbf6 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/IntentBuilder.java @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.intent; + +import android.content.Intent; + +import org.cryptomator.presentation.presenter.ContextHolder; + +public interface IntentBuilder { + + void startActivity(ContextHolder contextHolder); + + Intent build(ContextHolder contextHolder); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/SetPasswordIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/SetPasswordIntent.java new file mode 100644 index 000000000..d38d36bcb --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/SetPasswordIntent.java @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.SetPasswordActivity; + +@Intent(SetPasswordActivity.class) +public interface SetPasswordIntent { + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/SettingsIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/SettingsIntent.java new file mode 100644 index 000000000..b9f296ba1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/SettingsIntent.java @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.ui.activity.SettingsActivity; + +@Intent(SettingsActivity.class) +public interface SettingsIntent { +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java new file mode 100644 index 000000000..14b654af6 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/TextEditorIntent.java @@ -0,0 +1,12 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.presentation.model.CloudFileModel; +import org.cryptomator.presentation.ui.activity.TextEditorActivity; + +@Intent(TextEditorActivity.class) +public interface TextEditorIntent { + + CloudFileModel textFile(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/VaultListIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/VaultListIntent.java new file mode 100644 index 000000000..384883d24 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/VaultListIntent.java @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; +import org.cryptomator.presentation.ui.activity.VaultListActivity; + +@Intent(VaultListActivity.class) +public interface VaultListIntent { + + @Optional + Boolean stopEditFileNotification(); + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/intent/WebDavAddOrChangeIntent.java b/presentation/src/main/java/org/cryptomator/presentation/intent/WebDavAddOrChangeIntent.java new file mode 100644 index 000000000..55f1f5162 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/intent/WebDavAddOrChangeIntent.java @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.intent; + +import org.cryptomator.generator.Intent; +import org.cryptomator.generator.Optional; +import org.cryptomator.presentation.model.WebDavCloudModel; +import org.cryptomator.presentation.ui.activity.WebDavAddOrChangeActivity; + +@Intent(WebDavAddOrChangeActivity.class) +public interface WebDavAddOrChangeIntent { + + @Optional + WebDavCloudModel webDavCloud(); +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/CrashLogging.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/CrashLogging.kt new file mode 100644 index 000000000..1cba63046 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/CrashLogging.kt @@ -0,0 +1,17 @@ +package org.cryptomator.presentation.logging + +import timber.log.Timber + +class CrashLogging private constructor(private val systemUncaughtExceptionHandler: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(t: Thread, e: Throwable) { + Timber.tag("CrashLogging").e(e) + systemUncaughtExceptionHandler?.uncaughtException(t, e) + } + + companion object { + fun setup() { + Thread.setDefaultUncaughtExceptionHandler(CrashLogging(Thread.getDefaultUncaughtExceptionHandler())) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/DebugLogger.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/DebugLogger.kt new file mode 100644 index 000000000..0a7fb5708 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/DebugLogger.kt @@ -0,0 +1,18 @@ +package org.cryptomator.presentation.logging + +import timber.log.Timber.DebugTree + +class DebugLogger : DebugTree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val loggingMessage = if (t != null) { + """ + $message + ErrorCode: ${GeneratedErrorCode.of(t)} + """.trimIndent() + } else { + message + } + + super.log(priority, tag, loggingMessage, t) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/FormattedTime.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/FormattedTime.kt new file mode 100644 index 000000000..cc61c1206 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/FormattedTime.kt @@ -0,0 +1,32 @@ +package org.cryptomator.presentation.logging + +import java.text.SimpleDateFormat +import java.util.* + +internal class FormattedTime private constructor(private val timestamp: Long) { + + @Volatile + private var formatted: String? = null + + override fun toString(): String { + if (formatted == null) { + formatted = SimpleDateFormat(FORMAT, Locale.getDefault()).format(Date(timestamp)) + } + return formatted!! + } + + companion object { + private const val FORMAT = "yyyyMMddHHmmss.SSS" + + @Volatile + private var now = FormattedTime(0) + fun now(): FormattedTime { + var localNow = now + if (localNow.timestamp < System.currentTimeMillis()) { + localNow = FormattedTime(System.currentTimeMillis()) + now = localNow + } + return localNow + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/GeneratedErrorCode.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/GeneratedErrorCode.kt new file mode 100644 index 000000000..9f6540383 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/GeneratedErrorCode.kt @@ -0,0 +1,51 @@ +package org.cryptomator.presentation.logging + +import java.util.* + +internal object GeneratedErrorCode { + + private const val A_PRIME = Int.MAX_VALUE + + fun of(e: Throwable): String { + return format(originCode(rootCause(e))) + ':' + format(traceCode(e)) + } + + private fun rootCause(e: Throwable): Throwable? { + return if (e.cause == null) { + e + } else { + e.cause + } + } + + private fun format(code: Int): String { + var value = code + value = value and 0xfffff xor (value ushr 20) + value = value or 0x100000 + return value.toString(32).substring(1).toUpperCase(Locale.getDefault()) + } + + private fun traceCode(throwable: Throwable?): Int { + var e: Throwable? = throwable + var result = -0x596764e6 + while (e != null) { + result = result * A_PRIME + originCode(e) + e.stackTrace.forEach { element -> + result = result * A_PRIME + element.className.hashCode() + result = result * A_PRIME + element.methodName.hashCode() + } + e = e.cause + } + return result + } + + private fun originCode(e: Throwable?, stack: Array? = e?.stackTrace): Int { + var result = 0x6c528c4a + result = result * A_PRIME + e?.javaClass?.name.hashCode() + if (stack?.isNotEmpty() == true) { + result = result * A_PRIME + stack[0].className.hashCode() + result = result * A_PRIME + stack[0].methodName.hashCode() + } + return result + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/LogRotator.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/LogRotator.kt new file mode 100644 index 000000000..bc24a2026 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/LogRotator.kt @@ -0,0 +1,99 @@ +package org.cryptomator.presentation.logging + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.PrintWriter + +internal class LogRotator(context: Context) { + + private val context: Context + private val logfiles: List + + @Volatile + private var logfile: Logfile + + private fun createLogDirIfMissing() { + val logsDir = Logfiles.logsDir(context) + check(!(!logsDir.exists() && !logsDir.mkdirs())) { "Creating logs dir failed" } + } + + private fun indexOfNewestLogfile(): Int { + var index = 0 + var newestLastModified = 0L + logfiles.indices.forEach { i -> + val lastModified = logfiles[i].lastModified() + if (lastModified > newestLastModified) { + index = i + newestLastModified = lastModified + } + } + return index + } + + fun log(message: String?) { + logfile().log(message) + } + + private fun logfile(): Logfile { + // correct and fast double checked locking approach + var localLogfile = logfile + if (localLogfile.mustBeRotated()) { + synchronized(this) { + localLogfile = logfile + if (localLogfile.mustBeRotated()) { + logfile = localLogfile.next() + localLogfile = logfile + } + } + } + return localLogfile + } + + private inner class Logfile(index: Int, deleteIfPresent: Boolean = false) { + + private val index: Int = index % logfiles.size + + private val writer: PrintWriter + + private lateinit var measuringOutputStream: SizeMeasuringOutputStream + + fun mustBeRotated(): Boolean { + return measuringOutputStream.size() > Logfiles.ROTATION_FILE_SIZE + } + + private fun open(logfile: File, deleteIfPresent: Boolean): PrintWriter { + return try { + if (deleteIfPresent && logfile.exists()) { + check(logfile.delete()) { "Failed to delete log file" } + } + measuringOutputStream = SizeMeasuringOutputStream(FileOutputStream(logfile, true)) + PrintWriter(measuringOutputStream, true) + } catch (e: IOException) { + throw IllegalStateException("Opening ", e) + } + } + + operator fun next(): Logfile { + writer.close() + return Logfile(index + 1, true) + } + + fun log(message: String?) { + writer.println(message) + } + + init { + writer = open(logfiles[this.index], deleteIfPresent) + } + } + + init { + check(Logfiles.NUMBER_OF_LOGFILES >= 2) { "LogRotator needs at least two logfiles" } + this.context = context + createLogDirIfMissing() + logfiles = Logfiles.logfiles(context) + logfile = Logfile(indexOfNewestLogfile()) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/Logfiles.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/Logfiles.kt new file mode 100644 index 000000000..065ac882f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/Logfiles.kt @@ -0,0 +1,42 @@ +package org.cryptomator.presentation.logging + +import android.content.Context +import java.io.File +import java.util.* + +object Logfiles { + + const val NUMBER_OF_LOGFILES = 2 + + /** + * Maximum size of all logfiles + */ + private const val MAX_LOGS_SIZE = (1 shl 20 // 1 MiB + .toLong().toInt()).toLong() + + /** + * When this size is reached a logfile is rotated + */ + const val ROTATION_FILE_SIZE = MAX_LOGS_SIZE / NUMBER_OF_LOGFILES + + @JvmStatic + fun logfiles(context: Context): List { + return (0 until NUMBER_OF_LOGFILES).mapTo(ArrayList(NUMBER_OF_LOGFILES)) { logfile(context, it) } + } + + fun logsDir(context: Context): File { + return File(context.cacheDir, "logs") + } + + fun existingLogfiles(context: Context): List { + return logfiles(context).filterTo(ArrayList(NUMBER_OF_LOGFILES)) { it.exists() } + } + + private fun logfile(context: Context, index: Int): File { + return File(logsDir(context), logfileName(index)) + } + + private fun logfileName(index: Int): String { + return "$index.txt" + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/ReleaseLogger.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/ReleaseLogger.kt new file mode 100644 index 000000000..e89a6833b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/ReleaseLogger.kt @@ -0,0 +1,61 @@ +package org.cryptomator.presentation.logging + +import android.content.Context +import android.util.Log +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber + +class ReleaseLogger(context: Context) : Timber.Tree() { + + private val priorityNames = charArrayOf( // + '?', // + '?', // + 'V', // + 'D', // + 'I', // + 'W', // + 'E', // + 'A' // + ) + private val log: LogRotator + + override fun isLoggable(tag: String?, priority: Int): Boolean { + return debugMode || priority >= LOG_LEVEL_WHEN_DEBUG_IS_DISABLED + } + + override fun log(priority: Int, tag: String?, message: String, throwable: Throwable?) { + val line = StringBuilder() + line // + .append(priorityNames[validPriority(priority)]).append('\t') // + .append(FormattedTime.now()).append('\t') // + .append(tag ?: "App").append('\t') // + .append(message) + if (throwable != null) { + line.append("\nErrorCode: ").append(GeneratedErrorCode.of(throwable)) + } + log.log(line.toString()) + } + + private fun validPriority(priority: Int): Int { + return if (priority in 1..7) { + priority + } else 0 + } + + companion object { + private const val LOG_LEVEL_WHEN_DEBUG_IS_DISABLED = Log.INFO + + @Volatile + private var debugMode: Boolean = false + + fun updateDebugMode(debugMode: Boolean) { + Companion.debugMode = debugMode + Timber.tag("Logging").i(if (debugMode) "Debug mode enabled" else "Debug mode disabled") + } + } + + init { + debugMode = SharedPreferencesHandler(context).debugMode() + log = LogRotator(context) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/logging/SizeMeasuringOutputStream.kt b/presentation/src/main/java/org/cryptomator/presentation/logging/SizeMeasuringOutputStream.kt new file mode 100644 index 000000000..79732fb55 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/logging/SizeMeasuringOutputStream.kt @@ -0,0 +1,42 @@ +package org.cryptomator.presentation.logging + +import java.io.IOException +import java.io.OutputStream +import java.util.concurrent.atomic.AtomicLong + +internal class SizeMeasuringOutputStream(private val delegate: OutputStream) : OutputStream() { + + private val size = AtomicLong(0) + + fun size(): Long { + return size.get() + } + + @Throws(IOException::class) + override fun write(b: Int) { + delegate.write(b) + size.incrementAndGet() + } + + @Throws(IOException::class) + override fun write(b: ByteArray) { + delegate.write(b) + size.addAndGet(b.size.toLong()) + } + + @Throws(IOException::class) + override fun write(b: ByteArray, off: Int, len: Int) { + delegate.write(b, off, len) + size.addAndGet(len.toLong()) + } + + @Throws(IOException::class) + override fun flush() { + delegate.flush() + } + + @Throws(IOException::class) + override fun close() { + delegate.close() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/AutoUploadFilesStore.kt b/presentation/src/main/java/org/cryptomator/presentation/model/AutoUploadFilesStore.kt new file mode 100644 index 000000000..d2f08ecfb --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/AutoUploadFilesStore.kt @@ -0,0 +1,11 @@ +package org.cryptomator.presentation.model + +import java.io.Serializable + +data class AutoUploadFilesStore( + val uris: Set +) : Serializable { + companion object { + private const val serialVersionUID: Long = 8901228478188469059 + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt new file mode 100644 index 000000000..34029dbb1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudFileModel.kt @@ -0,0 +1,26 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.CloudFile +import org.cryptomator.domain.usecases.ResultRenamed +import org.cryptomator.presentation.util.FileIcon +import org.cryptomator.util.Optional +import java.util.Date + +class CloudFileModel(cloudFile: CloudFile, val icon: FileIcon) : CloudNodeModel(cloudFile) { + + val modified: Optional = cloudFile.modified + val size: Optional = cloudFile.size + + constructor(cloudFileRenamed: ResultRenamed, icon: FileIcon) : this(cloudFileRenamed.value(), icon) { + oldName = cloudFileRenamed.oldName + } + + override val isFile: Boolean + get() = true + override val isFolder: Boolean + get() = false + + val isCryptomatorFile: Boolean + get() = name.endsWith(".cryptomator") + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudFolderModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudFolderModel.kt new file mode 100644 index 000000000..3a8bc18ef --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudFolderModel.kt @@ -0,0 +1,26 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.data.cloud.crypto.CryptoCloud +import org.cryptomator.domain.CloudFolder +import org.cryptomator.domain.CloudType +import org.cryptomator.domain.usecases.ResultRenamed + +class CloudFolderModel(cloudFolder: CloudFolder) : CloudNodeModel(cloudFolder) { + + constructor(cloudFolderRenamed: ResultRenamed) : this(cloudFolderRenamed.value()) { + oldName = cloudFolderRenamed.oldName + } + + override val isFile: Boolean + get() = false + override val isFolder: Boolean + get() = true + + fun vault(): VaultModel? { + return if (toCloudNode().cloud.type() == CloudType.CRYPTO) { + VaultModel((toCloudNode().cloud as CryptoCloud).vault) + } else { + null + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudModel.kt new file mode 100644 index 000000000..2a9836a6e --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudModel.kt @@ -0,0 +1,29 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Cloud +import java.io.Serializable + +abstract class CloudModel internal constructor(private val cloud: Cloud) : Serializable { + + abstract fun name(): Int + abstract fun username(): String? + abstract fun cloudType(): CloudTypeModel + + fun toCloud(): Cloud { + return cloud + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + return if (other == null || javaClass != other.javaClass) false else internalEquals(other as CloudModel) + } + + private fun internalEquals(o: CloudModel): Boolean { + return cloud == o.cloud + } + + override fun hashCode(): Int { + return cloud.hashCode() + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt new file mode 100644 index 000000000..38e53a2b8 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudNodeModel.kt @@ -0,0 +1,45 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.CloudNode +import org.cryptomator.util.Optional +import java.io.Serializable + +abstract class CloudNodeModel internal constructor(private val cloudNode: T) : Serializable { + + var oldName: String? = null + var progress: Optional = Optional.empty() + var isSelected = false + val name: String + get() = cloudNode.name + val simpleName: String + get() = cloudNode.name.substring(0, cloudNode.name.lastIndexOf(".")) + val path: String + get() = cloudNode.path + val parent: CloudFolderModel + get() = CloudFolderModel(cloudNode.parent) + + abstract val isFile: Boolean + abstract val isFolder: Boolean + + fun hasParent(): Boolean { + return cloudNode.parent != null + } + + fun toCloudNode(): T { + return cloudNode + } + + override fun equals(other: Any?): Boolean { + if (other === this) return true + return if (other == null || javaClass != other.javaClass) false else internalEquals(other as CloudNodeModel<*>) + } + + private fun internalEquals(o: CloudNodeModel<*>): Boolean { + return cloudNode == o.cloudNode + } + + override fun hashCode(): Int { + return cloudNode.hashCode() + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt new file mode 100644 index 000000000..a9c3dfcf1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CloudTypeModel.kt @@ -0,0 +1,77 @@ +package org.cryptomator.presentation.model + +import android.os.Build +import org.cryptomator.domain.CloudType +import org.cryptomator.presentation.R + +enum class CloudTypeModel(builder: Builder) { + + CRYPTO(Builder("CRYPTO", R.string.cloud_names_crypto)), // + DROPBOX(Builder("DROPBOX", R.string.cloud_names_dropbox) // + .withCloudImageResource(R.drawable.cloud_type_dropbox) // + .withCloudImageLargeResource(R.drawable.cloud_type_dropbox_large)), // + GOOGLE_DRIVE(Builder("GOOGLE_DRIVE", R.string.cloud_names_google_drive) // + .withCloudImageResource(R.drawable.cloud_type_google_drive) // + .withCloudImageLargeResource(R.drawable.cloud_type_google_drive_large)), // + ONEDRIVE(Builder("ONEDRIVE", R.string.cloud_names_onedrive) // + .withCloudImageResource(R.drawable.cloud_type_onedrive) // + .withCloudImageLargeResource(R.drawable.cloud_type_onedrive_large)), // + WEBDAV(Builder("WEBDAV", R.string.cloud_names_webdav) // + .withCloudImageResource(R.drawable.cloud_type_webdav) // + .withCloudImageLargeResource(R.drawable.cloud_type_webdav_large) // + .withMultiInstances()), // + LOCAL(Builder("LOCAL", R.string.cloud_names_local_storage) // + .withCloudImageResource(R.drawable.storage_type_local) // + .withCloudImageLargeResource(R.drawable.storage_type_local_large) // + .withMultiInstancesIfLollipopOrLater()); + + val cloudName: String + val displayNameResource: Int + val cloudImageResource: Int + val cloudImageLargeResource: Int + val isMultiInstance: Boolean + + private class Builder(val cloudName: String, val displayNameResource: Int) { + var cloudImageResource = 0 + var cloudImageLargeResource = 0 + var multiInstances = false + + fun withCloudImageResource(cloudImageResource: Int): Builder { + this.cloudImageResource = cloudImageResource + return this + } + + fun withCloudImageLargeResource(cloudImageLargeResource: Int): Builder { + this.cloudImageLargeResource = cloudImageLargeResource + return this + } + + fun withMultiInstances(): Builder { + multiInstances = true + return this + } + + fun withMultiInstancesIfLollipopOrLater(): Builder { + multiInstances = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + return this + } + } + + companion object { + fun valueOf(type: CloudType): CloudTypeModel { + return valueOf(type.name) + } + + fun valueOf(type: CloudTypeModel): CloudType { + return CloudType.valueOf(type.name) + } + } + + init { + cloudName = builder.cloudName + displayNameResource = builder.displayNameResource + cloudImageResource = builder.cloudImageResource + cloudImageLargeResource = builder.cloudImageLargeResource + isMultiInstance = builder.multiInstances + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/CryptoCloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/CryptoCloudModel.kt new file mode 100644 index 000000000..edde63124 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/CryptoCloudModel.kt @@ -0,0 +1,30 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.data.cloud.crypto.CryptoCloud +import org.cryptomator.domain.Cloud + +class CryptoCloudModel(cloud: Cloud) : CloudModel(cloud) { + + private val vault: VaultModel + + override fun name(): Int { + throw IllegalStateException("Should not be invoked") + } + + override fun username(): String? { + return "" + } + + override fun cloudType(): CloudTypeModel { + return CloudTypeModel.CRYPTO + } + + fun vault(): VaultModel { + return vault + } + + init { + val cryptoCloud = cloud as CryptoCloud + vault = VaultModel(cryptoCloud.vault) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/DropboxCloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/DropboxCloudModel.kt new file mode 100644 index 000000000..692e9058d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/DropboxCloudModel.kt @@ -0,0 +1,24 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.DropboxCloud +import org.cryptomator.presentation.R + +class DropboxCloudModel(cloud: Cloud) : CloudModel(cloud) { + + override fun name(): Int { + return R.string.cloud_names_dropbox + } + + override fun username(): String? { + return cloud().username() + } + + private fun cloud(): DropboxCloud { + return toCloud() as DropboxCloud + } + + override fun cloudType(): CloudTypeModel { + return CloudTypeModel.DROPBOX + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/FileProgressStateModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/FileProgressStateModel.kt new file mode 100644 index 000000000..562fa8f12 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/FileProgressStateModel.kt @@ -0,0 +1,21 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.CloudFile +import org.cryptomator.presentation.util.FileIcon + +class FileProgressStateModel(file: CloudFile, icon: FileIcon, name: String, image: Image, text: Text) : ProgressStateModel(name, image, text) { + + val file: CloudFileModel = CloudFileModel(file, icon) + + fun `is`(name: String): Boolean { + return name == name() + } + + companion object { + const val UPLOAD = "UPLOAD" + const val ENCRYPTION = "ENCRYPTION" + const val DOWNLOAD = "DOWNLOAD" + const val DECRYPTION = "DECRYPTION" + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/GoogleDriveCloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/GoogleDriveCloudModel.kt new file mode 100644 index 000000000..7bb139f70 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/GoogleDriveCloudModel.kt @@ -0,0 +1,24 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.GoogleDriveCloud +import org.cryptomator.presentation.R + +class GoogleDriveCloudModel(cloud: Cloud) : CloudModel(cloud) { + + override fun name(): Int { + return R.string.cloud_names_google_drive + } + + override fun username(): String? { + return cloud().username() + } + + override fun cloudType(): CloudTypeModel { + return CloudTypeModel.GOOGLE_DRIVE + } + + private fun cloud(): GoogleDriveCloud { + return toCloud() as GoogleDriveCloud + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFile.kt b/presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFile.kt new file mode 100644 index 000000000..a60ce24ca --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFile.kt @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.model + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +@SuppressLint("ParcelCreator") +data class ImagePreviewFile( + val cloudFileModel: CloudFileModel, + var uri: Uri? +) : Parcelable diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFilesStore.kt b/presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFilesStore.kt new file mode 100644 index 000000000..8f4b307e3 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/ImagePreviewFilesStore.kt @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.model + +import java.io.Serializable + +data class ImagePreviewFilesStore( + val cloudFileModels: ArrayList, + var index: Int +) : Serializable diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/LocalStorageModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/LocalStorageModel.kt new file mode 100644 index 000000000..3a0a36129 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/LocalStorageModel.kt @@ -0,0 +1,45 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.LocalStorageCloud +import org.cryptomator.presentation.R +import org.cryptomator.util.Encodings +import java.net.URLDecoder + +class LocalStorageModel(cloud: Cloud) : CloudModel(cloud) { + + override fun name(): Int { + return R.string.cloud_names_local_storage + } + + override fun username(): String? { + return "" + } + + override fun cloudType(): CloudTypeModel { + return CloudTypeModel.LOCAL + } + + private fun cloud(): LocalStorageCloud { + return toCloud() as LocalStorageCloud + } + + fun location(): String { + val displayToken = prepareTokenForDisplay() + return displayToken.substring(displayToken.lastIndexOf(":") + 1) + } + + fun storage(): String { + val displayToken = prepareTokenForDisplay() + val displayTokenWithoutLocation = displayToken.replace(location(), "") + return displayTokenWithoutLocation.substring(displayTokenWithoutLocation.lastIndexOf("/") + 1, displayTokenWithoutLocation.lastIndexOf(":")) + } + + private fun prepareTokenForDisplay(): String { + return URLDecoder.decode(cloud().rootUri(), Encodings.UTF_8.name()) + } + + fun uri(): String { + return cloud().rootUri() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/OnedriveCloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/OnedriveCloudModel.kt new file mode 100644 index 000000000..85ef77b0b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/OnedriveCloudModel.kt @@ -0,0 +1,24 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.OnedriveCloud +import org.cryptomator.presentation.R + +class OnedriveCloudModel(cloud: Cloud) : CloudModel(cloud) { + + override fun name(): Int { + return R.string.cloud_names_onedrive + } + + override fun username(): String? { + return cloud().username() + } + + private fun cloud(): OnedriveCloud { + return toCloud() as OnedriveCloud + } + + override fun cloudType(): CloudTypeModel { + return CloudTypeModel.ONEDRIVE + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/ProgressModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/ProgressModel.kt new file mode 100644 index 000000000..8e7ce1dd4 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/ProgressModel.kt @@ -0,0 +1,23 @@ +package org.cryptomator.presentation.model + +import java.io.Serializable + +class ProgressModel constructor(private val state: ProgressStateModel, private val percentage: Int = UNKNOWN_PROGRESS_PERCENTAGE) : Serializable { + + fun progress(): Int { + return percentage + } + + fun state(): ProgressStateModel { + return state + } + + companion object { + @JvmField + val GENERIC = ProgressModel(ProgressStateModel.UNKNOWN) + + @JvmField + val COMPLETED = ProgressModel(ProgressStateModel.COMPLETED) + const val UNKNOWN_PROGRESS_PERCENTAGE = -1 + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/ProgressStateModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/ProgressStateModel.kt new file mode 100644 index 000000000..f67a55c0f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/ProgressStateModel.kt @@ -0,0 +1,81 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.presentation.R +import java.io.Serializable + +open class ProgressStateModel private constructor(private val name: String, image: Image, text: Text, selectable: Boolean) : Serializable { + + private val imageResourceId: Int + private val textResourceId: Int + val isSelectable: Boolean + + private constructor(name: String) : this(name, noImage(), noText()) + private constructor(name: String, text: Text) : this(name, noImage(), text) + private constructor(name: String, text: Text, selectable: Boolean) : this(name, noImage(), text, selectable) + internal constructor(name: String, image: Image, text: Text) : this(name, image, text, true) + + fun imageResourceId(): Int { + return imageResourceId + } + + fun textResourceId(): Int { + return textResourceId + } + + interface Image { + fun id(): Int + } + + interface Text { + fun id(): Int + } + + fun name(): String { + return name + } + + companion object { + val AUTHENTICATION = ProgressStateModel("AUTHENTICATION", text(R.string.action_progress_authentication)) + val RENAMING = ProgressStateModel("RENAMING", text(R.string.action_progress_renaming)) + val MOVING = ProgressStateModel("MOVING", text(R.string.action_progress_moving), false) + val DELETION = ProgressStateModel("DELETION", text(R.string.action_progress_deleting), false) + val CREATING_FOLDER = ProgressStateModel("FOLDER", text(R.string.dialog_progress_creating_folder)) + val CREATING_TEXT_FILE = ProgressStateModel("FILE", text(R.string.dialog_progress_creating_text_file)) + val UNLOCKING_VAULT = ProgressStateModel("VAULT", text(R.string.dialog_progress_unlocking_vault)) + val CHANGING_PASSWORD = ProgressStateModel("PASSWORD", text(R.string.dialog_progress_change_password)) + val CREATING_VAULT = ProgressStateModel("VAULT", text(R.string.dialog_progress_creating_vault)) + val UNKNOWN = ProgressStateModel("UNKNOWN_MIMETYPE", text(R.string.dialog_progress_please_wait)) + val COMPLETED = ProgressStateModel("COMPLETED") + + // utils + fun noImage(): Image { + return image(0) + } + + fun noText(): Text { + return text(0) + } + + fun image(id: Int): Image { + return object : Image { + override fun id(): Int { + return id + } + } + } + + fun text(id: Int): Text { + return object : Text { + override fun id(): Int { + return id + } + } + } + } + + init { + imageResourceId = image.id() + textResourceId = text.id() + isSelectable = selectable + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/SharedFileModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/SharedFileModel.kt new file mode 100644 index 000000000..1cb2a364c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/SharedFileModel.kt @@ -0,0 +1,32 @@ +package org.cryptomator.presentation.model + +class SharedFileModel(val id: Any, var fileName: String) : Comparable { + + fun setNewFileName(name: String) { + fileName = name + } + + override fun compareTo(other: SharedFileModel): Int { + val nameComparisonResult = fileName.compareTo(other.fileName) + return if (nameComparisonResult == 0) { + hashCode() - other.hashCode() + } else { + nameComparisonResult + } + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + return (other === this // + || (other != null // + && javaClass == other.javaClass // + && internalEquals(other as SharedFileModel))) + } + + private fun internalEquals(obj: SharedFileModel): Boolean { + return id == obj.id + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt new file mode 100644 index 000000000..762d4a959 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/VaultModel.kt @@ -0,0 +1,33 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Vault +import java.io.Serializable + +class VaultModel(private val vault: Vault) : Serializable { + + val vaultId: Long + get() = vault.id + val name: String + get() = vault.name + val path: String + get() = vault.path + val isLocked: Boolean + get() = !vault.isUnlocked + + fun toVault(): Vault { + return vault + } + + val cloudType: CloudTypeModel + get() = CloudTypeModel.valueOf(vault.cloudType) + val password: String? + get() = vault.password + + override fun equals(other: Any?): Boolean { + return vault == (other as VaultModel).toVault() + } + + override fun hashCode(): Int { + return vault.hashCode() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/WebDavCloudModel.kt b/presentation/src/main/java/org/cryptomator/presentation/model/WebDavCloudModel.kt new file mode 100644 index 000000000..69431f239 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/WebDavCloudModel.kt @@ -0,0 +1,40 @@ +package org.cryptomator.presentation.model + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.WebDavCloud +import org.cryptomator.presentation.R + +class WebDavCloudModel(cloud: Cloud) : CloudModel(cloud) { + + override fun name(): Int { + return R.string.cloud_names_webdav + } + + override fun username(): String? { + return cloud().username() + } + + override fun cloudType(): CloudTypeModel { + return CloudTypeModel.WEBDAV + } + + fun url(): String { + return cloud().url() + } + + fun accessToken(): String { + return cloud().password() + } + + fun id(): Long { + return cloud().id() + } + + fun certificate(): String? { + return cloud().certificate() + } + + private fun cloud(): WebDavCloud { + return toCloud() as WebDavCloud + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudModelComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudModelComparator.kt new file mode 100644 index 000000000..c3209967c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudModelComparator.kt @@ -0,0 +1,16 @@ +package org.cryptomator.presentation.model.comparator + +import android.content.Context +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.WebDavCloudModel +import java.util.* + +class CloudModelComparator(private val context: Context) : Comparator { + override fun compare(o1: CloudModel, o2: CloudModel): Int { + return if (o1 is WebDavCloudModel && o2 is WebDavCloudModel) { + o1.url().compareTo(o2.url().toUpperCase(Locale.getDefault()), ignoreCase = true) + } else { + context.getString(o1.name()).compareTo(context.getString(o2.name()), ignoreCase = true) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateNewestFirstComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateNewestFirstComparator.kt new file mode 100644 index 000000000..aa1f652d2 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateNewestFirstComparator.kt @@ -0,0 +1,31 @@ +package org.cryptomator.presentation.model.comparator + +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import kotlin.Comparator + +class CloudNodeModelDateNewestFirstComparator : Comparator> { + override fun compare(o1: CloudNodeModel<*>?, o2: CloudNodeModel<*>?): Int { + return if (o1 is CloudFolderModel && o2 is CloudFileModel) { + -1 + } else if (o1 is CloudFileModel && o2 is CloudFolderModel) { + 1 + } else if (o1 is CloudFolderModel && o2 is CloudFolderModel) { + return o1.name.compareTo(o2.name, true) + } else { + val o1ModifyDate = (o1 as CloudFileModel).modified + val o2ModifyDate = (o2 as CloudFileModel).modified + + return if (o1ModifyDate.isPresent && o2ModifyDate.isPresent) { + o2ModifyDate.get().compareTo(o1ModifyDate.get()) + } else if (o2ModifyDate.isPresent) { + -1 + } else if (o1ModifyDate.isPresent) { + 1 + } else { + 0 + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateOldestFirstComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateOldestFirstComparator.kt new file mode 100644 index 000000000..4413c812c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelDateOldestFirstComparator.kt @@ -0,0 +1,31 @@ +package org.cryptomator.presentation.model.comparator + +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import kotlin.Comparator + +class CloudNodeModelDateOldestFirstComparator : Comparator> { + override fun compare(o1: CloudNodeModel<*>?, o2: CloudNodeModel<*>?): Int { + return if (o1 is CloudFolderModel && o2 is CloudFileModel) { + -1 + } else if (o1 is CloudFileModel && o2 is CloudFolderModel) { + 1 + } else if (o1 is CloudFolderModel && o2 is CloudFolderModel) { + return o1.name.compareTo(o2.name, true) + } else { + val o1ModifyDate = (o1 as CloudFileModel).modified + val o2ModifyDate = (o2 as CloudFileModel).modified + + return if (o1ModifyDate.isPresent && o2ModifyDate.isPresent) { + o1ModifyDate.get().compareTo(o2ModifyDate.get()) + } else if (o1ModifyDate.isPresent) { + -1 + } else if (o2ModifyDate.isPresent) { + 1 + } else { + 0 + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameAZComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameAZComparator.kt new file mode 100644 index 000000000..a1a850889 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameAZComparator.kt @@ -0,0 +1,18 @@ +package org.cryptomator.presentation.model.comparator + +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import kotlin.Comparator + +class CloudNodeModelNameAZComparator : Comparator> { + override fun compare(o1: CloudNodeModel<*>, o2: CloudNodeModel<*>): Int { + return if (o1 is CloudFolderModel && o2 is CloudFileModel) { + -1 + } else if (o1 is CloudFileModel && o2 is CloudFolderModel) { + 1 + } else { + return o1.name.compareTo(o2.name, true) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameZAComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameZAComparator.kt new file mode 100644 index 000000000..dab09afac --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelNameZAComparator.kt @@ -0,0 +1,18 @@ +package org.cryptomator.presentation.model.comparator + +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import kotlin.Comparator + +class CloudNodeModelNameZAComparator : Comparator> { + override fun compare(o1: CloudNodeModel<*>, o2: CloudNodeModel<*>): Int { + return if (o1 is CloudFolderModel && o2 is CloudFileModel) { + -1 + } else if (o1 is CloudFileModel && o2 is CloudFolderModel) { + 1 + } else { + return o2.name.compareTo(o1.name, true) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeBiggestFirstComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeBiggestFirstComparator.kt new file mode 100644 index 000000000..79660cf7b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeBiggestFirstComparator.kt @@ -0,0 +1,31 @@ +package org.cryptomator.presentation.model.comparator + +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import kotlin.Comparator + +class CloudNodeModelSizeBiggestFirstComparator : Comparator> { + override fun compare(o1: CloudNodeModel<*>?, o2: CloudNodeModel<*>?): Int { + return if (o1 is CloudFolderModel && o2 is CloudFileModel) { + -1 + } else if (o1 is CloudFileModel && o2 is CloudFolderModel) { + 1 + } else if (o1 is CloudFolderModel && o2 is CloudFolderModel) { + return o1.name.compareTo(o2.name, true) + } else { + val o1Size = (o1 as CloudFileModel).size + val o2Size = (o2 as CloudFileModel).size + + return if (o2Size.isPresent && o1Size.isPresent) { + o2Size.get().compareTo(o1Size.get()) + } else if (o2Size.isPresent) { + -1 + } else if (o1Size.isPresent) { + 1 + } else { + 0 + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeSmallestFirstComparator.kt b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeSmallestFirstComparator.kt new file mode 100644 index 000000000..339dc84f0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/comparator/CloudNodeModelSizeSmallestFirstComparator.kt @@ -0,0 +1,31 @@ +package org.cryptomator.presentation.model.comparator + +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import kotlin.Comparator + +class CloudNodeModelSizeSmallestFirstComparator : Comparator> { + override fun compare(o1: CloudNodeModel<*>?, o2: CloudNodeModel<*>?): Int { + return if (o1 is CloudFolderModel && o2 is CloudFileModel) { + -1 + } else if (o1 is CloudFileModel && o2 is CloudFolderModel) { + 1 + } else if (o1 is CloudFolderModel && o2 is CloudFolderModel) { + return o1.name.compareTo(o2.name, true) + } else { + val o1Size = (o1 as CloudFileModel).size + val o2Size = (o2 as CloudFileModel).size + + return if (o1Size.isPresent && o2Size.isPresent) { + o1Size.get().compareTo(o2Size.get()) + } else if (o1Size.isPresent) { + -1 + } else if (o2Size.isPresent) { + 1 + } else { + 0 + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFileModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFileModelMapper.kt new file mode 100644 index 000000000..18975788a --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFileModelMapper.kt @@ -0,0 +1,23 @@ +package org.cryptomator.presentation.model.mappers + +import org.cryptomator.domain.CloudFile +import org.cryptomator.domain.usecases.ResultRenamed +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.util.FileIcon +import org.cryptomator.presentation.util.FileUtil +import javax.inject.Inject + +class CloudFileModelMapper @Inject constructor(private val fileUtil: FileUtil) : ModelMapper() { + + override fun fromModel(model: CloudFileModel): CloudFile { + return model.toCloudNode() + } + + override fun toModel(domainObject: CloudFile): CloudFileModel { + return CloudFileModel(domainObject, FileIcon.fileIconFor(domainObject.name, fileUtil)) + } + + fun toModel(resultRenamed: ResultRenamed): CloudFileModel { + return CloudFileModel(resultRenamed, FileIcon.fileIconFor(resultRenamed.value().name, fileUtil)) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFolderModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFolderModelMapper.kt new file mode 100644 index 000000000..9642ff9d1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudFolderModelMapper.kt @@ -0,0 +1,20 @@ +package org.cryptomator.presentation.model.mappers + +import org.cryptomator.domain.CloudFolder +import org.cryptomator.domain.usecases.ResultRenamed +import org.cryptomator.presentation.model.CloudFolderModel +import javax.inject.Inject + +class CloudFolderModelMapper @Inject constructor() : ModelMapper() { + override fun fromModel(model: CloudFolderModel): CloudFolder { + return model.toCloudNode() + } + + override fun toModel(domainObject: CloudFolder): CloudFolderModel { + return CloudFolderModel(domainObject) + } + + fun toModel(resultRenamed: ResultRenamed): CloudFolderModel { + return CloudFolderModel(resultRenamed) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt new file mode 100644 index 000000000..9beca39f7 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudModelMapper.kt @@ -0,0 +1,24 @@ +package org.cryptomator.presentation.model.mappers + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.model.* +import javax.inject.Inject + +@PerView +class CloudModelMapper @Inject constructor() : ModelMapper() { + override fun fromModel(model: CloudModel): Cloud { + return model.toCloud() + } + + override fun toModel(domainObject: Cloud): CloudModel { + return when (CloudTypeModel.valueOf(domainObject.type())) { + CloudTypeModel.DROPBOX -> DropboxCloudModel(domainObject) + CloudTypeModel.GOOGLE_DRIVE -> GoogleDriveCloudModel(domainObject) + CloudTypeModel.ONEDRIVE -> OnedriveCloudModel(domainObject) + CloudTypeModel.CRYPTO -> CryptoCloudModel(domainObject) + CloudTypeModel.LOCAL -> LocalStorageModel(domainObject) + CloudTypeModel.WEBDAV -> WebDavCloudModel(domainObject) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudNodeModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudNodeModelMapper.kt new file mode 100644 index 000000000..2f0410eea --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/CloudNodeModelMapper.kt @@ -0,0 +1,30 @@ +package org.cryptomator.presentation.model.mappers + +import org.cryptomator.domain.CloudFile +import org.cryptomator.domain.CloudFolder +import org.cryptomator.domain.CloudNode +import org.cryptomator.domain.usecases.ResultRenamed +import org.cryptomator.presentation.model.CloudNodeModel +import javax.inject.Inject + +class CloudNodeModelMapper @Inject constructor(private val cloudFileModelMapper: CloudFileModelMapper, private val cloudFolderModelMapper: CloudFolderModelMapper) : ModelMapper, CloudNode>() { + override fun fromModel(model: CloudNodeModel<*>): CloudNode { + return model.toCloudNode() + } + + fun toModel(resultRenamed: ResultRenamed<*>): CloudNodeModel<*> { + return if (resultRenamed.value() is CloudFolder) { + cloudFolderModelMapper.toModel(resultRenamed as ResultRenamed) + } else { + cloudFileModelMapper.toModel(resultRenamed as ResultRenamed) + } + } + + override fun toModel(domainObject: CloudNode): CloudNodeModel<*> { + return if (domainObject is CloudFolder) { + cloudFolderModelMapper.toModel(domainObject) + } else { + cloudFileModelMapper.toModel(domainObject as CloudFile) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ModelMapper.kt new file mode 100644 index 000000000..5082db87f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ModelMapper.kt @@ -0,0 +1,16 @@ +package org.cryptomator.presentation.model.mappers + +abstract class ModelMapper internal constructor() { + + fun fromModels(models: Iterable): List { + return models.mapTo(ArrayList()) { fromModel(it) } + } + + fun toModels(domainObjects: Iterable): List { + return domainObjects.mapTo(ArrayList()) { toModel(it) } + } + + abstract fun fromModel(model: M): D + abstract fun toModel(domainObject: D): M + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressModelMapper.kt new file mode 100644 index 000000000..b3a571adc --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressModelMapper.kt @@ -0,0 +1,19 @@ +package org.cryptomator.presentation.model.mappers + +import org.cryptomator.domain.usecases.cloud.Progress +import org.cryptomator.presentation.model.ProgressModel +import javax.inject.Inject + +class ProgressModelMapper @Inject internal constructor(private val progressStateModelMapper: ProgressStateModelMapper) : ModelMapper>() { + /** + * @throws IllegalStateException + */ + @Deprecated("Not implemented") + override fun fromModel(model: ProgressModel): Progress<*> { + throw IllegalStateException("Not implemented") + } + + override fun toModel(domainObject: Progress<*>): ProgressModel { + return ProgressModel(progressStateModelMapper.toModel(domainObject.state()), domainObject.asPercentage()) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressStateModelMapper.kt b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressStateModelMapper.kt new file mode 100644 index 000000000..4bf42e6f3 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/model/mappers/ProgressStateModelMapper.kt @@ -0,0 +1,50 @@ +package org.cryptomator.presentation.model.mappers + +import org.cryptomator.domain.usecases.cloud.DownloadState +import org.cryptomator.domain.usecases.cloud.ProgressState +import org.cryptomator.domain.usecases.cloud.UploadState +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.FileProgressStateModel +import org.cryptomator.presentation.model.ProgressStateModel +import org.cryptomator.presentation.util.FileIcon +import org.cryptomator.presentation.util.FileUtil +import javax.inject.Inject + +class ProgressStateModelMapper @Inject internal constructor(private val fileUtil: FileUtil) : ModelMapper() { + /** + * @throws IllegalStateException + */ + @Deprecated("Not implemented") + override fun fromModel(model: ProgressStateModel): ProgressState? { + throw IllegalStateException("Not implemented") + } + + override fun toModel(domainObject: ProgressState?): ProgressStateModel { + if (domainObject is UploadState) { + return toModel(domainObject) + } else if (domainObject is DownloadState) { + return toModel(domainObject) + } + return ProgressStateModel.COMPLETED + } + + fun toModel(state: UploadState): ProgressStateModel { + return if (state.isUpload) { + FileProgressStateModel(state.file(), FileIcon.fileIconFor(state.file().name, fileUtil), FileProgressStateModel.UPLOAD, ProgressStateModel.image(R.drawable.ic_file_upload), + ProgressStateModel.text(R.string.dialog_progress_upload_file)) + } else { + FileProgressStateModel(state.file(), FileIcon.fileIconFor(state.file().name, fileUtil), FileProgressStateModel.ENCRYPTION, ProgressStateModel.image(R.drawable.ic_lock_closed), + ProgressStateModel.text(R.string.dialog_progress_encryption)) + } + } + + fun toModel(state: DownloadState): ProgressStateModel { + return if (state.isDownload) { + FileProgressStateModel(state.file(), FileIcon.fileIconFor(state.file().name, fileUtil), FileProgressStateModel.DOWNLOAD, ProgressStateModel.image(R.drawable.ic_file_download), + ProgressStateModel.text(R.string.dialog_progress_download_file)) + } else { + FileProgressStateModel(state.file(), FileIcon.fileIconFor(state.file().name, fileUtil), FileProgressStateModel.DECRYPTION, ProgressStateModel.image(R.drawable.ic_lock_open), + ProgressStateModel.text(R.string.dialog_progress_decryption)) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/ActivityHolder.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/ActivityHolder.kt new file mode 100644 index 000000000..a07b1d0f4 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/ActivityHolder.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.presenter + +import android.app.Activity + +interface ActivityHolder : ContextHolder { + + fun activity(): Activity + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt new file mode 100644 index 000000000..14bcac5a3 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/AuthenticateCloudPresenter.kt @@ -0,0 +1,381 @@ +package org.cryptomator.presentation.presenter + +import android.Manifest +import android.accounts.AccountManager +import android.content.ActivityNotFoundException +import com.dropbox.core.android.Auth +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.services.drive.DriveScopes +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.di.PerView +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.domain.exception.NetworkConnectionException +import org.cryptomator.domain.exception.authentication.* +import org.cryptomator.domain.usecases.cloud.AddOrChangeCloudConnectionUseCase +import org.cryptomator.domain.usecases.cloud.GetUsernameUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.BuildConfig +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.model.mappers.CloudModelMapper +import org.cryptomator.presentation.ui.activity.view.AuthenticateCloudView +import org.cryptomator.presentation.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 + +@PerView +class AuthenticateCloudPresenter @Inject constructor( // + exceptionHandlers: ExceptionHandlers, // + private val cloudModelMapper: CloudModelMapper, // + private val addOrChangeCloudConnectionUseCase: AddOrChangeCloudConnectionUseCase, // + private val getUsernameUseCase: GetUsernameUseCase, // + private val addExistingVaultWorkflow: AddExistingVaultWorkflow, // + private val createNewVaultWorkflow: CreateNewVaultWorkflow) : Presenter(exceptionHandlers) { + + private val strategies = arrayOf( // + DropboxAuthStrategy(), // + GoogleDriveAuthStrategy(), // + OnedriveAuthStrategy(), // + WebDAVAuthStrategy(), // + LocalStorageAuthStrategy() // + ) + + override fun workflows(): Iterable> { + return listOf(createNewVaultWorkflow, addExistingVaultWorkflow) + } + + override fun resumed() { + val cloud = view?.intent()?.cloud() + val error = view?.intent()?.error() + handleNetworkConnectionExceptionIfRequired(error) + view?.intent()?.let { cloud?.let { cloud -> authStrategyFor(cloud).resumed(it) } } + } + + private fun handleNetworkConnectionExceptionIfRequired(error: AuthenticationException?) { + if (error != null && ExceptionUtil.contains(error, NetworkConnectionException::class.java)) { + view?.showMessage(R.string.error_no_network_connection) + finish() + } + } + + private fun authStrategyFor(cloud: CloudModel): AuthStrategy { + strategies.forEach { strategy -> + if (strategy.supports(cloud)) { + return strategy + } + } + return FailingAuthStrategy() + } + + private fun getUsernameAndSuceedAuthentication(cloud: Cloud) { + getUsernameUseCase.withCloud(cloud).run(object : DefaultResultHandler() { + override fun onSuccess(username: String) { + succeedAuthenticationWith(updateUsernameOf(cloud, username)) + } + + override fun onError(e: Throwable) { + super.onError(e) + finish() + } + }) + } + + private fun updateUsernameOf(cloud: Cloud, username: String): Cloud { + when (cloud.type()) { + CloudType.DROPBOX -> return DropboxCloud.aCopyOf(cloud as DropboxCloud).withUsername(username).build() + CloudType.ONEDRIVE -> return OnedriveCloud.aCopyOf(cloud as OnedriveCloud).withUsername(username).build() + } + throw IllegalStateException("Cloud " + cloud.type() + " is not supported") + } + + private fun succeedAuthenticationWith(cloud: Cloud) { + addOrChangeCloudConnectionUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + finishWithResult(cloudModelMapper.toModel(cloud)) + } + + override fun onError(e: Throwable) { + super.onError(e) + finish() + } + }) + } + + private fun failAuthentication(cloudName: Int) { + view?.showMessage(String.format(getString(R.string.screen_authenticate_auth_authentication_failed), getString(cloudName))) + finish() + } + + private fun failAuthentication(error: PermissionNotGrantedException) { + finishWithResult(error) + } + + private inner class DropboxAuthStrategy : AuthStrategy { + private var authenticationStarted = false + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.DROPBOX + } + + override fun resumed(intent: AuthenticateCloudIntent) { + if (authenticationStarted) { + handleAuthenticationResult(intent.cloud()) + } else { + startAuthentication() + } + } + + private fun startAuthentication() { + showProgress(ProgressModel(ProgressStateModel.AUTHENTICATION)) + authenticationStarted = true + Auth.startOAuth2Authentication(context(), BuildConfig.DROPBOX_API_KEY) + view?.skipTransition() + } + + private fun handleAuthenticationResult(cloudModel: CloudModel) { + val authToken = Auth.getOAuth2Token() + if (authToken == null) { + failAuthentication(cloudModel.name()) + } else { + getUsernameAndSuceedAuthentication( // + DropboxCloud.aCopyOf(cloudModel.toCloud() as DropboxCloud) // + .withAccessToken(encrypt(authToken)) // + .build()) + } + } + } + + private inner class GoogleDriveAuthStrategy : AuthStrategy { + private var authenticationStarted = false + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.GOOGLE_DRIVE + } + + override fun resumed(intent: AuthenticateCloudIntent) { + if (!authenticationStarted) { + startAuthentication(intent) + } + } + + private fun startAuthentication(intent: AuthenticateCloudIntent) { + showProgress(ProgressModel(ProgressStateModel.AUTHENTICATION)) + authenticationStarted = true + if (intent.recoveryAction() != null) { + handleUserRecoverableAuthenticationException(intent) + } else { + chooseAccount(intent.cloud()) + } + } + + private fun handleUserRecoverableAuthenticationException(intent: AuthenticateCloudIntent) { + requestActivityResult(ActivityResultCallbacks.onUserRecoveryFinished(intent.cloud()), intent.recoveryAction()) + } + + private fun chooseAccount(cloud: CloudModel) { + val chooseAccountIntent = GoogleAccountCredential.usingOAuth2(context(), setOf(DriveScopes.DRIVE)).newChooseAccountIntent() + try { + requestActivityResult( // + ActivityResultCallbacks.onGoogleDriveAuthenticated(cloud), // + chooseAccountIntent) + } catch (e: ActivityNotFoundException) { + view?.showMessage(R.string.error_play_services_not_available) + finish() + } + } + } + + @Callback(dispatchResultOkOnly = false) + fun onUserRecoveryFinished(result: ActivityResult, cloud: CloudModel) { + if (result.isResultOk) { + succeedAuthenticationWith(cloud.toCloud()) + } else { + failAuthentication(cloud.name()) + } + } + + @Callback(dispatchResultOkOnly = false) + fun onGoogleDriveAuthenticated(result: ActivityResult, cloud: CloudModel) { + if (result.isResultOk) { + val accountName = result.intent()?.extras?.getString(AccountManager.KEY_ACCOUNT_NAME) + succeedAuthenticationWith(GoogleDriveCloud.aCopyOf(cloud.toCloud() as GoogleDriveCloud) // + .withUsername(accountName) // + .withAccessToken(accountName) // + .build()) + } else { + failAuthentication(cloud.name()) + } + } + + private inner class OnedriveAuthStrategy : AuthStrategy { + private var authenticationStarted = false + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.ONEDRIVE + } + + override fun resumed(intent: AuthenticateCloudIntent) { + if (!authenticationStarted) { + startAuthentication(intent.cloud()) + } + } + + private fun startAuthentication(cloud: CloudModel) { + authenticationStarted = true + val authenticationAdapter = OnedriveClientFactory.instance(context(), (cloud.toCloud() as OnedriveCloud).accessToken()).authenticationAdapter + authenticationAdapter.login(activity(), object : ICallback { + override fun success(accessToken: String?) { + if (accessToken == null) { + Timber.tag("AuthicateCloudPrester").e("Onedrive access token is empty") + failAuthentication(cloud.name()) + } else { + showProgress(ProgressModel(ProgressStateModel.AUTHENTICATION)) + handleAuthenticationResult(cloud, accessToken) + } + } + + override fun failure(ex: ClientException) { + Timber.tag("AuthicateCloudPrester").e(ex) + failAuthentication(cloud.name()) + } + }) + } + + private fun handleAuthenticationResult(cloud: CloudModel, accessToken: String) { + getUsernameAndSuceedAuthentication( // + OnedriveCloud.aCopyOf(cloud.toCloud() as OnedriveCloud) // + .withAccessToken(accessToken) // + .build()) + } + } + + private inner class WebDAVAuthStrategy : AuthStrategy { + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.WEBDAV + } + + override fun resumed(intent: AuthenticateCloudIntent) { + handleWebDavAuthenticationExceptionIfRequired(intent.cloud() as WebDavCloudModel, intent.error()) + } + + private fun handleWebDavAuthenticationExceptionIfRequired(cloud: WebDavCloudModel, e: AuthenticationException) { + Timber.tag("AuthicateCloudPrester").e(e) + when { + ExceptionUtil.contains(e, WrongCredentialsException::class.java) -> { + failAuthentication(cloud.name()) + } + ExceptionUtil.contains(e, WebDavCertificateUntrustedAuthenticationException::class.java) -> { + handleCertificateUntrustedExceptionIfRequired(cloud, e) + } + ExceptionUtil.contains(e, WebDavServerNotFoundException::class.java) -> { + view?.showMessage(R.string.error_server_not_found) + finish() + } + ExceptionUtil.contains(e, WebDavNotSupportedException::class.java) -> { + view?.showMessage(R.string.screen_cloud_error_webdav_not_supported) + finish() + } + } + } + + private fun handleCertificateUntrustedExceptionIfRequired(cloud: WebDavCloudModel, e: AuthenticationException) { + val untrustedException = ExceptionUtil.extract(e, WebDavCertificateUntrustedAuthenticationException::class.java) + try { + val certificate = X509CertificateHelper.convertFromPem(untrustedException.get().certificate) + view?.showUntrustedCertificateDialog(cloud.toCloud() as WebDavCloud, certificate) + } catch (ex: CertificateException) { + Timber.tag("AuthicateCloudPrester").e(ex) + throw FatalBackendException(ex) + } + } + } + + fun onAcceptWebDavCertificateClicked(cloud: WebDavCloud?, certificate: X509Certificate?) { + try { + val webDavCloudWithAcceptedCert = WebDavCloud.aCopyOf(cloud) // + .withCertificate(X509CertificateHelper.convertToPem(certificate)) // + .build() + finishWithResultAndExtra(cloudModelMapper.toModel(webDavCloudWithAcceptedCert), // + WEBDAV_ACCEPTED_UNTRUSTED_CERTIFICATE, // + true) + } catch (e: CertificateEncodingException) { + Timber.tag("AuthicateCloudPrester").e(e) + throw FatalBackendException(e) + } + } + + fun onAcceptWebDavCertificateDenied() { + finish() + } + + private inner class LocalStorageAuthStrategy : AuthStrategy { + private var authenticationStarted = false + override fun supports(cloud: CloudModel): Boolean { + return cloud.cloudType() == CloudTypeModel.LOCAL + } + + override fun resumed(intent: AuthenticateCloudIntent) { + if (!authenticationStarted) { + startAuthentication(intent.cloud()) + } + } + + private fun startAuthentication(cloud: CloudModel) { + authenticationStarted = true + requestPermissions(PermissionsResultCallbacks.onLocalStorageAuthenticated(cloud), // + R.string.permission_snackbar_auth_local_vault, // + Manifest.permission.READ_EXTERNAL_STORAGE, // + Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + + @Callback + fun onLocalStorageAuthenticated(result: PermissionsResult, cloud: CloudModel) { + if (result.granted()) { + succeedAuthenticationWith(cloud.toCloud()) + } else { + failAuthentication(PermissionNotGrantedException(R.string.permission_snackbar_auth_local_vault)) + } + } + + private fun encrypt(password: String): String { + return CredentialCryptor // + .getInstance(context()) // + .encrypt(password) + } + + private inner class FailingAuthStrategy : AuthStrategy { + override fun supports(cloud: CloudModel): Boolean { + return false + } + + override fun resumed(intent: AuthenticateCloudIntent) { + view?.showError(R.string.error_authentication_failed) + finish() + } + } + + private interface AuthStrategy { + fun supports(cloud: CloudModel): Boolean + fun resumed(intent: AuthenticateCloudIntent) + } + + companion object { + const val WEBDAV_ACCEPTED_UNTRUSTED_CERTIFICATE = "acceptedUntrustedCertificate" + } + + init { + unsubscribeOnDestroy(addOrChangeCloudConnectionUseCase, getUsernameUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt new file mode 100644 index 000000000..d60097142 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/AutoUploadChooseVaultPresenter.kt @@ -0,0 +1,199 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.CloudFolder +import org.cryptomator.domain.Vault +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.GetDecryptedCloudForVaultUseCase +import org.cryptomator.domain.usecases.cloud.GetRootFolderUseCase +import org.cryptomator.domain.usecases.vault.GetVaultListUseCase +import org.cryptomator.domain.usecases.vault.RemoveStoredVaultPasswordsUseCase +import org.cryptomator.domain.usecases.vault.UnlockVaultUseCase +import org.cryptomator.domain.usecases.vault.VaultOrUnlockToken +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.model.mappers.CloudFolderModelMapper +import org.cryptomator.presentation.ui.activity.view.AutoUploadChooseVaultView +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler +import org.cryptomator.util.SharedPreferencesHandler +import java.util.* +import javax.inject.Inject + +@PerView +class AutoUploadChooseVaultPresenter @Inject constructor( // + private val getVaultListUseCase: GetVaultListUseCase, // + private val getRootFolderUseCase: GetRootFolderUseCase, // + private val getDecryptedCloudForVaultUseCase: GetDecryptedCloudForVaultUseCase, // + private val unlockVaultUseCase: UnlockVaultUseCase, // + private val removeStoredVaultPasswordsUseCase: RemoveStoredVaultPasswordsUseCase, // + private val cloudFolderModelMapper: CloudFolderModelMapper, // + private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val authenticationExceptionHandler: AuthenticationExceptionHandler, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + private var selectedVault: VaultModel? = null + private var location: CloudFolderModel? = null + private var authenticationState: AuthenticationState? = null + + fun displayVaults() { + getVaultListUseCase.run(object : DefaultResultHandler>() { + override fun onSuccess(vaults: List) { + if (vaults.isEmpty()) { + view?.displayDialogUnableToUploadFiles() + } else { + val vaultModels = vaults.mapTo(ArrayList()) { VaultModel(it) } + view?.displayVaults(vaultModels) + } + } + }) + } + + fun onVaultSelected(vault: VaultModel?) { + selectedVault = vault + } + + fun onChooseVaultPressed() { + selectedVault?.let { sharedPreferencesHandler.photoUploadVault(it.vaultId) } + finish() + } + + fun onChooseLocationPressed() { + authenticate(selectedVault) + } + + private fun authenticate(vaultModel: VaultModel?, authenticationState: AuthenticationState = AuthenticationState.CHOOSE_LOCATION) { + setAuthenticationState(authenticationState) + vaultModel?.let { onCloudOfVaultAuthenticated(it.toVault()) } + } + + private fun setAuthenticationState(authenticationState: AuthenticationState) { + this.authenticationState = authenticationState + } + + private fun onCloudOfVaultAuthenticated(authenticatedVault: Vault) { + if (authenticatedVault.isUnlocked) { + decryptedCloudFor(authenticatedVault) + } else { + if (!isPaused) { + view?.showEnterPasswordDialog(VaultModel(authenticatedVault)) + } + } + } + + private fun decryptedCloudFor(vault: Vault) { + getDecryptedCloudForVaultUseCase // + .withVault(vault) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + rootFolderFor(cloud) + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException( // + this@AutoUploadChooseVaultPresenter, // + e, // + ActivityResultCallbacks.decryptedCloudForAfterAuthInAutoPhotoUpload(vault))) { + super.onError(e) + } + } + }) + } + + @Callback + fun decryptedCloudForAfterAuthInAutoPhotoUpload(result: ActivityResult, vault: Vault?) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + decryptedCloudFor(Vault.aCopyOf(vault).withCloud(cloud).build()) + } + + private fun rootFolderFor(cloud: Cloud) { + getRootFolderUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(folder: CloudFolder) { + when (authenticationState) { + AuthenticationState.CHOOSE_LOCATION -> { + location = cloudFolderModelMapper.toModel(folder) + selectedVault?.let { navigateToVaultContent(it, location) } + } + AuthenticationState.INIT_ROOT -> location = cloudFolderModelMapper.toModel(folder) + } + } + }) + } + + private fun navigateToVaultContent(vaultModel: VaultModel, decryptedRoot: CloudFolderModel?) { + requestActivityResult( // + ActivityResultCallbacks.onAutoUploadChooseLocation(vaultModel), // + Intents.browseFilesIntent() // + .withFolder(decryptedRoot) // + .withTitle(vaultModel.name) // + .withChooseCloudNodeSettings( // + ChooseCloudNodeSettings.chooseCloudNodeSettings() // + .withExtraTitle(context().getString(R.string.screen_file_browser_share_destination_title)) // + .withExtraToolbarIcon(R.drawable.ic_clear) // + .withButtonText(context().getString(R.string.screen_file_browser_share_button_text)) // + .selectingFolders() // + .build())) + } + + @Callback + fun onAutoUploadChooseLocation(result: ActivityResult, vaultModel: VaultModel?) { + location = result.singleResult as CloudFolderModel + location?.let { sharedPreferencesHandler.photoUploadVaultFolder(it.path) } + location?.let { view?.showChosenLocation(it) } + } + + fun onUnlockCanceled() { + // empty + } + + fun onUnlockPressed(vaultModel: VaultModel, password: String?) { + view?.showProgress(ProgressModel.GENERIC) + unlockVaultUseCase // + .withVaultOrUnlockToken(VaultOrUnlockToken.from(vaultModel.toVault())) // + .andPassword(password) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + view?.showProgress(ProgressModel.COMPLETED) + rootFolderFor(cloud) + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException( // + this@AutoUploadChooseVaultPresenter, // + e, // + ActivityResultCallbacks.unlockVaultAfterAuth(vaultModel.toVault(), password))) { + showError(e) + } + } + }) + } + + fun onBiometricKeyInvalidated(vaultModel: VaultModel?) { + removeStoredVaultPasswordsUseCase.run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + view?.showBiometricAuthKeyInvalidatedDialog() + } + }) + } + + fun useConfirmationInFaceUnlockBiometricAuthentication(): Boolean { + return sharedPreferencesHandler.useConfirmationInFaceUnlockBiometricAuthentication() + } + + enum class AuthenticationState { + CHOOSE_LOCATION, INIT_ROOT + } + + init { + unsubscribeOnDestroy(getVaultListUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BiometricAuthSettingsPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BiometricAuthSettingsPresenter.kt new file mode 100644 index 000000000..0c1dd158b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BiometricAuthSettingsPresenter.kt @@ -0,0 +1,203 @@ +package org.cryptomator.presentation.presenter + +import android.content.Intent +import android.provider.Settings +import org.cryptomator.cryptolib.api.InvalidPassphraseException +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.Vault +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.vault.* +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.ui.activity.view.BiometricAuthSettingsView +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +@PerView +class BiometricAuthSettingsPresenter @Inject constructor( // + private val getVaultListUseCase: GetVaultListUseCase, // + private val saveVaultUseCase: SaveVaultUseCase, // + private val removeStoredVaultPasswordsUseCase: RemoveStoredVaultPasswordsUseCase, // + private val checkVaultPasswordUseCase: CheckVaultPasswordUseCase, // + private val unlockVaultUseCase: UnlockVaultUseCase, // + private val lockVaultUseCase: LockVaultUseCase, // + exceptionMappings: ExceptionHandlers, // + private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val authenticationExceptionHandler: AuthenticationExceptionHandler) : Presenter(exceptionMappings) { + + fun loadVaultList() { + updateVaultListView() + } + + private fun updateVaultListView() { + getVaultListUseCase.run(object : DefaultResultHandler>() { + override fun onSuccess(vaults: List) { + if (vaults.isNotEmpty()) { + val vaultModels = vaults.mapTo(ArrayList()) { VaultModel(it) } + view?.renderVaultList(vaultModels) + } + } + }) + } + + fun updateVaultEntityWithChangedBiometricAuthSettings(vaultModel: VaultModel, useBiometricAuth: Boolean) { + if (useBiometricAuth) { + view?.showEnterPasswordDialog(VaultModel(vaultModel.toVault())) + } else { + removePasswordAndSave(vaultModel.toVault()) + } + } + + fun verifyPassword(vaultModel: VaultModel) { + Timber.tag("BiomtricAuthSettngsPres").i("Checking entered vault password") + if (vaultModel.isLocked) { + unlockVault(vaultModel) + } else { + checkPassword(vaultModel) + } + } + + private fun checkPassword(vaultModel: VaultModel) { + view?.showProgress(ProgressModel.GENERIC) + checkVaultPasswordUseCase // + .withVault(vaultModel.toVault()) // + .andPassword(vaultModel.password) // + .run(object : DefaultResultHandler() { + override fun onSuccess(passwordCorrect: Boolean) { + if (passwordCorrect) { + Timber.tag("BiomtricAuthSettngsPres").i("Password is correct") + onPasswordCheckSucceeded(vaultModel) + } else { + Timber.tag("BiomtricAuthSettngsPres").i("Password is wrong") + showError(InvalidPassphraseException()) + } + } + + override fun onError(e: Throwable) { + super.onError(e) + Timber.tag("BiomtricAuthSettngsPres").e(e, "Password check failed") + } + }) + } + + private fun unlockVault(vaultModel: VaultModel) { + view?.showProgress(ProgressModel.GENERIC) + unlockVaultUseCase // + .withVaultOrUnlockToken(VaultOrUnlockToken.from(vaultModel.toVault())) // + .andPassword(vaultModel.password) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + Timber.tag("BiomtricAuthSettngsPres").i("Password is correct") + onUnlockSucceeded(vaultModel) + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException(this@BiometricAuthSettingsPresenter, e, ActivityResultCallbacks.unlockVaultAfterAuth(vaultModel.toVault()))) { + showError(e) + Timber.tag("BiomtricAuthSettngsPres").e(e, "Password check failed") + } + } + }) + } + + private fun onUnlockSucceeded(vaultModel: VaultModel) { + lockVaultUseCase + .withVault(vaultModel.toVault()) + .run(object : DefaultResultHandler() { + override fun onSuccess(vault: Vault) { + super.onSuccess(vault) + onPasswordCheckSucceeded(vaultModel) + } + + override fun onError(e: Throwable) { + Timber.tag("BiomtricAuthSettngsPres").e(e, "Locking vault after unlocking failed but continue to save changes") + onPasswordCheckSucceeded(vaultModel) + } + }) + } + + @Callback + fun unlockVaultAfterAuth(result: ActivityResult, vault: Vault?) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + val vaultWithUpdatedCloud = Vault.aCopyOf(vault).withCloud(cloud).build() + unlockVault(VaultModel(vaultWithUpdatedCloud)) + } + + private fun onPasswordCheckSucceeded(vaultModel: VaultModel) { + view?.showBiometricAuthenticationDialog(vaultModel) + } + + fun saveVault(vault: Vault?) { + saveVaultUseCase // + .withVault(vault) // + .run(object : ProgressCompletingResultHandler() { + override fun onSuccess(vault: Vault) { + Timber.tag("BiomtricAuthSettngsPres").i("Saved updated vault successfully") + } + }) + } + + fun switchedGeneralBiometricAuthSettings(isChecked: Boolean) { + sharedPreferencesHandler // + .changeUseBiometricAuthentication(isChecked) + if (isChecked) { + loadVaultList() + } else { + view?.clearVaultList() + removePasswordFromAllVaults() + } + } + + private fun removePasswordFromAllVaults() { + getVaultListUseCase.run(object : DefaultResultHandler>() { + override fun onSuccess(vaults: List) { + vaults.filter { it.password != null }.forEach { removePasswordAndSave(it) } + } + }) + } + + private fun removePasswordAndSave(vault: Vault) { + val vaultWithRemovedPassword = Vault // + .aCopyOf(vault) // + .withSavedPassword(null) // + .build() + saveVault(vaultWithRemovedPassword) + } + + fun onSetupBiometricAuthInSystemClicked() { + val openSecuritySettings = Intent(Settings.ACTION_SECURITY_SETTINGS) + requestActivityResult(ActivityResultCallbacks.onSetupFingerCompleted(), openSecuritySettings) + } + + @Callback + fun onSetupFingerCompleted(result: ActivityResult?) { + view?.showSetupBiometricAuthDialog() + } + + fun onBiometricAuthKeyInvalidated(vaultModel: VaultModel?) { + removeStoredVaultPasswordsUseCase.run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + view?.showBiometricAuthKeyInvalidatedDialog() + } + }) + } + + fun onUnlockCanceled() { + loadVaultList() + } + + fun useConfirmationInFaceUnlockBiometricAuthentication(): Boolean { + return sharedPreferencesHandler.useConfirmationInFaceUnlockBiometricAuthentication() + } + + init { + unsubscribeOnDestroy(getVaultListUseCase, saveVaultUseCase, checkVaultPasswordUseCase, removeStoredVaultPasswordsUseCase, unlockVaultUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt new file mode 100644 index 000000000..c3f9f7fe1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -0,0 +1,1238 @@ +package org.cryptomator.presentation.presenter + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.DocumentsContract +import android.widget.Toast +import androidx.annotation.RequiresApi +import org.cryptomator.domain.CloudFile +import org.cryptomator.domain.CloudFolder +import org.cryptomator.domain.CloudNode +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.exception.* +import org.cryptomator.domain.usecases.* +import org.cryptomator.domain.usecases.cloud.* +import org.cryptomator.domain.usecases.vault.AssertUnlockedUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.generator.InjectIntent +import org.cryptomator.generator.InstanceState +import org.cryptomator.presentation.CryptomatorApp +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.exception.IllegalFileNameException +import org.cryptomator.presentation.intent.BrowseFilesIntent +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.IntentBuilder +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.* +import org.cryptomator.presentation.model.mappers.* +import org.cryptomator.presentation.service.OpenWritableFileNotification +import org.cryptomator.presentation.ui.activity.view.BrowseFilesView +import org.cryptomator.presentation.ui.dialog.ExportCloudFilesDialog +import org.cryptomator.presentation.ui.dialog.FileNameDialog +import org.cryptomator.presentation.util.* +import org.cryptomator.presentation.workflow.* +import org.cryptomator.util.ExceptionUtil +import org.cryptomator.util.Optional +import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.Supplier +import org.cryptomator.util.file.FileCacheUtils +import org.cryptomator.util.file.MimeType +import org.cryptomator.util.file.MimeTypes +import timber.log.Timber +import java.io.* +import java.security.DigestInputStream +import java.security.MessageDigest +import java.util.* +import javax.inject.Inject +import kotlin.collections.ArrayList +import kotlin.reflect.KClass + +@PerView +class BrowseFilesPresenter @Inject constructor( // + private val getCloudListUseCase: GetCloudListUseCase, // + private val createFolderUseCase: CreateFolderUseCase, // + private val downloadFilesUseCase: DownloadFilesUseCase, // + private val deleteNodesUseCase: DeleteNodesUseCase, // + private val uploadFilesUseCase: UploadFilesUseCase, // + private val renameFileUseCase: RenameFileUseCase, // + private val renameFolderUseCase: RenameFolderUseCase, // + private val copyDataUseCase: CopyDataUseCase, // + private val assertUnlockedUseCase: AssertUnlockedUseCase, // + private val fileUtil: FileUtil, // + private val fileNameBlacklist: FileNameBlacklist, // + private val folderNameBlacklist: FolderNameBlacklist, // + private val moveFilesUseCase: MoveFilesUseCase, // + private val moveFoldersUseCase: MoveFoldersUseCase, // + private val getCloudListRecursiveUseCase: GetCloudListRecursiveUseCase, // + private val contentResolverUtil: ContentResolverUtil, // + private val addExistingVaultWorkflow: AddExistingVaultWorkflow, // + private val createNewVaultWorkflow: CreateNewVaultWorkflow, // + private val fileCacheUtils: FileCacheUtils, // + authenticationExceptionHandler: AuthenticationExceptionHandler, // + private val cloudNodeModelMapper: CloudNodeModelMapper, // + private val cloudFileModelMapper: CloudFileModelMapper, // + private val cloudFolderModelMapper: CloudFolderModelMapper, // + private val mimeTypes: MimeTypes, // + private val progressStateModelMapper: ProgressStateModelMapper, // + private val progressModelMapper: ProgressModelMapper, // + private val shareFileHelper: ShareFileHelper, // + private val downloadFileUtil: DownloadFileUtil, // + private val sharedPreferencesHandler: SharedPreferencesHandler, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + private val authenticationExceptionHandler: AuthenticationExceptionHandler + private lateinit var filesForUpload: MutableMap + private lateinit var existingFilesForUpload: MutableMap + private lateinit var downloadFiles: MutableList + + @InjectIntent + lateinit var intent: BrowseFilesIntent + + @JvmField + @InstanceState + var uploadLocation: CloudFolderModel? = null + + @JvmField + @InstanceState + var uriToOpenedFile: Uri? = null + + @JvmField + @InstanceState + var openedCloudFile: CloudFileModel? = null + + @JvmField + @InstanceState + var openedCloudFileMd5: Optional = Optional.empty() + + private var openWritableFileNotification: Optional = Optional.empty() + + override fun workflows(): Iterable> { + return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) + } + + override fun resumed() { + val vault = view?.folder?.vault() + if (vault != null) { + assertUnlockedUseCase // + .withVault(vault.toVault()) // + .run(DefaultResultHandler()) + } + setRefreshOnBackpressEnabled(enableRefreshOnBackpressSupplier.setInAction(false)) + } + + fun onWindowFocusChanged(hasFocus: Boolean) { + if (hasFocus) { + resumed() + } + } + + fun onBackPressed() { + unsubscribeAll() + } + + fun onFolderDisplayed(folder: CloudFolderModel) { + view?.showLoading(true) + getCloudList(folder) + view?.updateTitle(folder) + } + + fun onRefreshTriggered(cloudModel: CloudFolderModel) { + view?.showLoading(true) + getCloudList(cloudModel) + } + + private fun getCloudList(cloudFolderModel: CloudFolderModel) { + getCloudListUseCase // + .withFolder(cloudFolderModel.toCloudNode()) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(cloudNodes: List) { + if (cloudNodes.isEmpty()) { + clearCloudList() + } else { + showCloudNodesCollectionInView(cloudNodes) + } + view?.showLoading(false) + } + + override fun onError(e: Throwable) { + view?.showLoading(false) + when { + authenticationExceptionHandler.handleAuthenticationException(this@BrowseFilesPresenter, e, ActivityResultCallbacks.getCloudListAfterAuthentication(cloudFolderModel)) -> { + return + } + e is EmptyDirFileException -> { + Intents.emptyDirIdFileInfoIntent() // + .withDirName(e.dirName) // + .withDirFilePath(e.filePath) // + .startActivity(this@BrowseFilesPresenter) + } + e is SymLinkException -> { + view?.showSymLinkDialog() + } + e is NoDirFileException -> { + view?.showNoDirFileDialog(e.cryptoFolderName, e.cloudFolderPath) + } + else -> { + super.onError(e) + } + } + } + }) + } + + @Callback + fun getCloudListAfterAuthentication(result: ActivityResult, cloudFolderModel: CloudFolderModel) { + val cloudModel = result.getSingleResult(CloudModel::class.java) + getCloudList(cloudFolderModelMapper.toModel(cloudFolderModel.toCloudNode().withCloud(cloudModel.toCloud()))) + } + + fun onCreateFolderPressed(cloudFolder: CloudFolderModel, folderName: String?) { + createFolderUseCase // + .withParent(cloudFolder.toCloudNode()) // + .andFolderName(folderName) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloudFolder: CloudFolder) { + view?.addOrUpdateCloudNode(cloudFolderModelMapper.toModel(cloudFolder)) + view?.closeDialog() + } + }) + } + + private fun copyFile(downloadFiles: List) { + downloadFiles.forEach { downloadFile -> + try { + val source = FileInputStream(fileUtil.fileFor(cloudFileModelMapper.toModel(downloadFile.downloadFile))) + + copyDataUseCase // + .withSource(source) // + .andTarget(downloadFile.dataSink) // + .run(object : DefaultResultHandler() { + override fun onFinished() { + view?.showMessage(R.string.screen_file_browser_msg_file_exported) + } + }) + } catch (e: FileNotFoundException) { + showError(e) + } + } + } + + private fun showCloudNodesCollectionInView(cloudNodes: List) { + val cloudNodeModels = cloudNodeModelMapper.toModels(cloudNodes).filter { cloudNode -> !isBlacklistedCloudNode(cloudNode) } + view?.showCloudNodes(cloudNodeModels) + } + + private fun isBlacklistedCloudNode(cloudNode: CloudNodeModel<*>): Boolean { + return if (cloudNode is CloudFileModel) { + fileNameBlacklist.isBlacklisted(cloudNode) + } else { + folderNameBlacklist.isBlacklisted(cloudNode as CloudFolderModel) + } + } + + private fun readFiles(cloudFiles: List) { + // TODO disable rotation + downloadFilesUseCase // + .withDownloadFiles(downloadFileUtil.createDownloadFilesFor(this, cloudFiles)) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onFinished() { + view?.closeDialog() + } + + override fun onSuccess(files: List) { + handleSuccessAfterReadingFiles(files, Intent.ACTION_SEND_MULTIPLE) + } + + override fun onError(e: Throwable) { + view?.closeDialog() + super.onError(e) + } + }) + } + + private fun readFilesWithProgress(cloudFiles: List, actionAfterDownload: String) { + view?.showProgress(cloudFiles, // + ProgressModel(progressStateModelMapper.toModel( // + DownloadState.download(cloudFiles[0].toCloudNode())), 0)) + downloadFilesUseCase // + .withDownloadFiles(downloadFileUtil.createDownloadFilesFor(this, cloudFiles)) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onFinished() { + view?.hideProgress(cloudFiles) + } + + override fun onProgress(progress: Progress) { + if (!progress.isOverallComplete) { + view?.showProgress(cloudFileModelMapper.toModel(progress.state().file()), // + progressModelMapper.toModel(progress)) + } + } + + override fun onSuccess(files: List) { + handleSuccessAfterReadingFiles(files, actionAfterDownload) + } + + override fun onError(e: Throwable) { + view?.hideProgress(cloudFiles) + super.onError(e) + } + }) + } + + private fun handleSuccessAfterReadingFiles(files: List, actionAfterDownload: String) { + try { + if (Intent.ACTION_VIEW == actionAfterDownload) { + viewFile(cloudFileModelMapper.toModel(files[0])) + } else { + if (Intent.ACTION_SEND_MULTIPLE == actionAfterDownload) { + shareFiles(cloudFileModelMapper.toModels(files)) + } else { + shareFileHelper.shareFile(this@BrowseFilesPresenter, cloudFileModelMapper.toModel(files[0])) + } + } + } catch (e: ActivityNotFoundException) { + view?.showFileTypeNotSupportedDialog(cloudFileModelMapper.toModel(files[0])) + } + } + + private fun exportFile(downloadFiles: List) { + view?.showDialog(ExportCloudFilesDialog.newInstance(downloadFiles.size)) + downloadFilesUseCase // + .withDownloadFiles(downloadFiles) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onProgress(progress: Progress) { + view?.showProgress(progressModelMapper.toModel(progress)) + } + + override fun onSuccess(cloudFiles: List) { + view?.closeDialog() + if (cloudFiles.size > 1) { + view?.showMessage(R.string.screen_file_browser_msg_files_exported) + } else { + view?.showMessage(R.string.screen_file_browser_msg_file_exported) + } + } + + override fun onError(e: Throwable) { + view?.closeDialog() + super.onError(e) + } + }) + } + + private fun uploadFiles(files: List) { + uploadLocation?.let { + uploadFilesUseCase // + .withParent(it.toCloudNode()) + .andFiles(files) // + .run(object : DefaultProgressAwareResultHandler, UploadState>() { + override fun onProgress(progress: Progress) { + view?.showProgress(progressModelMapper.toModel(progress)) + if (progress.isCompleteAndHasState && progress.state().isUpload) { + onUploadFileCompleted(progress.state().file().name) + } + } + + override fun onSuccess(files: List) { + files.forEach { file -> view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(file)) } + onFileUploadCompleted() + } + + override fun onError(e: Throwable) { + onFileUploadError() + if (ExceptionUtil.contains(e, CloudNodeAlreadyExistsException::class.java)) { + ExceptionUtil.extract(e, CloudNodeAlreadyExistsException::class.java).get().message + ?.let { message -> onCloudNodeAlreadyExists(message) } + } else { + super.onError(e) + } + } + }) + } + } + + private fun onUploadFileCompleted(name: String) { + filesForUpload.remove(name) + } + + private fun onCloudNodeAlreadyExists(fileNameAlreadyExists: String) { + addToExistingFiles(fileNameAlreadyExists) + view?.showReplaceDialog(listOf(fileNameAlreadyExists), filesForUpload.size) + } + + private fun clearCloudList() { + view?.showCloudNodes(ArrayList()) + } + + fun onRenameCloudNodePressed(cloudNodeModel: CloudNodeModel<*>, newCloudNodeName: String) { + if (cloudNodeModel is CloudFileModel) { + renameCloudFile(cloudNodeModel, newCloudNodeName) + } else { + renameCloudFolder(cloudNodeModel as CloudFolderModel, newCloudNodeName) + } + } + + private fun renameCloudFolder(cloudFolderModel: CloudFolderModel, newCloudFolderName: String) { + renameFolderUseCase // + .withFolder(cloudFolderModel.toCloudNode()) // + .andNewName(newCloudFolderName) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(cloudFolderResultRenamed: ResultRenamed) { + view?.replaceRenamedCloudNode(cloudNodeModelMapper.toModel(cloudFolderResultRenamed)) + view?.closeDialog() + } + }) + } + + private fun renameCloudFile(cloudFileModel: CloudFileModel, newCloudFileName: String) { + renameFileUseCase // + .withFile(cloudFileModel.toCloudNode()) // + .andNewName(newCloudFileName) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(cloudFileResultRenamed: ResultRenamed) { + view?.replaceRenamedCloudNode(cloudNodeModelMapper.toModel(cloudFileResultRenamed)) + view?.closeDialog() + } + }) + } + + fun onDeleteCloudNodes(nodes: List>) { + deleteCloudNode(nodes) + } + + private fun deleteCloudNode(nodes: List>) { + view?.showProgress(nodes, ProgressModel(ProgressStateModel.DELETION)) + deleteNodesUseCase // + .withCloudNodes(cloudNodeModelMapper.fromModels(nodes)) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(cloudNodes: List) { + view?.deleteCloudNodesFromAdapter(cloudNodeModelMapper.toModels(cloudNodes)) + } + }) + } + + private fun viewFile(cloudFile: CloudFileModel) { + val lowerFileName = cloudFile.name.toLowerCase(Locale.getDefault()) + if (lowerFileName.endsWith(".txt") || lowerFileName.endsWith(".md") || lowerFileName.endsWith(".todo")) { + startIntent(Intents.textEditorIntent() // + .withTextFile(cloudFile)) + } else if (!lowerFileName.endsWith(".gif") && mimeTypes.fromFilename(cloudFile.name) // + .orElse(MimeType.WILDCARD_MIME_TYPE) // + .mediatype == "image") { + val cloudFileNodes = previewCloudFileNodes + val imagePreviewStore = ImagePreviewFilesStore( // + cloudFileNodes, // + cloudFileNodes.indexOf(cloudFile)) + startIntent(Intents.imagePreviewIntent() // + .withWithImagePreviewFiles(fileUtil.storeImagePreviewFiles(imagePreviewStore))) + } else { + viewExternalFile(cloudFile) + } + } + + private fun viewExternalFile(cloudFile: CloudFileModel) { + val viewFileIntent = Intent(Intent.ACTION_VIEW) + fileUtil.contentUriFor(cloudFile).let { + uriToOpenedFile = it + openedCloudFile = cloudFile + openedCloudFileMd5 = calculateDigestFromUri(it) + viewFileIntent.setDataAndType( // + uriToOpenedFile, // + mimeTypes.fromFilename(cloudFile.name).map(MimeType.TO_STRING).orElse(null)) + viewFileIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { + openWritableFileNotification = Optional.of(OpenWritableFileNotification(context(), it)) + openWritableFileNotification.ifPresent { obj: OpenWritableFileNotification -> obj.show() } + val cryptomatorApp = activity().application as CryptomatorApp + cryptomatorApp.suspendLock() + } + activity().startActivityForResult(viewFileIntent, OPEN_FILE_FINISHED) + } + } + + private val previewCloudFileNodes: ArrayList + get() { + val previewCloudFiles = ArrayList() + view?.renderedCloudNodes() + ?.filterIsInstance() + ?.filterTo(previewCloudFiles) { + !it.name.endsWith(".gif") // + && mimeTypes.fromFilename(it.name) // + .orElse(MimeType.WILDCARD_MIME_TYPE).mediatype == "image" + } + return previewCloudFiles + } + + private fun shareFiles(shareFiles: List) { + val shareFilesIntent = Intent(Intent.ACTION_SEND_MULTIPLE) + shareFilesIntent.type = combinedMimeType(shareFiles).toString() + shareFilesIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, fileUtil.contentUrisFor(shareFiles)) + shareFilesIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + startIntent(Intent.createChooser(shareFilesIntent, getString(R.string.screen_file_browser_share_intent_chooser_title))) + disableSelectionMode() + } + + private fun combinedMimeType(shareFiles: List): MimeType { + var result: MimeType? = null + shareFiles.forEach { file -> + val type = mimeTypes.fromFilename(file.name).orElse(MimeType.WILDCARD_MIME_TYPE) + result = result?.combine(type) ?: type + } + return result ?: MimeType.WILDCARD_MIME_TYPE + } + + fun onFileClicked(cloudFile: CloudFileModel) { + readFilesWithProgress(listOf(cloudFile), Intent.ACTION_VIEW) + } + + fun onShareNodesClicked(nodes: List?>) { + val filesToShare: MutableList = ArrayList() + val foldersForRecursiveDirListing: MutableList = ArrayList() + nodes.forEach { node -> + if (node is CloudFileModel) { + filesToShare.add(node) + } else if (node is CloudFolderModel) { + foldersForRecursiveDirListing.add(node) + } + } + collectFolderContentForSharing(foldersForRecursiveDirListing, filesToShare) + disableSelectionMode() + } + + private fun collectFolderContentForSharing(folders: List, filesToShare: List) { + view?.showProgress(ProgressModel.GENERIC) + getCloudListRecursiveUseCase // + .withFolders(cloudFolderModelMapper.fromModels(folders)) // + .run(object : DefaultResultHandler() { + override fun onFinished() { + Timber.tag("BrowseFilesPresenter").d("collectFolderContentForSharing onFinished") + } + + override fun onSuccess(cloudNodeRecursiveListing: CloudNodeRecursiveListing) { + Timber.tag("BrowseFilesPresenter").d("cloud node recursive listing") + prepareSharingOf(cloudNodeRecursiveListing, filesToShare) + } + }) + } + + private fun prepareSharingOf(cloudNodeRecursiveListing: CloudNodeRecursiveListing, filesToShare: List) { + val files: MutableList = ArrayList(filesToShare) + cloudNodeRecursiveListing.foldersContent.forEach { folderRecursiveListing -> + files.addAll(prepareFolderContentForSharing(folderRecursiveListing)) + } + if (files.isEmpty()) { + view?.showMessage(R.string.screen_file_browser_nothing_to_share) + view?.closeDialog() + } else { + readFiles(files) + } + } + + private fun prepareFolderContentForSharing(folderContent: CloudFolderRecursiveListing): List { + val files: MutableList = ArrayList(cloudFileModelMapper.toModels(folderContent.files)) + folderContent.folders.forEach { folderRecursiveListing -> + files.addAll(prepareFolderContentForSharing(folderRecursiveListing)) + } + return files + } + + fun onShareFolderClicked(cloudFolder: CloudFolderModel?) { + val nodes = ArrayList?>() + nodes.add(cloudFolder) + onShareNodesClicked(nodes) + } + + fun onShareFileClicked(cloudFile: CloudFileModel) { + readFilesWithProgress(listOf(cloudFile), Intent.ACTION_SEND) + } + + private fun moveCloudFile(targetFolder: CloudFolderModel, sourceFiles: List) { + view?.showProgress(sourceFiles, ProgressModel(ProgressStateModel.MOVING)) + moveFilesUseCase // + .withParent(targetFolder.toCloudNode()) // + .andSourceFiles(cloudFileModelMapper.fromModels(sourceFiles)) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(cloudFiles: List) { + view?.deleteCloudNodesFromAdapter(sourceFiles) + } + }) + } + + private fun moveCloudFolder(targetFolder: CloudFolderModel, sourceFolders: List) { + view?.showProgress(sourceFolders, ProgressModel(ProgressStateModel.MOVING)) + moveFoldersUseCase // + .withParent(targetFolder.toCloudNode()) // + .andSourceFolders(cloudFolderModelMapper.fromModels(sourceFolders)) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(cloudFolder: List) { + view?.deleteCloudNodesFromAdapter(sourceFolders) + } + }) + } + + private fun prepareSelectedFilesForUpload(fileUris: List) { + filesForUpload = HashMap() + existingFilesForUpload = HashMap() + fileUris.forEach { uri -> + contentResolverUtil.fileName(uri)?.let { + filesForUpload[it] = createUploadFile(it, uri, false) + } + } + checkForExistingFilesOrUploadFiles() + } + + private fun checkForExistingFilesOrUploadFiles() { + view?.let { + if (hasUsedFileNamesAtLocation(it.renderedCloudNodes())) { + it.showReplaceDialog(ArrayList(existingFilesForUpload.keys), filesForUpload.size) + } else { + uploadFiles(filesForUpload, emptyMap()) + } + } + } + + private fun uploadFiles(nonReplacing: Map, replacing: Map) { + if (nonReplacing.size + replacing.size == 0) { + return + } + view?.showUploadDialog(nonReplacing.size + replacing.size) + val filesReadyForUpload: MutableList = ArrayList() + filesReadyForUpload.addAll(nonReplacing.values) + filesReadyForUpload.addAll(replacing.values) + uploadFiles(filesReadyForUpload) + } + + private fun hasUsedFileNamesAtLocation(currentCloudNodes: List>): Boolean { + currentCloudNodes + .filter { filesForUpload.containsKey(it.name) } + .forEach { + if (it is CloudFileModel) { + addToExistingFiles(it.name) + } else { + // remove file when name is used by a folder + filesForUpload.remove(it.name) + } + } + return existingFilesForUpload.isNotEmpty() + } + + private fun addToExistingFiles(nodeName: String) { + existingFilesForUpload[nodeName] = replacingUploadFile(nodeName) + } + + private fun replacingUploadFile(nodeName: String): UploadFile { + return UploadFile.aCopyOf( // + filesForUpload[nodeName]) // + .thatIsReplacing(true) // + .build() + } + + fun uploadFilesAndReplaceExistingFiles() { + differencesOfUploadAndExistingFiles() + uploadFiles(filesForUpload, existingFilesForUpload) + } + + fun uploadFilesAndSkipExistingFiles() { + differencesOfUploadAndExistingFiles() + uploadFiles(filesForUpload, emptyMap()) + } + + private fun onFileUploadCompleted() { + view?.showProgress(ProgressModel.COMPLETED) + uploadLocation = null + } + + private fun onFileUploadError() { + view?.closeDialog() + } + + private fun differencesOfUploadAndExistingFiles() { + filesForUpload.keys.removeAll(existingFilesForUpload.keys) + } + + fun onFolderChosen(chosenFolder: CloudFolderModel?) { + if (view?.hasExcludedFolder() == true) { + view?.showMessage(context().getString(R.string.error_file_or_folder_exists)) + } else { + finishWithResult(chosenFolder) + } + } + + fun onFileChosen(cloudFile: CloudFileModel?) { + finishWithResult(cloudFile) + } + + fun onFolderClicked(cloudFolderModel: CloudFolderModel) { + unsubscribeAll() + view?.navigateTo(cloudFolderModel) + } + + fun onExportFileClicked(cloudFile: CloudFileModel, trigger: ExportOperation) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + exportFileToDownloadDirectory(cloudFile, trigger) + } else { + exportFileToUserSelectedLocation(cloudFile, trigger) + } + } + + fun onExportNodesClicked(selectedCloudFiles: ArrayList>, trigger: ExportOperation) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + exportNodesToUserSelectedLocation(selectedCloudFiles, trigger) + } + } + + private fun exportFileToDownloadDirectory(fileToExport: CloudFileModel, exportOperation: ExportOperation) { + requestPermissions(PermissionsResultCallbacks.exportFileToDownloadDirectory(fileToExport, exportOperation), // + R.string.permission_message_export_file, Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + @Callback + fun exportFileToDownloadDirectory(result: PermissionsResult, fileToExport: CloudFileModel, exportOperation: ExportOperation) { + if (result.granted()) { + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val cryptomatorDownloads = File(downloads, context().getString(R.string.download_subdirectory_name)) + cryptomatorDownloads.mkdirs() + if (cryptomatorDownloads.isDirectory) { + val target = File(cryptomatorDownloads, fileToExport.name) + try { + val downloadFile = DownloadFile.Builder() // + .setDownloadFile(fileToExport.toCloudNode()) // + .setDataSink(FileOutputStream(target)) // + .build() + exportOperation.export(this, listOf(downloadFile)) + } catch (e: FileNotFoundException) { + showError(e) + } + } else { + view?.showError(R.string.screen_file_browser_msg_creating_download_dir_failed) + } + } + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + private fun exportFileToUserSelectedLocation(fileToExport: CloudFileModel, exportOperation: ExportOperation) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_TITLE, fileToExport.name) + requestActivityResult(ActivityResultCallbacks.exportFileToUserSelectedLocation(fileToExport, exportOperation), intent) + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun exportNodesToUserSelectedLocation(nodesToExport: ArrayList>, exportOperation: ExportOperation) { + try { + requestActivityResult( // + ActivityResultCallbacks.pickedLocalStorageLocation(nodesToExport, exportOperation), // + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) + } catch (exception: ActivityNotFoundException) { + Toast // + .makeText( // + activity().applicationContext, // + context().getText(R.string.screen_cloud_local_error_no_content_provider), // + Toast.LENGTH_SHORT) // + .show() + Timber.tag("BrowseFilesPresenter").e(exception, "Export file: No ContentProvider on system") + } + } + + @Callback + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + fun pickedLocalStorageLocation(result: ActivityResult, // + nodesToExport: ArrayList>, // + exportOperation: ExportOperation) { + val pickedLocalStorageLocation = DocumentsContract.buildChildDocumentsUriUsingTree( // + Uri.parse(result.intent().data.toString()), // + DocumentsContract.getTreeDocumentId( // + Uri.parse(result.intent().data.toString()))) + collectNodesToExport(pickedLocalStorageLocation, // + exportOperation, // + nodesToExport) + disableSelectionMode() + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun collectNodesToExport(parentUri: Uri, // + exportOperation: ExportOperation, // + nodesToExport: List>) { + val filesToExport: MutableList = ArrayList() + val foldersForRecursiveDirListing: MutableList = ArrayList() + nodesToExport.forEach { node -> + if (node is CloudFileModel) { + filesToExport.add(node) + } else { + foldersForRecursiveDirListing.add(node as CloudFolderModel) + } + } + collectFolderContentForExport(parentUri, exportOperation, foldersForRecursiveDirListing, filesToExport) + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun collectFolderContentForExport(parentUri: Uri, exportOperation: ExportOperation, folders: List, // + filesToExport: List) { + view?.showProgress(ProgressModel.GENERIC) + getCloudListRecursiveUseCase // + .withFolders(cloudFolderModelMapper.fromModels(folders)) // + .run(object : DefaultResultHandler() { + override fun onFinished() { + Timber.tag("BrowseFilesPresenter").d("collectFolderContentForExport onFinished") + } + + override fun onSuccess(cloudNodeRecursiveListing: CloudNodeRecursiveListing) { + Timber.tag("BrowseFilesPresenter").d("cloud node recursive listing") + prepareExportingOf(parentUri, exportOperation, filesToExport, cloudNodeRecursiveListing) + } + }) + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun prepareExportingOf(parentUri: Uri, exportOperation: ExportOperation, filesToExport: List, cloudNodeRecursiveListing: CloudNodeRecursiveListing) { + downloadFiles = ArrayList() + downloadFiles.addAll(prepareFilesForExport(cloudFileModelMapper.fromModels(filesToExport), parentUri)) + cloudNodeRecursiveListing.foldersContent.forEach { folderRecursiveListing -> + prepareFolderContentForExport(folderRecursiveListing, parentUri) + } + if (downloadFiles.isEmpty()) { + view?.showMessage(R.string.screen_file_browser_nothing_to_export) + view?.closeDialog() + } else { + exportOperation.export(this, downloadFiles) + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun prepareFilesForExport(filesToExport: List, parentUri: Uri): List { + return filesToExport.mapTo(ArrayList()) { createDownloadFile(it, parentUri) } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun prepareFolderContentForExport(cloudFolderRecursiveListing: CloudFolderRecursiveListing, parentUri: Uri) { + createFolder(parentUri, cloudFolderRecursiveListing.parent.name)?.let { + downloadFiles.addAll(prepareFilesForExport(cloudFolderRecursiveListing.files, it)) + cloudFolderRecursiveListing.folders.forEach { childFolder -> + prepareFolderContentForExport(childFolder, it) + } + } ?: throw FatalBackendException("Failed to create parent folder for export") + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun createFolder(parentUri: Uri, folderName: String): Uri? { + return try { + DocumentsContract.createDocument( // + context().contentResolver, // + parentUri, // + DocumentsContract.Document.MIME_TYPE_DIR, // + folderName) + } catch (e: FileNotFoundException) { + Timber.tag("BrowseFilesPresenter").e(e) + throw IllegalStateException("Creating folder failed") + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private fun createDownloadFile(file: CloudFile, documentUri: Uri): DownloadFile { + return try { + DownloadFile.Builder() // + .setDownloadFile(file) // + .setDataSink(contentResolverUtil.openOutputStream( // + createNewDocumentUri(documentUri, file.name))) // + .build() + } catch (e: FileNotFoundException) { + showError(e) + disableSelectionMode() + throw FatalBackendException(e) + } catch (e: NoSuchCloudFileException) { + showError(e) + disableSelectionMode() + throw FatalBackendException(e) + } catch (e: IllegalFileNameException) { + showError(e) + disableSelectionMode() + throw FatalBackendException(e) + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Throws(IllegalFileNameException::class, NoSuchCloudFileException::class) + private fun createNewDocumentUri(parentUri: Uri, fileName: String): Uri { + val mimeType = mimeTypes.fromFilename(fileName) // + .orElse(MimeType.APPLICATION_OCTET_STREAM) + val newDocumentUri: Uri? + newDocumentUri = try { + DocumentsContract.createDocument( // + context().contentResolver, // + parentUri, // + mimeType.toString(), // + fileName) + } catch (e: FileNotFoundException) { + throw NoSuchCloudFileException(fileName) + } + if (newDocumentUri == null) { + throw IllegalFileNameException() + } + return newDocumentUri + } + + @Callback + fun exportFileToUserSelectedLocation(result: ActivityResult, fileToExport: CloudFileModel, exportOperation: ExportOperation) { + requestPermissions(PermissionsResultCallbacks.exportFileToUserSelectedLocation(result.intent().dataString, fileToExport, exportOperation), // + R.string.permission_message_export_file, // + Manifest.permission.READ_EXTERNAL_STORAGE) + } + + @Callback + fun exportFileToUserSelectedLocation(result: PermissionsResult, uriString: String?, fileToExport: CloudFileModel, exportOperation: ExportOperation) { + if (result.granted()) { + try { + val downloadFile = DownloadFile.Builder() // + .setDownloadFile(fileToExport.toCloudNode()) // + .setDataSink(contentResolverUtil.openOutputStream(Uri.parse(uriString))) // + .build() + exportOperation.export(this, listOf(downloadFile)) + } catch (e: FileNotFoundException) { + showError(e) + } + } + } + + fun onUploadFilesClicked(folder: CloudFolderModel) { + uploadLocation = folder + requestPermissions(PermissionsResultCallbacks.selectFiles(), // + R.string.permission_message_upload_file, // + Manifest.permission.READ_EXTERNAL_STORAGE) + } + + fun onUploadCanceled() { + uploadFilesUseCase.cancel() + } + + @Callback + fun selectFiles(result: PermissionsResult) { + if (result.granted()) { + var intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + intent = Intent.createChooser(intent, context().getString(R.string.screen_file_browser_upload_files_chooser_title)) + requestActivityResult(ActivityResultCallbacks.selectedFiles(), intent) + } + } + + @Callback + fun selectedFiles(result: ActivityResult) { + val fileUris = getFileUrisFromIntent(result.intent()) + prepareSelectedFilesForUpload(fileUris) + } + + private fun getFileUrisFromIntent(intent: Intent): List { + val fileUris: MutableList = ArrayList() + intent.clipData?.let { + (0 until it.itemCount).forEach { i -> + fileUris.add(it.getItemAt(i).uri) + } + } ?: intent.data?.let { + fileUris.add(it) + } + return fileUris + } + + private fun moveIntentFor(parent: CloudFolderModel, sourceNodes: List>): IntentBuilder { + val foldersToMove = nodesFor(sourceNodes, CloudFolderModel::class) as List + return Intents.browseFilesIntent() // + .withTitle(effectiveMoveTitle()) // + .withFolder(parent) // + .withChooseCloudNodeSettings( // + ChooseCloudNodeSettings.chooseCloudNodeSettings() // + .withExtraTitle(effectiveMoveExtraTitle(sourceNodes)) // + .withButtonText(context().getString(R.string.screen_file_browser_move_button_text)) // + .withNavigationMode(ChooseCloudNodeSettings.NavigationMode.MOVE_CLOUD_NODE) // + .withExtraToolbarIcon(R.drawable.ic_clear) // + .selectingFoldersNotContaining(sourceNodes.map { node -> node.name }) // + .excludingFolder(if (foldersToMove.isEmpty()) null else foldersToMove) // + .build()) + } + + private fun effectiveMoveTitle(): String { + return if (intent.folder().name.isEmpty()) // + intent.title() else // + intent.folder().name + } + + private fun effectiveMoveExtraTitle(sourceNodes: List>): String { + return context().resources.getQuantityString(R.plurals.screen_file_browser_subtitle_move, sourceNodes.size, sourceNodes[0].name, sourceNodes.size) + } + + private fun nodesFor(nodes: List>, nodeTypeClass: KClass>): List> { + return nodes.filter { node -> nodeTypeClass.isInstance(node) } + } + + fun onMoveNodeClicked(parent: CloudFolderModel, nodeToMove: CloudNodeModel<*>) { + val cloudNodeModels = ArrayList>() + cloudNodeModels.add(nodeToMove) + onMoveNodesClicked(parent, cloudNodeModels) + } + + fun onMoveNodesClicked(parent: CloudFolderModel, nodesToMove: ArrayList>) { + requestActivityResult(ActivityResultCallbacks.moveNodes(nodesToMove), // + moveIntentFor(parent, nodesToMove)) + } + + @Callback + fun moveNodes(result: ActivityResult, nodesToMove: ArrayList>) { + setRefreshOnBackpressEnabled(enableRefreshOnBackpressSupplier.setInAction(true)) + val targetFolder = result.getSingleResult(CloudFolderModel::class.java) + moveCloudFile(targetFolder, nodesFor(nodesToMove, CloudFileModel::class) as List) + moveCloudFolder(targetFolder, nodesFor(nodesToMove, CloudFolderModel::class) as List) + disableSelectionMode() + } + + fun disableSelectionMode() { + setRefreshOnBackpressEnabled(enableRefreshOnBackpressSupplier.setInSelectionMode(false)) + view?.disableSelectionMode() + } + + fun onCreateNewTextFileClicked() { + view?.showDialog(FileNameDialog()) + } + + fun onOpenWithTextFileClicked(textFile: CloudFileModel, newlyCreated: Boolean, internalEditor: Boolean) { + val decryptData = downloadFileUtil.createDecryptedDataFor(this, textFile) + downloadFilesUseCase // + .withDownloadFiles( // + listOf(DownloadFile.Builder() // + .setDownloadFile(textFile.toCloudNode()) // + .setDataSink(decryptData) // + .build())) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onProgress(progress: Progress) { + if (!newlyCreated) { + view?.showProgress(textFile, progressModelMapper.toModel(progress)) + } + } + + override fun onSuccess(files: List) { + if (!newlyCreated) { + view?.hideProgress(textFile) + } + if (internalEditor) { + startIntent(Intents.textEditorIntent() // + .withTextFile(textFile)) + } else { + viewExternalFile(textFile) + } + } + + override fun onError(e: Throwable) { + super.onError(e) + view?.hideProgress(textFile) + } + }) + } + + fun onCreateNewTextFileClicked(parent: CloudFolderModel, fileName: String) { + view?.showProgress(ProgressModel(ProgressStateModel.CREATING_TEXT_FILE)) + val tmpFileUri = fileCacheUtils.tmpFile().empty().create() + uploadFilesUseCase // + .withParent(parent.toCloudNode()) // + .andFiles(listOf(createUploadFile(fileName, tmpFileUri, false))) // + .run(object : DefaultProgressAwareResultHandler, UploadState>() { + override fun onSuccess(cloudFile: List) { + val cloudFileModel = cloudFileModelMapper.toModel(cloudFile[0]) + view?.addOrUpdateCloudNode(cloudFileModel) + onOpenWithTextFileClicked(cloudFileModel, newlyCreated = true, internalEditor = true) + view?.closeDialog() + } + }) + } + + private fun createUploadFile(fileName: String, uri: Uri, replacing: Boolean): UploadFile { + return UploadFile.anUploadFile() // + .withFileName(fileName) // + .withDataSource(UriBasedDataSource.from(uri)) // + .thatIsReplacing(replacing) // + .build() + } + + fun onFolderRedisplayed(folder: CloudFolderModel) { + view?.updateTitle(folder) + } + + fun onAddContentClicked() { + view?.showAddContentDialog() + } + + fun onNodeSettingsClicked(node: CloudNodeModel<*>) { + view?.showNodeSettingsDialog(node) + } + + fun onSelectionModeActivated() { + setRefreshOnBackpressEnabled(enableRefreshOnBackpressSupplier.setInSelectionMode(true)) + view?.enableSelectionMode() + } + + fun onSelectedNodesChanged(selectedNodes: Int) { + if (selectedNodes == 0) { + view?.disableGeneralSelectionActions() + } else { + view?.enableGeneralSelectionActions() + } + view?.updateSelectionTitle(selectedNodes) + } + + fun onFolderReloadContent(folder: CloudFolderModel) { + getCloudList(folder) + } + + fun onExportFolderClicked(cloudFolder: CloudFolderModel, exportTriggeredByUser: ExportOperation) { + val nodes = ArrayList>() + nodes.add(cloudFolder) + onExportNodesClicked(nodes, exportTriggeredByUser) + } + + fun exportNodesCanceled() { + downloadFilesUseCase.unsubscribe() + view?.closeDialog() + } + + fun invalidateOptionsMenu() { + activity().invalidateOptionsMenu() + } + + fun openFileFinished() { + try { + // necessary see https://gitlab.skymatic.de/cryptomator/android/-/issues/569 + Thread.sleep(500) + } catch (e: InterruptedException) { + Timber.tag("BrowseFilesPresenter").e(e, "Failed to sleep after resuming editing, necessary for google office apps") + } + if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { + val cryptomatorApp = activity().application as CryptomatorApp + cryptomatorApp.unSuspendLock() + } + hideWritableNotification() + + context().revokeUriPermission(uriToOpenedFile, Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + + uriToOpenedFile?.let { + val hashAfterEdit = calculateDigestFromUri(it) + if (hashAfterEdit.isPresent && openedCloudFileMd5.isPresent // + && Arrays.equals(hashAfterEdit.get(), openedCloudFileMd5.get())) { + Timber.tag("BrowseFilesPresenter").i("Opened app finished, file not changed") + } else { + uploadChangedFile() + } + } + } + + private fun uploadChangedFile() { + view?.showUploadDialog(1) + openedCloudFile?.let { openedCloudFile -> + uriToOpenedFile?.let { uriToOpenedFile -> + uploadFilesUseCase // + .withParent(openedCloudFile.parent.toCloudNode()) // + .andFiles(listOf(createUploadFile(openedCloudFile.name, uriToOpenedFile, true))) // + .run(object : DefaultProgressAwareResultHandler, UploadState>() { + override fun onProgress(progress: Progress) { + view?.showProgress(progressModelMapper.toModel(progress)) + } + + override fun onSuccess(files: List) { + files.forEach { file -> + view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(file)) + } + onFileUploadCompleted() + } + + override fun onError(e: Throwable) { + onFileUploadError() + if (ExceptionUtil.contains(e, CloudNodeAlreadyExistsException::class.java)) { + ExceptionUtil.extract(e, CloudNodeAlreadyExistsException::class.java).get().message?.let { + onCloudNodeAlreadyExists(it) + } ?: super.onError(e) + } else { + super.onError(e) + } + } + }) + } + } + } + + private fun hideWritableNotification() { + // openWritableFileNotification can not be made serializable because of this, can be null after Activity resumed + if (openWritableFileNotification.isAbsent) { + openWritableFileNotification = Optional.of(OpenWritableFileNotification(context(), Uri.EMPTY)) + } + openWritableFileNotification.ifPresent { obj: OpenWritableFileNotification -> obj.hide() } + } + + private fun calculateDigestFromUri(uri: Uri): Optional { + val digest = MessageDigest.getInstance("MD5") + DigestInputStream(context().contentResolver.openInputStream(uri), digest).use { dis -> + val buffer = ByteArray(8192) + // Read all bytes: + while (dis.read(buffer) > -1) { + } + } + return Optional.ofNullable(digest.digest()) + } + + interface ExportOperation : Serializable { + + fun export(presenter: BrowseFilesPresenter, downloadFiles: List) + + } + + private val enableRefreshOnBackpressSupplier = RefreshSupplier() + + class RefreshSupplier : Supplier { + private var inSelectionMode = false + private var inAction = false + fun setInAction(inAction: Boolean): RefreshSupplier { + this.inAction = inAction + return this + } + + fun setInSelectionMode(inSelectionMode: Boolean): RefreshSupplier { + this.inSelectionMode = inSelectionMode + return this + } + + override fun get(): Boolean { + return !(inSelectionMode || inAction) + } + } + + companion object { + const val OPEN_FILE_FINISHED = 12 + val EXPORT_AFTER_APP_CHOOSER: ExportOperation = object : ExportOperation { + override fun export(presenter: BrowseFilesPresenter, downloadFiles: List) { + presenter.copyFile(downloadFiles) + } + } + val EXPORT_TRIGGERED_BY_USER: ExportOperation = object : ExportOperation { + override fun export(presenter: BrowseFilesPresenter, downloadFiles: List) { + presenter.exportFile(downloadFiles) + } + } + } + + init { + unsubscribeOnDestroy( // + getCloudListUseCase, // + createFolderUseCase, // + downloadFilesUseCase, // + deleteNodesUseCase, // + uploadFilesUseCase, // + renameFileUseCase, // + renameFolderUseCase, // + copyDataUseCase, // + moveFilesUseCase, // + moveFoldersUseCase) + this.authenticationExceptionHandler = authenticationExceptionHandler + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt new file mode 100644 index 000000000..afabda800 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/ChooseCloudServicePresenter.kt @@ -0,0 +1,85 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.domain.usecases.cloud.GetCloudsUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.CloudTypeModel +import org.cryptomator.presentation.model.mappers.CloudModelMapper +import org.cryptomator.presentation.ui.activity.view.ChooseCloudServiceView +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow +import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow +import org.cryptomator.presentation.workflow.Workflow +import javax.inject.Inject + +@PerView +class ChooseCloudServicePresenter @Inject constructor( // + private val getCloudsUseCase: GetCloudsUseCase, // + private val cloudModelMapper: CloudModelMapper, // + private val addExistingVaultWorkflow: AddExistingVaultWorkflow, // + private val createNewVaultWorkflow: CreateNewVaultWorkflow, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + override fun workflows(): Iterable> { + return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) + } + + override fun resumed() { + val cloudTypeModels: MutableList = ArrayList(listOf(*CloudTypeModel.values())) + cloudTypeModels.remove(CloudTypeModel.CRYPTO) + view?.render(cloudTypeModels) + } + + fun cloudPicked(cloudTypeModel: CloudTypeModel) { + if (cloudTypeModel.isMultiInstance) { + handleMultiInstanceClouds(cloudTypeModel) + } else { + handleSingleInstanceClouds(cloudTypeModel) + } + } + + private fun handleMultiInstanceClouds(cloudTypeModel: CloudTypeModel) { + startCloudConnectionListActivity(cloudTypeModel) + } + + private fun startCloudConnectionListActivity(cloudTypeModel: CloudTypeModel) { + requestActivityResult( // + ActivityResultCallbacks.cloudConnectionListFinished(), // + Intents.cloudConnectionListIntent() // + .withCloudType(cloudTypeModel) // + .withDialogTitle(context().getString(R.string.screen_cloud_connections_title)) // + .withFinishOnCloudItemClick(true)) + } + + @Callback + fun cloudConnectionListFinished(result: ActivityResult) { + val cloud = result.intent().getSerializableExtra(CloudConnectionListPresenter.SELECTED_CLOUD) as Cloud + onCloudSelected(cloud) + } + + private fun handleSingleInstanceClouds(cloudTypeModel: CloudTypeModel) { + getCloudsUseCase // + .withCloudType(CloudTypeModel.valueOf(cloudTypeModel)) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(clouds: List) { + if (clouds.size > 1) { + throw FatalBackendException("More then one cloud") + } + onCloudSelected(clouds[0]) + } + }) + } + + private fun onCloudSelected(cloud: Cloud) { + finishWithResult(cloudModelMapper.toModel(cloud)) + } + + init { + unsubscribeOnDestroy(getCloudsUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt new file mode 100644 index 000000000..e4e565aca --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudConnectionListPresenter.kt @@ -0,0 +1,217 @@ +package org.cryptomator.presentation.presenter + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.annotation.RequiresApi +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.LocalStorageCloud +import org.cryptomator.domain.Vault +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.cloud.AddOrChangeCloudConnectionUseCase +import org.cryptomator.domain.usecases.cloud.GetCloudsUseCase +import org.cryptomator.domain.usecases.cloud.RemoveCloudUseCase +import org.cryptomator.domain.usecases.vault.DeleteVaultUseCase +import org.cryptomator.domain.usecases.vault.GetVaultListUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +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.WebDavCloudModel +import org.cryptomator.presentation.model.mappers.CloudModelMapper +import org.cryptomator.presentation.ui.activity.view.CloudConnectionListView +import org.cryptomator.presentation.workflow.ActivityResult +import timber.log.Timber +import java.util.* +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject + +@PerView +class CloudConnectionListPresenter @Inject constructor( // + private val getCloudsUseCase: GetCloudsUseCase, // + private val removeCloudUseCase: RemoveCloudUseCase, // + private val addOrChangeCloudConnectionUseCase: AddOrChangeCloudConnectionUseCase, // + private val getVaultListUseCase: GetVaultListUseCase, // + private val deleteVaultUseCase: DeleteVaultUseCase, // + private val cloudModelMapper: CloudModelMapper, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + private val selectedCloudType = AtomicReference() + private var defaultLocalStorageCloud: LocalStorageCloud? = null + fun setSelectedCloudType(selectedCloudType: CloudTypeModel) { + this.selectedCloudType.set(selectedCloudType) + } + + fun loadCloudList() { + getCloudsUseCase // + .withCloudType(CloudTypeModel.valueOf(selectedCloudType.get())) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(clouds: List) { + val cloudModels: MutableList = ArrayList() + clouds.forEach { cloud -> + if (CloudTypeModel.LOCAL == selectedCloudType.get()) { + if ((cloud as LocalStorageCloud).rootUri() == null) { + defaultLocalStorageCloud = cloud + return@forEach + } + } + cloudModels.add(cloudModelMapper.toModel(cloud)) + } + view?.showCloudModels(cloudModels) + } + }) + } + + fun onDeleteCloudClicked(cloudModel: CloudModel) { + getVaultListUseCase.run(object : DefaultResultHandler>() { + override fun onSuccess(vaults: List) { + val vaultsOfCloud = vaultsFor(cloudModel, vaults) + if (vaultsOfCloud.isEmpty()) { + deleteCloud(cloudModel) + } else { + view?.showCloudConnectionHasVaultsDialog(cloudModel, vaultsOfCloud) + } + } + }) + } + + private fun vaultsFor(cloudModel: CloudModel, allVaults: List): ArrayList { + return allVaults.filterTo(ArrayList()) { it.cloud.type() == cloudModel.toCloud().type() } + } + + fun onDeleteCloudConnectionAndVaults(cloudModel: CloudModel, vaultsOfCloud: ArrayList) { + vaultsOfCloud.forEach { vault -> + deleteVault(vault) + } + deleteCloud(cloudModel) + } + + private fun deleteVault(vault: Vault) { + deleteVaultUseCase.withVault(vault).run(DefaultResultHandler()) + } + + private fun deleteCloud(cloudModel: CloudModel) { + if (cloudModel.cloudType() == CloudTypeModel.LOCAL) { + releaseUriPermissionForLocalStorageCloud(cloudModel as LocalStorageModel) + } + deleteCloud(cloudModel.toCloud()) + } + + private fun releaseUriPermissionForLocalStorageCloud(cloudModel: LocalStorageModel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && (cloudModel.toCloud() as LocalStorageCloud).rootUri() != null) { + releaseUriPermission(cloudModel.uri()) + } + } + + private fun deleteCloud(cloud: Cloud) { + removeCloudUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(ignore: Void?) { + loadCloudList() + } + }) + } + + fun onAddConnectionClicked() { + when (selectedCloudType.get()) { + CloudTypeModel.WEBDAV -> requestActivityResult(ActivityResultCallbacks.addChangeWebDavCloud(), // + Intents.webDavAddOrChangeIntent()) + CloudTypeModel.LOCAL -> openDocumentTree() + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun openDocumentTree() { + try { + requestActivityResult( // + ActivityResultCallbacks.pickedLocalStorageLocation(), // + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) + } catch (exception: ActivityNotFoundException) { + Toast // + .makeText( // + activity().applicationContext, // + context().getText(R.string.screen_cloud_local_error_no_content_provider), // + Toast.LENGTH_SHORT) // + .show() + Timber.tag("CloudConnListPresenter").e(exception, "No ContentProvider on system") + } + } + + 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") + } + } + + fun onNodeSettingsClicked(cloudModel: CloudModel) { + view?.showNodeSettings(cloudModel) + } + + @Callback + fun addChangeWebDavCloud(result: ActivityResult?) { + loadCloudList() + } + + @Callback + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + fun pickedLocalStorageLocation(result: ActivityResult) { + val rootTreeUriOfLocalStorage = result.intent().data + persistUriPermission(rootTreeUriOfLocalStorage) + addOrChangeCloudConnectionUseCase.withCloud(LocalStorageCloud.aLocalStorage() // + .withRootUri(rootTreeUriOfLocalStorage.toString()) // + .build()) // + .run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + loadCloudList() + } + }) + } + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + private fun persistUriPermission(rootTreeUriOfLocalStorage: Uri?) { + rootTreeUriOfLocalStorage?.let { + context() // + .contentResolver // + .takePersistableUriPermission( // + it, // + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + } + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + private fun releaseUriPermission(uri: String) { + context() // + .contentResolver // + .releasePersistableUriPermission( // + Uri.parse(uri), // + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + fun onCloudConnectionClicked(cloudModel: CloudModel) { + if (view?.isFinishOnNodeClicked == true) { + finishWithResult(SELECTED_CLOUD, cloudModel.toCloud()) + } + } + + fun onDefaultLocalCloudConnectionClicked() { + finishWithResult(SELECTED_CLOUD, defaultLocalStorageCloud) + } + + companion object { + const val SELECTED_CLOUD = "selectedCloudConnection" + } + + init { + unsubscribeOnDestroy(getCloudsUseCase, removeCloudUseCase, addOrChangeCloudConnectionUseCase, getVaultListUseCase, deleteVaultUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt new file mode 100644 index 000000000..4b2b6f993 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CloudSettingsPresenter.kt @@ -0,0 +1,141 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.LocalStorageCloud +import org.cryptomator.domain.WebDavCloud +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.domain.usecases.cloud.GetAllCloudsUseCase +import org.cryptomator.domain.usecases.cloud.GetCloudsUseCase +import org.cryptomator.domain.usecases.cloud.LogoutCloudUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +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.WebDavCloudModel +import org.cryptomator.presentation.model.mappers.CloudModelMapper +import org.cryptomator.presentation.ui.activity.view.CloudSettingsView +import org.cryptomator.presentation.workflow.ActivityResult +import java.util.* +import javax.inject.Inject + +@PerView +class CloudSettingsPresenter @Inject constructor( // + private val getAllCloudsUseCase: GetAllCloudsUseCase, // + private val getCloudsUseCase: GetCloudsUseCase, // + private val logoutCloudUsecase: LogoutCloudUseCase, // + private val cloudModelMapper: CloudModelMapper, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + private val nonSingleLoginClouds: Set = EnumSet.of( // + CloudTypeModel.CRYPTO, // + CloudTypeModel.LOCAL, // + CloudTypeModel.WEBDAV) + + fun loadClouds() { + getAllCloudsUseCase.run(CloudsSubscriber()) + } + + fun onCloudClicked(cloudModel: CloudModel) { + if (isWebdavOrLocal(cloudModel)) { + startConnectionListActivity(cloudModel.cloudType()) + } else { + if (isLoggedIn(cloudModel)) { + logoutCloudUsecase // + .withCloud(cloudModel.toCloud()) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + loadClouds() + } + }) + } else { + loginCloud(cloudModel) + } + } + } + + private fun isWebdavOrLocal(cloudModel: CloudModel): Boolean { + return cloudModel is WebDavCloudModel || cloudModel is LocalStorageModel + } + + private fun loginCloud(cloudModel: CloudModel) { + getCloudsUseCase // + .withCloudType(CloudTypeModel.valueOf(cloudModel.cloudType())) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(clouds: List) { + if (clouds.size > 1) { + throw FatalBackendException("More then one cloud") + } + startAuthentication(clouds[0]) + } + }) + } + + private fun isLoggedIn(cloudModel: CloudModel): Boolean { + return cloudModel.username() != null + } + + private fun startConnectionListActivity(cloudTypeModel: CloudTypeModel) { + requestActivityResult( // + ActivityResultCallbacks.webDavConnectionListFinisheds(), // + Intents.cloudConnectionListIntent() // + .withCloudType(cloudTypeModel) // + .withDialogTitle(effectiveTitle(cloudTypeModel)) // + .withFinishOnCloudItemClick(false)) + } + + private fun effectiveTitle(cloudTypeModel: CloudTypeModel): String { + when (cloudTypeModel) { + CloudTypeModel.WEBDAV -> return context().getString(R.string.screen_cloud_settings_webdav_connections) + CloudTypeModel.LOCAL -> return context().getString(R.string.screen_cloud_settings_local_storage_locations) + } + return context().getString(R.string.screen_cloud_settings_title) + } + + @Callback + fun webDavConnectionListFinisheds(result: ActivityResult) { + val cloud = result.intent().getSerializableExtra(CloudConnectionListPresenter.SELECTED_CLOUD) as Cloud + startAuthentication(cloud) + } + + private fun startAuthentication(cloud: Cloud) { + requestActivityResult( // + ActivityResultCallbacks.onCloudAuthenticated(), // + Intents.authenticateCloudIntent() // + .withCloud(cloudModelMapper.toModel(cloud))) + } + + @Callback + fun onCloudAuthenticated(result: ActivityResult) { + view?.update(result.getSingleResult(CloudModel::class.java)) + } + + private inner class CloudsSubscriber : DefaultResultHandler>() { + override fun onSuccess(clouds: List) { + val cloudModel = cloudModelMapper.toModels(clouds).filter { isSingleLoginCloud(it) }.toMutableList() // + .also { + it.add(aWebdavCloud()) + it.add(aLocalCloud()) + } + view?.render(cloudModel) + } + + private fun aWebdavCloud(): WebDavCloudModel { + return WebDavCloudModel(WebDavCloud.aWebDavCloudCloud().build()) + } + + private fun aLocalCloud(): CloudModel { + return LocalStorageModel(LocalStorageCloud.aLocalStorage().build()) + } + } + + private fun isSingleLoginCloud(cloudModel: CloudModel): Boolean { + return !nonSingleLoginClouds.contains(cloudModel.cloudType()) + } + + init { + unsubscribeOnDestroy(getAllCloudsUseCase, getCloudsUseCase, logoutCloudUsecase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/ContextHolder.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/ContextHolder.kt new file mode 100644 index 000000000..ad4de1692 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/ContextHolder.kt @@ -0,0 +1,7 @@ +package org.cryptomator.presentation.presenter + +import android.content.Context + +interface ContextHolder { + fun context(): Context +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/CreateVaultPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/CreateVaultPresenter.kt new file mode 100644 index 000000000..6b4e12731 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/CreateVaultPresenter.kt @@ -0,0 +1,34 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.ui.activity.view.CreateVaultView +import org.cryptomator.presentation.util.FileNameValidator.Companion.isInvalidName +import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow +import org.cryptomator.presentation.workflow.Workflow +import javax.inject.Inject + +@PerView +class CreateVaultPresenter @Inject constructor( // + private val createNewVaultWorkflow: CreateNewVaultWorkflow, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + override fun workflows(): Iterable> { + return setOf(createNewVaultWorkflow) + } + + fun onCreateVaultClicked(vaultName: String) { + when { + vaultName.isEmpty() -> { + view?.showMessage(R.string.screen_enter_vault_name_msg_name_empty) + } + isInvalidName(vaultName) -> { + view?.showMessage(R.string.error_vault_name_contains_invalid_characters) + } + else -> { + finishWithResult(vaultName) + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/EmptyDirIdFileInfoPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/EmptyDirIdFileInfoPresenter.kt new file mode 100644 index 000000000..e59ae5e07 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/EmptyDirIdFileInfoPresenter.kt @@ -0,0 +1,17 @@ +package org.cryptomator.presentation.presenter + +import android.content.Intent +import android.net.Uri +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.ui.activity.view.EmptyDirFileView +import javax.inject.Inject + +@PerView +class EmptyDirIdFileInfoPresenter @Inject constructor(exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + fun onShowMoreInfoButtonPressed() { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse("https://cryptomator.org/help/articles/sanitizer") + context().startActivity(intent) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/ImagePreviewPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/ImagePreviewPresenter.kt new file mode 100644 index 000000000..da636827c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/ImagePreviewPresenter.kt @@ -0,0 +1,201 @@ +package org.cryptomator.presentation.presenter + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import org.cryptomator.domain.CloudFile +import org.cryptomator.domain.CloudNode +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.domain.usecases.CopyDataUseCase +import org.cryptomator.domain.usecases.cloud.DeleteNodesUseCase +import org.cryptomator.domain.usecases.cloud.DownloadFilesUseCase +import org.cryptomator.domain.usecases.cloud.DownloadState +import org.cryptomator.generator.Callback +import org.cryptomator.generator.InstanceState +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.model.ImagePreviewFile +import org.cryptomator.presentation.model.ImagePreviewFilesStore +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.mappers.CloudFileModelMapper +import org.cryptomator.presentation.ui.activity.view.ImagePreviewView +import org.cryptomator.presentation.ui.dialog.ConfirmDeleteCloudNodeDialog +import org.cryptomator.presentation.util.ContentResolverUtil +import org.cryptomator.presentation.util.DownloadFileUtil +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.presentation.util.ShareFileHelper +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.PermissionsResult +import org.cryptomator.util.ExceptionUtil +import timber.log.Timber +import java.io.* +import java.util.* +import javax.inject.Inject + +@PerView +class ImagePreviewPresenter @Inject constructor( // + exceptionMappings: ExceptionHandlers, // + private val shareFileHelper: ShareFileHelper, // + private val contentResolverUtil: ContentResolverUtil, // + private val copyDataUseCase: CopyDataUseCase, // + private val downloadFilesUseCase: DownloadFilesUseCase, // + private val deleteNodesUseCase: DeleteNodesUseCase, // + private val downloadFileUtil: DownloadFileUtil, // + private val fileUtil: FileUtil, // + private val cloudFileModelMapper: CloudFileModelMapper) : Presenter(exceptionMappings) { + + private var isSystemUiVisible = true + + @InstanceState + lateinit var pageIndexes: ArrayList + + fun onExportImageClicked(uri: Uri) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + copyFileToDownloadDirectory(uri) + } else { + copyFileToUserSelectedLocation(uri) + } + } + + private fun copyFileToDownloadDirectory(uri: Uri) { + requestPermissions(PermissionsResultCallbacks.copyFileToDownloadDirectory(uri.toString()), // + R.string.permission_message_export_file, Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + @Callback + fun copyFileToDownloadDirectory(result: PermissionsResult, uriString: String?) { + if (result.granted()) { + val uriFile = Uri.parse(uriString) + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val cryptomatorDownloads = File(downloads, context().getString(R.string.download_subdirectory_name)) + cryptomatorDownloads.mkdirs() + if (cryptomatorDownloads.isDirectory) { + val target = File(cryptomatorDownloads, contentResolverUtil.fileName(uriFile)) + try { + copyFile(contentResolverUtil.openInputStream(uriFile), FileOutputStream(target)) + } catch (e: FileNotFoundException) { + showError(e) + } + } else { + view?.showError(R.string.screen_file_browser_msg_creating_download_dir_failed) + } + } + } + + private fun copyFile(source: InputStream?, target: OutputStream?) { + if (source == null || target == null) { + throw FatalBackendException("Input- or OutputStream is null") + } + copyDataUseCase // + .withSource(source) // + .andTarget(target) // + .run(object : DefaultResultHandler() { + override fun onFinished() { + view?.showMessage(R.string.screen_file_browser_msg_file_exported) + } + }) + } + + private fun copyFileToUserSelectedLocation(uri: Uri) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "*/*" + intent.putExtra(Intent.EXTRA_TITLE, contentResolverUtil.fileName(uri)) + requestActivityResult(ActivityResultCallbacks.copyFileToUserSelectedLocation(uri.toString()), intent) + } + + @Callback + fun copyFileToUserSelectedLocation(result: ActivityResult, sourceUri: String?) { + requestPermissions(PermissionsResultCallbacks.copyFileToUserSelectedLocation(result.intent()?.dataString, sourceUri), // + R.string.permission_message_export_file, // + Manifest.permission.READ_EXTERNAL_STORAGE) + } + + @Callback + fun copyFileToUserSelectedLocation(result: PermissionsResult, targetUri: String?, sourceUri: String?) { + if (result.granted()) { + try { + copyFile(contentResolverUtil.openInputStream(Uri.parse(sourceUri)), // + contentResolverUtil.openOutputStream(Uri.parse(targetUri))) + } catch (e: FileNotFoundException) { + showError(e) + } + } + } + + fun onShareImageClicked(uri: Uri) { + shareFileHelper.shareFile(this, uri) + } + + fun onDeleteImageClicked(imagePreviewFile: ImagePreviewFile) { + view?.showDialog(ConfirmDeleteCloudNodeDialog.newInstance(listOf(imagePreviewFile.cloudFileModel))) + } + + fun onDeleteImageConfirmed(imagePreviewFile: ImagePreviewFile, index: Int) { + view?.showProgress(ProgressModel.GENERIC) + deleteNodesUseCase + .withCloudNodes(listOf(imagePreviewFile.cloudFileModel.toCloudNode())) + .run(object : ProgressCompletingResultHandler?>() { + override fun onFinished() { + view?.showProgress(ProgressModel.COMPLETED) + view?.onImageDeleted(index) + } + + override fun onError(e: Throwable) { + Timber.tag("ImagePreviewPresenter").e(e, "Failed to delete preview image") + view?.showProgress(ProgressModel.COMPLETED) + } + }) + } + + fun onImagePreviewClicked() { + if (isSystemUiVisible) { + view?.hideSystemUi() + } else { + view?.showSystemUi() + } + isSystemUiVisible = !isSystemUiVisible + } + + fun onMissingImagePreviewFile(imagePreviewFile: ImagePreviewFile) { + downloadFilesUseCase // + .withDownloadFiles(downloadFileUtil.createDownloadFilesFor(this, listOf(imagePreviewFile.cloudFileModel))) // + .run(object : DefaultProgressAwareResultHandler, DownloadState>() { + override fun onSuccess(result: List) { + cloudFileModelMapper.toModel(result[0]) + imagePreviewFile.uri = fileUtil.contentUriFor(cloudFileModelMapper.toModel(result[0])) + view?.showImagePreview(imagePreviewFile) + view?.hideProgressBar(imagePreviewFile) + } + + override fun onError(e: Throwable) { + if (ExceptionUtil.contains(e, IOException::class.java, ExceptionUtil.thatContainsMessage("Stream Closed"))) { + // ignore error + Timber.tag("ImagePreviewPresenter").d("User swiped to quickly and close the stream before finishing the download.") + } else { + super.onError(e) + } + } + }) + } + + fun getImagePreviewFileStore(path: String): ImagePreviewFilesStore { + return fileUtil.getImagePreviewFiles(path) + } + + fun getImagePreviewFiles(imagePreviewFileStore: ImagePreviewFilesStore, index: Int): ArrayList { + val imagePreviewFiles = imagePreviewFileStore.cloudFileModels.mapTo(ArrayList()) { ImagePreviewFile(it, null) } + + // first file is already downloaded + val cloudFileModel = imagePreviewFileStore.cloudFileModels[index] + imagePreviewFiles[index] = ImagePreviewFile(cloudFileModel, fileUtil.contentUriFor(cloudFileModel)) + return imagePreviewFiles + } + + init { + unsubscribeOnDestroy(copyDataUseCase, downloadFilesUseCase, deleteNodesUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt new file mode 100644 index 000000000..8a321d0fb --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/LicenseCheckPresenter.kt @@ -0,0 +1,52 @@ +package org.cryptomator.presentation.presenter + +import android.net.Uri +import org.cryptomator.domain.usecases.DoLicenseCheckUseCase +import org.cryptomator.domain.usecases.LicenseCheck +import org.cryptomator.domain.usecases.NoOpResultHandler +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.ui.activity.view.UpdateLicenseView +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import javax.inject.Inject + +class LicenseCheckPresenter @Inject internal constructor( + exceptionHandlers: ExceptionHandlers, // + private val doLicenseCheckUsecase: DoLicenseCheckUseCase, // + private val sharedPreferencesHandler: SharedPreferencesHandler) : Presenter(exceptionHandlers) { + + fun validate(data: Uri?) { + if (data != null) { + val license = data.lastPathSegment ?: "" + view?.showOrUpdateLicenseDialog(license) + doLicenseCheckUsecase + .withLicense(license) + .run(CheckLicenseStatusSubscriber()) + } + } + + fun validateDialogAware(license: String?) { + doLicenseCheckUsecase + .withLicense(license) + .run(CheckLicenseStatusSubscriber()) + } + + private inner class CheckLicenseStatusSubscriber : NoOpResultHandler() { + override fun onSuccess(licenseCheck: LicenseCheck) { + super.onSuccess(licenseCheck) + view?.closeDialog() + Timber.tag("LicenseCheckPresenter").i("Your license is valid!") + sharedPreferencesHandler.setMail(licenseCheck.mail()) + view?.showConfirmationDialog(licenseCheck.mail()) + } + + override fun onError(t: Throwable) { + super.onError(t) + showError(t) + } + } + + init { + unsubscribeOnDestroy(doLicenseCheckUsecase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/Presenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/Presenter.kt new file mode 100644 index 000000000..da26d858e --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/Presenter.kt @@ -0,0 +1,327 @@ +package org.cryptomator.presentation.presenter + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import org.cryptomator.domain.usecases.NoOpResultHandler +import org.cryptomator.domain.usecases.ProgressAwareResultHandler +import org.cryptomator.domain.usecases.cloud.ProgressState +import org.cryptomator.generator.BoundCallback +import org.cryptomator.generator.InstanceState +import org.cryptomator.generator.Unsubscribable +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.exception.PermissionNotGrantedException +import org.cryptomator.presentation.intent.IntentBuilder +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.ui.activity.view.View +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AsyncResult +import org.cryptomator.presentation.workflow.PermissionsResult +import org.cryptomator.presentation.workflow.Workflow +import org.cryptomator.util.Supplier +import timber.log.Timber +import java.io.Serializable +import java.util.* +import kotlin.collections.ArrayList +import kotlin.collections.HashMap +import kotlin.collections.HashSet + +abstract class Presenter protected constructor(private val exceptionMappings: ExceptionHandlers) : ActivityHolder { + var isPaused = false + private set + private var refreshOnBackpressEnabled = Supplier { true } + + var view: V? = null + set(value) { + field = value + workflows().forEach { workflow -> + workflow.setup(this, activity().intent) + } + } + + private val unsubscribables: MutableList = ArrayList() + + protected fun unsubscribeOnDestroy(vararg unsubscribables: Unsubscribable) { + this.unsubscribables.addAll(listOf(*unsubscribables)) + } + + fun resume() { + logLifecycle("resume") + isPaused = false + dispatchLaterAsyncResults() + resumed() + } + + fun pause() { + logLifecycle("pause") + isPaused = true + } + + fun finishWithResult(result: Serializable?) { + finishWithResult(SINGLE_RESULT, result) + } + + fun finishWithResultAndExtra(result: Serializable?, extraName: String?, extraResult: Serializable?) { + val data = Intent() + if (result == null) { + activity().setResult(Activity.RESULT_CANCELED) + } else { + data.putExtra(SINGLE_RESULT, result) + data.putExtra(extraName, extraResult) + activity().setResult(Activity.RESULT_OK, data) + } + finish() + } + + fun finishWithResult(resultName: String, result: Serializable?) { + activeWorkflow()?.dispatch(result) + ?: run { + val data = Intent() + when (result) { + null -> { + activity().setResult(Activity.RESULT_CANCELED) + } + is Throwable -> { + data.putExtra(resultName, result) + activity().setResult(Activity.RESULT_CANCELED, data) + } + else -> { + data.putExtra(resultName, result) + activity().setResult(Activity.RESULT_OK, data) + } + } + finish() + } + } + + private fun activeWorkflow(): Workflow<*>? { + workflows().forEach { workflow -> + if (workflow.isRunning) { + return workflow + } + } + return null + } + + override fun activity(): Activity { + return view!!.activity() + } + + override fun context(): Context { + return view!!.context() + } + + fun getString(resId: Int): String { + return context().getString(resId) + } + + fun finish() { + view?.finish() + } + + fun startIntent(intentBuilder: IntentBuilder) { + startIntent(intentBuilder.build(this)) + } + + fun startIntent(intent: Intent?) { + activity().startActivity(intent) + } + + open fun resumed() {} + + + fun destroy() { + logLifecycle("destroy") + unsubscribeAll() + destroyed() + } + + protected fun unsubscribeAll() { + unsubscribables.forEach { unsubscribable -> + unsubscribable.unsubscribe() + } + } + + open fun destroyed() {} + + open fun workflows(): Iterable> { + return emptyList() + } + + fun onNewIntent(intent: Intent) { + workflows().forEach { workflow -> + workflow.complete(intent) + } + } + + open inner class DefaultResultHandler : NoOpResultHandler() { + override fun onError(e: Throwable) { + showError(e) + } + } + + open inner class DefaultProgressAwareResultHandler : ProgressAwareResultHandler.NoOp() { + override fun onError(e: Throwable) { + showError(e) + } + } + + open inner class ProgressCompletingResultHandler : DefaultResultHandler() { + override fun onFinished() { + view?.showProgress(ProgressModel.COMPLETED) + } + } + + fun showError(e: Throwable) { + view?.let { exceptionMappings.handle(it, e) } + } + + fun showProgress(progress: ProgressModel) { + if (!isPaused) { + view?.showProgress(progress) + } + } + + @JvmField + @InstanceState + var nextActivityForResultRequestCode = 1 + + @JvmField + @InstanceState + var nextRequestPermissionsRequestCode = 1 + + @JvmField + @InstanceState + var activityResultCallbacks = HashMap>() + + @JvmField + @InstanceState + var permissionsResultCallbacks = HashMap>() + + @JvmField + @InstanceState + var permissionSnackbarText = HashMap() + private val toDispatchLater = Collections.synchronizedSet(HashSet()) + + fun requestActivityResult(callback: BoundCallback<*, out ActivityResult?>, intentBuilder: IntentBuilder) { + requestActivityResult(callback, intentBuilder.build(this)) + } + + fun requestActivityResult(callback: BoundCallback<*, out ActivityResult?>, intent: Intent?) { + val requestCode = nextActivityForResultRequestCode++ + activityResultCallbacks[requestCode] = callback + activity().startActivityForResult(intent, requestCode) + } + + fun requestPermissions(callback: BoundCallback<*, out PermissionsResult>, permissionSnackbarTextId: Int, vararg requiredPermissions: String) { + val missingPermissions = missingPermissions(requiredPermissions) + val requestCode = nextRequestPermissionsRequestCode++ + if (missingPermissions.isEmpty()) { + dispatch(PermissionsResult(callback, true)) + return + } + permissionsResultCallbacks[requestCode] = callback + permissionSnackbarText[requestCode] = permissionSnackbarTextId + requestPermissions(missingPermissions, requestCode) + } + + private fun requestPermissions(missingPermissions: Array, requestCode: Int) { + ActivityCompat.requestPermissions(activity(), missingPermissions, requestCode) + } + + private fun showSnackbarWithAppSettings(permissionRationaleId: Int) { + showError(PermissionNotGrantedException(permissionRationaleId)) + } + + private fun missingPermissions(permissions: Array): Array { + val result = arrayOfNulls(permissions.size) + var numberMissing = 0 + permissions + .asSequence() + .filter { ContextCompat.checkSelfPermission(activity(), it) != PackageManager.PERMISSION_GRANTED } + .forEach { result[numberMissing++] = it } + return result.copyOfRange(0, numberMissing) + } + + private fun dispatch(asyncResult: AsyncResult) { + val callback = asyncResult.callback() + val instance = findInstanceFor(callback) + if (instance == null) { + Timber.e("No instance found for callback type %s", callback.declaringType.name) + } else { + callback.call(instance, asyncResult) + } + } + + private fun dispatchLater(asyncResult: AsyncResult) { + if (isPaused) { + toDispatchLater.add(asyncResult) + } else { + dispatch(asyncResult) + } + } + + private fun dispatchLaterAsyncResults() { + val toDispatch = toDispatchLater.iterator() + while (toDispatch.hasNext()) { + dispatch(toDispatch.next()) + toDispatch.remove() + } + } + + private fun findInstanceFor(callback: BoundCallback<*, *>): Any? { + if (callback.declaringType.isInstance(this)) { + return this + } + return workflows().firstOrNull { callback.declaringType.isInstance(it) } + } + + fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + val callback = activityResultCallbacks.remove(requestCode) + if (callback == null) { + Timber.tag("ActivityResult").w("Missing callback") + return + } + if (resultCode == Activity.RESULT_OK || callback.acceptsNonOkResults()) { + dispatch(ActivityResult(callback, intent, resultCode == Activity.RESULT_OK)) + } + } + + fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + val callback = permissionsResultCallbacks[requestCode]!! + val permissionRationaleId = permissionSnackbarText[requestCode]!! + val permissionsGranted = allGranted(grantResults) + if (!permissionsGranted) { + showSnackbarWithAppSettings(permissionRationaleId) + } + dispatchLater(PermissionsResult(callback, permissionsGranted)) + } + + private fun allGranted(grantResults: IntArray): Boolean { + grantResults.forEach { grantResult -> + if (grantResult != PackageManager.PERMISSION_GRANTED) { + return false + } + } + return true + } + + private fun logLifecycle(method: String) { + Timber.tag("PresenterLifecycle").d("$method $this") + } + + fun setRefreshOnBackpressEnabled(refreshOnBackpressEnabled: BrowseFilesPresenter.RefreshSupplier) { + this.refreshOnBackpressEnabled = refreshOnBackpressEnabled + } + + fun isRefreshOnBackpressEnabled(): Boolean { + return refreshOnBackpressEnabled.get() + } + + companion object { + const val SINGLE_RESULT = "singleResult" + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SetPasswordPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SetPasswordPresenter.kt new file mode 100644 index 000000000..0532ec357 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SetPasswordPresenter.kt @@ -0,0 +1,36 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.ui.activity.view.SetPasswordView +import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow +import org.cryptomator.presentation.workflow.Workflow +import javax.inject.Inject + +@PerView +class SetPasswordPresenter @Inject constructor( // + private val createNewVaultWorkflow: CreateNewVaultWorkflow, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + override fun workflows(): Iterable> { + return setOf(createNewVaultWorkflow) + } + + fun validatePasswords(password: String, passwordRetyped: String) { + if (valid(password, passwordRetyped)) { + finishWithResult(password) + } + } + + private fun valid(password: String, passwordRetyped: String): Boolean { + if (password.isEmpty()) { + view?.showMessage(R.string.screen_set_password_msg_password_empty) + return false + } else if (password != passwordRetyped) { + view?.showMessage(R.string.screen_set_password_msg_password_mismatch) + return false + } + return true + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt new file mode 100644 index 000000000..be8508742 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SettingsPresenter.kt @@ -0,0 +1,237 @@ +package org.cryptomator.presentation.presenter + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import android.os.Build +import android.widget.Toast +import org.cryptomator.data.util.NetworkConnectionCheck +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.DoUpdateCheckUseCase +import org.cryptomator.domain.usecases.DoUpdateUseCase +import org.cryptomator.domain.usecases.NoOpResultHandler +import org.cryptomator.domain.usecases.UpdateCheck +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.BuildConfig +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.logging.Logfiles +import org.cryptomator.presentation.logging.ReleaseLogger +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.service.PhotoContentJob.Companion.scheduleJob +import org.cryptomator.presentation.ui.activity.view.SettingsView +import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog +import org.cryptomator.presentation.ui.dialog.UpdateAppDialog +import org.cryptomator.presentation.util.EmailBuilder +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.presentation.workflow.PermissionsResult +import org.cryptomator.util.Optional +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.inject.Inject + +@PerView +class SettingsPresenter @Inject internal constructor( + private val updateCheckUseCase: DoUpdateCheckUseCase, // + private val updateUseCase: DoUpdateUseCase, // + private val networkConnectionCheck: NetworkConnectionCheck, // + exceptionMappings: ExceptionHandlers, // + private val fileUtil: FileUtil, // + private val sharedPreferencesHandler: SharedPreferencesHandler) : Presenter(exceptionMappings) { + + fun onSendErrorReportClicked() { + view?.showProgress(ProgressModel.GENERIC) + // no usecase here because the backend is not involved + CreateErrorReportArchiveTask().execute() + } + + fun onDebugModeChanged(enabled: Boolean) { + ReleaseLogger.updateDebugMode(enabled) + } + + private fun sendErrorReport(attachment: File) { + EmailBuilder.anEmail() // + .to("support@cryptomator.org") // + .withSubject(context().getString(R.string.error_report_subject)) // + .withBody(errorReportEmailBody()) // + .attach(attachment) // + .send(activity()) + } + + private fun errorReportEmailBody(): String { + var variant = "PlayStore" + if (BuildConfig.FLAVOR == "license") { + variant = "ApkStore" + } + return StringBuilder().append("## ").append(context().getString(R.string.error_report_subject)).append("\n\n") // + .append("### ").append(context().getString(R.string.error_report_section_summary)).append('\n') // + .append(context().getString(R.string.error_report_summary_description)).append("\n\n") // + .append("### ").append(context().getString(R.string.error_report_section_device)).append("\n") // + .append("Cryptomator v").append(BuildConfig.VERSION_NAME).append(" (").append(BuildConfig.VERSION_CODE).append(") ").append(variant).append("\n") // + .append("Android ").append(Build.VERSION.RELEASE).append(" / API").append(Build.VERSION.SDK_INT).append("\n") // + .append("Device ").append(Build.MODEL) // + .toString() + } + + fun grantLocalStoragePermissionForAutoUpload() { + requestPermissions(PermissionsResultCallbacks.onLocalStoragePermissionGranted(), // + R.string.permission_snackbar_auth_auto_upload, // + Manifest.permission.READ_EXTERNAL_STORAGE, // + Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + @Callback + fun onLocalStoragePermissionGranted(result: PermissionsResult) { + if (result.granted()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + scheduleJob(context()) + } + } else { + view?.disableAutoUpload() + } + } + + fun onCheckUpdateClicked() { + if (networkConnectionCheck.isPresent) { + updateCheckUseCase // + .withVersion(BuildConfig.VERSION_NAME) + .run(object : NoOpResultHandler?>() { + override fun onSuccess(result: Optional?) { + if (result?.isPresent == true) { + result.get()?.let { updateStatusRetrieved(it, context()) } + } else { + Timber.tag("SettingsPresenter").i("UpdateCheck finished, latest version") + Toast.makeText(context(), getString(R.string.notification_update_check_finished_latest), Toast.LENGTH_SHORT).show() + } + sharedPreferencesHandler.updateExecuted() + view?.refreshUpdateTimeView() + } + + override fun onError(e: Throwable) { + showError(e) + } + }) + } else { + Toast.makeText(context(), R.string.error_update_no_internet, Toast.LENGTH_SHORT).show() + } + } + + private fun updateStatusRetrieved(updateCheck: UpdateCheck, context: Context) { + showNextMessage(updateCheck.releaseNote(), context) + } + + private fun showNextMessage(message: String, context: Context) { + if (message.isNotEmpty()) { + view?.showDialog(UpdateAppAvailableDialog.newInstance(message)) + } else { + view?.showDialog(UpdateAppAvailableDialog.newInstance(context.getText(R.string.dialog_update_available_message).toString())) + } + } + + fun installUpdate() { + view?.showDialog(UpdateAppDialog.newInstance()) + val uri = fileUtil.contentUriForNewTempFile("cryptomator.apk") + val file = fileUtil.tempFile("cryptomator.apk") + updateUseCase // + .withFile(file) // + .run(object : NoOpResultHandler() { + override fun onError(e: Throwable) { + showError(e) + } + + override fun onSuccess(result: Void?) { + super.onSuccess(result) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, "application/vnd.android.package-archive") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context().startActivity(intent) + } + }) + } + + private inner class CreateErrorReportArchiveTask : AsyncTask() { + override fun doInBackground(vararg params: Void?): File? { + return try { + createErrorReportArchive() + } catch (e: IOException) { + publishProgress(e) + null + } + } + + override fun onProgressUpdate(vararg values: IOException?) { + Timber.e(values[0], "Sending error report failed") + view?.showError(R.string.screen_settings_error_report_failed) + } + + override fun onPostExecute(attachment: File?) { + attachment?.let { sendErrorReport(it) } + view?.showProgress(ProgressModel.COMPLETED) + } + } + + @Throws(IOException::class) + private fun createErrorReportArchive(): File { + val logfileArchive = prepareLogfileArchive() + createZipArchive(logfileArchive, Logfiles.logfiles(context())) + return logfileArchive + } + + @Throws(IOException::class) + private fun prepareLogfileArchive(): File { + val logsDir = File(activity().cacheDir, "logs") + if (!logsDir.exists() && !logsDir.mkdirs()) { + throw IOException("Failed to create logs directory") + } + val logfileArchive = File(logsDir, "logs.zip") + deleteIfExists(logfileArchive) + return logfileArchive + } + + @Throws(IOException::class) + private fun createZipArchive(target: File, entries: Iterable) { + ZipOutputStream(FileOutputStream(target)).use { logs -> + Logfiles.existingLogfiles(activity()).forEach { logfile -> + addLogfile(logs, logfile) + } + } + } + + @Throws(IOException::class) + private fun addLogfile(logs: ZipOutputStream, logfile: File) { + val entry = ZipEntry(logfile.name) + entry.time = logfile.lastModified() + logs.putNextEntry(entry) + FileInputStream(logfile).use { `in` -> + val buffer = ByteArray(4096) + var count = 0 + while (count != EOF) { + logs.write(buffer, 0, count) + count = `in`.read(buffer) + } + } + } + + private fun deleteIfExists(file: File) { + if (file.exists()) { + // noinspection ResultOfMethodCallIgnored + file.delete() + } + } + + companion object { + private const val EOF = -1 + } + + init { + unsubscribeOnDestroy(updateCheckUseCase, updateUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt new file mode 100644 index 000000000..382aafbe5 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SharedFilesPresenter.kt @@ -0,0 +1,454 @@ +package org.cryptomator.presentation.presenter + +import android.Manifest +import android.net.Uri +import org.cryptomator.data.cloud.crypto.CryptoCloud +import org.cryptomator.domain.* +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.GetDecryptedCloudForVaultUseCase +import org.cryptomator.domain.usecases.cloud.* +import org.cryptomator.domain.usecases.vault.GetVaultListUseCase +import org.cryptomator.domain.usecases.vault.RemoveStoredVaultPasswordsUseCase +import org.cryptomator.domain.usecases.vault.UnlockVaultUseCase +import org.cryptomator.domain.usecases.vault.VaultOrUnlockToken +import org.cryptomator.generator.Callback +import org.cryptomator.generator.InstanceState +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.* +import org.cryptomator.presentation.model.mappers.CloudFolderModelMapper +import org.cryptomator.presentation.model.mappers.ProgressModelMapper +import org.cryptomator.presentation.ui.activity.view.SharedFilesView +import org.cryptomator.presentation.util.ContentResolverUtil +import org.cryptomator.presentation.util.FileNameValidator.Companion.isInvalidName +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler +import org.cryptomator.presentation.workflow.PermissionsResult +import org.cryptomator.util.Optional +import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.file.FileCacheUtils +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +@PerView +class SharedFilesPresenter @Inject constructor( // + private val getVaultListUseCase: GetVaultListUseCase, // + private val unlockVaultUseCase: UnlockVaultUseCase, // + private val getRootFolderUseCase: GetRootFolderUseCase, // + private val getDecryptedCloudForVaultUseCase: GetDecryptedCloudForVaultUseCase, // + private val uploadFilesUseCase: UploadFilesUseCase, // + private val getCloudListUseCase: GetCloudListUseCase, // + private val removeStoredVaultPasswordsUseCase: RemoveStoredVaultPasswordsUseCase, // + private val contentResolverUtil: ContentResolverUtil, // + private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val fileCacheUtils: FileCacheUtils, // + private val authenticationExceptionHandler: AuthenticationExceptionHandler, // + private val cloudFolderModelMapper: CloudFolderModelMapper, // + private val progressModelMapper: ProgressModelMapper, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + private val filesForUpload: MutableSet = HashSet() + private val existingFilesForUpload: MutableSet = HashSet() + + @JvmField + @InstanceState + var selectedVault: VaultModel? = null + + @JvmField + @InstanceState + var location: CloudFolderModel? = null + private var hasFileNameConflict = false + private var authenticationState: AuthenticationState? = null + private var tmpTextFileUri: Uri? = null + + fun onFileShared(uri: Uri) { + if (contentResolverUtil.isFileUriPointingToFolder(uri)) { + Timber.tag("SharedFile").i("Received 1 folder") + collectFolderContent(uri) + } else { + Timber.tag("SharedFile").i("Received 1 file") + contentResolverUtil.fileName(uri) + ?.let { filesForUpload.add(createUploadFile(it, uri)) } + ?: Timber.tag("SharedFile").i("The file doesn't have a path in the URI") + } + } + + fun onFilesShared(uris: List) { + Timber.tag("SharedFile").i("Received %d files", uris.size) + uris.forEach { uri -> + contentResolverUtil.fileName(uri) + ?.let { filesForUpload.add(createUploadFile(it, uri)) } + ?: Timber.tag("SharedFile").i("The file doesn't have a path in the URI") + } + } + + fun onTextShared(text: String?) { + tmpTextFileUri = fileCacheUtils.tmpFile().withContent(text).create() + val fileName = context().getString(R.string.screen_share_files_new_text_file) + tmpTextFileUri?.let { filesForUpload.add(createUploadFile(fileName, it)) } + Timber.tag("SharedText").i("Received text") + } + + fun initialize() { + displayFilesToUpload() + displayVaults() + } + + private fun collectFolderContent(uri: Uri) { + val fileUris = contentResolverUtil.collectFolderContent(uri) + onFilesShared(fileUris) + } + + fun displayVaults() { + getVaultListUseCase.run(object : DefaultResultHandler>() { + override fun onSuccess(vaults: List) { + if (vaults.isEmpty()) { + view?.displayDialogUnableToUploadFiles() + } else { + val vaultModels: MutableList = vaults.mapTo(ArrayList()) { VaultModel(it) } + view?.displayVaults(vaultModels) + } + } + }) + } + + private fun displayFilesToUpload() { + view?.displayFilesToUpload(filesForUploadAsSharedFileModels()) + } + + private fun filesForUploadAsSharedFileModels(): List { + return filesForUpload.mapTo(ArrayList(filesForUpload.size)) { SharedFileModel(it, it.fileName) } + } + + private fun authenticate(vaultModel: VaultModel?, authenticationState: AuthenticationState = AuthenticationState.CHOOSE_LOCATION) { + setAuthenticationState(authenticationState) + vaultModel?.let { onCloudOfVaultAuthenticated(it.toVault()) } + } + + private fun decryptedCloudFor(vault: Vault) { + getDecryptedCloudForVaultUseCase // + .withVault(vault) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + rootFolderFor(cloud) + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException(this@SharedFilesPresenter, e, ActivityResultCallbacks.decryptedCloudForAfterAuth(vault))) { + super.onError(e) + } + } + }) + } + + @Callback + fun decryptedCloudForAfterAuth(result: ActivityResult, vault: Vault?) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + decryptedCloudFor(Vault.aCopyOf(vault).withCloud(cloud).build()) + } + + private fun rootFolderFor(cloud: Cloud) { + getRootFolderUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(folder: CloudFolder) { + when (authenticationState) { + AuthenticationState.CHOOSE_LOCATION -> navigateToVaultContent((folder.cloud as CryptoCloud).vault, folder) + AuthenticationState.INIT_ROOT -> { + location = cloudFolderModelMapper.toModel(folder) + checkForUsedFileNames(folder) + } + } + } + }) + } + + private fun navigateToVaultContent(vault: Vault, folder: CloudFolder) { + if (!isPaused) { + navigateToVaultContent(VaultModel(vault), cloudFolderModelMapper.toModel(folder)) + } + } + + fun onUnlockPressed(vaultModel: VaultModel, password: String?) { + view?.showProgress(ProgressModel.GENERIC) + unlockVaultUseCase // + .withVaultOrUnlockToken(VaultOrUnlockToken.from(vaultModel.toVault())) // + .andPassword(password) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + view?.showProgress(ProgressModel.COMPLETED) + rootFolderFor(cloud) + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException(this@SharedFilesPresenter, e, ActivityResultCallbacks.unlockVaultAfterAuth(vaultModel.toVault(), password))) { + showError(e) + } + } + }) + } + + @Callback + fun unlockVaultAfterAuth(result: ActivityResult, vault: Vault?, password: String?) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + val vaultWithUpdatedCloud = Vault.aCopyOf(vault).withCloud(cloud).build() + onUnlockPressed(VaultModel(vaultWithUpdatedCloud), password) + } + + private fun setLocation(location: CloudFolderModel) { + this.location = location + } + + private fun uploadFiles(nonReplacing: Set, // + replacing: Set, // + folder: CloudFolder) { + if (nonReplacing.size + replacing.size == 0) { + view?.finish() + } + view?.showUploadDialog(nonReplacing.size + replacing.size) + val filesReadyForUpload: MutableList = ArrayList() + filesReadyForUpload.addAll(nonReplacing) + filesReadyForUpload.addAll(replacing) + uploadFiles(folder, filesReadyForUpload) + } + + private fun uploadFiles(folder: CloudFolder, files: List) { + uploadFilesUseCase // + .withParent(folder) // + .andFiles(files) // + .run(object : DefaultProgressAwareResultHandler, UploadState>() { + override fun onProgress(progress: Progress) { + view?.showProgress(progressModelMapper.toModel(progress)) + } + + override fun onFinished() { + onFileUploadCompleted() + } + }) + } + + private fun onFileUploadCompleted() { + deleteTmpFileIfPresent() + view?.showProgress(ProgressModel.COMPLETED) + view?.showMessage(context().getString(R.string.screen_share_files_msg_success)) + view?.finish() + } + + private fun deleteTmpFileIfPresent() { + tmpTextFileUri?.let { fileCacheUtils.deleteTmpFile(it) } + } + + fun onReplaceExistingFilesPressed() { + differencesOfUploadAndExistingFiles() + location?.let { uploadFiles(filesForUpload, existingFilesForUpload, it.toCloudNode()) } + } + + fun onSkipExistingFilesPressed() { + differencesOfUploadAndExistingFiles() + location?.let { uploadFiles(filesForUpload, emptySet(), it.toCloudNode()) } + } + + private fun differencesOfUploadAndExistingFiles() { + filesForUpload.removeAll(existingFilesForUpload) + } + + private fun checkForUsedFileNames(folder: CloudFolder) { + view?.showProgress(ProgressModel.GENERIC) + getCloudListUseCase // + .withFolder(folder) // + .run(object : DefaultResultHandler>() { + override fun onSuccess(currentCloudNodes: List) { + checkForExistingFilesOrUploadFiles(folder, currentCloudNodes) + } + }) + } + + private fun hasUsedFileNamesAtLocation(currentCloudNodes: List): Boolean { + existingFilesForUpload.clear() + currentCloudNodes.forEach { cloudNode -> + val uploadFileWithName = fileForUploadWithName(cloudNode.name) + if (uploadFileWithName.isPresent) { + if (cloudNode is CloudFile) { + filesForUpload.remove(uploadFileWithName.get()) + existingFilesForUpload.add( // + UploadFile.aCopyOf(uploadFileWithName.get()) // + .thatIsReplacing(true) // + .build()) + } else { + // remove file when name is used by a folder + filesForUpload.remove(uploadFileWithName.get()) + } + } + } + return existingFilesForUpload.isNotEmpty() + } + + private fun fileForUploadWithName(name: String): Optional { + return filesForUpload + .firstOrNull { it.fileName == name } + ?.let { Optional.of(it) } + ?: Optional.empty() + } + + private fun checkForExistingFilesOrUploadFiles(folder: CloudFolder, currentCloudNodes: List) { + if (hasUsedFileNamesAtLocation(currentCloudNodes)) { + view?.showReplaceDialog(namesOfExistingFiles(), filesForUpload.size) + } else { + uploadFiles(filesForUpload, emptySet(), folder) + } + } + + private fun namesOfExistingFiles(): List { + return existingFilesForUpload.mapTo(ArrayList()) { it.fileName } + } + + private fun prepareSavingFiles() { + location?.let { checkForUsedFileNames(it.toCloudNode()) } + ?: authenticate(selectedVault, AuthenticationState.INIT_ROOT) + } + + fun onSaveButtonPressed(filesForUpload: List) { + updateFileNames(filesForUpload) + when { + hasFileNameConflict() -> { + view?.showMessage(R.string.screen_share_files_msg_filenames_must_be_unique) + } + hasInvalidFileNames(filesForUpload) -> { + view?.showMessage(R.string.error_names_contains_invalid_characters) + } + else -> { + requestPermissions(PermissionsResultCallbacks.saveFilesPermissionCallback(), // + R.string.permission_message_share_file, // + Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + } + + private fun hasInvalidFileNames(filesForUpload: List): Boolean { + filesForUpload.forEach { file -> + if (isInvalidName(file.fileName)) { + return true + } + } + return false + } + + private fun updateFileNames(sharedFiles: List) { + filesForUpload.clear() + sharedFiles.mapTo(filesForUpload) { // + UploadFile.aCopyOf(it.id as UploadFile).withFileName(it.fileName).build() + } + } + + private fun navigateToVaultContent(vaultModel: VaultModel, decryptedRoot: CloudFolderModel) { + requestActivityResult( // + ActivityResultCallbacks.onChooseLocation(vaultModel), // + Intents.browseFilesIntent() // + .withFolder(decryptedRoot) // + .withTitle(vaultModel.name) // + .withChooseCloudNodeSettings( // + ChooseCloudNodeSettings.chooseCloudNodeSettings() // + .withExtraTitle(context().getString(R.string.screen_file_browser_share_destination_title)) // + .withExtraToolbarIcon(R.drawable.ic_clear) // + .withButtonText(context().getString(R.string.screen_file_browser_share_button_text)) // + .selectingFolders() // + .build())) + } + + @Callback + fun onChooseLocation(result: ActivityResult, vaultModel: VaultModel?) { + val folder = result.singleResult as CloudFolderModel + setLocation(folder) + view?.showChosenLocation(folder) + } + + @Callback + fun saveFilesPermissionCallback(result: PermissionsResult) { + if (result.granted()) { + prepareSavingFiles() + } + } + + private fun onCloudOfVaultAuthenticated(authenticatedVault: Vault) { + if (authenticatedVault.isUnlocked) { + decryptedCloudFor(authenticatedVault) + } else { + if (!isPaused) { + view?.showEnterPasswordDialog(VaultModel(authenticatedVault)) + } + } + } + + fun onChooseLocationPressed() { + authenticate(selectedVault) + } + + fun onFileNameConflict(hasFileNameConflict: Boolean) { + this.hasFileNameConflict = hasFileNameConflict + if (hasFileNameConflict()) { + view?.showMessage(R.string.screen_share_files_msg_filenames_must_be_unique) + } + } + + private fun hasFileNameConflict(): Boolean { + return hasFileNameConflict + } + + fun onVaultSelected(vault: VaultModel?) { + selectedVault = vault + } + + private fun setAuthenticationState(authenticationState: AuthenticationState) { + this.authenticationState = authenticationState + } + + fun onUploadCanceled() { + uploadFilesUseCase.unsubscribe() + view?.closeDialog() + } + + fun onBiometricAuthKeyInvalidated() { + removeStoredVaultPasswordsUseCase.run(object : DefaultResultHandler() { + override fun onFinished() { + view?.showBiometricAuthKeyInvalidatedDialog() + } + }) + selectedVault?.let { + selectedVault = VaultModel(Vault.aCopyOf(it.toVault()).withSavedPassword(null).build()) + } + } + + fun onUnlockCanceled() { + // empty + } + + fun useConfirmationInFaceUnlockBiometricAuthentication(): Boolean { + return sharedPreferencesHandler.useConfirmationInFaceUnlockBiometricAuthentication() + } + + private enum class AuthenticationState { + CHOOSE_LOCATION, INIT_ROOT + } + + private fun createUploadFile(fileName: String, uri: Uri): UploadFile { + return UploadFile.anUploadFile() // + .withFileName(fileName) // + .withDataSource(UriBasedDataSource.from(uri)) // + .thatIsReplacing(false) // + .build() + } + + init { + unsubscribeOnDestroy( // + getRootFolderUseCase, // + unlockVaultUseCase, // + getVaultListUseCase, // + getDecryptedCloudForVaultUseCase, // + uploadFilesUseCase, // + getCloudListUseCase, // + removeStoredVaultPasswordsUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/SplashPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/SplashPresenter.kt new file mode 100644 index 000000000..ba6cfd0de --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/SplashPresenter.kt @@ -0,0 +1,15 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.di.PerView +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.ui.activity.view.SplashView +import javax.inject.Inject + +@PerView +class SplashPresenter @Inject constructor(exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + override fun resumed() { + Intents.vaultListIntent().startActivity(this) + finish() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt new file mode 100644 index 000000000..94d19f1f0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt @@ -0,0 +1,121 @@ +package org.cryptomator.presentation.presenter + +import org.cryptomator.domain.CloudFile +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.cloud.DataSource +import org.cryptomator.domain.usecases.cloud.UploadFile +import org.cryptomator.domain.usecases.cloud.UploadFilesUseCase +import org.cryptomator.domain.usecases.cloud.UploadState +import org.cryptomator.generator.InstanceState +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.ui.activity.view.TextEditorView +import org.cryptomator.presentation.util.ContentResolverUtil +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.util.file.FileCacheUtils +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject + +@PerView +class TextEditorPresenter @Inject constructor( // + private val fileCacheUtils: FileCacheUtils, // + private val fileUtil: FileUtil, // + private val contentResolverUtil: ContentResolverUtil, // + private val uploadFilesUseCase: UploadFilesUseCase, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + private val textFile = AtomicReference() + + @JvmField + @InstanceState + var existingTextFileContent = AtomicReference("") + + @JvmField + @InstanceState + var didLoadFileContent = false + + @JvmField + @InstanceState + var lastFilterLocation = 0 + + @JvmField + @InstanceState + var query: String? = null + + fun onBackPressed() { + if (hasUnsavedChanges()) { + view?.showUnsavedChangesDialog() + } else { + view?.performBackPressed() + } + } + + private fun hasUnsavedChanges(): Boolean { + return existingTextFileContent.get() != view?.textFileContent + } + + fun saveChanges() { + if (!hasUnsavedChanges()) { + return + } + view?.let { + it.showProgress(ProgressModel.GENERIC) + val uri = fileCacheUtils.tmpFile() // + .withContent(it.textFileContent) // + .create() + uploadFile(textFile.get().name, UriBasedDataSource.from(uri)) + } + } + + private fun uploadFile(fileName: String, dataSource: DataSource) { + uploadFilesUseCase // + .withParent(textFile.get().parent.toCloudNode()) // + .andFiles(listOf( // + UploadFile.anUploadFile() // + .withFileName(fileName) // + .withDataSource(dataSource) // + .thatIsReplacing(true) // + .build() // + )) // + .run(object : DefaultProgressAwareResultHandler, UploadState>() { + override fun onFinished() { + view?.showProgress(ProgressModel.COMPLETED) + view?.finish() + view?.showMessage(R.string.screen_text_editor_save_success) + } + + override fun onError(e: Throwable) { + view?.showProgress(ProgressModel.COMPLETED) + showError(e) + } + }) + } + + fun loadFileContent() { + // only load file content once since EditText retains its own instance state + if (didLoadFileContent) { + return + } + val textFileUri = fileUtil.contentUriFor(textFile.get()) + try { + val data = contentResolverUtil.openInputStream(textFileUri) + data?.let { + existingTextFileContent.set(fileCacheUtils.read(it)) + view?.displayTextFileContent(existingTextFileContent.get()) + didLoadFileContent = true + } + } catch (e: IOException) { + showError(e) + } + } + + fun setTextFile(textFile: CloudFileModel) { + this.textFile.set(textFile) + } + + init { + unsubscribeOnDestroy(uploadFilesUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt new file mode 100644 index 000000000..a1354c6f4 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt @@ -0,0 +1,37 @@ +package org.cryptomator.presentation.presenter + +import android.content.Context +import android.net.Uri +import org.cryptomator.domain.usecases.cloud.DataSource +import org.cryptomator.presentation.util.ContentResolverUtil +import org.cryptomator.util.Optional +import java.io.IOException +import java.io.InputStream + +class UriBasedDataSource private constructor(private val uri: Uri) : DataSource { + + override fun size(context: Context): Optional { + return Optional.ofNullable(ContentResolverUtil(context).fileSize(uri)) + } + + @Throws(IOException::class) + override fun open(context: Context): InputStream? { + return ContentResolverUtil(context).openInputStream(uri) + } + + override fun decorate(delegate: DataSource): DataSource { + return delegate + } + + @Throws(IOException::class) + override fun close() { + // do nothing + } + + companion object { + @JvmStatic + fun from(uri: Uri): UriBasedDataSource { + return UriBasedDataSource(uri) + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt new file mode 100644 index 000000000..4e85e0aa2 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -0,0 +1,700 @@ +package org.cryptomator.presentation.presenter + +import android.app.KeyguardManager +import android.app.admin.DevicePolicyManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Handler +import androidx.biometric.BiometricManager +import org.cryptomator.data.cloud.crypto.CryptoCloud +import org.cryptomator.data.util.NetworkConnectionCheck +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.CloudFolder +import org.cryptomator.domain.Vault +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.exception.NetworkConnectionException +import org.cryptomator.domain.exception.authentication.AuthenticationException +import org.cryptomator.domain.exception.license.LicenseNotValidException +import org.cryptomator.domain.exception.update.SSLHandshakePreAndroid5UpdateCheckException +import org.cryptomator.domain.usecases.* +import org.cryptomator.domain.usecases.cloud.GetRootFolderUseCase +import org.cryptomator.domain.usecases.vault.* +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.BuildConfig +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.intent.Intents +import org.cryptomator.presentation.model.* +import org.cryptomator.presentation.model.mappers.CloudFolderModelMapper +import org.cryptomator.presentation.service.AutoUploadService +import org.cryptomator.presentation.ui.activity.LicenseCheckActivity +import org.cryptomator.presentation.ui.activity.view.VaultListView +import org.cryptomator.presentation.ui.dialog.* +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.presentation.workflow.* +import org.cryptomator.util.Optional +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import java.io.Serializable +import javax.inject.Inject + +@PerView +class VaultListPresenter @Inject constructor( // + private val getVaultListUseCase: GetVaultListUseCase, // + private val deleteVaultUseCase: DeleteVaultUseCase, // + private val renameVaultUseCase: RenameVaultUseCase, // + private val lockVaultUseCase: LockVaultUseCase, // + private val getDecryptedCloudForVaultUseCase: GetDecryptedCloudForVaultUseCase, // + private val prepareUnlockUseCase: PrepareUnlockUseCase, // + private val unlockVaultUseCase: UnlockVaultUseCase, // + private val getRootFolderUseCase: GetRootFolderUseCase, // + private val addExistingVaultWorkflow: AddExistingVaultWorkflow, // + private val createNewVaultWorkflow: CreateNewVaultWorkflow, // + private val saveVaultUseCase: SaveVaultUseCase, // + private val changePasswordUseCase: ChangePasswordUseCase, // + private val removeStoredVaultPasswordsUseCase: RemoveStoredVaultPasswordsUseCase, // + private val licenseCheckUseCase: DoLicenseCheckUseCase, // + private val updateCheckUseCase: DoUpdateCheckUseCase, // + private val updateUseCase: DoUpdateUseCase, // + private val networkConnectionCheck: NetworkConnectionCheck, // + private val fileUtil: FileUtil, // + private val authenticationExceptionHandler: AuthenticationExceptionHandler, // + private val cloudFolderModelMapper: CloudFolderModelMapper, // + private val sharedPreferencesHandler: SharedPreferencesHandler, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + private var vaultAction: VaultAction? = null + private var changedVaultPassword = false + private var startedUsingPrepareUnlock = false + private var retryUnlockHandler: Handler? = null + + @Volatile + private var running = false + override fun workflows(): Iterable> { + return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) + } + + override fun destroyed() { + super.destroyed() + if (retryUnlockHandler != null) { + running = false + retryUnlockHandler?.removeCallbacks(null) + } + } + + fun onWindowFocusChanged(hasFocus: Boolean) { + if (hasFocus) { + loadVaultList() + if (retryUnlockHandler != null) { + running = false + retryUnlockHandler?.removeCallbacks(null) + } + } + } + + fun prepareView() { + if (!sharedPreferencesHandler.isScreenLockDialogAlreadyShown) { + val keyguardManager = context().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (!keyguardManager.isKeyguardSecure) { + view?.showDialog(AskForLockScreenDialog.newInstance()) + } + sharedPreferencesHandler.setScreenLockDialogAlreadyShown() + } + checkLicense() + } + + private fun checkLicense() { + if (BuildConfig.FLAVOR == "license") { + licenseCheckUseCase // + .withLicense("") // + .run(object : NoOpResultHandler() { + override fun onSuccess(licenseCheck: LicenseCheck) { + if (sharedPreferencesHandler.doUpdate()) { + checkForAppUpdates() + } + } + + override fun onError(e: Throwable) { + var license: String? = "" + if (e is LicenseNotValidException) { + license = e.license + } + val intent = Intent(context(), LicenseCheckActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.data = Uri.parse(String.format("app://cryptomator/%s", license)) + context().startActivity(intent) + } + }) + } + } + + private fun checkForAppUpdates() { + if (networkConnectionCheck.isPresent) { + updateCheckUseCase // + .withVersion(BuildConfig.VERSION_NAME) // + .run(object : NoOpResultHandler>() { + override fun onSuccess(updateCheck: Optional) { + if (updateCheck.isPresent) { + updateStatusRetrieved(updateCheck.get(), context()) + } else { + Timber.tag("VaultListPresenter").i("UpdateCheck finished, latest version") + } + sharedPreferencesHandler.updateExecuted() + } + + override fun onError(e: Throwable) { + if (e is SSLHandshakePreAndroid5UpdateCheckException) { + Timber.tag("SettingsPresenter").e(e, "Update check failed due to Android pre 5 and SSL Handshake not accepted") + } else { + showError(e) + } + } + }) + } else { + Timber.tag("VaultListPresenter").i("Update check not started due to no internal connection") + } + } + + private fun updateStatusRetrieved(updateCheck: UpdateCheck, context: Context) { + showNextMessage(updateCheck.releaseNote(), context) + } + + private fun showNextMessage(message: String, context: Context) { + if (message.isNotEmpty()) { + view?.showDialog(UpdateAppAvailableDialog.newInstance(message)) + } else { + view?.showDialog(UpdateAppAvailableDialog.newInstance(context.getText(R.string.dialog_update_available_message).toString())) + } + } + + private fun assertUnlockingVaultIsLocked() { + if (view?.isShowingDialog(EnterPasswordDialog::class) == true) { + if (view?.currentDialog() != null) { + val vaultModel = (view?.currentDialog() as EnterPasswordDialog).vaultModel() + if (view?.isVaultLocked(vaultModel) == false) { + view?.closeDialog() + } + } + } + } + + fun loadVaultList() { + view?.hideVaultCreationHint() + vaultList + assertUnlockingVaultIsLocked() + } + + fun deleteVault(vaultModel: VaultModel) { + deleteVault(vaultModel.toVault()) + } + + private fun deleteVault(vault: Vault) { + deleteVaultUseCase // + .withVault(vault) // + .run(object : DefaultResultHandler() { + override fun onSuccess(vaultId: Long) { + view?.deleteVaultFromAdapter(vaultId) + } + }) + } + + fun renameVault(vaultModel: VaultModel, newVaultName: String?) { + renameVaultUseCase // + .withVault(vaultModel.toVault()) // + .andNewVaultName(newVaultName) // + .run(object : DefaultResultHandler() { + override fun onSuccess(vault: Vault) { + view?.renameVault(VaultModel(vault)) + view?.closeDialog() + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException( // + this@VaultListPresenter, e, // + ActivityResultCallbacks.renameVaultAfterAuthentication(vaultModel.toVault(), newVaultName))) { + showError(e) + } + } + }) + } + + @Callback + fun renameVaultAfterAuthentication(result: ActivityResult, vault: Vault?, newVaultName: String?) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + val vaultWithUpdatedCloud = Vault.aCopyOf(vault).withCloud(cloud).build() + renameVault(VaultModel(vaultWithUpdatedCloud), newVaultName) + } + + fun onUnlockCanceled() { + prepareUnlockUseCase.unsubscribe() + } + + private fun browseFilesOf(vault: VaultModel) { + getDecryptedCloudForVaultUseCase // + .withVault(vault.toVault()) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + getRootFolderAndNavigateInto(cloud) + } + }) + } + + private fun getRootFolderAndNavigateInto(cloud: Cloud) { + getRootFolderUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(folder: CloudFolder) { + navigateToVaultContent((folder.cloud as CryptoCloud).vault, folder) + } + }) + } + + private fun lockVault(vaultModel: VaultModel) { + lockVaultUseCase // + .withVault(vaultModel.toVault()) // + .run(object : DefaultResultHandler() { + override fun onSuccess(vault: Vault) { + view?.addOrUpdateVault(VaultModel(vault)) + } + }) + } + + private val vaultList: Unit + get() { + getVaultListUseCase.run(object : DefaultResultHandler>() { + override fun onSuccess(vaults: List) { + val vaultModels = vaults.mapTo(ArrayList()) { VaultModel(it) } + if (vaultModels.isEmpty()) { + view?.showVaultCreationHint() + } else { + view?.hideVaultCreationHint() + view?.renderVaultList(vaultModels) + } + } + }) + } + + private fun navigateToVaultContent(vault: Vault, cloudFolder: CloudFolder) { + if (!isPaused) { + view?.navigateToVaultContent(VaultModel(vault), cloudFolderModelMapper.toModel(cloudFolder)) + } + } + + fun onVaultLockClicked(vault: VaultModel) { + lockVault(vault) + } + + fun onVaultClicked(vault: VaultModel) { + startedUsingPrepareUnlock = sharedPreferencesHandler.backgroundUnlockPreparation() + startVaultAction(vault, VaultAction.UNLOCK) + } + + private fun startVaultAction(vault: VaultModel, vaultAction: VaultAction) { + this.vaultAction = vaultAction + val cloud = vault.toVault().cloud + if (cloud != null) { + onCloudOfVaultAuthenticated(vault.toVault()) + } else { + if (vault.isLocked) { + onVaultWithoutCloudClickedAndLocked(vault) + } else { + lockVaultUseCase // + .withVault(vault.toVault()) // + .run(object : DefaultResultHandler() { + override fun onSuccess(vault: Vault) { + onVaultWithoutCloudClickedAndLocked(VaultModel(vault)) + } + }) + } + } + } + + private fun onVaultWithoutCloudClickedAndLocked(vault: VaultModel) { + if (isWebdavOrLocal(vault.cloudType)) { + requestActivityResult( // + ActivityResultCallbacks.cloudConnectionForVaultSelected(vault), // + Intents.cloudConnectionListIntent() // + .withCloudType(vault.cloudType) // + .withDialogTitle(context().getString(R.string.screen_cloud_connections_title)) // + .withFinishOnCloudItemClick(true)) + } + } + + private fun isWebdavOrLocal(cloudType: CloudTypeModel): Boolean { + return cloudType == CloudTypeModel.WEBDAV || cloudType == CloudTypeModel.LOCAL + } + + @Callback + fun cloudConnectionForVaultSelected(result: ActivityResult, vaultModel: VaultModel) { + val cloud = result.intent().getSerializableExtra(CloudConnectionListPresenter.SELECTED_CLOUD) as Cloud + val vault = Vault.aCopyOf(vaultModel.toVault()) // + .withCloud(cloud) // + .build() + saveVaultUseCase // + .withVault(vault) // + .run(object : DefaultResultHandler() { + override fun onSuccess(vault: Vault) { + view?.addOrUpdateVault(VaultModel(vault)) + onCloudOfVaultAuthenticated(vault) + } + }) + } + + private fun onCloudOfVaultAuthenticated(authenticatedVault: Vault) { + val authenticatedVaultModel = VaultModel(authenticatedVault) + when (vaultAction) { + VaultAction.UNLOCK -> requireUserAuthentication(authenticatedVaultModel) + VaultAction.RENAME -> view?.showRenameDialog(authenticatedVaultModel) + VaultAction.CHANGE_PASSWORD -> view?.showChangePasswordDialog(authenticatedVaultModel) + } + vaultAction = null + } + + private fun requireUserAuthentication(authenticatedVault: VaultModel) { + view?.addOrUpdateVault(authenticatedVault) + if (authenticatedVault.isLocked) { + if (!isPaused) { + if (canUseBiometricOn(authenticatedVault)) { + if (startedUsingPrepareUnlock) { + startPrepareUnlockUseCase(authenticatedVault.toVault()) + } + view?.showBiometricDialog(authenticatedVault) + } else { + startPrepareUnlockUseCase(authenticatedVault.toVault()) + view?.showEnterPasswordDialog(authenticatedVault) + } + } + } else { + browseFilesOf(authenticatedVault) + } + } + + fun startPrepareUnlockUseCase(vault: Vault) { + pendingUnlock = null + prepareUnlockUseCase // + .withVault(vault) // + .run(object : DefaultResultHandler() { + override fun onSuccess(unlockToken: UnlockToken) { + if (!startedUsingPrepareUnlock && vault.password != null) { + doUnlock(unlockToken, vault.password) + } else { + unlockTokenObtained(unlockToken) + } + } + + override fun onError(e: Throwable) { + if (e is AuthenticationException) { + view?.cancelBasicAuthIfRunning() + } + if (!authenticationExceptionHandler.handleAuthenticationException(this@VaultListPresenter, e, ActivityResultCallbacks.authenticatedAfterUnlock(vault))) { + super.onError(e) + if (e is NetworkConnectionException) { + running = true + retryUnlockHandler = Handler() + restartUnlockUseCase(vault) + } + } + } + }) + } + + private fun restartUnlockUseCase(vault: Vault) { + retryUnlockHandler?.postDelayed({ + if (running) { + prepareUnlockUseCase // + .withVault(vault) // + .run(object : DefaultResultHandler() { + override fun onSuccess(unlockToken: UnlockToken) { + if (!startedUsingPrepareUnlock && vault.password != null) { + doUnlock(unlockToken, vault.password) + } else { + unlockTokenObtained(unlockToken) + } + } + + override fun onError(e: Throwable) { + if (e is NetworkConnectionException) { + restartUnlockUseCase(vault) + } + } + }) + } + }, 1000) + } + + private fun doUnlock(token: UnlockToken, password: String) { + unlockVaultUseCase // + .withVaultOrUnlockToken(VaultOrUnlockToken.from(token)) // + .andPassword(password) // + .run(object : DefaultResultHandler() { + override fun onSuccess(cloud: Cloud) { + navigateToVaultContent(cloud) + } + }) + } + + private fun navigateToVaultContent(cloud: Cloud) { + getRootFolderUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(folder: CloudFolder) { + val vault = (folder.cloud as CryptoCloud).vault + view?.addOrUpdateVault(VaultModel(vault)) + navigateToVaultContent(vault, folder) + view?.showProgress(ProgressModel.COMPLETED) + if (checkToStartAutoImageUpload(vault)) { + context().startService(AutoUploadService.startAutoUploadIntent(context(), folder.cloud)) + } + } + }) + } + + private fun checkToStartAutoImageUpload(vault: Vault): Boolean { + return if (sharedPreferencesHandler.usePhotoUpload() && sharedPreferencesHandler.photoUploadVault() == vault.id) { + !sharedPreferencesHandler.autoPhotoUploadOnlyUsingWifi() || networkConnectionCheck.checkWifiOnAndConnected() + } else false + } + + private fun unlockTokenObtained(unlockToken: UnlockToken) { + pendingUnlockFor(unlockToken.vault)?.setUnlockToken(unlockToken, this) + } + + fun onUnlockClick(vault: VaultModel, password: String?) { + view?.showProgress(ProgressModel.GENERIC) + pendingUnlockFor(vault.toVault())?.setPassword(password, this) + } + + private var pendingUnlock: PendingUnlock? = null + private fun pendingUnlockFor(vault: Vault): PendingUnlock? { + if (pendingUnlock == null) { + pendingUnlock = PendingUnlock(vault) + } + return if (pendingUnlock?.belongsTo(vault) == true) { + pendingUnlock + } else { + PendingUnlock.NO_OP_PENDING_UNLOCK + } + } + + @Callback(dispatchResultOkOnly = false) + fun authenticatedAfterUnlock(result: ActivityResult, vault: Vault) { + if (result.isResultOk) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + if (startedUsingPrepareUnlock) { + startPrepareUnlockUseCase(Vault.aCopyOf(vault).withCloud(cloud).build()) + if (view?.stoppedBiometricAuthDuringCloudAuthentication() == true) { + view?.showBiometricDialog(VaultModel(vault)) + } + } else { + view?.showProgress(ProgressModel.GENERIC) + startPrepareUnlockUseCase(vault) + } + } else { + view?.closeDialog() + val error = result.getSingleResult(Throwable::class.java) + error?.let { showError(it) } + } + } + + private fun canUseBiometricOn(vault: VaultModel): Boolean { + return vault.password != null && BiometricManager.from(context()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS + } + + fun onAddExistingVault() { + addExistingVaultWorkflow.start() + } + + fun onCreateVault() { + createNewVaultWorkflow.start() + } + + fun onAddOrCreateVaultCompleted(vault: Vault) { + view?.addOrUpdateVault(VaultModel(vault)) + view?.hideVaultCreationHint() + view?.closeDialog() + } + + fun onChangePasswordClicked(vaultModel: VaultModel) { + startVaultAction(vaultModel, VaultAction.CHANGE_PASSWORD) + } + + fun onChangePasswordClicked(vaultModel: VaultModel, oldPassword: String?, newPassword: String?) { + view?.showProgress(ProgressModel(ProgressStateModel.CHANGING_PASSWORD)) + changePasswordUseCase.withVault(vaultModel.toVault()) // + .andOldPassword(oldPassword) // + .andNewPassword(newPassword) // + .run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + view?.showProgress(ProgressModel.COMPLETED) + view?.showMessage(R.string.screen_vault_list_change_password_successful) + if (canUseBiometricOn(vaultModel)) { + changedVaultPassword = true + view?.getEncryptedPasswordWithBiometricAuthentication(VaultModel( // + Vault.aCopyOf(vaultModel.toVault()) // + .withSavedPassword(newPassword) // + .build())) + } + } + + override fun onError(e: Throwable) { + if (!authenticationExceptionHandler.handleAuthenticationException( // + this@VaultListPresenter, e, // + ActivityResultCallbacks.changePasswordAfterAuthentication(vaultModel.toVault(), oldPassword, newPassword))) { + showError(e) + } + } + }) + } + + @Callback + fun changePasswordAfterAuthentication(result: ActivityResult, vault: Vault?, oldPassword: String?, newPassword: String?) { + val cloud = result.getSingleResult(CloudModel::class.java).toCloud() + val vaultWithUpdatedCloud = Vault.aCopyOf(vault).withCloud(cloud).build() + onChangePasswordClicked(VaultModel(vaultWithUpdatedCloud), oldPassword, newPassword) + } + + private fun save(vaultModel: VaultModel) { + saveVaultUseCase // + .withVault(vaultModel.toVault()) // + .run(DefaultResultHandler()) + } + + fun onBiometricKeyInvalidated() { + removeStoredVaultPasswordsUseCase.run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + view?.showBiometricAuthKeyInvalidatedDialog() + } + + override fun onError(e: Throwable) { + Timber.tag("VaultListPresenter").e(e, "Error while removing vault passwords") + } + }) + } + + fun onVaultSettingsClicked(vaultModel: VaultModel) { + view?.showVaultSettingsDialog(vaultModel) + } + + fun onCreateVaultClicked() { + view?.showAddVaultBottomSheet() + } + + fun onRenameVaultClicked(vaultModel: VaultModel) { + startVaultAction(vaultModel, VaultAction.RENAME) + } + + fun onAskForLockScreenFinished(setScreenLock: Boolean) { + if (setScreenLock) { + try { + view?.activity()?.startActivity(Intent(DevicePolicyManager.ACTION_SET_NEW_PASSWORD)) + } catch (e: ActivityNotFoundException) { + Timber.tag("VaultListPresenter").d(e, "Device Policy Manager not found") + view?.showError(R.string.error_device_policy_manager_not_found) + } + } + } + + fun onDeleteMissingVaultClicked(vault: Vault) { + deleteVault(vault) + } + + fun onFilteredTouchEventForSecurity() { + view?.showDialog(AppIsObscuredInfoDialog.newInstance()) + } + + fun onBiometricAuthenticationSucceeded(vaultModel: VaultModel) { + if (changedVaultPassword) { + changedVaultPassword = false + save(vaultModel) + } else { + if (startedUsingPrepareUnlock) { + onUnlockClick(vaultModel, vaultModel.password) + } else { + view?.showProgress(ProgressModel.GENERIC) + startPrepareUnlockUseCase(vaultModel.toVault()) + } + } + } + + fun useConfirmationInFaceUnlockBiometricAuthentication(): Boolean { + return sharedPreferencesHandler.useConfirmationInFaceUnlockBiometricAuthentication() + } + + private enum class VaultAction { + UNLOCK, RENAME, CHANGE_PASSWORD + } + + fun installUpdate() { + view?.showDialog(UpdateAppDialog.newInstance()) + val uri = fileUtil.contentUriForNewTempFile("cryptomator.apk") + val file = fileUtil.tempFile("cryptomator.apk") + updateUseCase // + .withFile(file) // + .run(object : NoOpResultHandler() { + override fun onError(e: Throwable) { + showError(e) + } + + override fun onSuccess(aVoid: Void?) { + super.onSuccess(aVoid) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, "application/vnd.android.package-archive") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context().startActivity(intent) + } + }) + } + + fun startedUsingPrepareUnlock(): Boolean { + return startedUsingPrepareUnlock + } + + private open class PendingUnlock(private val vault: Vault?) : Serializable { + + private var unlockToken: UnlockToken? = null + private var password: String? = null + + fun setUnlockToken(unlockToken: UnlockToken?, presenter: VaultListPresenter) { + this.unlockToken = unlockToken + continueIfComplete(presenter) + } + + fun setPassword(password: String?, presenter: VaultListPresenter) { + this.password = password + continueIfComplete(presenter) + } + + open fun continueIfComplete(presenter: VaultListPresenter) { + unlockToken?.let { token -> password?.let { password -> presenter.doUnlock(token, password) } } + } + + fun belongsTo(vault: Vault): Boolean { + return vault == this.vault + } + + companion object { + val NO_OP_PENDING_UNLOCK: PendingUnlock = object : PendingUnlock(null) { + override fun continueIfComplete(presenter: VaultListPresenter) { + // empty + } + } + } + } + + init { + unsubscribeOnDestroy( // + deleteVaultUseCase, // + renameVaultUseCase, // + lockVaultUseCase, // + getVaultListUseCase, // + saveVaultUseCase, // + removeStoredVaultPasswordsUseCase, // + unlockVaultUseCase, // + prepareUnlockUseCase, // + licenseCheckUseCase, // + updateCheckUseCase, // + updateUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/WebDavAddOrChangePresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/WebDavAddOrChangePresenter.kt new file mode 100644 index 000000000..07e92b896 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/WebDavAddOrChangePresenter.kt @@ -0,0 +1,127 @@ +package org.cryptomator.presentation.presenter + +import android.widget.Toast +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.cryptomator.domain.Cloud +import org.cryptomator.domain.WebDavCloud +import org.cryptomator.domain.di.PerView +import org.cryptomator.domain.usecases.cloud.AddOrChangeCloudConnectionUseCase +import org.cryptomator.domain.usecases.cloud.ConnectToWebDavUseCase +import org.cryptomator.generator.Callback +import org.cryptomator.presentation.R +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.ProgressStateModel +import org.cryptomator.presentation.ui.activity.view.WebDavAddOrChangeView +import org.cryptomator.presentation.workflow.ActivityResult +import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler +import org.cryptomator.util.crypto.CredentialCryptor +import javax.inject.Inject + +@PerView +class WebDavAddOrChangePresenter @Inject internal constructor( // + private val addOrChangeCloudConnectionUseCase: AddOrChangeCloudConnectionUseCase, // + private val connectToWebDavUseCase: ConnectToWebDavUseCase, // + private val authenticationExceptionHandler: AuthenticationExceptionHandler, // + exceptionMappings: ExceptionHandlers) : Presenter(exceptionMappings) { + + fun checkUserInput(urlPort: String, username: String, password: String, cloudId: Long?, certificate: String?) { + var statusMessage: String? = null + + if (password.isEmpty()) { + statusMessage = getString(R.string.screen_webdav_settings_msg_password_must_not_be_empty) + } + if (username.isEmpty()) { + statusMessage = getString(R.string.screen_webdav_settings_msg_username_must_not_be_empty) + } + if (urlPort.isEmpty()) { + statusMessage = getString(R.string.screen_webdav_settings_msg_url_must_not_be_empty) + } else if (!isValid(urlPort)) { + statusMessage = getString(R.string.screen_webdav_settings_msg_url_is_invalid) + } + if (statusMessage != null) { + Toast.makeText(context(), statusMessage, Toast.LENGTH_SHORT).show() + } else { + val urlPortWithoutTrailingSlash = if (urlPort.endsWith("/")) urlPort.substring(0, urlPort.length - 1) else urlPort + val encryptedPassword = encryptPassword(password) + if (cloudId == null && urlPortWithoutTrailingSlash[4] != 's') { + view?.showAskForHttpDialog(urlPortWithoutTrailingSlash, username, encryptedPassword, cloudId, certificate) + } else { + view?.onCheckUserInputSucceeded(urlPortWithoutTrailingSlash, username, encryptedPassword, cloudId, certificate) + } + } + } + + private fun encryptPassword(password: String): String { + return CredentialCryptor // + .getInstance(context()) // + .encrypt(password) + } + + private fun isValid(urlPort: String): Boolean { + return urlPort.toHttpUrlOrNull() != null + } + + private fun mapToCloud(username: String, password: String, hostPort: String, id: Long?, certificate: String?): WebDavCloud { + var builder = WebDavCloud // + .aWebDavCloudCloud() // + .withUrl(hostPort) // + .withUsername(username) // + .withPassword(password) + + if (id != null) { + builder = builder.withId(id) + } + + if (certificate != null) { + builder = builder.withCertificate(certificate) + } + + return builder.build() + } + + fun authenticate(username: String, password: String, urlPort: String, cloudId: Long?, certificate: String?) { + authenticate(mapToCloud(username, password, urlPort, cloudId, certificate)) + } + + private fun authenticate(cloud: WebDavCloud) { + view?.showProgress(ProgressModel(ProgressStateModel.AUTHENTICATION)) + connectToWebDavUseCase // + .withCloud(cloud) // + .run(object : DefaultResultHandler() { + override fun onSuccess(void: Void?) { + onCloudAuthenticated(cloud) + } + + override fun onError(e: Throwable) { + view?.showProgress(ProgressModel.COMPLETED) + if (!authenticationExceptionHandler.handleAuthenticationException(this@WebDavAddOrChangePresenter, e, ActivityResultCallbacks.handledAuthenticationWebDavCloud())) { + super.onError(e) + } + } + }) + } + + @Callback + fun handledAuthenticationWebDavCloud(result: ActivityResult) { + if (result.intent().extras?.getBoolean(AuthenticateCloudPresenter.WEBDAV_ACCEPTED_UNTRUSTED_CERTIFICATE, false) == true) { + authenticate((result.singleResult as CloudModel).toCloud() as WebDavCloud) + } + } + + 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, connectToWebDavUseCase) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadNotification.kt b/presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadNotification.kt new file mode 100644 index 000000000..08219fecb --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadNotification.kt @@ -0,0 +1,130 @@ +package org.cryptomator.presentation.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.NotificationManager.IMPORTANCE_LOW +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_CANCEL_CURRENT +import android.content.Context +import android.content.Intent +import android.content.Intent.* +import androidx.core.app.NotificationCompat +import org.cryptomator.presentation.R +import org.cryptomator.presentation.service.AutoUploadService.cancelAutoUploadIntent +import org.cryptomator.presentation.ui.activity.VaultListActivity +import org.cryptomator.presentation.util.ResourceHelper.Companion.getColor +import org.cryptomator.presentation.util.ResourceHelper.Companion.getString +import java.lang.String.format + +class AutoUploadNotification(private val context: Context, private val amountOfPictures: Int) { + private val builder: NotificationCompat.Builder + private var notificationManager: NotificationManager? = null + private var alreadyUploadedPictures = 0 + + init { + this.notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( // + NOTIFICATION_CHANNEL_ID, // + NOTIFICATION_CHANNEL_NAME, // + IMPORTANCE_LOW) + notificationManager?.createNotificationChannel(notificationChannel) + } + + this.builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) // + .setContentTitle(context.getString(R.string.notification_auto_upload_title)) // + .setSmallIcon(R.mipmap.ic_launcher) // + .setColor(getColor(R.color.colorPrimary)) // + .addAction(cancelNowAction()) + .setGroup(NOTIFICATION_GROUP_KEY) + .setOngoing(true) + } + + private fun cancelNowAction(): NotificationCompat.Action { + return NotificationCompat.Action.Builder( // + R.drawable.ic_lock, // + getString(R.string.notification_cancel_auto_upload), // + cancelNowIntent() // + ).build() + } + + private fun cancelNowIntent(): PendingIntent { + val intentAction = cancelAutoUploadIntent(context) + return PendingIntent.getService(context, 0, intentAction, FLAG_CANCEL_CURRENT) + } + + private fun startTheActivity(): PendingIntent { + val startTheActivity = Intent(context, VaultListActivity::class.java) + startTheActivity.action = ACTION_MAIN + startTheActivity.flags = FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK + return PendingIntent.getActivity(context, 0, startTheActivity, FLAG_CANCEL_CURRENT) + } + + fun update(progress: Int) { + builder.setContentIntent(startTheActivity()) + builder // + .setContentText( // + String.format(context.getString(R.string.notification_auto_upload_message), // + alreadyUploadedPictures + 1, // + amountOfPictures)) // + .setProgress(100, progress, false) + show() + } + + fun updateFinishedFile() { + alreadyUploadedPictures += 1 + update(100) + } + + fun showFolderMissing() { + showErrorWithMessage(context.getString(R.string.notification_auto_upload_failed_due_to_folder_not_exists)) + } + + fun showVaultLockedDuringUpload() { + showErrorWithMessage(context.getString(R.string.notification_auto_upload_failed_due_to_vault_locked)) + } + + fun showGeneralErrorDuringUpload() { + showErrorWithMessage(context.getString(R.string.notification_auto_upload_failed_general_error)) + } + + private fun showErrorWithMessage(message: String) { + builder.setContentIntent(startTheActivity()) + builder // + .setContentTitle(context.getString(R.string.notification_auto_upload_failed_title)) + .setContentText(message) // + .setProgress(0, 0, false) + .setAutoCancel(true) + .setOngoing(false) + .mActions.clear() + show() + } + + fun showUploadFinished(size: Int) { + builder.setContentIntent(startTheActivity()) + builder // + .setContentTitle(context.getString(R.string.notification_auto_upload_finished_title)) + .setContentText(format(context.getString(R.string.notification_auto_upload_finished_message), size)) // + .setProgress(0, 0, false) + .setAutoCancel(true) + .setOngoing(false) + .mActions.clear() + show() + } + + fun show() { + notificationManager?.notify(NOTIFICATION_ID, builder.build()) + } + + fun hide() { + notificationManager?.cancel(NOTIFICATION_ID) + } + + companion object { + private const val NOTIFICATION_ID = 94874 + private const val NOTIFICATION_CHANNEL_ID = "65478" + private const val NOTIFICATION_CHANNEL_NAME = "Cryptomator" + private const val NOTIFICATION_GROUP_KEY = "CryptomatorGroup" + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadService.java new file mode 100644 index 000000000..df8d31519 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/AutoUploadService.java @@ -0,0 +1,293 @@ +package org.cryptomator.presentation.service; + +import static java.lang.String.format; +import static org.cryptomator.domain.usecases.cloud.UploadFile.anUploadFile; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.cryptomator.domain.Cloud; +import org.cryptomator.domain.CloudFile; +import org.cryptomator.domain.CloudFolder; +import org.cryptomator.domain.exception.BackendException; +import org.cryptomator.domain.exception.CancellationException; +import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException; +import org.cryptomator.domain.exception.FatalBackendException; +import org.cryptomator.domain.exception.MissingCryptorException; +import org.cryptomator.domain.exception.NoSuchCloudFileException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.CancelAwareDataSource; +import org.cryptomator.domain.usecases.cloud.DataSource; +import org.cryptomator.domain.usecases.cloud.Flag; +import org.cryptomator.domain.usecases.cloud.UploadFile; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.presentation.model.AutoUploadFilesStore; +import org.cryptomator.presentation.presenter.UriBasedDataSource; +import org.cryptomator.presentation.util.ContentResolverUtil; +import org.cryptomator.presentation.util.FileUtil; +import org.cryptomator.util.Optional; +import org.cryptomator.util.SharedPreferencesHandler; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +import androidx.annotation.Nullable; + +import timber.log.Timber; + +public class AutoUploadService extends Service { + + private static final String ACTION_CANCEL_AUTO_UPLOAD = "CANCEL_AUTO_UPLOAD"; + private static final String ACTION_START_AUTO_UPLOAD = "START_AUTO_UPLOAD"; + + private static Cloud cloud; + + public static Intent cancelAutoUploadIntent(Context context) { + Intent cancelAutoUploadIntent = new Intent(context, AutoUploadService.class); + cancelAutoUploadIntent.setAction(ACTION_CANCEL_AUTO_UPLOAD); + return cancelAutoUploadIntent; + } + + public static Intent startAutoUploadIntent(Context context, Cloud myCloud) { + cloud = myCloud; + Intent startAutoUpload = new Intent(context, AutoUploadService.class); + startAutoUpload.setAction(ACTION_START_AUTO_UPLOAD); + return startAutoUpload; + } + + private AutoUploadNotification notification; + + private CloudContentRepository cloudContentRepository; + private ContentResolverUtil contentResolverUtil; + private FileUtil fileUtil; + private List uploadFiles; + private CloudFolder parent; + private Context context; + + private long startTimeAutoUploadNotificationDelay; + private long elapsedTimeAutoUploadNotificationDelay = 0L; + + private Thread worker; + + private volatile boolean cancelled; + private final Flag cancelledFlag = new Flag() { + @Override + public boolean get() { + return cancelled; + } + }; + + private void startBackgroundImageUpload(Cloud cloud) { + try { + uploadFiles = getUploadFiles(fileUtil.getAutoUploadFilesStore()); + } catch (FatalBackendException e) { + notification = new AutoUploadNotification(context, 0); + notification.showGeneralErrorDuringUpload(); + Timber.tag("AutoUploadService").e(e, "Auto upload failed, unable to get images from file store"); + return; + } + + if (uploadFiles.isEmpty()) { + return; + } + + Timber.tag("AutoUploadService").i("Starting background upload"); + notification = new AutoUploadNotification(context, uploadFiles.size()); + notification.show(); + + final String autoUploadFolderPath = new SharedPreferencesHandler(context).photoUploadVaultFolder(); + cancelled = false; + + worker = new Thread(() -> { + try { + if (autoUploadFolderPath.isEmpty()) { + parent = cloudContentRepository.root(cloud); + } else { + parent = cloudContentRepository.resolve(cloud, autoUploadFolderPath); + } + + upload(progress -> updateNotification(progress.asPercentage())); + } catch (FatalBackendException | BackendException | MissingCryptorException e) { + if (e instanceof NoSuchCloudFileException) { + notification.showFolderMissing(); + } else if (e instanceof MissingCryptorException) { + notification.showVaultLockedDuringUpload(); + } else if (e instanceof CancellationException) { + Timber.tag("AutoUploadService").i("Upload canceled by user"); + } else { + notification.showGeneralErrorDuringUpload(); + } + + Timber.tag("AutoUploadService").e(e, "Failed to auto upload image(s)."); + } + }); + + worker.start(); + } + + private void updateNotification(int asPercentage) { + if (elapsedTimeAutoUploadNotificationDelay > 200 && !cancelled) { + new Handler(Looper.getMainLooper()).post(() -> { + notification.update(asPercentage); + + startTimeAutoUploadNotificationDelay = System.currentTimeMillis(); + elapsedTimeAutoUploadNotificationDelay = 0; + }); + } else + elapsedTimeAutoUploadNotificationDelay = new Date().getTime() - startTimeAutoUploadNotificationDelay; + } + + private ArrayList getUploadFiles(AutoUploadFilesStore autoUploadFilesStore) { + ArrayList uploadFiles = new ArrayList<>(); + + for (String path : autoUploadFilesStore.getUris()) { + Uri uri = Uri.fromFile(new File(path)); + String fileName = contentResolverUtil.fileName(uri); + uploadFiles.add(createUploadFile(fileName, uri)); + } + + return uploadFiles; + } + + private UploadFile createUploadFile(String fileName, Uri uri) { + return anUploadFile() // + .withFileName(fileName) // + .withDataSource(UriBasedDataSource.from(uri)) // + .thatIsReplacing(false).build(); + } + + private void upload(ProgressAware progressAware) throws BackendException { + Set uploadedCloudFileNames = new HashSet<>(); + + for (UploadFile file : uploadFiles) { + try { + CloudFile uploadedFile = upload(file, progressAware); + notification.updateFinishedFile(); + uploadedCloudFileNames.add(uploadedFile.getName()); + Timber.tag("AutoUploadService").i("Uploaded file"); + Timber.tag("AutoUploadService").v(format("Uploaded file %s", file.getFileName())); + } catch (CloudNodeAlreadyExistsException e) { + Timber.tag("AutoUploadService").i("Not uploading file because it already exists in the cloud"); + Timber.tag("AutoUploadService").v(format("Not uploading file because it already exists in the cloud %s", file.getFileName())); + } catch (Exception e) { + cancelled = true; + fileUtil.removeImagesFromAutoUploads(uploadedCloudFileNames); + throw e; + } + } + + fileUtil.removeImagesFromAutoUploads(uploadedCloudFileNames); + notification.showUploadFinished(uploadedCloudFileNames.size()); + } + + private CloudFile upload(UploadFile uploadFile, ProgressAware progressAware) throws BackendException { + try (DataSource dataSource = uploadFile.getDataSource()) { + return upload(uploadFile, dataSource, progressAware); + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + + private CloudFile upload(UploadFile uploadFile, DataSource dataSource, ProgressAware progressAware) throws BackendException { + return writeCloudFile( // + uploadFile.getFileName(), // + CancelAwareDataSource.wrap(dataSource, cancelledFlag), // + uploadFile.getReplacing(), // + progressAware); + } + + private CloudFile writeCloudFile(String fileName, CancelAwareDataSource dataSource, boolean replacing, ProgressAware progressAware) throws BackendException { + Optional size = dataSource.size(context); + CloudFile source = cloudContentRepository.file(parent, fileName, size); + return cloudContentRepository.write( // + source, // + dataSource, // + progressAware, // + replacing, // + size.get()); + } + + @Override + public void onCreate() { + super.onCreate(); + Timber.tag("AutoUploadService").d("created"); + notification = new AutoUploadNotification(this, 5); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Timber.tag("AutoUploadService").i("started"); + if (isStartAutoUpload(intent)) { + Timber.tag("AutoUploadService").i("Received start upload"); + + startBackgroundImageUpload(cloud); + } else if (isCancelAutoUpload(intent)) { + Timber.tag("AutoUploadService").i("Received stop auto upload"); + + cancelled = true; + + hideNotification(); + } + return START_STICKY; + } + + private boolean isStartAutoUpload(Intent intent) { + return intent != null // + && ACTION_START_AUTO_UPLOAD.equals(intent.getAction()); + } + + private boolean isCancelAutoUpload(Intent intent) { + return intent != null // + && ACTION_CANCEL_AUTO_UPLOAD.equals(intent.getAction()); + } + + @Override + public void onDestroy() { + Timber.tag("AutoUploadService").i("onDestroyed"); + if (worker != null) { + worker.interrupt(); + } + hideNotification(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + Timber.tag("AutoUploadService").i("App killed by user"); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return new Binder(); + } + + public class Binder extends android.os.Binder { + + Binder() { + } + + public void init(CloudContentRepository myCloudContentRepository, FileUtil myFileUtil, ContentResolverUtil myContentResolverUtil, Context myContext) { + cloudContentRepository = myCloudContentRepository; + fileUtil = myFileUtil; + contentResolverUtil = myContentResolverUtil; + context = myContext; + } + } + + private void hideNotification() { + if (notification != null) { + notification.hide(); + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/AutolockTimeout.java b/presentation/src/main/java/org/cryptomator/presentation/service/AutolockTimeout.java new file mode 100644 index 000000000..a90719b52 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/AutolockTimeout.java @@ -0,0 +1,59 @@ +package org.cryptomator.presentation.service; + +import org.cryptomator.util.LockTimeout; + +import static java.lang.System.currentTimeMillis; + +class AutolockTimeout { + + private static final long APP_IS_ACTIVE = -1L; + + private volatile long appInactiveSince = APP_IS_ACTIVE; + private LockTimeout lockTimeout = LockTimeout.ONE_MINUTE; + + private long configuredTimeout = 0; + private long timeOfAutolock; + + public void setAppIsActive(boolean appIsActive) { + if (appIsActive) { + appInactiveSince = APP_IS_ACTIVE; + } else { + appInactiveSince = currentTimeMillis(); + } + recompute(); + } + + public void setLockTimeout(LockTimeout lockTimeout) { + this.lockTimeout = lockTimeout; + recompute(); + } + + public boolean expired() { + return !isDisabled() && timeOfAutolock <= currentTimeMillis(); + } + + public int timeRemaining() { + if (appInactiveSince == APP_IS_ACTIVE) { + return 0; + } + return (int) (timeOfAutolock - currentTimeMillis()); + } + + public long configuredTimeout() { + return configuredTimeout; + } + + private void recompute() { + if (isDisabled()) { + configuredTimeout = 0; + timeOfAutolock = 0L; + } else { + configuredTimeout = lockTimeout.getDurationMillis(); + timeOfAutolock = appInactiveSince + configuredTimeout; + } + } + + public boolean isDisabled() { + return appInactiveSince == APP_IS_ACTIVE || lockTimeout.isDisabled(); + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java b/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java new file mode 100644 index 000000000..6d3951e5b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java @@ -0,0 +1,220 @@ +package org.cryptomator.presentation.service; + +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.cryptomator.data.cloud.crypto.Cryptors; +import org.cryptomator.presentation.util.FileUtil; +import org.cryptomator.util.Consumer; +import org.cryptomator.util.LockTimeout; +import org.cryptomator.util.SharedPreferencesHandler; + +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.IBinder; + +import androidx.annotation.Nullable; + +import timber.log.Timber; + +public class CryptorsService extends Service { + + private static final String ACTION_LOCK_ALL = "CRYPTOMATOR_LOCK_ALL"; + + public static Intent lockAllIntent(Context context) { + Intent lockAllIntent = new Intent(context, CryptorsService.class); + lockAllIntent.setAction(ACTION_LOCK_ALL); + return lockAllIntent; + } + + private final Cryptors.Default cryptors = new Cryptors.Default(); + private SharedPreferencesHandler sharedPreferencesHandler; + private UnlockedNotification notification; + private final AutolockTimeout autolockTimeout = new AutolockTimeout(); + private volatile boolean running = true; + private volatile boolean lockSuspended = false; + private BroadcastReceiver screenLockReceiver; + private FileUtil fileUtil; + + private final Lock unlockedLock = new ReentrantLock(); + private final Condition vaultsUnlockedAndInBackground = unlockedLock.newCondition(); + + private final Thread worker = new Thread(new Runnable() { + @Override + public void run() { + while (running) { + try { + waitUntilVaultsUnlockedAndInBackground(); + if (!lockSuspended) { + if (autolockTimeout.expired()) { + Timber.tag("CryptorsService").i("autolock timeout expired"); + destroyCryptorsAndHideNotification(); + } else { + notification.update(); + } + } + Thread.sleep(1000); + } catch (InterruptedException e) { + continue; + } + } + } + }); + + private void waitUntilVaultsUnlockedAndInBackground() throws InterruptedException { + unlockedLock.lock(); + try { + if (cryptors.isEmpty() || autolockTimeout.isDisabled()) { + vaultsUnlockedAndInBackground.await(); + } + } finally { + unlockedLock.unlock(); + } + } + + @Override + public void onCreate() { + super.onCreate(); + Timber.tag("CryptorsService").d("created"); + notification = new UnlockedNotification(this, autolockTimeout); + sharedPreferencesHandler = new SharedPreferencesHandler(this); + sharedPreferencesHandler.addLockTimeoutChangedListener(onLockTimeoutChanged); + worker.start(); + + IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_OFF); + screenLockReceiver = new ScreenLockReceiver(); + registerReceiver(screenLockReceiver, filter); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Timber.tag("CryptorsService").i("started"); + if (isLockAll(intent)) { + Timber.tag("CryptorsService").i("Received Lock all intent"); + destroyCryptorsAndStopService(); + return START_NOT_STICKY; + } + return START_STICKY; + } + + private boolean isLockAll(Intent intent) { + return intent != null // + && ACTION_LOCK_ALL.equals(intent.getAction()); + } + + @Override + public void onDestroy() { + running = false; + worker.interrupt(); + Timber.tag("CryptorsService").i("destroyed"); + unregisterReceiver(screenLockReceiver); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + Timber.tag("CryptorsService").i("App killed by user"); + destroyCryptorsAndStopService(); + } + + private void destroyCryptorsAndStopService() { + destroyCryptorsAndHideNotification(); + stopCryptorsService(); + } + + private void onUnlockCountChanged(int unlocked) { + if (unlocked == 0) { + if (fileUtil != null) { + fileUtil.cleanupDecryptedFiles(); + } + } + notification.setUnlockedCount(unlocked); + notification.update(); + signalVaultsUnlockedAndInBackgroundIfRequired(); + } + + private void onAppInForegroundChanged(boolean appInForeground) { + autolockTimeout.setAppIsActive(appInForeground); + notification.update(); + signalVaultsUnlockedAndInBackgroundIfRequired(); + } + + private void signalVaultsUnlockedAndInBackgroundIfRequired() { + unlockedLock.lock(); + try { + if (!cryptors.isEmpty() && !autolockTimeout.isDisabled()) { + vaultsUnlockedAndInBackground.signal(); + } + } finally { + unlockedLock.unlock(); + } + } + + private void onLockTimeoutChanged(LockTimeout lockTimeout) { + autolockTimeout.setLockTimeout(lockTimeout); + notification.update(); + } + + private final Consumer onLockTimeoutChanged = this::onLockTimeoutChanged; + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return new Binder(); + } + + public class Binder extends android.os.Binder { + + Binder() { + cryptors.setOnChangeListener(() -> onUnlockCountChanged(cryptors.size())); + } + + public Cryptors.Default cryptors() { + return cryptors; + } + + public void appInForeground(boolean appInForeground) { + onAppInForegroundChanged(appInForeground); + } + + public void suspendLock() { + lockSuspended = true; + } + + public void unSuspendLock() { + lockSuspended = false; + } + + public void setFileUtil(FileUtil mfileUtil) { + fileUtil = mfileUtil; + } + } + + class ScreenLockReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(Intent.ACTION_SCREEN_OFF) && // + sharedPreferencesHandler.lockOnScreenOff() && // + !lockSuspended) { + Timber.tag("CryptorsService").i("ScreenLock received, destroying cryptors and shutting down service"); + + destroyCryptorsAndHideNotification(); + + stopCryptorsService(); + } + } + } + + private void stopCryptorsService() { + Intent myService = new Intent(CryptorsService.this, CryptorsService.class); + stopService(myService); + } + + private void destroyCryptorsAndHideNotification() { + cryptors.destroyAll(); + notification.hide(); + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/OpenWritableFileNotification.kt b/presentation/src/main/java/org/cryptomator/presentation/service/OpenWritableFileNotification.kt new file mode 100644 index 000000000..5b81fa80f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/OpenWritableFileNotification.kt @@ -0,0 +1,73 @@ +package org.cryptomator.presentation.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.NotificationManager.IMPORTANCE_LOW +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_CANCEL_CURRENT +import android.content.Context +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION +import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION +import android.net.Uri +import androidx.core.app.NotificationCompat +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.Intents.vaultListIntent +import org.cryptomator.presentation.presenter.ContextHolder +import org.cryptomator.presentation.util.ResourceHelper +import org.cryptomator.presentation.util.ResourceHelper.Companion.getColor + +class OpenWritableFileNotification(private val context: Context, private val uriToOpenendFile: Uri) { + + private val builder: NotificationCompat.Builder + private var notificationManager: NotificationManager? = null + + init { + this.notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( // + NOTIFICATION_CHANNEL_ID, // + NOTIFICATION_CHANNEL_NAME, // + IMPORTANCE_LOW) + notificationManager?.createNotificationChannel(notificationChannel) + } + + this.builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) // + .setContentTitle(context.getString(R.string.notification_open_writable_file_title)) // + .setContentText(context.getString(R.string.notification_open_writable_file_message)) // + .setSmallIcon(R.mipmap.ic_launcher) // + .setColor(getColor(R.color.colorPrimary)) // + .setGroup(NOTIFICATION_GROUP_KEY) + .setOngoing(true) + .addAction(cancelNowAction()) + } + + private fun cancelNowAction(): NotificationCompat.Action { + return NotificationCompat.Action.Builder( // + R.drawable.ic_lock, // + ResourceHelper.getString(R.string.notification_cancel_open_writable_file), // + cancelNowIntent() // + ).build() + } + + private fun cancelNowIntent(): PendingIntent { + context.revokeUriPermission(uriToOpenendFile, FLAG_GRANT_WRITE_URI_PERMISSION or FLAG_GRANT_READ_URI_PERMISSION) + val startTheActivity = vaultListIntent().withStopEditFileNotification(true).build(context as ContextHolder) + return PendingIntent.getActivity(context, 0, startTheActivity, FLAG_CANCEL_CURRENT) + } + + fun show() { + notificationManager?.notify(NOTIFICATION_ID, builder.build()) + } + + fun hide() { + notificationManager?.cancel(NOTIFICATION_ID) + } + + companion object { + private const val NOTIFICATION_ID = 94875 + private const val NOTIFICATION_CHANNEL_ID = "65478" + private const val NOTIFICATION_CHANNEL_NAME = "Cryptomator" + private const val NOTIFICATION_GROUP_KEY = "CryptomatorGroup" + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/PhotoContentJob.kt b/presentation/src/main/java/org/cryptomator/presentation/service/PhotoContentJob.kt new file mode 100644 index 000000000..8250f2561 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/PhotoContentJob.kt @@ -0,0 +1,148 @@ +package org.cryptomator.presentation.service + +import android.app.job.JobInfo +import android.app.job.JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS +import android.app.job.JobParameters +import android.app.job.JobScheduler +import android.app.job.JobService +import android.content.ComponentName +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.provider.MediaStore +import android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI +import androidx.annotation.RequiresApi +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.util.file.MimeTypeMap_Factory +import org.cryptomator.util.file.MimeTypes +import timber.log.Timber +import java.lang.String.format +import java.util.* + +@RequiresApi(api = Build.VERSION_CODES.N) +class PhotoContentJob : JobService() { + + private val mHandler = Handler() + private val mWorker: Runnable = Runnable { + scheduleJob(applicationContext) + jobFinished(mRunningParams, false) + } + + private lateinit var mRunningParams: JobParameters + + override fun onStartJob(params: JobParameters): Boolean { + Timber.tag("PhotoContentJob").i("Job started!") + + val fileUtil = FileUtil(baseContext, MimeTypes(MimeTypeMap_Factory.newInstance())) + + mRunningParams = params + if (params.triggeredContentAuthorities != null) { + if (params.triggeredContentUris != null) { + val ids = getIds(params) + if (ids != null && ids.size > 0) { + val selection = buildSelection(ids) + var cursor: Cursor? = null + try { + cursor = contentResolver.query(EXTERNAL_CONTENT_URI, PROJECTION, selection, null, null) + cursor?.let { + while (cursor.moveToNext()) { + val dir = cursor.getString(PROJECTION_DATA) + try { + fileUtil.addImageToAutoUploads(dir) + Timber.tag("PhotoContentJob").i("Added file to UploadList") + Timber.tag("PhotoContentJob").d(format("Added file to UploadList %s", dir)) + } catch (e: FatalBackendException) { + Timber.tag("PhotoContentJob").e(e, "Failed to add image to auto upload list") + } + } + } ?: Timber.tag("PhotoContentJob").e("Error: no access to media!") + } catch (e: SecurityException) { + Timber.tag("PhotoContentJob").e("Error: no access to media!") + } finally { + cursor?.close() + } + } + } else { + Timber.tag("PhotoContentJob").w("Photos rescan needed!") + return true + } + } else { + Timber.tag("PhotoContentJob").w("No photos content") + } + + mHandler.post(mWorker) + return false + } + + private fun getIds(params: JobParameters): ArrayList? { + return params.triggeredContentUris + ?.map { it.pathSegments } + ?.filter { it != null && it.size == EXTERNAL_PATH_SEGMENTS.size + 1 } + ?.mapTo(ArrayList()) { it[it.size - 1] } + } + + private fun buildSelection(ids: ArrayList): String { + val selection = StringBuilder() + ids.indices.forEach { i -> + if (selection.isNotEmpty()) { + selection.append(" OR ") + } + selection.append(MediaStore.Images.ImageColumns._ID) + selection.append("='") + selection.append(ids[i]) + selection.append("'") + } + return selection.toString() + } + + override fun onStopJob(params: JobParameters): Boolean { + Timber.tag("PhotoContentJob").i("onStopJob called, must stop, reschedule later") + mHandler.removeCallbacks(mWorker) + return true + } + + override fun onDestroy() { + super.onDestroy() + Timber.tag("PhotoContentJob").i("Service is destroyed") + } + + companion object { + + private val MEDIA_URI = Uri.parse("content://" + MediaStore.AUTHORITY + "/") + internal val EXTERNAL_PATH_SEGMENTS = EXTERNAL_CONTENT_URI.pathSegments + internal val PROJECTION = arrayOf(MediaStore.Images.ImageColumns._ID, MediaStore.Images.ImageColumns.DATA) + + internal const val PROJECTION_DATA = 1 + + private val jobInfo: JobInfo + private const val PHOTOS_CONTENT_JOB = 23 + + init { + val builder = JobInfo.Builder(PHOTOS_CONTENT_JOB, ComponentName("org.cryptomator", PhotoContentJob::class.java.name)) + builder.addTriggerContentUri(JobInfo.TriggerContentUri(EXTERNAL_CONTENT_URI, FLAG_NOTIFY_FOR_DESCENDANTS)) + builder.addTriggerContentUri(JobInfo.TriggerContentUri(MEDIA_URI, FLAG_NOTIFY_FOR_DESCENDANTS)) + jobInfo = builder.build() + } + + fun scheduleJob(context: Context) { + context.getSystemService(JobScheduler::class.java)?.let { + val result = it.schedule(jobInfo) + if (result == JobScheduler.RESULT_SUCCESS) { + Timber.tag("PhotoContentJob").i("Job rescheduled!") + } else { + Timber.tag("PhotoContentJob").e("Failed to reschedule job!") + } + } ?: Timber.tag("PhotoContentJob").e("Service not found!") + } + + fun cancelJob(context: Context) { + context.getSystemService(JobScheduler::class.java)?.let { + it.cancel(PHOTOS_CONTENT_JOB) + Timber.tag("PhotoContentJob").i("Job canceled!") + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UnlockedNotification.java b/presentation/src/main/java/org/cryptomator/presentation/service/UnlockedNotification.java new file mode 100644 index 000000000..d8c53ba2d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UnlockedNotification.java @@ -0,0 +1,127 @@ +package org.cryptomator.presentation.service; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; + +import androidx.core.app.NotificationCompat; + +import org.cryptomator.presentation.R; +import org.cryptomator.presentation.ui.activity.VaultListActivity; +import org.cryptomator.presentation.util.ResourceHelper; + +import timber.log.Timber; + +import static android.app.NotificationManager.IMPORTANCE_LOW; +import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; +import static android.content.Intent.ACTION_MAIN; +import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static java.lang.Math.round; +import static java.lang.String.format; +import static java.util.Locale.getDefault; + +class UnlockedNotification { + + private static final int NOTIFICATION_ID = 94873; + private static final String NOTIFICATION_CHANNEL_ID = "65478"; + private static final String NOTIFICATION_CHANNEL_NAME = "Cryptomator"; + private static final String NOTIFICATION_GROUP_KEY = "CryptomatorGroup"; + + private final Service service; + private final NotificationCompat.Builder builder; + + private int unlocked = 0; + private final AutolockTimeout autolockTimeout; + + public UnlockedNotification(Service service, AutolockTimeout autolockTimeout) { + this.service = service; + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + NotificationManager notificationManager = (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + NotificationChannel notificationChannel = new NotificationChannel( // + NOTIFICATION_CHANNEL_ID, // + NOTIFICATION_CHANNEL_NAME, // + IMPORTANCE_LOW); + notificationManager.createNotificationChannel(notificationChannel); + } else { + Timber.tag("UnlockedNotification").e("Failed to get notification service for creating notification channel"); + } + } + + this.builder = new NotificationCompat.Builder(service, NOTIFICATION_CHANNEL_ID) // + .setSmallIcon(R.mipmap.ic_launcher) // + .setColor(ResourceHelper.Companion.getColor(R.color.colorPrimary)) // + .addAction(lockNowAction()) // + .setGroup(NOTIFICATION_GROUP_KEY) // + .setOngoing(true); + this.autolockTimeout = autolockTimeout; + } + + private NotificationCompat.Action lockNowAction() { + return new NotificationCompat.Action.Builder( // + R.drawable.ic_lock, // + ResourceHelper.Companion.getString(R.string.notification_lock_all), // + lockNowIntent() // + ).build(); + } + + private PendingIntent lockNowIntent() { + return PendingIntent.getService( // + service.getApplicationContext(), // + 0, // + CryptorsService.lockAllIntent(service.getApplicationContext()), // + FLAG_CANCEL_CURRENT); + } + + private PendingIntent startTheActivity() { + Intent startTheActivity = new Intent(service, VaultListActivity.class); + startTheActivity.setAction(ACTION_MAIN); + startTheActivity.setFlags(FLAG_ACTIVITY_CLEAR_TASK | FLAG_ACTIVITY_NEW_TASK); + return PendingIntent.getActivity(service, 0, startTheActivity, 0); + } + + public void setUnlockedCount(int unlocked) { + this.unlocked = unlocked; + } + + public void update() { + builder.setContentIntent(startTheActivity()); + if (autolockTimeout.isDisabled()) { + builder // + .setContentText(null) // + .setProgress(0, 0, false); + } else { + builder // + .setContentText(service.getString(R.string.notification_timeout, readableAutoLockTimeout())) // + .setProgress((int) autolockTimeout.configuredTimeout(), autolockTimeout.timeRemaining(), false); + } + if (unlocked == 0) { + hide(); + } else { + builder.setContentTitle(service.getString(R.string.notification_unlocked, unlocked)); + show(); + } + } + + public void show() { + service.startForeground(NOTIFICATION_ID, builder.build()); + } + + public void hide() { + service.stopForeground(true); + } + + private String readableAutoLockTimeout() { + int seconds = autolockTimeout.timeRemaining() / 1000; + if (seconds < 60) { + return format(getDefault(), "%ds", seconds); + } + return format(getDefault(), "%dm", round(seconds / 60.0f)); + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/AuthenticateCloudActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/AuthenticateCloudActivity.kt new file mode 100644 index 000000000..48c473452 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/AuthenticateCloudActivity.kt @@ -0,0 +1,49 @@ +package org.cryptomator.presentation.ui.activity + +import org.cryptomator.domain.WebDavCloud +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.AuthenticateCloudIntent +import org.cryptomator.presentation.presenter.AuthenticateCloudPresenter +import org.cryptomator.presentation.ui.activity.view.AuthenticateCloudView +import org.cryptomator.presentation.ui.dialog.AssignSslCertificateDialog + +import java.security.cert.X509Certificate + +import javax.inject.Inject + +@Activity(layout = R.layout.activity_empty) +class AuthenticateCloudActivity : BaseActivity(), + AuthenticateCloudView, + AssignSslCertificateDialog.Callback { + + @Inject + lateinit var presenter: AuthenticateCloudPresenter + + @InjectIntent + lateinit var authenticateCloudIntent: AuthenticateCloudIntent + + override fun intent(): AuthenticateCloudIntent = authenticateCloudIntent + + override fun finish() { + super.finish() + skipTransition() + } + + override fun skipTransition() { + overridePendingTransition(0, 0) + } + + override fun showUntrustedCertificateDialog(cloud: WebDavCloud, certificate: X509Certificate) { + showDialog(AssignSslCertificateDialog.newInstance(cloud, certificate)) + } + + override fun onAcceptCertificateClicked(cloud: WebDavCloud, certificate: X509Certificate) { + presenter.onAcceptWebDavCertificateClicked(cloud, certificate) + } + + override fun onAcceptCertificateDenied() { + presenter.onAcceptWebDavCertificateDenied() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/AutoUploadChooseVaultActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/AutoUploadChooseVaultActivity.kt new file mode 100644 index 000000000..c8a25f49d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/AutoUploadChooseVaultActivity.kt @@ -0,0 +1,109 @@ +package org.cryptomator.presentation.ui.activity + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.presenter.AutoUploadChooseVaultPresenter +import org.cryptomator.presentation.ui.activity.view.AutoUploadChooseVaultView +import org.cryptomator.presentation.ui.dialog.BiometricAuthKeyInvalidatedDialog +import org.cryptomator.presentation.ui.dialog.EnterPasswordDialog +import org.cryptomator.presentation.ui.dialog.NotEnoughVaultsDialog +import org.cryptomator.presentation.ui.fragment.AutoUploadChooseVaultFragment +import org.cryptomator.presentation.util.BiometricAuthentication +import javax.inject.Inject + +@Activity +class AutoUploadChooseVaultActivity : BaseActivity(), // + AutoUploadChooseVaultView, // + NotEnoughVaultsDialog.Callback, // + EnterPasswordDialog.Callback, + BiometricAuthentication.Callback { + + @Inject + lateinit var presenter: AutoUploadChooseVaultPresenter + + override fun setupView() { + setupToolbar() + } + + private fun setupToolbar() { + toolbar.title = getString(R.string.screen_settings_auto_photo_upload_title) + setSupportActionBar(toolbar) + supportActionBar?.let { + it.setDisplayHomeAsUpEnabled(true) + it.setHomeAsUpIndicator(R.drawable.ic_clear) + } + } + + override fun createFragment(): Fragment? = AutoUploadChooseVaultFragment() + + + override fun displayVaults(vaults: List) { + autoUploadChooseVaultFragment().displayVaults(vaults) + } + + override fun displayDialogUnableToUploadFiles() { + NotEnoughVaultsDialog // + .withContext(this) // + .andTitle(getString(R.string.dialog_unable_to_auto_upload_files_title)) // + .show() + } + + override fun onNotEnoughVaultsOkClicked() { + finish() + } + + override fun onNotEnoughVaultsCreateVaultClicked() { + // FIXME #202: vault list activity is twice on the stack + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + launchIntent?.let { startActivity(it) } + finish() + } + + override fun showChosenLocation(location: CloudFolderModel) { + autoUploadChooseVaultFragment().showChosenLocation(location) + } + + override fun onUnlockCanceled() { + presenter.onUnlockCanceled() + } + + override fun onUnlockClick(vaultModel: VaultModel, password: String) { + presenter.onUnlockPressed(vaultModel, password) + } + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun showEnterPasswordDialog(vaultModel: VaultModel) { + if (vaultWithBiometricAuthEnabled(vaultModel)) { + BiometricAuthentication(this, context(), BiometricAuthentication.CryptoMode.DECRYPT, presenter.useConfirmationInFaceUnlockBiometricAuthentication()) + .startListening(autoUploadChooseVaultFragment(), vaultModel) + } else { + showDialog(EnterPasswordDialog.newInstance(vaultModel)) + } + } + + override fun onBiometricAuthenticated(vault: VaultModel) { + presenter.onUnlockPressed(vault, vault.password) + } + + override fun onBiometricAuthenticationFailed(vault: VaultModel) { + showDialog(EnterPasswordDialog.newInstance(vault)) + } + + override fun onBiometricKeyInvalidated(vault: VaultModel) { + presenter.onBiometricKeyInvalidated(vault) + } + + override fun showBiometricAuthKeyInvalidatedDialog() { + showDialog(BiometricAuthKeyInvalidatedDialog.newInstance()) + } + + private fun vaultWithBiometricAuthEnabled(vault: VaultModel): Boolean = vault.password != null + + private fun autoUploadChooseVaultFragment(): AutoUploadChooseVaultFragment = getCurrentFragment(R.id.fragmentContainer) as AutoUploadChooseVaultFragment +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BaseActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BaseActivity.kt new file mode 100644 index 000000000..19db72762 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BaseActivity.kt @@ -0,0 +1,390 @@ +package org.cryptomator.presentation.ui.activity + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.content.res.Configuration.ORIENTATION_PORTRAIT +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.WindowManager +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.BuildConfig +import org.cryptomator.presentation.CryptomatorApp +import org.cryptomator.presentation.R +import org.cryptomator.presentation.di.HasComponent +import org.cryptomator.presentation.di.component.ActivityComponent +import org.cryptomator.presentation.di.component.ApplicationComponent +import org.cryptomator.presentation.di.component.DaggerActivityComponent +import org.cryptomator.presentation.di.module.ActivityModule +import org.cryptomator.presentation.exception.ExceptionHandlers +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.presenter.InstanceStates +import org.cryptomator.presentation.presenter.Presenter +import org.cryptomator.presentation.ui.activity.view.View +import org.cryptomator.presentation.ui.dialog.GenericProgressDialog +import org.cryptomator.presentation.ui.snackbar.SnackbarAction +import org.cryptomator.util.SharedPreferencesHandler +import timber.log.Timber +import java.lang.String.format +import javax.inject.Inject +import kotlin.reflect.KClass + +abstract class BaseActivity : AppCompatActivity(), View, ActivityCompat.OnRequestPermissionsResultCallback, HasComponent { + + @Inject + lateinit var exceptionMappings: ExceptionHandlers + + @Inject + lateinit var sharedPreferencesHandler: SharedPreferencesHandler + + private var activityComponent: ActivityComponent? = null + + private var presenter: Presenter<*>? = null + + private var currentDialog: DialogFragment? = null + private var closeDialogOnResume: Boolean = false + + /** + * Get the Main Application component for dependency injection. + * + * @return [org.cryptomator.presentation.di.component.ApplicationComponent] + */ + private val applicationComponent: ApplicationComponent + get() = cryptomatorApp.component + + private val cryptomatorApp: CryptomatorApp + get() = application as CryptomatorApp + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + logLifecycle("onCreate") + + this.activityComponent = initializeDagger() + + javaClass.getAnnotation(Activity::class.java)?.let { + setContentView(getContentLayout(it)) + + Activities.setIntent(this) + this.presenter = Activities.initializePresenter(this) + afterIntentInjected() + + if (savedInstanceState != null) { + restoreState(savedInstanceState) + } + + setupView() + setupPresenter() + + if (savedInstanceState == null) { + createAndAddFragment() + } + + currentDialog = supportFragmentManager.findFragmentByTag(ACTIVE_DIALOG) as? DialogFragment + closeDialogOnResume = currentDialog != null + } ?: Timber.tag("BaseActivity").e("Failed to initialize Activity because config is null") + } + + override fun onStart() { + super.onStart() + logLifecycle("onStart") + } + + override fun onRestart() { + super.onRestart() + logLifecycle("onRestart") + } + + public override fun onResume() { + super.onResume() + logLifecycleAsInfo("onResume") + + // not using android extensions to access activityRootVIew because the view might be from different layouts with different type + findViewById(R.id.activityRootView)?.filterTouchesWhenObscured = sharedPreferencesHandler.disableAppWhenObscured() + + val config = javaClass.getAnnotation(Activity::class.java) + if (config?.secure == true && sharedPreferencesHandler.secureScreen() && !BuildConfig.DEBUG) { + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + if (closeDialogOnResume) { + closeDialog() + } + closeDialogOnResume = false + + if (cryptomatorApp.allVaultsLocked()) { + vaultExpectedToBeUnlocked() + } + } + + internal open fun vaultExpectedToBeUnlocked() { + } + + override fun onResumeFragments() { + super.onResumeFragments() + logLifecycle("onResumeFragments") + presenter?.resume() + } + + public override fun onPause() { + super.onPause() + logLifecycle("onPause") + presenter?.pause() + } + + override fun onStop() { + super.onStop() + logLifecycle("onStop") + } + + public override fun onDestroy() { + super.onDestroy() + logLifecycle("onDestroy") + presenter?.destroy() + } + + private fun getContentLayout(config: Activity): Int { + return if (config.layout == -1) { + R.layout.activity_layout + } else { + config.layout + } + } + + private fun initializeDagger(): ActivityComponent { + val activityComponent = DaggerActivityComponent.builder() + .applicationComponent(applicationComponent) + .activityModule(ActivityModule(this)) + .build() + Activities.inject(activityComponent, this) + return activityComponent + } + + private fun createAndAddFragment() { + val fragment = createFragment() + fragment?.let { addFragment(R.id.fragmentContainer, it) } + } + + private fun afterIntentInjected() { + + } + + internal open fun setupView() { + + } + + internal open fun setupPresenter() { + + } + + internal open fun createFragment(): Fragment? = null + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val menuResource = getCustomMenuResource() + if (menuResource != NO_MENU) { + menuInflater.inflate(menuResource, menu) + return true + } + return super.onCreateOptionsMenu(menu) + } + + open fun getCustomMenuResource(): Int = NO_MENU + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + onMenuItemSelected(item.itemId) + return super.onOptionsItemSelected(item) + } + + internal open fun onMenuItemSelected(itemId: Int): Boolean = false + + /** + * Adds a [Fragment] to this activity's layout. + * + * @param containerViewId The container view to whereClause add the fragment. + * @param fragment The fragment to be added. + */ + private fun addFragment(containerViewId: Int, fragment: Fragment) { + val fragmentTransaction = this.supportFragmentManager.beginTransaction() + fragmentTransaction.add(containerViewId, fragment) + fragmentTransaction.commit() + } + + @JvmOverloads + internal fun replaceFragment(fragment: Fragment, fragmentAnimation: FragmentAnimation, addToBackStack: Boolean = true) { + val transaction = supportFragmentManager.beginTransaction() + transaction.setCustomAnimations(fragmentAnimation.enter, fragmentAnimation.exit, fragmentAnimation.popEnter, fragmentAnimation.popExit) + transaction.replace(R.id.fragmentContainer, fragment) + if (addToBackStack) { + transaction.addToBackStack(null) + } + transaction.commit() + } + + override fun getComponent(): ActivityComponent? = activityComponent + + override fun activity(): android.app.Activity = this + + override fun context(): Context = this + + override fun showDialog(dialog: DialogFragment) { + closeDialog() + currentDialog = dialog + dialog.show(supportFragmentManager, ACTIVE_DIALOG) + } + + override fun isShowingDialog(dialog: KClass): Boolean { + return if (currentDialog != null) { + dialog.isInstance(currentDialog) + } else false + } + + override fun currentDialog(): DialogFragment? { + return currentDialog + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + logLifecycle("onNewIntent") + presenter?.onNewIntent(intent) + } + + override fun closeDialog() { + currentDialog?.dismissAllowingStateLoss() + currentDialog = null + } + + override fun showMessage(messageId: Int, vararg args: Any) { + val message = getString(messageId) + showMessage(message, *args) + } + + override fun showMessage(message: String, vararg args: Any) { + val formattedMessage = format(message, *args) + if (currentDialog is MessageDisplay) { + (currentDialog as MessageDisplay).showMessage(formattedMessage) + } else { + showToastMessage(formattedMessage) + } + Timber.tag("Message").i(formattedMessage) + } + + override fun showProgress(progress: ProgressModel) { + if (currentDialog is ProgressAware) { + (currentDialog as ProgressAware).showProgress(progress) + } else { + showDialog(GenericProgressDialog.create(progress)) + } + Timber.tag("Progress").v("%s %d%%", progress.state().name(), progress.progress()) + } + + override fun finish() { + logLifecycle("finish") + super.finish() + } + + override fun showError(messageId: Int) { + val message = getString(messageId) + if (currentDialog is ErrorDisplay) { + (currentDialog as ErrorDisplay).showError(messageId) + } else { + showToastMessage(message) + } + Timber.tag("Message").w(message) + } + + override fun showError(message: String) { + if (currentDialog is ErrorDisplay) { + (currentDialog as ErrorDisplay).showError(message) + } else { + showToastMessage(message) + } + Timber.tag("Message").w(message) + } + + override fun showSnackbar(messageId: Int, action: SnackbarAction) { + Snackbar.make(snackbarView(), messageId, Snackbar.LENGTH_INDEFINITE).setAction(action.text, action).show() + } + + internal open fun snackbarView(): android.view.View { + return activity().findViewById(R.id.locationsRecyclerView) as android.view.View? + ?: return activity().findViewById(R.id.coordinatorLayout) + } + + internal fun getCurrentFragment(fragmentContainer: Int): Fragment? = supportFragmentManager.findFragmentById(fragmentContainer) + + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (newConfig.orientation == ORIENTATION_LANDSCAPE) { + logLifecycle("onConfigurationChanged: landscape") + } else if (newConfig.orientation == ORIENTATION_PORTRAIT) { + logLifecycle("onConfigurationChanged: portrait") + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + logLifecycle("onSaveInstanceState") + saveState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + logLifecycle("onRestoreInstanceState") + restoreState(savedInstanceState) + } + + private fun saveState(state: Bundle) { + InstanceStates.save(presenter, state) + } + + private fun restoreState(state: Bundle) { + InstanceStates.restore(presenter, state) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + presenter?.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + presenter?.onActivityResult(requestCode, resultCode, intent) + } + + private fun logLifecycle(method: String) { + Timber.tag("ActivityLifecycle").d("%s %s", method, this) + } + + private fun logLifecycleAsInfo(method: String) { + Timber.tag("ActivityLifecycle").i("%s %s", method, this) + } + + private fun showToastMessage(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + internal enum class FragmentAnimation constructor( + val enter: Int, + val exit: Int, + val popEnter: Int, + val popExit: Int) { + + NAVIGATE_IN_TO_FOLDER(R.animator.enter_from_right, R.animator.exit_to_left, R.animator.enter_from_left, R.animator.exit_to_right), // + NAVIGATE_OUT_OF_FOLDER(R.animator.enter_from_left, R.animator.exit_to_right, R.animator.enter_from_right, R.animator.exit_to_left) + } + + companion object { + const val NO_MENU = -1 + private const val ACTIVE_DIALOG = "activeDialog" + } +} 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 new file mode 100644 index 000000000..928bd8e9a --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BiometricAuthSettingsActivity.kt @@ -0,0 +1,105 @@ +package org.cryptomator.presentation.ui.activity + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.domain.Vault +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.presenter.BiometricAuthSettingsPresenter +import org.cryptomator.presentation.ui.activity.view.BiometricAuthSettingsView +import org.cryptomator.presentation.ui.dialog.BiometricAuthKeyInvalidatedDialog +import org.cryptomator.presentation.ui.dialog.EnrollSystemBiometricDialog +import org.cryptomator.presentation.ui.dialog.EnterPasswordDialog +import org.cryptomator.presentation.ui.fragment.BiometricAuthSettingsFragment +import org.cryptomator.presentation.util.BiometricAuthentication +import javax.inject.Inject + +@Activity +class BiometricAuthSettingsActivity : BaseActivity(), // + EnterPasswordDialog.Callback, // + BiometricAuthSettingsView, // + BiometricAuthentication.Callback, // + EnrollSystemBiometricDialog.Callback { + + @Inject + lateinit var presenter: BiometricAuthSettingsPresenter + + override fun setupView() { + toolbar.setTitle(R.string.screen_settings_biometric_auth) + setSupportActionBar(toolbar) + + showSetupBiometricAuthDialog() + } + + override fun showSetupBiometricAuthDialog() { + val biometricAuthenticationAvailable = BiometricManager.from(context()).canAuthenticate() + if (biometricAuthenticationAvailable == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) { + showDialog(EnrollSystemBiometricDialog.newInstance()) + } + } + + override fun showBiometricAuthKeyInvalidatedDialog() { + showDialog(BiometricAuthKeyInvalidatedDialog.newInstance()) + } + + override fun createFragment(): Fragment? = BiometricAuthSettingsFragment() + + override fun renderVaultList(vaultModelCollection: List) { + biometricAuthSettingsFragment().showVaults(vaultModelCollection) + } + + override fun clearVaultList() { + biometricAuthSettingsFragment().clearVaultList() + } + + override fun showEnterPasswordDialog(vaultModel: VaultModel) { + showDialog(EnterPasswordDialog.newInstance(vaultModel)) + } + + override fun onUnlockClick(vaultModel: VaultModel, password: String) { + val vaultModelWithSavedPassword = VaultModel( // + Vault // + .aCopyOf(vaultModel.toVault()) // + .withSavedPassword(password) // + .build()) + + presenter.verifyPassword(vaultModelWithSavedPassword) + } + + override fun onUnlockCanceled() { + presenter.onUnlockCanceled() + } + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun showBiometricAuthenticationDialog(vaultModel: VaultModel) { + BiometricAuthentication(this, context(), BiometricAuthentication.CryptoMode.ENCRYPT, presenter.useConfirmationInFaceUnlockBiometricAuthentication()) + .startListening(biometricAuthSettingsFragment(), vaultModel) + } + + private fun biometricAuthSettingsFragment(): BiometricAuthSettingsFragment = getCurrentFragment(R.id.fragmentContainer) as BiometricAuthSettingsFragment + + override fun onSetupBiometricAuthInSystemClicked() { + presenter.onSetupBiometricAuthInSystemClicked() + } + + override fun onCancelSetupBiometricAuthInSystemClicked() { + finish() + } + + override fun onBiometricAuthenticated(vault: VaultModel) { + presenter.saveVault(vault.toVault()) + } + + override fun onBiometricAuthenticationFailed(vault: VaultModel) { + showError(getString(R.string.error_biometric_auth_aborted)) + biometricAuthSettingsFragment().addOrUpdateVault(vault) + } + + override fun onBiometricKeyInvalidated(vault: VaultModel) { + presenter.onBiometricAuthKeyInvalidated(vault) + } +} 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 new file mode 100644 index 000000000..325503885 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -0,0 +1,568 @@ +package org.cryptomator.presentation.ui.activity + +import android.content.Intent +import android.os.Build +import android.view.Menu +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.domain.CloudNode +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.BrowseFilesIntent +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.* +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.ProgressModel.Companion.COMPLETED +import org.cryptomator.presentation.model.comparator.* +import org.cryptomator.presentation.presenter.BrowseFilesPresenter +import org.cryptomator.presentation.presenter.BrowseFilesPresenter.Companion.OPEN_FILE_FINISHED +import org.cryptomator.presentation.ui.activity.view.BrowseFilesView +import org.cryptomator.presentation.ui.bottomsheet.FileSettingsBottomSheet +import org.cryptomator.presentation.ui.bottomsheet.FolderSettingsBottomSheet +import org.cryptomator.presentation.ui.bottomsheet.VaultContentActionBottomSheet +import org.cryptomator.presentation.ui.callback.BrowseFilesCallback +import org.cryptomator.presentation.ui.dialog.* +import org.cryptomator.presentation.ui.fragment.BrowseFilesFragment +import java.util.* +import java.util.regex.Pattern +import javax.inject.Inject + +@Activity +class BrowseFilesActivity : BaseActivity(), // + BrowseFilesView, // + BrowseFilesCallback, // + ReplaceDialog.Callback, // + FileNameDialog.Callback, // + ConfirmDeleteCloudNodeDialog.Callback, // + UploadCloudFileDialog.Callback, + ExportCloudFilesDialog.Callback, + SymLinkDialog.CallBack, + NoDirFileDialog.CallBack, + SearchView.OnQueryTextListener, + SearchView.OnCloseListener { + + @Inject + lateinit var browseFilesPresenter: BrowseFilesPresenter + + @InjectIntent + lateinit var browseFilesIntent: BrowseFilesIntent + + private var enableGeneralSelectionActions: Boolean = false + + private var navigationMode: ChooseCloudNodeSettings.NavigationMode? = null + + override fun setupView() { + setupToolbar() + setupNavigationMode() + } + + private fun setupNavigationMode() { + navigationMode = if (hasCloudNodeSettings()) { + browseFilesIntent.chooseCloudNodeSettings().navigationMode() + } else { + BROWSE_FILES + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + browseFilesPresenter.onWindowFocusChanged(hasFocus) + } + + override fun snackbarView(): View = browseFilesFragment().rootView() + + override val folder: CloudFolderModel + get() = browseFilesFragment().folder + + override fun createFragment(): Fragment = + BrowseFilesFragment.newInstance(browseFilesIntent.folder(), + browseFilesIntent.chooseCloudNodeSettings()) + + override fun onBackPressed() { + browseFilesPresenter.onBackPressed() + when { + isNavigationMode(SELECT_ITEMS) -> { + browseFilesPresenter.disableSelectionMode() + } + supportFragmentManager.backStackEntryCount > 0 -> { + supportFragmentManager.popBackStack() + } + hasCloudNodeSettings() && isNavigationMode(MOVE_CLOUD_NODE) && browseFilesFragment().folder.hasParent() -> { + createBackStackFor(browseFilesFragment().folder.parent) + } + else -> { + super.onBackPressed() + } + } + } + + private fun isNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode): Boolean = this.navigationMode == navigationMode + + private fun hasCloudNodeSettings(): Boolean = + browseFilesIntent.chooseCloudNodeSettings() != null + + override fun getCustomMenuResource(): Int { + return when { + isNavigationMode(SELECT_ITEMS) -> { + R.menu.menu_file_browser_selection_mode + } + hasCloudNodeSettings() && + browseFilesIntent.chooseCloudNodeSettings().selectionMode().allowsFolders() -> { + R.menu.menu_file_browser_select_folder + } + else -> { + R.menu.menu_file_browser + } + } + } + + override fun onMenuItemSelected(itemId: Int): Boolean = when (itemId) { + R.id.action_create_folder -> { + showCreateFolderDialog() + true + } + R.id.action_select_items -> { + browseFilesPresenter.onSelectionModeActivated() + true + } + R.id.action_refresh -> { + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + R.id.action_select_all_items -> { + browseFilesFragment().selectAllItems() + true + } + R.id.action_delete_items -> { + showConfirmDeleteNodeDialog(browseFilesFragment().selectedCloudNodes) + true + } + R.id.action_move_items -> { + browseFilesPresenter.onMoveNodesClicked(folder, // + browseFilesFragment().selectedCloudNodes as ArrayList>) + true + } + R.id.action_export_items -> { + browseFilesPresenter.onExportNodesClicked( // + browseFilesFragment().selectedCloudNodes as ArrayList>, // + BrowseFilesPresenter.EXPORT_TRIGGERED_BY_USER) + true + } + R.id.action_share_items -> { + browseFilesPresenter.onShareNodesClicked(browseFilesFragment().selectedCloudNodes) + true + } + R.id.action_sort_az -> { + browseFilesFragment().setSort(CloudNodeModelNameAZComparator()) + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + R.id.action_sort_za -> { + browseFilesFragment().setSort(CloudNodeModelNameZAComparator()) + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + R.id.action_sort_newest -> { + browseFilesFragment().setSort(CloudNodeModelDateNewestFirstComparator()) + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + R.id.action_sort_oldest -> { + browseFilesFragment().setSort(CloudNodeModelDateOldestFirstComparator()) + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + R.id.action_sort_biggest -> { + browseFilesFragment().setSort(CloudNodeModelSizeBiggestFirstComparator()) + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + R.id.action_sort_smallest -> { + browseFilesFragment().setSort(CloudNodeModelSizeSmallestFirstComparator()) + browseFilesPresenter.onRefreshTriggered(browseFilesFragment().folder) + true + } + android.R.id.home -> { + // Respond to the action bar's Up/Home button + if (isNavigationMode(SELECT_ITEMS)) { + browseFilesPresenter.disableSelectionMode() + } else { + // finish this activity and does not call the onCreate method of the parent activity + finish() + } + super.onMenuItemSelected(itemId) + } + else -> super.onMenuItemSelected(itemId) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + + if (requestCode == OPEN_FILE_FINISHED) { + browseFilesPresenter.openFileFinished() + } + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + if (isNavigationMode(SELECT_ITEMS)) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + menu.findItem(R.id.action_export_items).isVisible = false + } + menu.findItem(R.id.action_delete_items).isEnabled = enableGeneralSelectionActions + menu.findItem(R.id.action_move_items).isEnabled = enableGeneralSelectionActions + menu.findItem(R.id.action_export_items).isEnabled = enableGeneralSelectionActions + menu.findItem(R.id.action_share_items).isEnabled = enableGeneralSelectionActions + } + + val searchView = menu.findItem(R.id.action_search).actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setOnCloseListener(this) + + return super.onPrepareOptionsMenu(menu) + } + + private fun setupToolbar() { + toolbar.title = effectiveTitle(browseFilesIntent.folder()) + toolbar.subtitle = effectiveSubtitle() + setSupportActionBar(toolbar) + if (hasCloudNodeSettings()) { + effectiveToolbarIcon(browseFilesIntent.chooseCloudNodeSettings().extraToolbarIcon()) + } + } + + private fun effectiveToolbarIcon(extraToolbarIcon: Int) { + supportActionBar?.let { + if (extraToolbarIcon != ChooseCloudNodeSettings.NO_ICON) { + it.setDisplayHomeAsUpEnabled(true) + it.setHomeAsUpIndicator(extraToolbarIcon) + } + } + } + + private fun hideToolbarIcon() { + supportActionBar?.setDisplayHomeAsUpEnabled(false) + } + + private fun effectiveTitle(folder: CloudFolderModel?): String { + val defaultTitle = getString(R.string.screen_file_browser_default_title) + return folder?.name?.let { folderName -> + if (folderName.isNotEmpty()) { + folderName + } else { + defaultTitle + } + } ?: defaultTitle + } + + private fun effectiveSubtitle(): String? { + return if (browseFilesIntent.chooseCloudNodeSettings() == null) { + null + } else { + browseFilesIntent.chooseCloudNodeSettings().extraTitle() + } + } + + override fun showFileTypeNotSupportedDialog(file: CloudFileModel) { + showDialog(FileTypeNotSupportedDialog.newInstance(file)) + } + + override fun showReplaceDialog(existingFiles: List, size: Int) { + ReplaceDialog.withContext(this).show(existingFiles, size) + } + + override fun showUploadDialog(uploadingFiles: Int) { + showDialog(UploadCloudFileDialog.newInstance(uploadingFiles)) + } + + override fun renderedCloudNodes(): List> = browseFilesFragment().renderedCloudNodes() + + override fun onCreateFolderClick(folderName: String) { + browseFilesPresenter.onCreateFolderPressed(browseFilesFragment().folder, folderName) + } + + override fun onExportFileClicked(cloudFile: CloudFileModel) { + browseFilesPresenter.onExportFileClicked(cloudFile, BrowseFilesPresenter.EXPORT_TRIGGERED_BY_USER) + } + + override fun onExportFileAfterAppChooserClicked(cloudFile: CloudFileModel) { + browseFilesPresenter.onExportFileClicked(cloudFile, BrowseFilesPresenter.EXPORT_AFTER_APP_CHOOSER) + } + + override fun onExportCancelled() { + browseFilesPresenter.exportNodesCanceled() + } + + private fun currentFolderPath(): String { + val currentFolder = browseFilesFragment().folder + return currentFolder.vault()?.let { it.path + currentFolder.path } ?: currentFolder.path + } + + override fun onReplacePositiveClicked() { + browseFilesPresenter.uploadFilesAndReplaceExistingFiles() + } + + override fun onReplaceNegativeClicked() { + browseFilesPresenter.uploadFilesAndSkipExistingFiles() + } + + override fun onShareFolderClicked(cloudFolderModel: CloudFolderModel) { + browseFilesPresenter.onShareFolderClicked(cloudFolderModel) + } + + override fun onExportFolderClicked(cloudFolderModel: CloudFolderModel) { + browseFilesPresenter.onExportFolderClicked(cloudFolderModel, BrowseFilesPresenter.EXPORT_TRIGGERED_BY_USER) + } + + override fun onReplaceCanceled() { + showProgress(COMPLETED) + } + + override fun showNodeSettingsDialog(node: CloudNodeModel<*>) { + val cloudNodeSettingDialog: DialogFragment = if (node.isFolder) { + FolderSettingsBottomSheet.newInstance(node as CloudFolderModel, currentFolderPath()) + } else { + FileSettingsBottomSheet.newInstance(node as CloudFileModel, currentFolderPath()) + } + cloudNodeSettingDialog.show(supportFragmentManager, "CloudNodeSettings") + } + + override fun disableGeneralSelectionActions() { + enableGeneralSelectionActions = false + } + + override fun enableGeneralSelectionActions() { + enableGeneralSelectionActions = true + } + + override fun enableSelectionMode() { + changeNavigationMode(SELECT_ITEMS) + showSelectionMode() + } + + private fun showSelectionMode() { + updateSelectionTitle(0) + effectiveToolbarIcon(R.drawable.ic_clear) + invalidateOptionsMenu() + } + + override fun updateSelectionTitle(numberSelected: Int) { + if (numberSelected == 0) { + toolbar.title = getString(R.string.screen_file_browser_selection_mode_title_zero_elements) + } else { + toolbar.title = getString(R.string.screen_file_browser_selection_mode_title_one_or_more_elements, numberSelected) + } + } + + override fun disableSelectionMode() { + changeNavigationMode(BROWSE_FILES) + hideSelectionMode() + disableAllSelectionActions() + } + + private fun disableAllSelectionActions() { + enableGeneralSelectionActions = false + } + + private fun hideSelectionMode() { + updateTitle(folder) + hideToolbarIcon() + invalidateOptionsMenu() + } + + private fun changeNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode) { + this.navigationMode = navigationMode + triggerNavigationModeChanged() + } + + private fun triggerNavigationModeChanged() { + navigationMode?.let { browseFilesFragment().navigationModeChanged(it) } + } + + override fun navigateTo(folder: CloudFolderModel) { + replaceFragment(BrowseFilesFragment.newInstance(folder, + browseFilesIntent.chooseCloudNodeSettings()), + FragmentAnimation.NAVIGATE_IN_TO_FOLDER) + } + + override fun showAddContentDialog() { + VaultContentActionBottomSheet.newInstance(browseFilesFragment().folder) + .show(supportFragmentManager, "AddContentDialog") + } + + override fun updateTitle(folder: CloudFolderModel) { + toolbar.title = effectiveTitle(folder) + } + + override fun hasExcludedFolder(): Boolean { + browseFilesFragment().renderedCloudNodes().forEach { cloudNodeModel -> + browseFilesIntent.chooseCloudNodeSettings().excludeFolderContainingNames.forEach { name -> + if (Pattern.compile(Pattern.quote(name)).matcher(cloudNodeModel.name).matches()) { + return true + } + } + } + return false + } + + override fun showCloudNodes(nodes: List>) { + browseFilesFragment().show(nodes) + } + + override fun addOrUpdateCloudNode(node: CloudNodeModel<*>) { + browseFilesFragment().addOrUpdate(node) + } + + override fun onCreateNewFolderClicked() { + showCreateFolderDialog() + } + + private fun showCreateFolderDialog() { + showDialog(CreateFolderDialog()) + } + + override fun onUploadFilesClicked(folder: CloudFolderModel) { + browseFilesPresenter.onUploadFilesClicked(folder) + } + + override fun onCreateNewTextFileClicked() { + browseFilesPresenter.onCreateNewTextFileClicked() + } + + override fun onRenameFileClicked(cloudFile: CloudFileModel) { + onRenameCloudNodeClicked(cloudFile) + } + + override fun onRenameFolderClicked(cloudFolderModel: CloudFolderModel) { + onRenameCloudNodeClicked(cloudFolderModel) + } + + private fun onRenameCloudNodeClicked(cloudNodeModel: CloudNodeModel<*>) { + showDialog(CloudNodeRenameDialog.newInstance(cloudNodeModel)) + } + + override fun onDeleteNodeClicked(cloudFile: CloudNodeModel<*>) { + showConfirmDeleteNodeDialog(listOf(cloudFile)) + } + + override fun onShareFileClicked(cloudFile: CloudFileModel) { + browseFilesPresenter.onShareFileClicked(cloudFile) + } + + override fun onMoveFileClicked(cloudFile: CloudFileModel) { + browseFilesPresenter.onMoveNodeClicked(folder, cloudFile) + } + + override fun onOpenWithTextFileClicked(cloudFile: CloudFileModel) { + browseFilesPresenter.onOpenWithTextFileClicked(cloudFile, newlyCreated = false, internalEditor = false) + } + + private fun showConfirmDeleteNodeDialog(nodes: List>) { + showDialog(ConfirmDeleteCloudNodeDialog.newInstance(nodes)) + } + + override fun onMoveFolderClicked(cloudFolderModel: CloudFolderModel) { + browseFilesPresenter.onMoveNodeClicked(folder, cloudFolderModel) + } + + private fun createBackStackFor(sourceParent: CloudFolderModel) { + replaceFragment(BrowseFilesFragment.newInstance(sourceParent, + browseFilesIntent.chooseCloudNodeSettings()), + FragmentAnimation.NAVIGATE_OUT_OF_FOLDER, + false) + } + + override fun onRenameCloudNodeClicked(cloudNodeModel: CloudNodeModel<*>, newCloudNodeName: String) { + browseFilesPresenter.onRenameCloudNodePressed(cloudNodeModel, newCloudNodeName) + } + + override fun deleteCloudNodesFromAdapter(nodes: List>) { + browseFilesFragment().remove(nodes) + } + + override fun replaceRenamedCloudNode(node: CloudNodeModel) { + browseFilesFragment().replaceRenamedCloudFile(node) + } + + override fun showProgress(node: CloudNodeModel<*>, progress: ProgressModel) { + browseFilesFragment().showProgress(node, progress) + } + + override fun showProgress(nodes: List>, progress: ProgressModel) { + browseFilesFragment().showProgress(nodes, progress) + } + + override fun hideProgress(node: CloudNodeModel<*>) { + browseFilesFragment().hideProgress(node) + } + + override fun hideProgress(nodes: List>) { + browseFilesFragment().hideProgress(nodes) + } + + override fun showLoading(loading: Boolean) { + browseFilesFragment().showLoading(loading) + } + + private fun browseFilesFragment(): BrowseFilesFragment = getCurrentFragment(R.id.fragmentContainer) as BrowseFilesFragment + + override fun onCreateNewTextFileClicked(fileName: String) { + browseFilesPresenter.onCreateNewTextFileClicked(browseFilesFragment().folder, fileName) + } + + override fun onDeleteCloudNodeConfirmed(nodes: List>) { + browseFilesPresenter.onDeleteCloudNodes(nodes) + if (isNavigationMode(SELECT_ITEMS)) { + browseFilesPresenter.disableSelectionMode() + } + } + + override fun onUploadCanceled() { + browseFilesPresenter.onUploadCanceled() + } + + override fun onQueryTextSubmit(query: String?): Boolean { + updateFilter(query?.toLowerCase(Locale.getDefault())) + return false + } + + override fun onQueryTextChange(query: String?): Boolean { + if (sharedPreferencesHandler.useLiveSearch()) { + updateFilter(query) + } + return false + } + + private fun updateFilter(query: String?) { + showLoading(true) + browseFilesFragment().setFilterText(query.orEmpty()) + browseFilesPresenter.onFolderReloadContent(folder) + } + + override fun onClose(): Boolean { + updateFilter(String()) + return false + } + + override fun showSymLinkDialog() { + showDialog(SymLinkDialog.newInstance()) + } + + override fun showNoDirFileDialog(cryptoFolderName: String, cloudFolderPath: String) { + showDialog(NoDirFileDialog.newInstance(cryptoFolderName, cloudFolderPath)) + } + + override fun navigateFolderBackBecauseSymlink() { + onBackPressed() + } + + override fun navigateFolderBackBecauseNoDirFile() { + onBackPressed() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ChooseCloudServiceActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ChooseCloudServiceActivity.kt new file mode 100644 index 000000000..1ef30889b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ChooseCloudServiceActivity.kt @@ -0,0 +1,46 @@ +package org.cryptomator.presentation.ui.activity + +import android.view.MenuItem +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.ChooseCloudServiceIntent +import org.cryptomator.presentation.intent.Intents.cloudSettingsIntent +import org.cryptomator.presentation.model.CloudTypeModel +import org.cryptomator.presentation.presenter.ChooseCloudServicePresenter +import org.cryptomator.presentation.ui.activity.view.ChooseCloudServiceView +import org.cryptomator.presentation.ui.fragment.ChooseCloudServiceFragment +import javax.inject.Inject + +@Activity +class ChooseCloudServiceActivity : BaseActivity(), ChooseCloudServiceView { + + @Inject + lateinit var presenter: ChooseCloudServicePresenter + + @InjectIntent + lateinit var chooseCloudServiceIntent: ChooseCloudServiceIntent + + override fun setupView() { + toolbar.setTitle(R.string.screen_choose_cloud_service_title) + toolbar.subtitle = chooseCloudServiceIntent.subtitle() + setSupportActionBar(toolbar) + } + + override fun createFragment(): Fragment? = ChooseCloudServiceFragment() + + override fun getCustomMenuResource(): Int = R.menu.menu_cloud_services + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + presenter.startIntent(cloudSettingsIntent()) + return super.onOptionsItemSelected(item) + } + + override fun render(cloudModels: List) { + chooseCloudServiceFragment().render(cloudModels) + } + + private fun chooseCloudServiceFragment(): ChooseCloudServiceFragment = getCurrentFragment(R.id.fragmentContainer) as ChooseCloudServiceFragment +} 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 new file mode 100644 index 000000000..432a7baf9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudConnectionListActivity.kt @@ -0,0 +1,77 @@ +package org.cryptomator.presentation.ui.activity + +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.domain.Vault +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.CloudConnectionListIntent +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.presenter.CloudConnectionListPresenter +import org.cryptomator.presentation.ui.activity.view.CloudConnectionListView +import org.cryptomator.presentation.ui.bottomsheet.CloudConnectionSettingsBottomSheet +import org.cryptomator.presentation.ui.dialog.DeleteCloudConnectionWithVaultsDialog +import org.cryptomator.presentation.ui.fragment.CloudConnectionListFragment +import java.util.* +import javax.inject.Inject + +@Activity +class CloudConnectionListActivity : BaseActivity(), + CloudConnectionListView, + CloudConnectionSettingsBottomSheet.Callback, + DeleteCloudConnectionWithVaultsDialog.Callback { + + @Inject + lateinit var presenter: CloudConnectionListPresenter + + @InjectIntent + lateinit var cloudConnectionListIntent: CloudConnectionListIntent + + override val isFinishOnNodeClicked: Boolean + get() = cloudConnectionListIntent.finishOnCloudItemClick() + + override fun setupView() { + toolbar.title = cloudConnectionListIntent.dialogTitle() + setSupportActionBar(toolbar) + } + + override fun setupPresenter() { + presenter.setSelectedCloudType(cloudConnectionListIntent.cloudType()) + } + + override fun onStart() { + super.onStart() + connectionListFragment().setSelectedCloudType(cloudConnectionListIntent.cloudType()) + } + + override fun showCloudModels(cloudNodes: List) { + connectionListFragment().show(cloudNodes) + } + + private fun connectionListFragment(): CloudConnectionListFragment = getCurrentFragment(R.id.fragmentContainer) as CloudConnectionListFragment + + override fun createFragment(): Fragment? = CloudConnectionListFragment() + + override fun showNodeSettings(cloudModel: CloudModel) { + val cloudNodeSettingDialog = // + CloudConnectionSettingsBottomSheet.newInstance(cloudModel) + cloudNodeSettingDialog.show(supportFragmentManager, "CloudNodeSettings") + } + + override fun showCloudConnectionHasVaultsDialog(cloudModel: CloudModel, vaultsOfCloud: ArrayList) { + showDialog(DeleteCloudConnectionWithVaultsDialog.newInstance(cloudModel, vaultsOfCloud)) + } + + override fun onChangeCloudClicked(cloudModel: CloudModel) { + presenter.onChangeCloudClicked(cloudModel) + } + + override fun onDeleteCloudClicked(cloudModel: CloudModel) { + presenter.onDeleteCloudClicked(cloudModel) + } + + override fun onDeleteCloudConnectionAndVaults(cloudModel: CloudModel, vaultsOfCloud: ArrayList) { + presenter.onDeleteCloudConnectionAndVaults(cloudModel, vaultsOfCloud) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudSettingsActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudSettingsActivity.kt new file mode 100644 index 000000000..51ee01c58 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CloudSettingsActivity.kt @@ -0,0 +1,35 @@ +package org.cryptomator.presentation.ui.activity + +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.presenter.CloudSettingsPresenter +import org.cryptomator.presentation.ui.activity.view.CloudSettingsView +import org.cryptomator.presentation.ui.fragment.CloudSettingsFragment +import javax.inject.Inject + +@Activity +class CloudSettingsActivity : BaseActivity(), CloudSettingsView { + + @Inject + lateinit var cloudSettingsPresenter: CloudSettingsPresenter + + override fun setupView() { + toolbar.setTitle(R.string.screen_cloud_settings_title) + setSupportActionBar(toolbar) + } + + override fun createFragment(): Fragment? = CloudSettingsFragment() + + override fun render(cloudModels: List) { + cloudSettingsFragment().showClouds(cloudModels) + } + + override fun update(cloud: CloudModel) { + cloudSettingsFragment().update(cloud) + } + + private fun cloudSettingsFragment(): CloudSettingsFragment = getCurrentFragment(R.id.fragmentContainer) as CloudSettingsFragment +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CreateVaultActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CreateVaultActivity.kt new file mode 100644 index 000000000..d4c461647 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/CreateVaultActivity.kt @@ -0,0 +1,38 @@ +package org.cryptomator.presentation.ui.activity + +import android.view.inputmethod.EditorInfo +import kotlinx.android.synthetic.main.content_create_vault.* +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.presenter.CreateVaultPresenter +import org.cryptomator.presentation.ui.activity.view.CreateVaultView +import javax.inject.Inject + +@Activity(layout = R.layout.activity_create_vault) +class CreateVaultActivity : BaseActivity(), CreateVaultView { + + @Inject + lateinit var createVaultPresenter: CreateVaultPresenter + + override fun setupView() { + createVaultButton.setOnClickListener { + createVaultPresenter.onCreateVaultClicked(vaultNameEditText.text.toString()) + } + createVaultButton.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + createVaultPresenter.onCreateVaultClicked(vaultNameEditText.text.toString()) + } + false + } + setupToolbar() + + vaultNameEditText.requestFocus() + } + + private fun setupToolbar() { + toolbar.setTitle(R.string.screen_enter_vault_name_title) + setSupportActionBar(toolbar) + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/EmptyDirIdFileInfoActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/EmptyDirIdFileInfoActivity.kt new file mode 100644 index 000000000..5217c4780 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/EmptyDirIdFileInfoActivity.kt @@ -0,0 +1,37 @@ +package org.cryptomator.presentation.ui.activity + +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.EmptyDirIdFileInfoIntent +import org.cryptomator.presentation.presenter.EmptyDirIdFileInfoPresenter +import org.cryptomator.presentation.ui.activity.view.EmptyDirFileView +import org.cryptomator.presentation.ui.fragment.EmptyDirIdFileInfoFragment +import javax.inject.Inject + +@Activity(layout = R.layout.activity_empty_dir_file_info) +class EmptyDirIdFileInfoActivity : BaseActivity(), EmptyDirFileView { + + @Inject + lateinit var presenter: EmptyDirIdFileInfoPresenter + + @InjectIntent + lateinit var emptyDirIdFileInfoIntent: EmptyDirIdFileInfoIntent + + override fun setupView() { + setupToolbar() + } + + private fun setupToolbar() { + toolbar.title = getString(R.string.screen_empty_dir_file_info_title, + emptyDirIdFileInfoIntent.dirName()) + setSupportActionBar(toolbar) + } + + override fun onResume() { + super.onResume() + (supportFragmentManager.findFragmentByTag("EmptyDirIdFileInfoFragment") as EmptyDirIdFileInfoFragment) + .setDirFilePath(emptyDirIdFileInfoIntent.dirFilePath()) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ErrorDisplay.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ErrorDisplay.kt new file mode 100644 index 000000000..80ac348a0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ErrorDisplay.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.ui.activity + +interface ErrorDisplay { + + fun showError(messageId: Int) + + fun showError(message: String) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt new file mode 100644 index 000000000..4b4f7d59a --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ImagePreviewActivity.kt @@ -0,0 +1,260 @@ +package org.cryptomator.presentation.ui.activity + +import android.net.Uri +import android.os.Build +import android.view.View.* +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import androidx.viewpager.widget.PagerAdapter +import androidx.viewpager.widget.ViewPager +import kotlinx.android.synthetic.main.activity_image_preview.* +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.ImagePreviewIntent +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.ImagePreviewFile +import org.cryptomator.presentation.presenter.ImagePreviewPresenter +import org.cryptomator.presentation.ui.activity.view.ImagePreviewView +import org.cryptomator.presentation.ui.dialog.ConfirmDeleteCloudNodeDialog +import org.cryptomator.presentation.ui.fragment.ImagePreviewFragment +import org.cryptomator.util.Optional +import javax.inject.Inject + +@Activity(layout = R.layout.activity_image_preview) +class ImagePreviewActivity : BaseActivity(), ImagePreviewView, ConfirmDeleteCloudNodeDialog.Callback { + + @Inject + lateinit var presenter: ImagePreviewPresenter + + @InjectIntent + lateinit var imagePreviewIntent: ImagePreviewIntent + + private lateinit var imagePreviewSliderAdapter: ImagePreviewSliderAdapter + + lateinit var imagePreviewFiles: ArrayList + + private val currentImageUri: Uri? + get() = imagePreviewFiles[imagePreviewSliderAdapter.getIndex(viewPager.currentItem)].uri + + private val pageChangeListener = object : ViewPager.SimpleOnPageChangeListener() { + + override fun onPageSelected(position: Int) { + onImageChanged(position) + } + } + + override fun setupView() { + try { + val imagePreviewFileStore = presenter.getImagePreviewFileStore(imagePreviewIntent.withImagePreviewFiles()) + + val index = imagePreviewFileStore.index + imagePreviewFiles = presenter.getImagePreviewFiles(imagePreviewFileStore, index) + + deleteImage.setOnClickListener { + presenter.onDeleteImageClicked(imagePreviewFiles[imagePreviewSliderAdapter.getIndex(viewPager.currentItem)]) + } + exportImage.setOnClickListener { + currentImageUri?.let { presenter.onExportImageClicked(it) } + } + shareImage.setOnClickListener { + currentImageUri?.let { presenter.onShareImageClicked(it) } + } + + setupViewPager(index) + setupToolbar(index) + setupStatusBar() + toggleFullScreen() + attachSystemUiVisibilityChangeListener() + } catch (e: FatalBackendException) { + showError(getString(R.string.error_generic)) + finish() + } + } + + private fun setupViewPager(index: Int) { + imagePreviewSliderAdapter = ImagePreviewSliderAdapter(supportFragmentManager) + viewPager.adapter = imagePreviewSliderAdapter + viewPager.currentItem = index + viewPager.addOnPageChangeListener(pageChangeListener) + viewPager.pageMargin = 50 + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + hideStatusBar() + } + } + + private fun setupStatusBar() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.statusBarColor = ContextCompat.getColor(this, R.color.colorBlack) + } + } + + private fun setupToolbar(index: Int) { + updateTitle(index) + setSupportActionBar(toolbar) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_clear) + } + + private fun updateTitle(position: Int) { + toolbar.title = imagePreviewFiles[imagePreviewSliderAdapter.getIndex(position)].cloudFileModel.name + } + + override fun onMenuItemSelected(itemId: Int): Boolean = when (itemId) { + android.R.id.home -> { + // finish this activity and does not call the onCreate method of the parent activity + finish() + true + } + else -> super.onMenuItemSelected(itemId) + } + + private fun attachSystemUiVisibilityChangeListener() { + window.decorView.setOnSystemUiVisibilityChangeListener { flags -> + val visible = flags and SYSTEM_UI_FLAG_HIDE_NAVIGATION == 0 + setControlViewVisibility(if (visible) VISIBLE else GONE) + } + } + + /** + * Show or hide full screen. + */ + private fun toggleFullScreen() { + var newUiOptions = window.decorView.systemUiVisibility + newUiOptions = newUiOptions or SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + newUiOptions = newUiOptions xor SYSTEM_UI_FLAG_FULLSCREEN + + if (Build.VERSION.SDK_INT >= 19) { + newUiOptions = newUiOptions xor SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + window.decorView.systemUiVisibility = newUiOptions + } + + private fun hideStatusBar() { + var newUiOptions = window.decorView.systemUiVisibility + newUiOptions = newUiOptions or SYSTEM_UI_FLAG_FULLSCREEN + + window.decorView.systemUiVisibility = newUiOptions + } + + override fun hideSystemUi() { + toggleNavigationBar() + hideToolbar() + } + + /** + * Show or hide navigation bar. + */ + private fun toggleNavigationBar() { + var newUiOptions = window.decorView.systemUiVisibility + + newUiOptions = newUiOptions xor SYSTEM_UI_FLAG_HIDE_NAVIGATION + + window.decorView.systemUiVisibility = newUiOptions + } + + override fun showSystemUi() { + toggleNavigationBar() + showToolbar() + } + + override fun showImagePreview(imagePreviewFile: ImagePreviewFile) { + val imagePreviewFragmentOptional = fragmentFor(imagePreviewFile) + if (imagePreviewFragmentOptional.isPresent) { + imagePreviewFragmentOptional.get().showAndUpdateImage(imagePreviewFile) + } + } + + private fun fragmentFor(imagePreviewFile: ImagePreviewFile): Optional { + return supportFragmentManager.fragments + .map { it as ImagePreviewFragment } + .firstOrNull { it.imagePreviewFile() == imagePreviewFile } + ?.let { Optional.of(it) } + ?: Optional.empty() + } + + override fun hideProgressBar(imagePreviewFile: ImagePreviewFile) { + val imagePreviewFragmentOptional = fragmentFor(imagePreviewFile) + if (imagePreviewFragmentOptional.isPresent) { + imagePreviewFragmentOptional.get().hideProgressBar() + } + } + + override fun vaultExpectedToBeUnlocked() { + finish() + } + + override fun onDeleteCloudNodeConfirmed(nodes: List>) { + presenter.onDeleteImageConfirmed(imagePreviewFiles[imagePreviewSliderAdapter.getIndex(viewPager.currentItem)], viewPager.currentItem) + } + + override fun onImageDeleted(index: Int) { + imagePreviewSliderAdapter.deletePage(index) + updateTitle(index) + } + + private fun setControlViewVisibility(visibility: Int) { + controlView.visibility = visibility + } + + private fun onImageChanged(position: Int) { + updateTitle(position) + } + + private fun hideToolbar() { + supportActionBar?.hide() + } + + private fun showToolbar() { + supportActionBar?.show() + } + + inner class ImagePreviewSliderAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + init { + initPageIndexes() + } + + override fun getItem(position: Int): Fragment { + return ImagePreviewFragment.newInstance(imagePreviewFiles[presenter.pageIndexes[position]]) + } + + override fun getCount(): Int = presenter.pageIndexes.size + + // This is called when notifyDataSetChanged() is called + override fun getItemPosition(`object`: Any): Int { + // refresh all fragments when data set changed + return PagerAdapter.POSITION_NONE + } + + // Delete a page at a `position` + fun deletePage(position: Int) { + // Remove the corresponding item in the data set + presenter.pageIndexes.removeAt(position) + // Notify the adapter that the data set is changed + notifyDataSetChanged() + } + + fun getIndex(position: Int): Int { + return presenter.pageIndexes[position] + } + + private fun initPageIndexes() { + presenter.pageIndexes = ArrayList() + + (0 until imagePreviewFiles.size).forEach { i -> + presenter.pageIndexes.add(i) + } + } + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt new file mode 100644 index 000000000..a2e03e2ee --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicenseCheckActivity.kt @@ -0,0 +1,69 @@ +package org.cryptomator.presentation.ui.activity + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.Intents.vaultListIntent +import org.cryptomator.presentation.presenter.LicenseCheckPresenter +import org.cryptomator.presentation.ui.activity.view.UpdateLicenseView +import org.cryptomator.presentation.ui.dialog.LicenseConfirmationDialog +import org.cryptomator.presentation.ui.dialog.UpdateLicenseDialog +import java.util.* +import javax.inject.Inject +import kotlin.system.exitProcess + +@Activity +class LicenseCheckActivity : BaseActivity(), UpdateLicenseDialog.Callback, LicenseConfirmationDialog.Callback, UpdateLicenseView { + + @Inject + lateinit var licenseCheckPresenter: LicenseCheckPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setupToolbar() + + validate(intent) + } + + override fun checkLicenseClicked(license: String?) { + licenseCheckPresenter.validateDialogAware(license) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + validate(intent) + } + + fun validate(intent: Intent) { + val data: Uri? = intent.data + licenseCheckPresenter.validate(data) + } + + override fun showOrUpdateLicenseDialog(license: String) { + showDialog(UpdateLicenseDialog.newInstance(license)) + } + + override fun onCheckLicenseCanceled() { + exitProcess(0) + } + + private fun setupToolbar() { + toolbar.title = getString(R.string.app_name).toUpperCase(Locale.getDefault()) + setSupportActionBar(toolbar) + } + + override fun showConfirmationDialog(mail: String) { + showDialog(LicenseConfirmationDialog.newInstance(mail)) + } + + override fun licenseConfirmationClicked() { + vaultListIntent() // + .preventGoingBackInHistory() // + .startActivity(this) // + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicensesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicensesActivity.kt new file mode 100644 index 000000000..8e753b09a --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/LicensesActivity.kt @@ -0,0 +1,19 @@ +package org.cryptomator.presentation.ui.activity + +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R + +@Activity(layout = R.layout.activity_licenses) +class LicensesActivity : BaseActivity() { + + override fun setupView() { + setupToolbar() + } + + private fun setupToolbar() { + toolbar.setTitle(R.string.screen_licenses_title) + setSupportActionBar(toolbar) + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/MessageDisplay.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/MessageDisplay.kt new file mode 100644 index 000000000..217152c8a --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/MessageDisplay.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.ui.activity + +interface MessageDisplay { + + fun showMessage(messageId: Int, vararg args: Any) + + fun showMessage(message: String, vararg args: Any) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ProgressAware.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ProgressAware.kt new file mode 100644 index 000000000..2c8d6f20f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/ProgressAware.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.ui.activity + +import org.cryptomator.presentation.model.ProgressModel + +interface ProgressAware { + + fun showProgress(progress: ProgressModel) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SetPasswordActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SetPasswordActivity.kt new file mode 100644 index 000000000..66a8af5f8 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SetPasswordActivity.kt @@ -0,0 +1,24 @@ +package org.cryptomator.presentation.ui.activity + +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.presenter.SetPasswordPresenter +import org.cryptomator.presentation.ui.activity.view.SetPasswordView +import org.cryptomator.presentation.ui.fragment.SetPasswordFragment +import javax.inject.Inject + +@Activity +class SetPasswordActivity : BaseActivity(), SetPasswordView { + + @Inject + lateinit var setPasswordPresenter: SetPasswordPresenter + + override fun setupView() { + toolbar.setTitle(R.string.screen_set_password_title) + setSupportActionBar(toolbar) + } + + override fun createFragment(): Fragment = SetPasswordFragment() +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SettingsActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SettingsActivity.kt new file mode 100644 index 000000000..3fbf28e4b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SettingsActivity.kt @@ -0,0 +1,100 @@ +package org.cryptomator.presentation.ui.activity + +import android.content.Intent +import android.net.Uri +import android.view.View +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.presenter.SettingsPresenter +import org.cryptomator.presentation.ui.activity.view.SettingsView +import org.cryptomator.presentation.ui.dialog.* +import org.cryptomator.presentation.ui.fragment.SettingsFragment +import javax.inject.Inject + +@Activity(layout = R.layout.activity_settings) +class SettingsActivity : BaseActivity(), + SettingsView, + DebugModeDisclaimerDialog.Callback, + DisableAppWhenObscuredDisclaimerDialog.Callback, + DisableSecureScreenDisclaimerDialog.Callback, + UpdateAppAvailableDialog.Callback, // + UpdateAppDialog.Callback { + + @Inject + lateinit var presenter: SettingsPresenter + + override fun setupView() { + setupToolbar() + } + + private fun setupToolbar() { + toolbar.setTitle(R.string.screen_settings_title) + setSupportActionBar(toolbar) + } + + fun presenter(): SettingsPresenter = presenter + + override fun onDisclaimerAccepted() { + presenter.onDebugModeChanged(accepted()) + } + + override fun onDisclaimerRejected() { + settingsFragment().deactivateDebugMode() + } + + private fun settingsFragment(): SettingsFragment = supportFragmentManager.findFragmentByTag("SettingsFragment") as SettingsFragment + + private fun accepted(): Boolean = true + + + override fun onDisableAppObscuredDisclaimerAccepted() { + // do nothing, everything set accordingly + } + + override fun onDisableAppObscuredDisclaimerRejected() { + settingsFragment().disableAppWhenObscured() + } + + override fun onDisableSecureScreenDisclaimerAccepted() { + // do nothing, everything set accordingly + } + + override fun onDisableSecureScreenDisclaimerRejected() { + settingsFragment().secureScreen() + } + + fun grantLocalStoragePermissionForAutoUpload() { + presenter.grantLocalStoragePermissionForAutoUpload() + } + + override fun refreshUpdateTimeView() { + settingsFragment().setupUpdateCheck() + } + + override fun disableAutoUpload() { + settingsFragment().disableAutoUpload() + } + + override fun snackbarView(): View = settingsFragment().rootView() + + override fun cancelUpdateClicked() { + // Do nothing + } + + override fun showUpdateWebsite() { + val url = "https://cryptomator.org/de/android/" + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun installUpdate() { + presenter().installUpdate() + } + + override fun onUpdateAppDialogLoaded() { + showProgress(ProgressModel.GENERIC) + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt new file mode 100644 index 000000000..d77d47475 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SharedFilesActivity.kt @@ -0,0 +1,225 @@ +package org.cryptomator.presentation.ui.activity + +import android.content.ClipData +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.ProgressModel.Companion.COMPLETED +import org.cryptomator.presentation.model.SharedFileModel +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.presenter.SharedFilesPresenter +import org.cryptomator.presentation.ui.activity.view.SharedFilesView +import org.cryptomator.presentation.ui.dialog.* +import org.cryptomator.presentation.ui.fragment.SharedFilesFragment +import org.cryptomator.presentation.util.BiometricAuthentication +import timber.log.Timber +import java.lang.String.format +import java.util.* +import javax.inject.Inject + +@Activity +class SharedFilesActivity : BaseActivity(), // + SharedFilesView, // + EnterPasswordDialog.Callback, // + BiometricAuthentication.Callback, // + ReplaceDialog.Callback, // + NotEnoughVaultsDialog.Callback, // + UploadCloudFileDialog.Callback { + + @Inject + lateinit var presenter: SharedFilesPresenter + + private var contentName: String? = null + + override fun setupView() { + handleIncomingContent(intent) + setupToolbar() + } + + private fun handleIncomingContent(content: Intent) { + when (content.action) { + ACTION_SEND // fall through + , ACTION_SEND_MULTIPLE -> { + Timber.tag("Sharing").d("Received intent") + val clipData = content.clipData + if (clipData != null) { + handleIncomingClipData(clipData) + } + } + } + } + + private fun handleIncomingClipData(clipData: ClipData) { + Timber.tag("Sharing").d("Received %d ClipData.Items", clipData.itemCount) + if (clipData.itemCount == 1) { + handleIncomingClipDataWithSingleItem(clipData.getItemAt(0)) + } else { + handleIncomingClipDataWithMultipleItems(clipData) + } + } + + private fun handleIncomingClipDataWithSingleItem(item: ClipData.Item) { + if (item.text != null && item.uri == null) { + contentName = getString(R.string.screen_share_files_content_text) + handleSendText(item.text.toString()) + } else if (item.uri != null) { + contentName = getString(R.string.screen_share_files_content_file) + handleSendFile(item.uri) + } + } + + private fun handleIncomingClipDataWithMultipleItems(clipData: ClipData) { + val sharedFileUris = sharedFileUrisFrom(clipData) + Timber.tag("Sharing").d("%d uris extracted", sharedFileUris.size) + when { + sharedFileUris.size == 1 -> { + contentName = getString(R.string.screen_share_files_content_file) + handleSendFile(sharedFileUris[0]) + } + sharedFileUris.size > 1 -> { + contentName = getString(R.string.screen_share_files_content_files) + handleSendMultipleFiles(sharedFileUris) + } + } + } + + private fun sharedFileUrisFrom(clipData: ClipData): List { + val uriList = ArrayList(clipData.itemCount) + (0 until clipData.itemCount).forEach { i -> + clipData.getItemAt(i).uri + ?.let { uriList.add(it) } + ?: Timber.tag("Sharing").i("Item %d without uri", i) + } + return uriList + } + + private fun handleSendMultipleFiles(uriList: List) { + presenter.onFilesShared(uriList) + } + + private fun handleSendFile(fileUri: Uri) { + presenter.onFileShared(fileUri) + } + + private fun handleSendText(text: String) { + presenter.onTextShared(text) + } + + private fun setupToolbar() { + toolbar.title = format(getString(R.string.screen_share_files_title), contentName) + setSupportActionBar(toolbar) + supportActionBar?.let { + it.setDisplayHomeAsUpEnabled(true) + it.setHomeAsUpIndicator(R.drawable.ic_clear) + } + } + + override fun createFragment(): Fragment? = SharedFilesFragment() + + public override fun onMenuItemSelected(itemId: Int): Boolean = when (itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onMenuItemSelected(itemId) + } + + override fun displayVaults(vaults: List) { + sharedFilesFragment().displayVaults(vaults) + } + + override fun displayFilesToUpload(sharedFiles: List) { + sharedFilesFragment().displayFilesToUpload(sharedFiles) + } + + override fun displayDialogUnableToUploadFiles() { + //UnableToShareFilesDialog.withContext(this).show() + } + + private fun sharedFilesFragment(): SharedFilesFragment = getCurrentFragment(R.id.fragmentContainer) as SharedFilesFragment + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun showEnterPasswordDialog(vault: VaultModel) { + if (vaultWithBiometricAuthEnabled(vault)) { + BiometricAuthentication(this, context(), BiometricAuthentication.CryptoMode.DECRYPT, presenter.useConfirmationInFaceUnlockBiometricAuthentication()) + .startListening(sharedFilesFragment(), vault) + } else { + showDialog(EnterPasswordDialog.newInstance(vault)) + } + } + + override fun showReplaceDialog(existingFiles: List, size: Int) { + ReplaceDialog.withContext(this).show(existingFiles, size) + } + + override fun showChosenLocation(folder: CloudFolderModel) { + sharedFilesFragment().showChosenLocation(folder) + } + + override fun showBiometricAuthKeyInvalidatedDialog() { + showDialog(BiometricAuthKeyInvalidatedDialog.newInstance()) + } + + override fun showUploadDialog(uploadingFiles: Int) { + showDialog(UploadCloudFileDialog.newInstance(uploadingFiles)) + } + + override fun onUnlockClick(vaultModel: VaultModel, password: String) { + presenter.onUnlockPressed(vaultModel, password) + } + + override fun onUnlockCanceled() { + presenter.onUnlockCanceled() + } + + override fun onReplacePositiveClicked() { + presenter.onReplaceExistingFilesPressed() + } + + override fun onReplaceNegativeClicked() { + presenter.onSkipExistingFilesPressed() + } + + override fun onReplaceCanceled() { + showProgress(COMPLETED) + } + + override fun onNotEnoughVaultsOkClicked() { + finish() + } + + override fun onNotEnoughVaultsCreateVaultClicked() { + packageManager.getLaunchIntentForPackage(packageName) + ?.let { + it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(it) + } + finish() + } + + override fun onBiometricAuthenticated(vault: VaultModel) { + presenter.onUnlockPressed(vault, vault.password) + } + + override fun onBiometricAuthenticationFailed(vault: VaultModel) { + showDialog(EnterPasswordDialog.newInstance(vault)) + } + + override fun onBiometricKeyInvalidated(vault: VaultModel) { + presenter.onBiometricAuthKeyInvalidated() + } + + private fun vaultWithBiometricAuthEnabled(vault: VaultModel): Boolean = vault.password != null + + override fun onUploadCanceled() { + presenter.onUploadCanceled() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SplashActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SplashActivity.kt new file mode 100644 index 000000000..ca987ce00 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/SplashActivity.kt @@ -0,0 +1,14 @@ +package org.cryptomator.presentation.ui.activity + +import org.cryptomator.generator.Activity +import org.cryptomator.presentation.presenter.SplashPresenter +import org.cryptomator.presentation.ui.activity.view.SplashView + +import javax.inject.Inject + +@Activity(secure = false) +class SplashActivity : BaseActivity(), SplashView { + + @Inject + lateinit var splashPresenter: SplashPresenter +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt new file mode 100644 index 000000000..cf74574a0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/TextEditorActivity.kt @@ -0,0 +1,134 @@ +package org.cryptomator.presentation.ui.activity + +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.TextEditorIntent +import org.cryptomator.presentation.presenter.TextEditorPresenter +import org.cryptomator.presentation.ui.activity.view.TextEditorView +import org.cryptomator.presentation.ui.dialog.UnsavedChangesDialog +import org.cryptomator.presentation.ui.fragment.TextEditorFragment +import javax.inject.Inject + +@Activity +class TextEditorActivity : BaseActivity(), + TextEditorView, + UnsavedChangesDialog.Callback, + SearchView.OnQueryTextListener { + + @Inject + lateinit var textEditorPresenter: TextEditorPresenter + + @InjectIntent + lateinit var textEditorIntent: TextEditorIntent + + override val textFileContent: String + get() = textEditorFragment().textFileContent + + override fun setupView() { + textEditorPresenter.setTextFile(textEditorIntent.textFile()) + setupToolbar() + } + + override fun createFragment(): Fragment = TextEditorFragment() + + override fun onBackPressed() { + textEditorPresenter.onBackPressed() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + super.onCreateOptionsMenu(menu) + + menu.findItem(R.id.action_search) + .setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + menu.findItem(R.id.action_search_previous).isVisible = true + menu.findItem(R.id.action_search_next).isVisible = true + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + invalidateOptionsMenu() + return true + } + }) + return true + } + + override fun getCustomMenuResource(): Int = R.menu.menu_text_editor + + override fun onMenuItemSelected(itemId: Int): Boolean = when (itemId) { + R.id.action_save_changes -> { + textEditorPresenter.saveChanges() + true + } + R.id.action_search_previous -> { + textEditorFragment().onPreviousQuery() + true + } + R.id.action_search_next -> { + textEditorFragment().onNextQuery() + true + } + else -> { + super.onMenuItemSelected(itemId) + } + } + + override fun onQueryTextSubmit(query: String): Boolean { + textEditorFragment().onQueryText(query) + return true + } + + override fun onQueryTextChange(query: String): Boolean { + if (sharedPreferencesHandler.useLiveSearch()) { + textEditorFragment().onQueryText(query) + } + + return true + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + val searchView = menu.findItem(R.id.action_search).actionView as SearchView + searchView.setOnQueryTextListener(this) + + return super.onPrepareOptionsMenu(menu) + } + + private fun setupToolbar() { + toolbar.title = textEditorIntent.textFile().name + setSupportActionBar(toolbar) + } + + override fun performBackPressed() { + super.onBackPressed() + } + + override fun showUnsavedChangesDialog() { + UnsavedChangesDialog.withContext(this).show() + } + + override fun displayTextFileContent(textFileContent: String) { + textEditorFragment().displayTextFileContent(textFileContent) + } + + override fun onSaveChangesClicked() { + textEditorPresenter.saveChanges() + } + + override fun onDiscardChangesClicked() { + performBackPressed() + } + + override fun vaultExpectedToBeUnlocked() { + finish() + } + + private fun textEditorFragment(): TextEditorFragment = getCurrentFragment(R.id.fragmentContainer) as TextEditorFragment + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt new file mode 100644 index 000000000..8d8e46420 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -0,0 +1,282 @@ +package org.cryptomator.presentation.ui.activity + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.view.View +import androidx.annotation.RequiresApi +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.activity_layout_obscure_aware.* +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.domain.Vault +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.CryptomatorApp +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.Intents.browseFilesIntent +import org.cryptomator.presentation.intent.Intents.settingsIntent +import org.cryptomator.presentation.intent.VaultListIntent +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.presenter.VaultListPresenter +import org.cryptomator.presentation.service.OpenWritableFileNotification +import org.cryptomator.presentation.ui.activity.view.VaultListView +import org.cryptomator.presentation.ui.bottomsheet.AddVaultBottomSheet +import org.cryptomator.presentation.ui.bottomsheet.SettingsVaultBottomSheet +import org.cryptomator.presentation.ui.callback.VaultListCallback +import org.cryptomator.presentation.ui.dialog.* +import org.cryptomator.presentation.ui.fragment.VaultListFragment +import org.cryptomator.presentation.ui.layout.ObscuredAwareCoordinatorLayout.Listener +import org.cryptomator.presentation.util.BiometricAuthentication +import java.util.* +import javax.inject.Inject + +@Activity(layout = R.layout.activity_layout_obscure_aware) +class VaultListActivity : BaseActivity(), // + VaultListView, // + VaultListCallback, // + BiometricAuthentication.Callback, // + AskForLockScreenDialog.Callback, // + ChangePasswordDialog.Callback, // + VaultNotFoundDialog.Callback, + UpdateAppAvailableDialog.Callback, // + UpdateAppDialog.Callback, // + BetaConfirmationDialog.Callback { + + @Inject + lateinit var vaultListPresenter: VaultListPresenter + + @InjectIntent + lateinit var vaultListIntent: VaultListIntent + + private var biometricAuthentication: BiometricAuthentication? = null + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + vaultListPresenter.onWindowFocusChanged(hasFocus) + } + + override fun setupView() { + setupToolbar() + vaultListPresenter.prepareView() + activityRootView.setOnFilteredTouchEventForSecurityListener(object : Listener { + override fun onFilteredTouchEventForSecurity() { + vaultListPresenter.onFilteredTouchEventForSecurity() + } + }) + + if (stopEditFilePressed() && sharedPreferencesHandler.keepUnlockedWhileEditing()) { + hideNotification() + unSuspendLock() + } + } + + private fun stopEditFilePressed(): Boolean { + return vaultListIntent.stopEditFileNotification() != null && vaultListIntent.stopEditFileNotification() + } + + private fun hideNotification() { + OpenWritableFileNotification(context(), Uri.EMPTY).hide() + } + + private fun unSuspendLock() { + val cryptomatorApp = activity().application as CryptomatorApp + cryptomatorApp.unSuspendLock() + } + + override fun createFragment(): Fragment = VaultListFragment() + + override fun snackbarView(): View = vaultListFragment().rootView() + + override fun getCustomMenuResource(): Int = R.menu.menu_vault_list + + override fun onMenuItemSelected(itemId: Int): Boolean = when (itemId) { + R.id.action_settings -> { + vaultListPresenter.startIntent(settingsIntent()) + true + } + else -> super.onMenuItemSelected(itemId) + } + + override fun isVaultLocked(vaultModel: VaultModel): Boolean { + return vaultListFragment().isVaultLocked(vaultModel) + } + + private fun setupToolbar() { + toolbar.title = getString(R.string.app_name).toUpperCase(Locale.getDefault()) + setSupportActionBar(toolbar) + } + + override fun showAddVaultBottomSheet() { + showDialog(AddVaultBottomSheet()) + } + + override fun showRenameDialog(vaultModel: VaultModel) { + showDialog(VaultRenameDialog.newInstance(vaultModel)) + } + + override fun showEnterPasswordDialog(vault: VaultModel) { + showDialog(EnterPasswordDialog.newInstance(vault)) + } + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun showBiometricDialog(vault: VaultModel) { + biometricAuthentication = BiometricAuthentication(this, context(), BiometricAuthentication.CryptoMode.DECRYPT, vaultListPresenter.useConfirmationInFaceUnlockBiometricAuthentication()) + biometricAuthentication?.startListening(vaultListFragment(), vault) + } + + override fun showChangePasswordDialog(vaultModel: VaultModel) { + showDialog(ChangePasswordDialog.newInstance(vaultModel)) + } + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun getEncryptedPasswordWithBiometricAuthentication(vaultModel: VaultModel) { + biometricAuthentication = BiometricAuthentication(this, context(), BiometricAuthentication.CryptoMode.ENCRYPT, vaultListPresenter.useConfirmationInFaceUnlockBiometricAuthentication()) + biometricAuthentication?.startListening(vaultListFragment(), vaultModel) + } + + override fun showBiometricAuthKeyInvalidatedDialog() { + showDialog(BiometricAuthKeyInvalidatedDialog.newInstance()) + } + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun cancelBasicAuthIfRunning() { + biometricAuthentication?.stopListening() + } + + @RequiresApi(api = Build.VERSION_CODES.M) + override fun stoppedBiometricAuthDuringCloudAuthentication(): Boolean { + return biometricAuthentication?.stoppedBiometricAuthDuringCloudAuthentication() == true + } + + override fun showVaultSettingsDialog(vaultModel: VaultModel) { + val vaultSettingDialog = // + SettingsVaultBottomSheet.newInstance(vaultModel) + vaultSettingDialog.show(supportFragmentManager, "VaultSettings") + } + + override fun renderVaultList(vaultModelCollection: List) { + vaultListFragment().showVaults(vaultModelCollection) + } + + override fun showVaultCreationHint() { + vaultListFragment().showVaultCreationHint() + } + + override fun hideVaultCreationHint() { + vaultListFragment().hideVaultCreationHint() + } + + override fun deleteVaultFromAdapter(vaultId: Long) { + vaultListFragment().deleteVaultFromAdapter(vaultId) + } + + override fun addOrUpdateVault(vault: VaultModel) { + vaultListFragment().addOrUpdateVault(vault) + } + + override fun navigateToVaultContent(vault: VaultModel, decryptedRoot: CloudFolderModel) { + vaultListPresenter.startIntent(browseFilesIntent().withTitle(vault.name).withFolder(decryptedRoot)) + } + + override fun renameVault(vaultModel: VaultModel) { + vaultListFragment().addOrUpdateVault(vaultModel) + } + + override fun onAddExistingVault() { + vaultListPresenter.onAddExistingVault() + } + + override fun onCreateVault() { + vaultListPresenter.onCreateVault() + } + + override fun onUnlockClick(vaultModel: VaultModel, password: String) { + vaultListPresenter.onUnlockClick(vaultModel, password) + } + + override fun onUnlockCanceled() { + vaultListPresenter.onUnlockCanceled() + } + + override fun onDeleteVaultClick(vaultModel: VaultModel) { + VaultDeleteConfirmationDialog.newInstance(vaultModel) // + .show(supportFragmentManager, "VaultDeleteConfirmationDialog") + } + + override fun onRenameVaultClick(vaultModel: VaultModel) { + vaultListPresenter.onRenameVaultClicked(vaultModel) + } + + override fun onLockVaultClick(vaultModel: VaultModel) { + vaultListPresenter.onVaultLockClicked(vaultModel) + } + + override fun onChangePasswordClick(vaultModel: VaultModel) { + vaultListPresenter.onChangePasswordClicked(vaultModel) + } + + override fun onRenameClick(vaultModel: VaultModel, newVaultName: String) { + vaultListPresenter.renameVault(vaultModel, newVaultName) + } + + override fun onDeleteConfirmedClick(vaultModel: VaultModel) { + vaultListPresenter.deleteVault(vaultModel) + } + + override fun onAskForLockScreenFinished(setScreenLock: Boolean) { + vaultListPresenter.onAskForLockScreenFinished(setScreenLock) + } + + private fun vaultListFragment(): VaultListFragment = // + getCurrentFragment(R.id.fragmentContainer) as VaultListFragment + + override fun onChangePasswordClick(vaultModel: VaultModel, oldPassword: String, newPassword: String) { + vaultListPresenter.onChangePasswordClicked(vaultModel, oldPassword, newPassword) + } + + override fun onDeleteMissingVaultClicked(vault: Vault) { + vaultListPresenter.onDeleteMissingVaultClicked(vault) + } + + override fun onUpdateAppDialogLoaded() { + showProgress(ProgressModel.GENERIC) + } + + override fun installUpdate() { + vaultListPresenter.installUpdate() + } + + override fun cancelUpdateClicked() { + closeDialog() + } + + override fun showUpdateWebsite() { + val url = "https://cryptomator.org/de/android/" + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + + override fun onAskForBetaConfirmationFinished() { + sharedPreferencesHandler.setBetaScreenDialogAlreadyShown() + } + + override fun onBiometricAuthenticated(vault: VaultModel) { + vaultListPresenter.onBiometricAuthenticationSucceeded(vault) + } + + override fun onBiometricAuthenticationFailed(vault: VaultModel) { + val vaultWithoutPassword = Vault.aCopyOf(vault.toVault()).withSavedPassword(null).build() + if (!vaultListPresenter.startedUsingPrepareUnlock()) { + vaultListPresenter.startPrepareUnlockUseCase(vaultWithoutPassword) + } + showEnterPasswordDialog(VaultModel(vaultWithoutPassword)) + } + + override fun onBiometricKeyInvalidated(vault: VaultModel) { + vaultListPresenter.onBiometricKeyInvalidated() + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WebDavAddOrChangeActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WebDavAddOrChangeActivity.kt new file mode 100644 index 000000000..15715be89 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/WebDavAddOrChangeActivity.kt @@ -0,0 +1,55 @@ +package org.cryptomator.presentation.ui.activity + +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.toolbar_layout.* +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.generator.Activity +import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.WebDavAddOrChangeIntent +import org.cryptomator.presentation.presenter.WebDavAddOrChangePresenter +import org.cryptomator.presentation.ui.activity.view.WebDavAddOrChangeView +import org.cryptomator.presentation.ui.dialog.WebDavAskForHttpDialog +import org.cryptomator.presentation.ui.fragment.WebDavAddOrChangeFragment +import java.net.URI +import java.net.URISyntaxException +import javax.inject.Inject + +@Activity +class WebDavAddOrChangeActivity : BaseActivity(), + WebDavAddOrChangeView, + WebDavAskForHttpDialog.Callback { + + @Inject + lateinit var webDavAddOrChangePresenter: WebDavAddOrChangePresenter + + @InjectIntent + lateinit var webDavAddOrChangeIntent: WebDavAddOrChangeIntent + + override fun setupView() { + toolbar.setTitle(R.string.screen_webdav_settings_title) + setSupportActionBar(toolbar) + } + + override fun createFragment(): Fragment = WebDavAddOrChangeFragment.newInstance(webDavAddOrChangeIntent.webDavCloud()) + + override fun onCheckUserInputSucceeded(urlPort: String, username: String, password: String, cloudId: Long?, certificate: String?) { + webDavAddOrChangeFragment().hideKeyboard() + webDavAddOrChangePresenter.authenticate(username, password, urlPort, cloudId, certificate) + } + + override fun showAskForHttpDialog(urlPort: String, username: String, password: String, cloudId: Long?, certificate: String?) { + try { + showDialog(WebDavAskForHttpDialog.newInstance(URI(urlPort), username, password, cloudId, certificate)) + } catch (e: URISyntaxException) { + throw FatalBackendException(e) + } + } + + override fun onAksForHttpFinished(username: String, password: String, url: String, cloudId: Long?, certificate: String?) { + webDavAddOrChangePresenter.authenticate(username, password, url, cloudId, certificate) + } + + private fun webDavAddOrChangeFragment(): WebDavAddOrChangeFragment = getCurrentFragment(R.id.fragmentContainer) as WebDavAddOrChangeFragment + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AuthenticateCloudView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AuthenticateCloudView.kt new file mode 100644 index 000000000..e3453fe03 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AuthenticateCloudView.kt @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.domain.WebDavCloud +import org.cryptomator.presentation.intent.AuthenticateCloudIntent +import java.security.cert.X509Certificate + +interface AuthenticateCloudView : View { + + fun intent(): AuthenticateCloudIntent + fun skipTransition() + fun showUntrustedCertificateDialog(cloud: WebDavCloud, certificate: X509Certificate) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AutoUploadChooseVaultView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AutoUploadChooseVaultView.kt new file mode 100644 index 000000000..862222bcb --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/AutoUploadChooseVaultView.kt @@ -0,0 +1,14 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.VaultModel + +interface AutoUploadChooseVaultView : View { + + fun displayDialogUnableToUploadFiles() + fun displayVaults(vaults: List) + fun showChosenLocation(location: CloudFolderModel) + fun showEnterPasswordDialog(vaultModel: VaultModel) + fun showBiometricAuthKeyInvalidatedDialog() + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BiometricAuthSettingsView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BiometricAuthSettingsView.kt new file mode 100644 index 000000000..ae16db86f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BiometricAuthSettingsView.kt @@ -0,0 +1,14 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.VaultModel + +interface BiometricAuthSettingsView : View { + + fun renderVaultList(vaultModelCollection: List) + fun clearVaultList() + fun showBiometricAuthenticationDialog(vaultModel: VaultModel) + fun showEnterPasswordDialog(vaultModel: VaultModel) + fun showSetupBiometricAuthDialog() + fun showBiometricAuthKeyInvalidatedDialog() + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt new file mode 100644 index 000000000..3adac8502 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/BrowseFilesView.kt @@ -0,0 +1,39 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.domain.CloudNode +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.ProgressModel + +interface BrowseFilesView : View { + + val folder: CloudFolderModel + + fun showCloudNodes(nodes: List>) + fun addOrUpdateCloudNode(node: CloudNodeModel<*>) + fun deleteCloudNodesFromAdapter(nodes: List>) + fun replaceRenamedCloudNode(node: CloudNodeModel) + fun showLoading(loading: Boolean) + fun showProgress(node: CloudNodeModel<*>, progress: ProgressModel) + fun showProgress(nodes: List>, progress: ProgressModel) + fun hideProgress(node: CloudNodeModel<*>) + fun hideProgress(nodes: List>) + fun showFileTypeNotSupportedDialog(file: CloudFileModel) + fun showReplaceDialog(existingFiles: List, size: Int) + fun showUploadDialog(uploadingFiles: Int) + fun renderedCloudNodes(): List> + fun hasExcludedFolder(): Boolean + fun navigateTo(folder: CloudFolderModel) + fun updateTitle(folder: CloudFolderModel) + fun showAddContentDialog() + fun showNodeSettingsDialog(node: CloudNodeModel<*>) + fun disableGeneralSelectionActions() + fun enableGeneralSelectionActions() + fun enableSelectionMode() + fun updateSelectionTitle(numberSelected: Int) + fun disableSelectionMode() + fun showSymLinkDialog() + fun showNoDirFileDialog(cryptoFolderName: String, cloudFolderPath: String) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ChooseCloudServiceView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ChooseCloudServiceView.kt new file mode 100644 index 000000000..a144a19b9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ChooseCloudServiceView.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.CloudTypeModel + +interface ChooseCloudServiceView : View { + + fun render(cloudModels: List) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudConnectionListView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudConnectionListView.kt new file mode 100644 index 000000000..b94912e9f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudConnectionListView.kt @@ -0,0 +1,15 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.domain.Vault +import org.cryptomator.presentation.model.CloudModel +import java.util.* + +interface CloudConnectionListView : View { + + val isFinishOnNodeClicked: Boolean + + fun showCloudModels(cloudNodes: List) + fun showNodeSettings(cloudModel: CloudModel) + fun showCloudConnectionHasVaultsDialog(cloudModel: CloudModel, vaultsOfCloud: ArrayList) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudSettingsView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudSettingsView.kt new file mode 100644 index 000000000..2586ba2a9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CloudSettingsView.kt @@ -0,0 +1,10 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.CloudModel + +interface CloudSettingsView : View { + + fun render(cloudModels: List) + fun update(cloud: CloudModel) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CreateVaultView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CreateVaultView.kt new file mode 100644 index 000000000..035decd5c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/CreateVaultView.kt @@ -0,0 +1,3 @@ +package org.cryptomator.presentation.ui.activity.view + +interface CreateVaultView : View diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/EmptyDirFileView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/EmptyDirFileView.kt new file mode 100644 index 000000000..160992a20 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/EmptyDirFileView.kt @@ -0,0 +1,3 @@ +package org.cryptomator.presentation.ui.activity.view + +interface EmptyDirFileView : View diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ImagePreviewView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ImagePreviewView.kt new file mode 100644 index 000000000..4a0fcc0aa --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/ImagePreviewView.kt @@ -0,0 +1,13 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.ImagePreviewFile + +interface ImagePreviewView : View { + + fun hideSystemUi() + fun showSystemUi() + fun showImagePreview(imagePreviewFile: ImagePreviewFile) + fun hideProgressBar(imagePreviewFile: ImagePreviewFile) + fun onImageDeleted(index: Int) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SetPasswordView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SetPasswordView.kt new file mode 100644 index 000000000..8593b0eb7 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SetPasswordView.kt @@ -0,0 +1,3 @@ +package org.cryptomator.presentation.ui.activity.view + +interface SetPasswordView : View diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SettingsView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SettingsView.kt new file mode 100644 index 000000000..ddfd97a3b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SettingsView.kt @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.ui.activity.view + +interface SettingsView : View { + + fun disableAutoUpload() + fun refreshUpdateTimeView() + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt new file mode 100644 index 000000000..2be3cd55c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SharedFilesView.kt @@ -0,0 +1,20 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.SharedFileModel +import org.cryptomator.presentation.model.VaultModel + +interface SharedFilesView : View { + + override fun finish() + + fun displayVaults(vaults: List) + fun displayFilesToUpload(sharedFiles: List) + fun displayDialogUnableToUploadFiles() + fun showEnterPasswordDialog(vault: VaultModel) + fun showReplaceDialog(existingFiles: List, size: Int) + fun showChosenLocation(folder: CloudFolderModel) + fun showBiometricAuthKeyInvalidatedDialog() + fun showUploadDialog(uploadingFiles: Int) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SplashView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SplashView.kt new file mode 100644 index 000000000..fde02e3c1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/SplashView.kt @@ -0,0 +1,3 @@ +package org.cryptomator.presentation.ui.activity.view + +interface SplashView : View diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/TextEditorView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/TextEditorView.kt new file mode 100644 index 000000000..fc45e1dd7 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/TextEditorView.kt @@ -0,0 +1,11 @@ +package org.cryptomator.presentation.ui.activity.view + +interface TextEditorView : View { + + val textFileContent: String + + fun performBackPressed() + fun showUnsavedChangesDialog() + fun displayTextFileContent(textFileContent: String) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt new file mode 100644 index 000000000..6d4a192ab --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/UpdateLicenseView.kt @@ -0,0 +1,8 @@ +package org.cryptomator.presentation.ui.activity.view + +interface UpdateLicenseView : View { + + fun showOrUpdateLicenseDialog(license: String) + fun showConfirmationDialog(mail: String) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/VaultListView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/VaultListView.kt new file mode 100644 index 000000000..300ea4daa --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/VaultListView.kt @@ -0,0 +1,27 @@ +package org.cryptomator.presentation.ui.activity.view + +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.VaultModel + +interface VaultListView : View { + + fun renderVaultList(vaultModelCollection: List) + fun showVaultCreationHint() + fun hideVaultCreationHint() + fun deleteVaultFromAdapter(vaultId: Long) + fun addOrUpdateVault(vault: VaultModel) + fun renameVault(vaultModel: VaultModel) + fun navigateToVaultContent(vault: VaultModel, decryptedRoot: CloudFolderModel) + fun showEnterPasswordDialog(vault: VaultModel) + fun showBiometricDialog(vault: VaultModel) + fun showChangePasswordDialog(vaultModel: VaultModel) + fun getEncryptedPasswordWithBiometricAuthentication(vaultModel: VaultModel) + fun showVaultSettingsDialog(vaultModel: VaultModel) + fun showAddVaultBottomSheet() + fun showRenameDialog(vaultModel: VaultModel) + fun showBiometricAuthKeyInvalidatedDialog() + fun isVaultLocked(vaultModel: VaultModel): Boolean + fun cancelBasicAuthIfRunning() + fun stoppedBiometricAuthDuringCloudAuthentication(): Boolean + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/View.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/View.kt new file mode 100644 index 000000000..4f02d09f9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/View.kt @@ -0,0 +1,20 @@ +package org.cryptomator.presentation.ui.activity.view + +import androidx.fragment.app.DialogFragment +import org.cryptomator.presentation.presenter.ActivityHolder +import org.cryptomator.presentation.ui.activity.ErrorDisplay +import org.cryptomator.presentation.ui.activity.MessageDisplay +import org.cryptomator.presentation.ui.activity.ProgressAware +import org.cryptomator.presentation.ui.snackbar.SnackbarAction +import kotlin.reflect.KClass + +interface View : ProgressAware, MessageDisplay, ErrorDisplay, ActivityHolder { + + fun showDialog(dialog: DialogFragment) + fun isShowingDialog(dialog: KClass): Boolean + fun currentDialog(): DialogFragment? + fun closeDialog() + fun finish() + fun showSnackbar(messageId: Int, action: SnackbarAction) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WebDavAddOrChangeView.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WebDavAddOrChangeView.kt new file mode 100644 index 000000000..c13567cf9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/view/WebDavAddOrChangeView.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.ui.activity.view + +interface WebDavAddOrChangeView : View { + + fun onCheckUserInputSucceeded(urlPort: String, username: String, password: String, cloudId: Long?, certificate: String?) + + fun showAskForHttpDialog(urlPort: String, username: String, password: String, cloudId: Long?, certificate: String?) + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BiometricAuthSettingsAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BiometricAuthSettingsAdapter.kt new file mode 100644 index 000000000..e1e2bfef3 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BiometricAuthSettingsAdapter.kt @@ -0,0 +1,57 @@ +package org.cryptomator.presentation.ui.adapter + +import android.view.View +import com.google.android.material.switchmaterial.SwitchMaterial +import kotlinx.android.synthetic.main.item_biometric_auth_vault.view.* +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.ui.adapter.BiometricAuthSettingsAdapter.BiometricAuthSettingsViewHolder +import javax.inject.Inject + +class BiometricAuthSettingsAdapter // +@Inject +constructor() : RecyclerViewBaseAdapter() { + + private var onVaultBiometricAuthSettingsChanged: OnVaultBiometricAuthSettingsChanged? = null + + interface OnVaultBiometricAuthSettingsChanged { + fun onVaultBiometricAuthSettingsChanged(vaultModel: VaultModel, useBiometricAuth: Boolean) + } + + fun addOrUpdate(vault: VaultModel?) { + if (contains(vault)) { + replaceItem(vault) + } else { + addItem(vault) + } + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_biometric_auth_vault + } + + override fun createViewHolder(view: View, viewType: Int): BiometricAuthSettingsViewHolder { + return BiometricAuthSettingsViewHolder(view) + } + + fun setOnItemClickListener(onVaultBiometricAuthSettingsChanged: OnVaultBiometricAuthSettingsChanged) { + this.onVaultBiometricAuthSettingsChanged = onVaultBiometricAuthSettingsChanged + } + + inner class BiometricAuthSettingsViewHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + override fun bind(position: Int) { + val vaultModel = getItem(position) + + itemView.vaultName.text = vaultModel.name + itemView.cloud.setImageResource(vaultModel.cloudType.cloudImageResource) + + itemView.toggleBiometricAuth.isChecked = vaultModel.password != null + + //itemView.toggleBiometricAuth.setOnCheckedChangeListener doesn't work because bind can be executed multiple times + itemView.toggleBiometricAuth.setOnClickListener { switch -> + onVaultBiometricAuthSettingsChanged?.onVaultBiometricAuthSettingsChanged(vaultModel, (switch as SwitchMaterial).isChecked) + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt new file mode 100644 index 000000000..d8576bb5e --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/BrowseFilesAdapter.kt @@ -0,0 +1,485 @@ +package org.cryptomator.presentation.ui.adapter + +import android.os.PatternMatcher +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView +import kotlinx.android.synthetic.main.item_browse_files_node.view.* +import kotlinx.android.synthetic.main.view_cloud_file_content.view.* +import kotlinx.android.synthetic.main.view_cloud_file_progress.view.* +import kotlinx.android.synthetic.main.view_cloud_folder_content.view.* +import org.cryptomator.domain.CloudNode +import org.cryptomator.presentation.R +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.BROWSE_FILES +import org.cryptomator.presentation.intent.ChooseCloudNodeSettings.NavigationMode.SELECT_ITEMS +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel +import org.cryptomator.presentation.model.ProgressModel +import org.cryptomator.presentation.model.ProgressStateModel.Companion.COMPLETED +import org.cryptomator.presentation.model.comparator.* +import org.cryptomator.presentation.ui.adapter.BrowseFilesAdapter.VaultContentViewHolder +import org.cryptomator.presentation.util.DateHelper +import org.cryptomator.presentation.util.FileIcon +import org.cryptomator.presentation.util.FileSizeHelper +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.presentation.util.ResourceHelper.Companion.getDrawable +import org.cryptomator.util.Optional +import org.cryptomator.util.SharedPreferencesHandler +import java.util.* +import javax.inject.Inject + +class BrowseFilesAdapter @Inject +constructor(private val dateHelper: DateHelper, // + private val fileSizeHelper: FileSizeHelper, // + private val fileUtil: FileUtil, // + private val sharedPreferencesHandler: SharedPreferencesHandler) : RecyclerViewBaseAdapter, BrowseFilesAdapter.ItemClickListener, VaultContentViewHolder>(CloudNodeModelNameAZComparator()), FastScrollRecyclerView.SectionedAdapter { + + private var chooseCloudNodeSettings: ChooseCloudNodeSettings? = null + private var navigationMode: ChooseCloudNodeSettings.NavigationMode? = null + + private val isInSelectionMode: Boolean + get() = chooseCloudNodeSettings != null + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_browse_files_node + } + + override fun createViewHolder(view: View, viewType: Int): VaultContentViewHolder { + return VaultContentViewHolder(view) + } + + fun addOrReplaceCloudNode(cloudNodeModel: CloudNodeModel<*>) { + if (contains(cloudNodeModel)) { + replaceItem(cloudNodeModel) + } else { + addItem(cloudNodeModel) + } + } + + fun replaceRenamedCloudFile(cloudNode: CloudNodeModel) { + itemCollection.forEach { nodes -> + if (nodes.javaClass == cloudNode.javaClass && nodes.name == cloudNode.oldName) { + val position = positionOf(nodes) + replaceItem(position, cloudNode) + return + } + } + } + + override fun setCallback(callback: ItemClickListener) { + this.callback = callback + } + + fun setChooseCloudNodeSettings(chooseCloudNodeSettings: ChooseCloudNodeSettings?) { + this.chooseCloudNodeSettings = chooseCloudNodeSettings + } + + fun updateNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode) { + this.navigationMode = navigationMode + if (isNavigationMode(BROWSE_FILES)) { + itemCollection.forEach { node -> + node.isSelected = false + } + } + notifyDataSetChanged() + } + + fun renderedCloudNodes(): List> { + return itemCollection + } + + fun selectedCloudNodes(): List> { + return all.filter { it.isSelected } + } + + fun hasUnSelectedNode(): Boolean { + return itemCount > selectedCloudNodes().size + } + + fun filterNodes(nodes: List>?, filterText: String): List>? { + return if (filterText.isNotEmpty()) { + if (sharedPreferencesHandler.useGlobSearch()) { + nodes?.filter { cloudNode -> PatternMatcher(filterText, PatternMatcher.PATTERN_SIMPLE_GLOB).match(cloudNode.name) } + } else { + nodes?.filter { cloudNode -> cloudNode.name.toLowerCase(Locale.getDefault()).startsWith(filterText.toLowerCase(Locale.getDefault())) } + } + } else { + nodes + } + } + + inner class VaultContentViewHolder internal constructor(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + private var uiState: UiStateTest? = null + + private var currentProgressIcon: Int = 0 + + private var bound: CloudNodeModel<*>? = null + + override fun bind(position: Int) { + bound = getItem(position) + bound?.let { internalBind(it) } + } + + private fun internalBind(node: CloudNodeModel<*>) { + bindNodeImage(node) + bindSettings(node) + bindLongNodeClick(node) + bindFileOrFolder(node) + } + + private fun bindNodeImage(node: CloudNodeModel<*>) { + itemView.cloudNodeImage.setImageResource(bindCloudNodeImage(node)) + } + + private fun bindCloudNodeImage(cloudNodeModel: CloudNodeModel<*>): Int { + if (cloudNodeModel is CloudFileModel) { + return FileIcon.fileIconFor(cloudNodeModel.name, fileUtil).iconResource + } else if (cloudNodeModel is CloudFolderModel) { + return R.drawable.node_folder + } + throw IllegalStateException("Could not identify the CloudNodeModel type") + } + + private fun bindSettings(node: CloudNodeModel<*>) { + itemView.settings.setOnClickListener { callback.onNodeSettingsClicked(node) } + } + + private fun bindLongNodeClick(node: CloudNodeModel<*>) { + enableNodeLongClick { + node.isSelected = true + callback.onNodeLongClicked() + true + } + } + + private fun bindFileOrFolder(node: CloudNodeModel<*>) { + if (node is CloudFileModel) { + internalBind(node) + } else { + internalBind(node as CloudFolderModel) + } + } + + private fun internalBind(file: CloudFileModel) { + switchTo(FileDetails()) + bindFile(file) + bindProgressIfPresent(file) + bindSelectItemsModeIfPresent(file) + bindFileSelectionModeIfPresent(file) + } + + private fun bindFile(file: CloudFileModel) { + itemView.cloudFileText.text = file.name + itemView.cloudFileSubText.text = fileDetails(file) + + enableNodeClick { callback.onFileClicked(file) } + } + + private fun bindFileSelectionModeIfPresent(file: CloudFileModel) { + if (isInSelectionMode) { + disableNodeLongClick() + hideSettings() + if (!isSelectable(file)) { + itemView.cloudFileSubText.visibility = GONE + itemView.cloudFileSubText.text = "" + itemView.isEnabled = false + } + } + } + + private fun internalBind(folder: CloudFolderModel) { + switchTo(FolderDetails()) + bindFolder(folder) + bindSelectItemsModeIfPresent(folder) + bindFolderSelectionModeIfPresent(folder) + bindProgressIfPresent(folder) + } + + private fun bindSelectItemsModeIfPresent(node: CloudNodeModel<*>) { + if (isNavigationMode(SELECT_ITEMS)) { + if (node is CloudFileModel) { + switchTo(FileSelection()) + } else { + switchTo(FolderSelection()) + } + disableNodeLongClick() + bindNodeSelection(node) + } + } + + private fun bindProgressIfPresent(node: CloudNodeModel<*>) { + val progress = node.progress + if (progress.isPresent) { + showProgress(progress.get()) + } + } + + private fun bindFolder(folder: CloudFolderModel) { + itemView.cloudFolderText.text = folder.name + enableNodeClick { callback.onFolderClicked(folder) } + } + + private fun bindFolderSelectionModeIfPresent(folder: CloudFolderModel) { + if (isInSelectionMode) { + disableNodeLongClick() + hideSettings() + if (!isSelectable(folder)) { + itemView.isEnabled = false + } + } + } + + private fun hideSettings() { + itemView.settings.visibility = GONE + } + + private fun bindNodeSelection(cloudNodeModel: CloudNodeModel<*>) { + itemView.itemCheckBox.setOnCheckedChangeListener { _, isChecked -> + cloudNodeModel.isSelected = isChecked + callback.onSelectedNodesChanged(selectedCloudNodes().size) + } + enableNodeClick { itemView.itemCheckBox.toggle() } + + itemView.itemCheckBox.isChecked = cloudNodeModel.isSelected + } + + private fun fileDetails(cloudFile: CloudFileModel): String { + val formattedFileSize = fileSizeHelper.getFormattedFileSize(cloudFile.size) + val formattedModifiedDate = dateHelper.getFormattedModifiedDate(cloudFile.modified) + + return if (formattedFileSize.isPresent) { + if (formattedModifiedDate.isPresent) { + formattedFileSize.get() + " • " + formattedModifiedDate.get() + } else { + formattedFileSize.get() + } + } else if (formattedModifiedDate.isPresent) { + formattedModifiedDate.get() + } else { + "" + } + } + + fun showProgress(progress: ProgressModel?) { + bound?.progress = Optional.of(progress) + when { + progress?.state() === COMPLETED -> hideProgress() + progress?.progress() == ProgressModel.UNKNOWN_PROGRESS_PERCENTAGE -> showIndeterminateProgress(progress) + progress?.state() !== COMPLETED -> progress?.let { showDeterminateProgress(it) } + } + } + + private fun showIndeterminateProgress(progress: ProgressModel) { + uiState?.let { switchTo(it.indeterminateProgress()) } + if (uiState?.isForFile == true) { + itemView.cloudFileSubText.setText(progress.state().textResourceId()) + } else { + itemView.cloudFolderActionText.setText(progress.state().textResourceId()) + } + + if (!progress.state().isSelectable) { + disableNodeActions() + } + } + + private fun disableNodeActions() { + itemView.isEnabled = false + itemView.settings.visibility = GONE + } + + private fun enableNodeClick(clickListener: View.OnClickListener) { + itemView.setOnClickListener(clickListener) + } + + private fun enableNodeLongClick(longClickListener: View.OnLongClickListener) { + itemView.setOnLongClickListener(longClickListener) + } + + private fun disableNodeLongClick() { + itemView.setOnLongClickListener(null) + } + + private fun showDeterminateProgress(progress: ProgressModel) { + uiState?.let { switchTo(it.determinateProgress()) } + if (uiState?.isForFile == true) { + disableNodeActions() + itemView.cloudFile.progress = progress.progress() + if (currentProgressIcon != progress.state().imageResourceId()) { + currentProgressIcon = progress.state().imageResourceId() + itemView.progressIcon.setImageDrawable(getDrawable(currentProgressIcon)) + } + } else { + // no determinate progress for folders + itemView.cloudFolderActionText.setText(progress.state().textResourceId()) + } + } + + fun hideProgress() { + uiState?.let { switchTo(it.details()) } + bound?.progress = Optional.empty() + } + + private fun switchTo(state: UiStateTest) { + if (uiState !== state) { + uiState = state + uiState?.apply() + } + } + + fun selectNode(checked: Boolean) { + itemView.itemCheckBox.isChecked = checked + } + + abstract inner class UiStateTest(val isForFile: Boolean) { + fun details(): UiStateTest { + return if (isForFile) { + FileDetails() + } else { + FolderDetails() + } + } + + fun determinateProgress(): UiStateTest { + return if (isForFile) { + FileDeterminateProgress() + } else { + FolderIndeterminateProgress() // no determinate progress for folders + } + } + + fun indeterminateProgress(): UiStateTest { + return if (isForFile) { + FileIndeterminateProgress() + } else { + FolderIndeterminateProgress() + } + } + + abstract fun apply() + } + + inner class FileDetails : UiStateTest(true) { + override fun apply() { + itemView.isEnabled = true + itemView.cloudFolderContent.visibility = GONE + itemView.cloudFileContent.visibility = VISIBLE + itemView.cloudFileText.visibility = VISIBLE + itemView.cloudFileSubText.visibility = VISIBLE + itemView.cloudFileProgress.visibility = GONE + itemView.settings.visibility = VISIBLE + itemView.itemCheckBox.visibility = GONE + } + } + + inner class FolderDetails : UiStateTest(false) { + override fun apply() { + itemView.isEnabled = true + itemView.cloudFileContent.visibility = GONE + itemView.cloudFolderContent.visibility = VISIBLE + itemView.cloudFolderText.visibility = VISIBLE + itemView.cloudFolderActionText.visibility = GONE + itemView.settings.visibility = VISIBLE + itemView.itemCheckBox.visibility = GONE + } + } + + inner class FileDeterminateProgress : UiStateTest(true) { + override fun apply() { + itemView.cloudFolderContent.visibility = GONE + itemView.cloudFileContent.visibility = VISIBLE + itemView.cloudFileText.visibility = VISIBLE + itemView.cloudFileSubText.visibility = GONE + itemView.cloudFileProgress.visibility = VISIBLE + itemView.itemCheckBox.visibility = GONE + } + } + + inner class FileIndeterminateProgress : UiStateTest(true) { + override fun apply() { + itemView.cloudFolderContent.visibility = GONE + itemView.cloudFileContent.visibility = VISIBLE + itemView.cloudFileText.visibility = VISIBLE + itemView.cloudFileSubText.visibility = VISIBLE + itemView.cloudFileProgress.visibility = GONE + itemView.itemCheckBox.visibility = GONE + } + + } + + inner class FolderIndeterminateProgress : UiStateTest(false) { + override fun apply() { + itemView.cloudFileContent.visibility = GONE + itemView.cloudFolderContent.visibility = VISIBLE + itemView.cloudFolderText.visibility = VISIBLE + itemView.cloudFolderActionText.visibility = VISIBLE + itemView.itemCheckBox.visibility = GONE + } + } + + inner class FileSelection : UiStateTest(true) { + override fun apply() { + itemView.itemCheckBox.visibility = VISIBLE + itemView.settings.visibility = GONE + } + } + + inner class FolderSelection : UiStateTest(false) { + override fun apply() { + itemView.itemCheckBox.visibility = VISIBLE + itemView.settings.visibility = GONE + } + + } + } + + private fun isSelectable(folder: CloudFolderModel): Boolean { + return chooseCloudNodeSettings?.selectionMode()?.allowsFolders() == true // + && chooseCloudNodeSettings?.excludeFolder(folder) == false + } + + private fun isSelectable(file: CloudFileModel): Boolean { + return chooseCloudNodeSettings?.selectionMode()?.allowsFiles() == true // + && chooseCloudNodeSettings?.namePattern()?.matcher(file.name)?.matches() == true + } + + private fun isNavigationMode(navigationMode: ChooseCloudNodeSettings.NavigationMode): Boolean { + return this.navigationMode == navigationMode + } + + fun setSort(comparator: Comparator>) { + updateComparator(comparator) + } + + interface ItemClickListener { + fun onFolderClicked(cloudFolderModel: CloudFolderModel) + + fun onFileClicked(cloudNodeModel: CloudFileModel) + + fun onNodeSettingsClicked(cloudNodeModel: CloudNodeModel<*>) + + fun onNodeLongClicked() + + fun onSelectedNodesChanged(selectedNodes: Int) + } + + override fun getSectionName(position: Int): String { + val node = all[position] + + if (node.isFolder) { + return node.name.first().toString() + } + + val formattedFileSize = fileSizeHelper.getFormattedFileSize((node as CloudFileModel).size) + val formattedModifiedDate = dateHelper.getFormattedModifiedDate(node.modified) + + return when (comparator) { + is CloudNodeModelDateNewestFirstComparator, is CloudNodeModelDateOldestFirstComparator -> formattedModifiedDate.orElse(node.name.first().toString()) + is CloudNodeModelSizeBiggestFirstComparator, is CloudNodeModelSizeSmallestFirstComparator -> formattedFileSize.orElse(node.name.first().toString()) + else -> all[position].name.first().toString() + } + } +} 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 new file mode 100644 index 000000000..5166d46c9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudConnectionListAdapter.kt @@ -0,0 +1,81 @@ +package org.cryptomator.presentation.ui.adapter + +import android.content.Context +import android.net.Uri +import android.view.View +import kotlinx.android.synthetic.main.item_browse_cloud_model_connections.view.* +import kotlinx.android.synthetic.main.view_cloud_connection_content.view.* +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.LocalStorageModel +import org.cryptomator.presentation.model.WebDavCloudModel +import org.cryptomator.presentation.model.comparator.CloudModelComparator +import org.cryptomator.presentation.ui.adapter.CloudConnectionListAdapter.CloudConnectionHolder +import java.net.URISyntaxException +import javax.inject.Inject + +class CloudConnectionListAdapter @Inject +internal constructor(context: Context) : RecyclerViewBaseAdapter(CloudModelComparator(context)) { + + interface Callback { + fun onCloudConnectionClicked(cloudModel: CloudModel) + + fun onCloudSettingsClicked(cloudModel: CloudModel) + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_browse_cloud_model_connections + } + + override fun createViewHolder(view: View, viewType: Int): CloudConnectionHolder { + return CloudConnectionHolder(view) + } + + fun setOnItemClickListener(callback: Callback) { + this.callback = callback + } + + inner class CloudConnectionHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + override fun bind(position: Int) { + internalBind(getItem(position)) + } + + private fun internalBind(cloudModel: CloudModel) { + itemView.settings.setOnClickListener { callback.onCloudSettingsClicked(cloudModel) } + + itemView.cloudImage.setImageResource(cloudModel.cloudType().cloudImageResource) + + itemView.setOnClickListener { callback.onCloudConnectionClicked(cloudModel) } + + if (cloudModel is WebDavCloudModel) { + bindWebDavCloudModel(cloudModel) + } else if (cloudModel is LocalStorageModel) { + bindLocalStorageCloudModel(cloudModel) + } + } + + private fun bindWebDavCloudModel(cloudModel: WebDavCloudModel) { + try { + val uri = Uri.parse(cloudModel.url()) + itemView.cloudText.text = uri.authority + itemView.cloudSubText.text = String.format("%s • %s", cloudModel.username(), uri.path) + } catch (e: URISyntaxException) { + throw FatalBackendException("path in WebDAV cloud isn't correct (no uri)") + } + + } + + private fun bindLocalStorageCloudModel(cloudModel: LocalStorageModel) { + if (cloudModel.location().isEmpty()) { + itemView.cloudText.text = cloudModel.storage() + itemView.cloudSubText.visibility = View.GONE + } else { + itemView.cloudSubText.visibility = View.VISIBLE + itemView.cloudText.text = cloudModel.location() + itemView.cloudSubText.text = cloudModel.storage() + } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt new file mode 100644 index 000000000..5a22acbd0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudSettingsAdapter.kt @@ -0,0 +1,81 @@ +package org.cryptomator.presentation.ui.adapter + +import android.content.Context +import android.view.View +import kotlinx.android.synthetic.main.item_cloud_setting.view.* +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.CloudTypeModel +import org.cryptomator.presentation.ui.adapter.CloudSettingsAdapter.CloudSettingViewHolder +import javax.inject.Inject + +class CloudSettingsAdapter @Inject +constructor(private val context: Context) : RecyclerViewBaseAdapter() { + + interface OnItemClickListener { + fun onCloudClicked(cloudModel: CloudModel) + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_cloud_setting + } + + override fun createViewHolder(view: View, viewType: Int): CloudSettingViewHolder { + return CloudSettingViewHolder(view) + } + + fun notifyCloudChanged(changedCloud: CloudModel?) { + val position = positionOf(changedCloud) + if (position != -1) { + replaceItem(position, changedCloud) + } + } + + inner class CloudSettingViewHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + override fun bind(position: Int) { + val cloudModel = getItem(position) + + itemView.cloudImage.setImageResource(cloudModel.cloudType().cloudImageResource) + + if (webdav(cloudModel.cloudType())) { + itemView.cloudName.text = context.getString(R.string.screen_cloud_settings_webdav_connections) + } else if (local(cloudModel.cloudType())) { + 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 + } + } + + itemView.setOnClickListener { this@CloudSettingsAdapter.callback.onCloudClicked(cloudModel) } + } + + private fun isAlreadyLoggedIn(cloudModel: CloudModel): Boolean { + return cloudModel.username() != null + } + + private fun getCloudNameText(alreadyLoggedIn: Boolean, cloudModel: CloudModel): String { + return getCloudStatusText(alreadyLoggedIn) + " " + context.getString(cloudModel.name()) + } + + private fun getCloudStatusText(alreadyLoggedIn: Boolean): String { + return if (alreadyLoggedIn) + context.getString(R.string.screen_cloud_settings_sign_out_from_cloud) // + else + context.getString(R.string.screen_cloud_settings_log_in_to) + } + } + + private fun local(cloudType: CloudTypeModel): Boolean { + return CloudTypeModel.LOCAL == cloudType + } + + private fun webdav(cloudType: CloudTypeModel): Boolean { + return CloudTypeModel.WEBDAV == cloudType + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudsAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudsAdapter.kt new file mode 100644 index 000000000..9d1cf7907 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/CloudsAdapter.kt @@ -0,0 +1,35 @@ +package org.cryptomator.presentation.ui.adapter + +import android.view.View +import kotlinx.android.synthetic.main.item_cloud.view.* +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudTypeModel +import org.cryptomator.presentation.ui.adapter.CloudsAdapter.CloudViewHolder +import javax.inject.Inject + +class CloudsAdapter @Inject +constructor() : RecyclerViewBaseAdapter() { + + interface OnItemClickListener { + fun onCloudClicked(cloudTypeModel: CloudTypeModel) + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_cloud + } + + override fun createViewHolder(view: View, viewType: Int): CloudViewHolder { + return CloudViewHolder(view) + } + + inner class CloudViewHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + override fun bind(position: Int) { + val cloudTypeModel = getItem(position) + itemView.cloud.setImageResource(cloudTypeModel.cloudImageLargeResource) + itemView.cloudName.setText(cloudTypeModel.displayNameResource) + + itemView.cloud.setOnClickListener { callback.onCloudClicked(cloudTypeModel) } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/RecyclerViewBaseAdapter.java b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/RecyclerViewBaseAdapter.java new file mode 100644 index 000000000..529b02a01 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/RecyclerViewBaseAdapter.java @@ -0,0 +1,142 @@ +package org.cryptomator.presentation.ui.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class RecyclerViewBaseAdapter extends RecyclerView.Adapter { + + final List itemCollection; + + Callback callback; + + private Comparator comparator; + + RecyclerViewBaseAdapter() { + this.itemCollection = new ArrayList<>(); + } + + RecyclerViewBaseAdapter(Comparator comparator) { + this.itemCollection = new ArrayList<>(); + this.comparator = comparator; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(getItemLayout(viewType), parent, false); + return createViewHolder(view, viewType); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(position); + } + + @Override + public int getItemCount() { + if (itemCollection == null) { + return 0; + } else { + return itemCollection.size(); + } + } + + public boolean isEmpty() { + return (itemCollection == null || itemCollection.size() == 0); + } + + public void clear() { + itemCollection.clear(); + notifyDataSetChanged(); + } + + public void addAll(Collection items) { + itemCollection.addAll(items); + sort(); + notifyDataSetChanged(); + } + + public List getAll() { + return itemCollection; + } + + public void setCallback(Callback callback) { + this.callback = callback; + } + + public int positionOf(Item item) { + return itemCollection.indexOf(item); + } + + public void deleteItems(List items) { + for (Item item : items) { + deleteItem(item); + } + } + + public void deleteItem(Item item) { + int positionOf = positionOf(item); + itemCollection.remove(item); + notifyItemRemoved(positionOf); + } + + void addItem(Item item) { + itemCollection.add(item); + sort(); + notifyItemInserted(positionOf(item)); + } + + void replaceItem(Item item) { + replaceItem(positionOf(item), item); + } + + void replaceItem(int position, Item item) { + itemCollection.set(position, item); + notifyItemChanged(position); + } + + boolean contains(Item item) { + return itemCollection.contains(item); + } + + public Item getItem(int position) { + return itemCollection.get(position); + } + + protected abstract int getItemLayout(int viewType); + + protected abstract ViewHolder createViewHolder(View view, int viewType); + + public abstract class ItemViewHolder extends RecyclerView.ViewHolder { + + ItemViewHolder(View itemView) { + super(itemView); + } + + public abstract void bind(int position); + } + + private void sort() { + if (comparator != null) { + Collections.sort(itemCollection, comparator); + } + } + + void updateComparator(Comparator comparator) { + this.comparator = comparator; + } + + Comparator getComparator() { + return comparator; + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedFilesAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedFilesAdapter.kt new file mode 100644 index 000000000..8bd9125c5 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedFilesAdapter.kt @@ -0,0 +1,80 @@ +package org.cryptomator.presentation.ui.adapter + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import kotlinx.android.synthetic.main.item_shared_files.view.* +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.SharedFileModel +import org.cryptomator.presentation.ui.adapter.SharedFilesAdapter.FileViewHolder +import org.cryptomator.presentation.util.FileIcon +import org.cryptomator.presentation.util.FileUtil +import org.cryptomator.util.Comparators +import java.util.* +import javax.inject.Inject + +class SharedFilesAdapter @Inject +constructor(private val fileUtil: FileUtil) : RecyclerViewBaseAdapter(Comparators.naturalOrder()) { + + interface Callback { + fun onFileNameConflict(hasFileNameConflict: Boolean) + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_shared_files + } + + override fun createViewHolder(view: View, viewType: Int): FileViewHolder { + return FileViewHolder(view) + } + + fun show(files: List?) { + clear() + addAll(files) + callback.onFileNameConflict(hasFileNameConflict()) + } + + private fun hasFileNameConflict(): Boolean { + val files = HashSet() + itemCollection.forEach { file -> + if (!files.add(file.fileName)) { + return true + } + } + return false + } + + inner class FileViewHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + private var et_file_name_watcher: TextWatcher? = null + + override fun bind(position: Int) { + if (et_file_name_watcher != null) { + itemView.fileName.removeTextChangedListener(et_file_name_watcher) + } + val file = getItem(position) + itemView.fileImage.setImageResource(bindFileIcon(file.fileName)) + itemView.fileName.setText(file.fileName) + et_file_name_watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + + } + + override fun afterTextChanged(newFileName: Editable) { + file.setNewFileName(newFileName.toString()) + callback.onFileNameConflict(hasFileNameConflict()) + } + } + itemView.fileName.addTextChangedListener(et_file_name_watcher) + } + + private fun bindFileIcon(fileName: String): Int { + return FileIcon.fileIconFor(fileName, fileUtil).iconResource + } + } + +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedLocationsAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedLocationsAdapter.kt new file mode 100644 index 000000000..6ec8b9504 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/SharedLocationsAdapter.kt @@ -0,0 +1,106 @@ +package org.cryptomator.presentation.ui.adapter + +import android.view.View +import kotlinx.android.synthetic.main.item_shareable_location.view.* +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.ui.adapter.SharedLocationsAdapter.VaultViewHolder +import javax.inject.Inject + +class SharedLocationsAdapter @Inject +constructor() : RecyclerViewBaseAdapter() { + + private var selectedVault: VaultModel? = null + private var selectedLocation: String? = null + + interface Callback { + fun onVaultSelected(vault: VaultModel?) + + fun onChooseLocationPressed() + } + + fun setPreselectedVault(preselectedVault: VaultModel) { + this.selectedVault = preselectedVault + this.selectedLocation = null + } + + fun setPreselectedLocation(preselectedLocation: String) { + this.selectedLocation = preselectedLocation + } + + fun setSelectedLocation(selectedLocation: String) { + this.selectedLocation = selectedLocation + replaceItem(this.selectedVault) + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_shareable_location + } + + override fun createViewHolder(view: View, viewType: Int): VaultViewHolder { + return VaultViewHolder(view) + } + + private fun selectVault(selectedVault: VaultModel?) { + this.selectedLocation = null + if (this.selectedVault != null) { + replaceItem(this.selectedVault) + } + this.selectedVault = selectedVault + replaceItem(this.selectedVault) + callback.onVaultSelected(this.selectedVault) + } + + inner class VaultViewHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + private var boundVault: VaultModel? = null + + override fun bind(position: Int) { + removeListener() + + boundVault = getItem(position) + + boundVault?.let { + itemView.cloudImage.setImageResource(it.cloudType.cloudImageResource) + itemView.vaultName.text = it.name + + val boundVaultSelected = it == selectedVault + itemView.selectedVault.isChecked = boundVaultSelected + itemView.selectedVault.isClickable = !boundVaultSelected + if (boundVaultSelected) { + if (selectedLocation != null) { + itemView.chosenLocation.visibility = View.VISIBLE + itemView.chosenLocation.text = selectedLocation + } else { + itemView.chosenLocation.visibility = View.GONE + } + itemView.chooseFolderLocation.visibility = View.VISIBLE + } else { + itemView.chosenLocation.visibility = View.GONE + itemView.chooseFolderLocation.visibility = View.GONE + } + } + + bindListener() + } + + private fun removeListener() { + itemView.setOnClickListener(null) + itemView.selectedVault.setOnCheckedChangeListener(null) + } + + private fun bindListener() { + itemView.setOnClickListener { + if (!itemView.selectedVault.isChecked) { + selectVault(boundVault) + } + } + itemView.selectedVault.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + selectVault(boundVault) + } + } + itemView.chooseFolderLocation.setOnClickListener { callback.onChooseLocationPressed() } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/VaultsAdapter.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/VaultsAdapter.kt new file mode 100644 index 000000000..322e4f54b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/VaultsAdapter.kt @@ -0,0 +1,68 @@ +package org.cryptomator.presentation.ui.adapter + +import android.view.View +import kotlinx.android.synthetic.main.item_vault.view.* +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.ui.adapter.VaultsAdapter.VaultViewHolder +import javax.inject.Inject + +class VaultsAdapter @Inject +internal constructor() : RecyclerViewBaseAdapter() { + + interface OnItemClickListener { + fun onVaultClicked(vaultModel: VaultModel) + + fun onVaultSettingsClicked(vaultModel: VaultModel) + + fun onVaultLockClicked(vaultModel: VaultModel) + } + + override fun getItemLayout(viewType: Int): Int { + return R.layout.item_vault + } + + override fun createViewHolder(view: View, viewType: Int): VaultViewHolder { + return VaultViewHolder(view) + } + + fun deleteVault(vaultID: Long) { + deleteItem(getVault(vaultID)) + } + + fun addOrUpdateVault(vault: VaultModel?) { + if (contains(vault)) { + replaceItem(vault) + } else { + addItem(vault) + } + } + + private fun getVault(vaultId: Long): VaultModel? { + return itemCollection.firstOrNull { it.vaultId == vaultId } + } + + inner class VaultViewHolder(itemView: View) : RecyclerViewBaseAdapter<*, *, *>.ItemViewHolder(itemView) { + + override fun bind(position: Int) { + val vaultModel = getItem(position) + + itemView.vaultName.text = vaultModel.name + itemView.vaultPath.text = vaultModel.path + + itemView.cloudImage.setImageResource(vaultModel.cloudType.cloudImageResource) + + if (vaultModel.isLocked) { + itemView.unlockedImage.visibility = View.GONE + } else { + itemView.unlockedImage.visibility = View.VISIBLE + } + + itemView.setOnClickListener { callback.onVaultClicked(vaultModel) } + + itemView.unlockedImage.setOnClickListener { callback.onVaultLockClicked(vaultModel) } + + itemView.settings.setOnClickListener { callback.onVaultSettingsClicked(vaultModel) } + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/AddVaultBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/AddVaultBottomSheet.kt new file mode 100644 index 000000000..2e708f80f --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/AddVaultBottomSheet.kt @@ -0,0 +1,20 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import kotlinx.android.synthetic.main.dialog_bottom_sheet_add_vault.* +import org.cryptomator.generator.BottomSheet +import org.cryptomator.presentation.R + +@BottomSheet(R.layout.dialog_bottom_sheet_add_vault) +class AddVaultBottomSheet : BaseBottomSheet() { + + interface Callback { + fun onCreateVault() + fun onAddExistingVault() + } + + override fun setupView() { + title.text = getString(R.string.screen_vault_list_actions_title) + create_new_vault.setOnClickListener { callback?.onCreateVault() } + add_existing_vault.setOnClickListener { callback?.onAddExistingVault() } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/BaseBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/BaseBottomSheet.kt new file mode 100644 index 000000000..519d98fc9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/BaseBottomSheet.kt @@ -0,0 +1,51 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.cryptomator.presentation.R + +abstract class BaseBottomSheet : BottomSheetDialogFragment() { + + protected abstract fun setupView() + + var callback: Callback? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + // Verify that the host activity implements the callback interface + try { + callback = context as Callback + } catch (e: ClassCastException) { + // The activity doesn't implement the interface, throw exception + throw ClassCastException("$context must implement Callback") + } + } + + // Need to return the view here or onViewCreated won't be called by DialogFragment, sigh + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return requireActivity().layoutInflater.inflate(bottomSheetContent, null, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupView() + } + + override fun onResume() { + super.onResume() + if (isLandscape) { + val width = requireContext().resources.getDimensionPixelSize(R.dimen.landscape_bottom_sheet_width) + dialog?.window?.setLayout(width, ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + private val isLandscape: Boolean + get() = requireContext().resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + private val bottomSheetContent: Int + get() = javaClass.getAnnotation(org.cryptomator.generator.BottomSheet::class.java)!!.value +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt new file mode 100644 index 000000000..8eb8a2b8c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/CloudConnectionSettingsBottomSheet.kt @@ -0,0 +1,67 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import android.os.Bundle +import android.view.View +import kotlinx.android.synthetic.main.dialog_bottom_sheet_cloud_settings.* +import org.cryptomator.generator.BottomSheet +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudModel +import org.cryptomator.presentation.model.CloudTypeModel +import org.cryptomator.presentation.model.LocalStorageModel +import org.cryptomator.presentation.model.WebDavCloudModel + +@BottomSheet(R.layout.dialog_bottom_sheet_cloud_settings) +class CloudConnectionSettingsBottomSheet : BaseBottomSheet() { + + interface Callback { + fun onChangeCloudClicked(cloudModel: CloudModel) + fun onDeleteCloudClicked(cloudModel: CloudModel) + } + + override fun setupView() { + val cloudModel = requireArguments().getSerializable(CLOUD_NODE_ARG) as CloudModel + + when (cloudModel.cloudType()) { + CloudTypeModel.WEBDAV -> bindViewForWebDAV(cloudModel as WebDavCloudModel) + CloudTypeModel.LOCAL -> bindViewForLocal(cloudModel as LocalStorageModel) + else -> throw IllegalStateException("Cloud model is not binded in the view") + } + + iv_cloud_image.setImageResource(cloudModel.cloudType().cloudImageResource) + change_cloud.setOnClickListener { + callback?.onChangeCloudClicked(cloudModel) + dismiss() + } + delete_cloud.setOnClickListener { + callback?.onDeleteCloudClicked(cloudModel) + dismiss() + } + } + + private fun bindViewForLocal(cloudModel: LocalStorageModel) { + if (cloudModel.location().isEmpty()) { + tv_cloud_name.text = cloudModel.storage() + tv_cloud_subtext.visibility = View.GONE + } else { + tv_cloud_name.text = cloudModel.location() + tv_cloud_subtext.text = cloudModel.storage() + } + } + + private fun bindViewForWebDAV(cloudModel: WebDavCloudModel) { + change_cloud.visibility = View.VISIBLE + tv_cloud_name.text = cloudModel.url() + tv_cloud_subtext.text = cloudModel.username() + } + + companion object { + private const val CLOUD_NODE_ARG = "cloudModel" + fun newInstance(cloudModel: CloudModel): CloudConnectionSettingsBottomSheet { + val dialog = CloudConnectionSettingsBottomSheet() + val args = Bundle() + args.putSerializable(CLOUD_NODE_ARG, cloudModel) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt new file mode 100644 index 000000000..db4bac61c --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FileSettingsBottomSheet.kt @@ -0,0 +1,75 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import android.os.Bundle +import android.view.View +import kotlinx.android.synthetic.main.dialog_bottom_sheet_file_settings.* +import org.cryptomator.generator.BottomSheet +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudFileModel +import org.cryptomator.presentation.model.CloudNodeModel +import java.util.* + +@BottomSheet(R.layout.dialog_bottom_sheet_file_settings) +class FileSettingsBottomSheet : BaseBottomSheet() { + + interface Callback { + fun onExportFileClicked(cloudFile: CloudFileModel) + fun onRenameFileClicked(cloudFile: CloudFileModel) + fun onDeleteNodeClicked(cloudFile: CloudNodeModel<*>) + fun onShareFileClicked(cloudFile: CloudFileModel) + fun onMoveFileClicked(cloudFile: CloudFileModel) + fun onOpenWithTextFileClicked(cloudFile: CloudFileModel) + } + + override fun setupView() { + val cloudFileModel = requireArguments().getSerializable(FILE_ARG) as CloudFileModel + val parentFolderPath = requireArguments().getString(PARENT_FOLDER_PATH_ARG) + + iv_file_image.setImageResource(cloudFileModel.icon.iconResource) + tv_file_name.text = cloudFileModel.name + tv_file_path.text = parentFolderPath + + val lowerFileName = cloudFileModel.name.toLowerCase(Locale.getDefault()) + if (lowerFileName.endsWith(".txt") || lowerFileName.endsWith(".md") || lowerFileName.endsWith(".todo")) { + open_with_text.visibility = View.VISIBLE + open_with_text.setOnClickListener { + callback?.onOpenWithTextFileClicked(cloudFileModel) + dismiss() + } + } + + share_file.setOnClickListener { + callback?.onShareFileClicked(cloudFileModel) + dismiss() + } + move_file.setOnClickListener { + callback?.onMoveFileClicked(cloudFileModel) + dismiss() + } + export_file.setOnClickListener { + callback?.onExportFileClicked(cloudFileModel) + dismiss() + } + rename_file.setOnClickListener { + callback?.onRenameFileClicked(cloudFileModel) + dismiss() + } + delete_file.setOnClickListener { + callback?.onDeleteNodeClicked(cloudFileModel) + dismiss() + } + } + + companion object { + private const val FILE_ARG = "file" + private const val PARENT_FOLDER_PATH_ARG = "parentFolderPath" + fun newInstance(cloudFileModel: CloudFileModel, parentFolderPath: String): FileSettingsBottomSheet { + val dialog = FileSettingsBottomSheet() + val args = Bundle() + args.putSerializable(FILE_ARG, cloudFileModel) + args.putString(PARENT_FOLDER_PATH_ARG, parentFolderPath) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FolderSettingsBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FolderSettingsBottomSheet.kt new file mode 100644 index 000000000..78fb1d342 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/FolderSettingsBottomSheet.kt @@ -0,0 +1,67 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import android.os.Build +import android.os.Bundle +import android.view.View +import kotlinx.android.synthetic.main.dialog_bottom_sheet_folder_settings.* +import org.cryptomator.generator.BottomSheet +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudFolderModel +import org.cryptomator.presentation.model.CloudNodeModel + +@BottomSheet(R.layout.dialog_bottom_sheet_folder_settings) +class FolderSettingsBottomSheet : BaseBottomSheet() { + + interface Callback { + fun onShareFolderClicked(cloudFolderModel: CloudFolderModel) + fun onRenameFolderClicked(cloudFolderModel: CloudFolderModel) + fun onDeleteNodeClicked(cloudFolderModel: CloudNodeModel<*>) + fun onMoveFolderClicked(cloudFolderModel: CloudFolderModel) + fun onExportFolderClicked(cloudFolderModel: CloudFolderModel) + } + + override fun setupView() { + val cloudFolderModel = requireArguments().getSerializable(FOLDER_ARG) as CloudFolderModel + val parentFolderPath = requireArguments().getString(PARENT_FOLDER_PATH_ARG) + + tv_folder_name.text = cloudFolderModel.name + tv_folder_path.text = parentFolderPath + + share_folder.setOnClickListener { + callback?.onShareFolderClicked(cloudFolderModel) + dismiss() + } + rename_folder.setOnClickListener { + callback?.onRenameFolderClicked(cloudFolderModel) + dismiss() + } + move_folder.setOnClickListener { + callback?.onMoveFolderClicked(cloudFolderModel) + dismiss() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + export_folder.visibility = View.VISIBLE + export_folder.setOnClickListener { + callback?.onExportFolderClicked(cloudFolderModel) + dismiss() + } + } + delete_folder.setOnClickListener { + callback?.onDeleteNodeClicked(cloudFolderModel) + dismiss() + } + } + + companion object { + private const val FOLDER_ARG = "folder" + private const val PARENT_FOLDER_PATH_ARG = "parentFolderPath" + fun newInstance(cloudFolderModel: CloudFolderModel, parentFolderPath: String): FolderSettingsBottomSheet { + val dialog = FolderSettingsBottomSheet() + val args = Bundle() + args.putSerializable(FOLDER_ARG, cloudFolderModel) + args.putString(PARENT_FOLDER_PATH_ARG, parentFolderPath) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/SettingsVaultBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/SettingsVaultBottomSheet.kt new file mode 100644 index 000000000..6069373e9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/SettingsVaultBottomSheet.kt @@ -0,0 +1,59 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import android.os.Bundle +import android.widget.LinearLayout +import kotlinx.android.synthetic.main.dialog_bottom_sheet_vault_settings.* +import org.cryptomator.generator.BottomSheet +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.VaultModel + +@BottomSheet(R.layout.dialog_bottom_sheet_vault_settings) +class SettingsVaultBottomSheet : BaseBottomSheet() { + + interface Callback { + fun onDeleteVaultClick(vaultModel: VaultModel) + fun onRenameVaultClick(vaultModel: VaultModel) + fun onLockVaultClick(vaultModel: VaultModel) + fun onChangePasswordClick(vaultModel: VaultModel) + } + + override fun setupView() { + val vaultModel = requireArguments().getSerializable(VAULT_ARG) as VaultModel + + if (vaultModel.isLocked) { + lock_vault.visibility = LinearLayout.GONE + } + val cloudType = vaultModel.cloudType + cloud_image.setImageResource(cloudType.cloudImageResource) + vault_name.text = vaultModel.name + vault_path.text = vaultModel.path + + et_rename.setOnClickListener { + callback?.onRenameVaultClick(vaultModel) + dismiss() + } + delete_vault.setOnClickListener { + callback?.onDeleteVaultClick(vaultModel) + dismiss() + } + lock_vault.setOnClickListener { + callback?.onLockVaultClick(vaultModel) + dismiss() + } + change_password.setOnClickListener { + callback?.onChangePasswordClick(vaultModel) + dismiss() + } + } + + companion object { + private const val VAULT_ARG = "vault" + fun newInstance(vaultModel: VaultModel): SettingsVaultBottomSheet { + val dialog = SettingsVaultBottomSheet() + val args = Bundle() + args.putSerializable(VAULT_ARG, vaultModel) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/VaultContentActionBottomSheet.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/VaultContentActionBottomSheet.kt new file mode 100644 index 000000000..1dcc3f43d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/VaultContentActionBottomSheet.kt @@ -0,0 +1,56 @@ +package org.cryptomator.presentation.ui.bottomsheet + +import android.os.Bundle +import kotlinx.android.synthetic.main.dialog_bottom_sheet_vault_action.* +import org.cryptomator.generator.BottomSheet +import org.cryptomator.presentation.R +import org.cryptomator.presentation.model.CloudFolderModel + +@BottomSheet(R.layout.dialog_bottom_sheet_vault_action) +class VaultContentActionBottomSheet : BaseBottomSheet() { + + interface Callback { + fun onCreateNewFolderClicked() + fun onUploadFilesClicked(folder: CloudFolderModel) + fun onCreateNewTextFileClicked() + } + + override fun setupView() { + val folder = requireArguments().getSerializable(FOLDER_ARG) as CloudFolderModel + + title.text = String.format(getString(R.string.screen_file_browser_actions_title), folderPath(folder)) + + create_new_folder.setOnClickListener { + callback?.onCreateNewFolderClicked() + dismiss() + } + upload_files.setOnClickListener { + callback?.onUploadFilesClicked(folder) + dismiss() + } + create_new_text_file.setOnClickListener { + callback?.onCreateNewTextFileClicked() + dismiss() + } + } + + private fun folderPath(folder: CloudFolderModel): String { + val vault = folder.vault() + return if (vault == null) { + folder.path + } else { + vault.path + folder.path + } + } + + companion object { + private const val FOLDER_ARG = "folder" + fun newInstance(folder: CloudFolderModel): VaultContentActionBottomSheet { + val dialog = VaultContentActionBottomSheet() + val args = Bundle() + args.putSerializable(FOLDER_ARG, folder) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/callback/BrowseFilesCallback.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/callback/BrowseFilesCallback.kt new file mode 100644 index 000000000..4f7a7f47b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/callback/BrowseFilesCallback.kt @@ -0,0 +1,10 @@ +package org.cryptomator.presentation.ui.callback + +import org.cryptomator.presentation.ui.bottomsheet.FileSettingsBottomSheet +import org.cryptomator.presentation.ui.bottomsheet.FolderSettingsBottomSheet +import org.cryptomator.presentation.ui.bottomsheet.VaultContentActionBottomSheet +import org.cryptomator.presentation.ui.dialog.CloudNodeRenameDialog +import org.cryptomator.presentation.ui.dialog.CreateFolderDialog +import org.cryptomator.presentation.ui.dialog.FileTypeNotSupportedDialog + +interface BrowseFilesCallback : CreateFolderDialog.Callback, VaultContentActionBottomSheet.Callback, FileSettingsBottomSheet.Callback, FolderSettingsBottomSheet.Callback, CloudNodeRenameDialog.Callback, FileTypeNotSupportedDialog.Callback diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/callback/VaultListCallback.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/callback/VaultListCallback.kt new file mode 100644 index 000000000..4a478f598 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/callback/VaultListCallback.kt @@ -0,0 +1,9 @@ +package org.cryptomator.presentation.ui.callback + +import org.cryptomator.presentation.ui.bottomsheet.AddVaultBottomSheet +import org.cryptomator.presentation.ui.bottomsheet.SettingsVaultBottomSheet +import org.cryptomator.presentation.ui.dialog.EnterPasswordDialog +import org.cryptomator.presentation.ui.dialog.VaultDeleteConfirmationDialog +import org.cryptomator.presentation.ui.dialog.VaultRenameDialog + +interface VaultListCallback : AddVaultBottomSheet.Callback, EnterPasswordDialog.Callback, SettingsVaultBottomSheet.Callback, VaultDeleteConfirmationDialog.Callback, VaultRenameDialog.Callback diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AppIsObscuredInfoDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AppIsObscuredInfoDialog.kt new file mode 100644 index 000000000..697c979c9 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AppIsObscuredInfoDialog.kt @@ -0,0 +1,35 @@ +package org.cryptomator.presentation.ui.dialog + +import android.app.Activity +import android.content.DialogInterface +import android.text.method.LinkMovementMethod +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import kotlinx.android.synthetic.main.dialog_app_is_obscured_info.* +import org.cryptomator.generator.Dialog +import org.cryptomator.presentation.R + +@Dialog(R.layout.dialog_app_is_obscured_info) +class AppIsObscuredInfoDialog : BaseDialog() { + + public override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { + builder // + .setTitle(R.string.dialog_app_is_obscured_info_title) // + .setNeutralButton(R.string.dialog_app_is_obscured_info_neutral_button) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + return builder.create() + } + + override fun disableDialogWhenObscured(): Boolean { + return false + } + + public override fun setupView() { + tv_app_is_obscured_info.movementMethod = LinkMovementMethod.getInstance() + } + + companion object { + fun newInstance(): DialogFragment { + return AppIsObscuredInfoDialog() + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AskForLockScreenDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AskForLockScreenDialog.kt new file mode 100644 index 000000000..625e895cc --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AskForLockScreenDialog.kt @@ -0,0 +1,33 @@ +package org.cryptomator.presentation.ui.dialog + +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import kotlinx.android.synthetic.main.dialog_no_screen_lock_set.* +import org.cryptomator.generator.Dialog +import org.cryptomator.presentation.R + +@Dialog(R.layout.dialog_no_screen_lock_set) +class AskForLockScreenDialog : BaseDialog() { + + interface Callback { + fun onAskForLockScreenFinished(setScreenLock: Boolean) + } + + override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { + builder // + .setTitle(R.string.dialog_no_screen_lock_title) // + .setNeutralButton(getString(R.string.dialog_unable_to_share_positive_button)) { _: DialogInterface, _: Int -> callback?.onAskForLockScreenFinished(cb_select_screen_lock.isChecked) } + return builder.create() + } + + public override fun setupView() { + // empty + } + + companion object { + fun newInstance(): DialogFragment { + return AskForLockScreenDialog() + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AssignSslCertificateDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AssignSslCertificateDialog.kt new file mode 100644 index 000000000..e781375c5 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/AssignSslCertificateDialog.kt @@ -0,0 +1,82 @@ +package org.cryptomator.presentation.ui.dialog + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import kotlinx.android.synthetic.main.dialog_handle_ssl_certificate.* +import org.cryptomator.data.util.X509CertificateHelper +import org.cryptomator.domain.WebDavCloud +import org.cryptomator.domain.exception.FatalBackendException +import org.cryptomator.generator.Dialog +import org.cryptomator.presentation.R +import java.security.cert.CertificateException +import java.security.cert.X509Certificate + +@Dialog(R.layout.dialog_handle_ssl_certificate) +class AssignSslCertificateDialog : BaseDialog() { + + private lateinit var certificate: X509Certificate + + interface Callback { + fun onAcceptCertificateClicked(cloud: WebDavCloud, certificate: X509Certificate) + fun onAcceptCertificateDenied() + } + + public override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { + builder // + .setTitle(requireContext().getString(R.string.dialog_accept_ssl_certificate_title)) + .setPositiveButton(requireActivity().getString(R.string.dialog_unable_to_share_positive_button)) { _: DialogInterface, _: Int -> + val cloud = requireArguments().getSerializable(WEBDAV_CLOUD) as WebDavCloud + callback?.onAcceptCertificateClicked(cloud, certificate) + } // + .setNegativeButton(requireContext().getString(R.string.dialog_button_cancel)) { _: DialogInterface, _: Int -> + callback?.onAcceptCertificateDenied() + } + return builder.create() + } + + public override fun setupView() { + certificate = requireArguments().getSerializable(CERTIFICATE) as X509Certificate + try { + tv_finger_print_text.text = X509CertificateHelper.getFingerprintFormatted(certificate) + certificate_details.text = certificate.toString() + } catch (e: CertificateException) { + throw FatalBackendException(e) + } + + show_certificate.setOnClickListener { + run { + certificate_details.visibility = View.VISIBLE + show_certificate.visibility = View.GONE + } + } + + cb_accept_certificate.setOnCheckedChangeListener { _, isChecked -> + run { + alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).isEnabled = isChecked + } + } + } + + override fun onStart() { + super.onStart() + alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).isEnabled = false + } + + private val alertDialog: AlertDialog + get() = dialog as AlertDialog + + companion object { + private const val CERTIFICATE = "certificate" + private const val WEBDAV_CLOUD = "webdavcloud" + fun newInstance(cloud: WebDavCloud, certificate: X509Certificate): AssignSslCertificateDialog { + val dialog = AssignSslCertificateDialog() + val args = Bundle() + args.putSerializable(WEBDAV_CLOUD, cloud) + args.putSerializable(CERTIFICATE, certificate) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BaseDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BaseDialog.kt new file mode 100644 index 000000000..cbab7944b --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/BaseDialog.kt @@ -0,0 +1,124 @@ +package org.cryptomator.presentation.ui.dialog + +import android.app.Dialog +import android.content.Context +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import org.cryptomator.presentation.util.KeyboardHelper +import org.cryptomator.util.SharedPreferencesHandler +import org.cryptomator.util.Supplier + +abstract class BaseDialog : DialogFragment() { + + private lateinit var customDialog: View + + var callback: Callback? = null + + protected abstract fun setupDialog(builder: AlertDialog.Builder): Dialog + protected abstract fun setupView() + + fun show(fragmentManager: FragmentManager) { + show(fragmentManager, javaClass.simpleName) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + callback = context as Callback + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = AlertDialog.Builder(requireActivity()) + customDialog = requireActivity().layoutInflater.inflate(dialogContent, null) + builder.setView(customDialog) + val dialog = setupDialog(builder) + dialog.window?.decorView?.filterTouchesWhenObscured = disableDialogWhenObscured() + return dialog + } + + // Need to return the view here or onViewCreated won't be called by DialogFragment, sigh + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return customDialog + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupView() + } + + protected open fun disableDialogWhenObscured(): Boolean { + return SharedPreferencesHandler(requireContext()).disableAppWhenObscured() + } + + fun onWaitForResponse(view: View) { + view.isFocusable = false + allowClosingDialog(false) + enableButtons(false) + hideKeyboard(view) + enableOrientationChange(false) + } + + fun enableOrientationChange(enable: Boolean) { + if (enable) { + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + } else { + requireActivity().requestedOrientation = if (isLandscape) // + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else // + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } + + private val isLandscape: Boolean + get() = requireContext().resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + fun onErrorResponse(view: View?) { + if (view != null) { + view.isFocusableInTouchMode = true + } + allowClosingDialog(true) + enableButtons(true) + } + + private fun enableButtons(enabled: Boolean) { + val dialog = dialog as AlertDialog? + dialog?.getButton(Dialog.BUTTON_POSITIVE)?.isEnabled = enabled + dialog?.getButton(Dialog.BUTTON_NEGATIVE)?.isEnabled = enabled + } + + fun allowClosingDialog(allow: Boolean) { + // prevent closing the dialog on back key press + dialog?.setCancelable(allow) + // prevent closing the dialog on touch events outside the dialog + dialog?.setCanceledOnTouchOutside(allow) + } + + fun showKeyboard(dialog: Dialog) { + KeyboardHelper.showKeyboardForDialog(dialog) + } + + protected fun hideKeyboard(view: View) { + KeyboardHelper.hideKeyboard(requireActivity(), view) + } + + private val dialogContent: Int + get() = javaClass.getAnnotation(org.cryptomator.generator.Dialog::class.java)!!.value + + protected fun registerOnEditorDoneActionAndPerformButtonClick(editText: EditText, positiveButton: Supplier