From 28ff7cf1126f121a61ee1d501c770af8448b14b3 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 07:46:10 +0100 Subject: [PATCH 01/26] Add folder upload support Allow users to upload an entire folder (with subfolders) into a vault, preserving the directory structure. Uses ACTION_OPEN_DOCUMENT_TREE for folder selection and recursively creates vault folders before uploading files. Resolves #63 --- .../domain/usecases/cloud/DownloadFiles.java | 12 +- .../domain/usecases/cloud/StreamHelper.java | 38 +++ .../domain/usecases/cloud/UploadFiles.java | 37 +-- .../usecases/cloud/UploadFolderFiles.java | 133 ++++++++++ .../usecases/cloud/UploadFolderStructure.java | 45 ++++ .../usecases/cloud/UploadFolderFilesTest.kt | 250 ++++++++++++++++++ presentation/build.gradle | 1 + .../presenter/BrowseFilesPresenter.kt | 91 +++++++ .../ui/activity/BrowseFilesActivity.kt | 4 + .../VaultContentActionBottomSheet.kt | 5 + .../drawable-night/ic_folder_upload_gray.xml | 9 + .../res/drawable/ic_folder_upload_gray.xml | 9 + .../dialog_bottom_sheet_vault_action.xml | 6 + presentation/src/main/res/values/strings.xml | 2 + 14 files changed, 595 insertions(+), 47 deletions(-) create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/StreamHelper.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt create mode 100644 presentation/src/main/res/drawable-night/ic_folder_upload_gray.xml create mode 100644 presentation/src/main/res/drawable/ic_folder_upload_gray.xml 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 index 6d557336d..97761986c 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/DownloadFiles.java @@ -8,8 +8,6 @@ import org.cryptomator.generator.Parameter; import org.cryptomator.generator.UseCase; -import java.io.Closeable; -import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -32,18 +30,10 @@ public List execute(ProgressAware progressAware) throw cloudContentRepository.read(file.getDownloadFile(), null, file.getDataSink(), progressAware); downloadedFiles.add(file.getDownloadFile()); } finally { - closeQuietly(file.getDataSink()); + StreamHelper.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/StreamHelper.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/StreamHelper.java new file mode 100644 index 000000000..57df19a5c --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/StreamHelper.java @@ -0,0 +1,38 @@ +package org.cryptomator.domain.usecases.cloud; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +class StreamHelper { + + private static final int EOF = -1; + private static final int BUFFER_SIZE = 4096; + + private StreamHelper() { + } + + static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + try { + int read; + while ((read = in.read(buffer)) != EOF) { + out.write(buffer, 0, read); + } + } finally { + closeQuietly(in); + closeQuietly(out); + } + } + + static 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/UploadFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java index 375d34393..ae6fd984b 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java @@ -12,7 +12,6 @@ import org.cryptomator.generator.Parameter; import org.cryptomator.generator.UseCase; -import java.io.Closeable; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -26,8 +25,6 @@ @UseCase class UploadFiles { - private static final int EOF = -1; - private final Context context; private final CloudContentRepository cloudContentRepository; private final CloudFolder parent; @@ -104,7 +101,7 @@ private File copyDataToFile(DataSource dataSource) { File target = createTempFile("upload", "tmp", dir); InputStream in = CancelAwareDataSource.wrap(dataSource, cancelledFlag).open(context); OutputStream out = new FileOutputStream(target); - copy(in, out); + StreamHelper.copy(in, out); dataSource.modifiedDate(context).ifPresent(value -> target.setLastModified(value.getTime())); return target; } catch (IOException e) { @@ -123,36 +120,4 @@ private CloudFile writeCloudFile(String fileName, CancelAwareDataSource dataSour size); } - 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/UploadFolderFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java new file mode 100644 index 000000000..aa7fca6f5 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java @@ -0,0 +1,133 @@ +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 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 UploadFolderFiles { + + private final Context context; + private final CloudContentRepository cloudContentRepository; + private final CloudFolder parent; + private final UploadFolderStructure folderStructure; + + private volatile boolean cancelled; + private final Flag cancelledFlag = new Flag() { + @Override + public boolean get() { + return cancelled; + } + }; + + public UploadFolderFiles(Context context, // + CloudContentRepository cloudContentRepository, // + @Parameter CloudFolder parent, // + @Parameter UploadFolderStructure folderStructure) { + this.context = context; + this.cloudContentRepository = cloudContentRepository; + this.parent = parent; + this.folderStructure = folderStructure; + } + + public void onCancel() { + cancelled = true; + } + + public List execute(ProgressAware progressAware) throws BackendException { + cancelled = false; + try { + return uploadFolder(parent, folderStructure, progressAware); + } catch (BackendException | RuntimeException e) { + if (cancelled) { + throw new CancellationException(e); + } else { + throw e; + } + } + } + + private List uploadFolder(CloudFolder targetParent, UploadFolderStructure structure, ProgressAware progressAware) throws BackendException { + CloudFolder createdFolder = cloudContentRepository.create( // + cloudContentRepository.folder(targetParent, structure.getFolderName())); + + List uploadedFiles = new ArrayList<>(); + + for (UploadFile file : structure.getFiles()) { + uploadedFiles.add(upload(createdFolder, file, progressAware)); + } + + for (UploadFolderStructure subfolder : structure.getSubfolders()) { + uploadedFiles.addAll(uploadFolder(createdFolder, subfolder, progressAware)); + } + + return uploadedFiles; + } + + private CloudFile upload(CloudFolder folder, UploadFile uploadFile, ProgressAware progressAware) throws BackendException { + DataSource dataSource = uploadFile.getDataSource(); + if (dataSource.size(context) != null) { + return upload(folder, uploadFile, dataSource, progressAware); + } else { + File file = copyDataToFile(dataSource); + try { + return upload(folder, uploadFile, FileBasedDataSource.from(file), progressAware); + } finally { + file.delete(); + } + } + } + + private CloudFile upload(CloudFolder folder, UploadFile uploadFile, DataSource dataSource, ProgressAware progressAware) throws BackendException { + return writeCloudFile( // + folder, // + uploadFile.getFileName(), // + CancelAwareDataSource.wrap(dataSource, cancelledFlag), // + uploadFile.getReplacing(), // + progressAware); + } + + private CloudFile writeCloudFile(CloudFolder folder, String fileName, CancelAwareDataSource dataSource, boolean replacing, ProgressAware progressAware) throws BackendException { + Long size = dataSource.size(context); + CloudFile source = cloudContentRepository.file(folder, fileName, size); + return cloudContentRepository.write( // + source, // + dataSource, // + progressAware, // + replacing, // + size); + } + + 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); + StreamHelper.copy(in, out); + dataSource.modifiedDate(context).ifPresent(value -> target.setLastModified(value.getTime())); + return target; + } catch (IOException e) { + throw new FatalBackendException(e); + } + } + +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java new file mode 100644 index 000000000..1cf054856 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java @@ -0,0 +1,45 @@ +package org.cryptomator.domain.usecases.cloud; + +import java.util.ArrayList; +import java.util.List; + +public class UploadFolderStructure { + + private final String folderName; + private final List files; + private final List subfolders; + + public UploadFolderStructure(String folderName) { + this.folderName = folderName; + this.files = new ArrayList<>(); + this.subfolders = new ArrayList<>(); + } + + public String getFolderName() { + return folderName; + } + + public List getFiles() { + return files; + } + + public List getSubfolders() { + return subfolders; + } + + public void addFile(UploadFile file) { + this.files.add(file); + } + + public void addSubfolder(UploadFolderStructure subfolder) { + this.subfolders.add(subfolder); + } + + public int totalFileCount() { + int count = files.size(); + for (UploadFolderStructure subfolder : subfolders) { + count += subfolder.totalFileCount(); + } + return count; + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt new file mode 100644 index 000000000..ece9cab57 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt @@ -0,0 +1,250 @@ +package org.cryptomator.domain.usecases.cloud + +import android.content.Context +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.util.Optional +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.util.Arrays +import java.util.Date + +class UploadFolderFilesTest { + + private val context: Context = mock() + private var cloudContentRepository: CloudContentRepository = mock() + private val parent: CloudFolder = mock() + private val createdFolder: CloudFolder = mock() + private val createdSubfolder: CloudFolder = mock() + private val folderToCreate: CloudFolder = mock() + private val subfolderToCreate: CloudFolder = mock() + private val targetFile: CloudFile = mock() + private val resultFile: CloudFile = mock() + private val targetFile2: CloudFile = mock() + private val resultFile2: CloudFile = mock() + + private val progressAware: ProgressAware = mock() + + private fun any(type: Class): T = Mockito.any(type) + + @BeforeEach + fun setup() { + whenever(cloudContentRepository.folder(parent, "testFolder")).thenReturn(folderToCreate) + whenever(cloudContentRepository.create(folderToCreate)).thenReturn(createdFolder) + } + + @Test + @DisplayName("Upload folder with single file creates folder and uploads file") + @Throws(BackendException::class) + fun testUploadFolderWithSingleFile() { + val fileSize: Long = 1337 + val dataSource = dataSourceWithBytes(0, fileSize, fileSize) + + val structure = UploadFolderStructure("testFolder") + structure.addFile( + UploadFile.Builder() + .withFileName("file.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + + whenever(cloudContentRepository.file(createdFolder, "file.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + val result = inTest.execute(progressAware) + + verify(cloudContentRepository).folder(parent, "testFolder") + verify(cloudContentRepository).create(folderToCreate) + assertThat(result.size, `is`(1)) + assertThat(result[0], `is`(resultFile)) + } + + @Test + @DisplayName("Upload folder with subfolder creates folders recursively") + @Throws(BackendException::class) + fun testUploadFolderWithSubfolder() { + val fileSize: Long = 100 + val dataSource1 = dataSourceWithBytes(1, fileSize, fileSize) + val dataSource2 = dataSourceWithBytes(2, fileSize, fileSize) + + val subfolder = UploadFolderStructure("sub") + subfolder.addFile( + UploadFile.Builder() + .withFileName("subfile.txt") + .withDataSource(dataSource2) + .thatIsReplacing(false) + .build() + ) + + val structure = UploadFolderStructure("testFolder") + structure.addFile( + UploadFile.Builder() + .withFileName("rootfile.txt") + .withDataSource(dataSource1) + .thatIsReplacing(false) + .build() + ) + structure.addSubfolder(subfolder) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + + whenever(cloudContentRepository.folder(createdFolder, "sub")).thenReturn(subfolderToCreate) + whenever(cloudContentRepository.create(subfolderToCreate)).thenReturn(createdSubfolder) + + whenever(cloudContentRepository.file(createdFolder, "rootfile.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + whenever(cloudContentRepository.file(createdSubfolder, "subfile.txt", fileSize)).thenReturn(targetFile2) + whenever( + cloudContentRepository.write( + same(targetFile2), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile2) + + val result = inTest.execute(progressAware) + + verify(cloudContentRepository).folder(parent, "testFolder") + verify(cloudContentRepository).create(folderToCreate) + verify(cloudContentRepository).folder(createdFolder, "sub") + verify(cloudContentRepository).create(subfolderToCreate) + assertThat(result.size, `is`(2)) + assertThat(result[0], `is`(resultFile)) + assertThat(result[1], `is`(resultFile2)) + } + + @Test + @DisplayName("Upload empty folder creates folder but returns no files") + @Throws(BackendException::class) + fun testUploadEmptyFolder() { + val structure = UploadFolderStructure("testFolder") + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + + val result = inTest.execute(progressAware) + + verify(cloudContentRepository).folder(parent, "testFolder") + verify(cloudContentRepository).create(folderToCreate) + assertThat(result.size, `is`(0)) + } + + @Test + @DisplayName("totalFileCount returns correct count for nested structure") + fun testTotalFileCount() { + val dataSource = dataSourceWithBytes(0, 10, 10) + + val deepSub = UploadFolderStructure("deep") + deepSub.addFile( + UploadFile.Builder() + .withFileName("deep.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val sub = UploadFolderStructure("sub") + sub.addFile( + UploadFile.Builder() + .withFileName("sub1.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + sub.addFile( + UploadFile.Builder() + .withFileName("sub2.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + sub.addSubfolder(deepSub) + + val root = UploadFolderStructure("root") + root.addFile( + UploadFile.Builder() + .withFileName("root.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + root.addSubfolder(sub) + + assertThat(root.totalFileCount(), `is`(4)) + assertThat(sub.totalFileCount(), `is`(3)) + assertThat(deepSub.totalFileCount(), `is`(1)) + } + + private fun dataSourceWithBytes(value: Int, amount: Long, size: Long?): DataSource { + check(amount <= Int.MAX_VALUE) { "Can not use values > Integer.MAX_VALUE" } + val bytes = bytes(value, amount) + return object : DataSource { + override fun size(context: Context): Long? { + return size + } + + @Throws(IOException::class) + override fun open(context: Context): InputStream { + return ByteArrayInputStream(bytes) + } + + override fun decorate(delegate: DataSource): DataSource { + return delegate + } + + override fun modifiedDate(context: Context): Optional { + return Optional.of(Date()) + } + + @Throws(IOException::class) + override fun close() { + // do nothing + } + } + } + + private fun bytes(value: Int, amount: Long): ByteArray { + check(amount <= Int.MAX_VALUE) { "Can not use values > Integer.MAX_VALUE" } + val data = ByteArray(amount.toInt()) + Arrays.fill(data, value.toByte()) + return data + } +} diff --git a/presentation/build.gradle b/presentation/build.gradle index 56cc497ba..ddd7a9187 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -185,6 +185,7 @@ dependencies { implementation dependencies.androidxSwiperefresh implementation dependencies.androidxPreference implementation dependencies.androidxBiometric + implementation dependencies.documentFile implementation dependencies.appauth diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 20909d5e1..ceed2923b 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.provider.DocumentsContract import android.widget.Toast import androidx.core.net.toFile +import androidx.documentfile.provider.DocumentFile import org.cryptomator.data.cloud.crypto.CryptoFolder import org.cryptomator.domain.Cloud import org.cryptomator.domain.CloudFile @@ -39,6 +40,8 @@ import org.cryptomator.domain.usecases.cloud.RenameFileUseCase import org.cryptomator.domain.usecases.cloud.RenameFolderUseCase import org.cryptomator.domain.usecases.cloud.UploadFile import org.cryptomator.domain.usecases.cloud.UploadFilesUseCase +import org.cryptomator.domain.usecases.cloud.UploadFolderFilesUseCase +import org.cryptomator.domain.usecases.cloud.UploadFolderStructure import org.cryptomator.domain.usecases.cloud.UploadState import org.cryptomator.domain.usecases.vault.AssertUnlockedUseCase import org.cryptomator.generator.Callback @@ -98,6 +101,7 @@ class BrowseFilesPresenter @Inject constructor( // private val downloadFilesUseCase: DownloadFilesUseCase, // private val deleteNodesUseCase: DeleteNodesUseCase, // private val uploadFilesUseCase: UploadFilesUseCase, // + private val uploadFolderFilesUseCase: UploadFolderFilesUseCase, // private val renameFileUseCase: RenameFileUseCase, // private val renameFolderUseCase: RenameFolderUseCase, // private val copyDataUseCase: CopyDataUseCase, // @@ -1042,6 +1046,7 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadCanceled() { uploadFilesUseCase.cancel() + uploadFolderFilesUseCase.cancel() } @Callback @@ -1062,6 +1067,91 @@ class BrowseFilesPresenter @Inject constructor( // return fileUris } + fun onUploadFolderClicked(folder: CloudFolderModel) { + uploadLocation = folder + try { + requestActivityResult( // + ActivityResultCallbacks.selectedFolder(), // + 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, "Upload folder: No ContentProvider on system") + } + } + + @Callback + fun selectedFolder(result: ActivityResult) { + val treeUri = result.intent().data ?: return + context().contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: return + view?.showProgress(ProgressModel.GENERIC) + Thread { + val folderStructure = buildUploadFolderStructure(documentFile) + activity().runOnUiThread { + view?.showProgress(ProgressModel.COMPLETED) + if (folderStructure.totalFileCount() == 0) { + view?.showMessage(R.string.screen_file_browser_nothing_to_upload) + return@runOnUiThread + } + uploadFolder(folderStructure) + } + }.start() + } + + private fun buildUploadFolderStructure(documentFile: DocumentFile): UploadFolderStructure { + val structure = UploadFolderStructure(documentFile.name ?: "folder") + documentFile.listFiles().forEach { child -> + when { + child.isDirectory -> { + structure.addSubfolder(buildUploadFolderStructure(child)) + } + child.isFile -> { + child.name?.let { name -> + structure.addFile( + UploadFile.anUploadFile() // + .withFileName(name) // + .withDataSource(UriBasedDataSource.from(child.uri)) // + .thatIsReplacing(false) // + .build() + ) + } + } + } + } + return structure + } + + private fun uploadFolder(folderStructure: UploadFolderStructure) { + uploadLocation?.let { + view?.showUploadDialog(folderStructure.totalFileCount()) + uploadFolderFilesUseCase // + .withParent(it.toCloudNode()) // + .andFolderStructure(folderStructure) // + .run(object : DefaultProgressAwareResultHandler, UploadState>() { + override fun onProgress(progress: Progress) { + view?.showProgress(progressModelMapper.toModel(progress)) + } + + override fun onSuccess(files: List) { + onFileUploadCompleted() + getCloudList(it) + } + + override fun onError(e: Throwable) { + onFileUploadError() + super.onError(e) + } + }) + } + } + private fun moveIntentFor(parent: CloudFolderModel, sourceNodes: List>): IntentBuilder { val foldersToMove = nodesFor(sourceNodes, CloudFolderModel::class) as List return Intents.browseFilesIntent() // @@ -1284,6 +1374,7 @@ class BrowseFilesPresenter @Inject constructor( // downloadFilesUseCase, // deleteNodesUseCase, // uploadFilesUseCase, // + uploadFolderFilesUseCase, // renameFileUseCase, // renameFolderUseCase, // copyDataUseCase, // diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index f3d42fa02..a5bbdee65 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -470,6 +470,10 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi browseFilesPresenter.onUploadFilesClicked(folder) } + override fun onUploadFolderClicked(folder: CloudFolderModel) { + browseFilesPresenter.onUploadFolderClicked(folder) + } + override fun onCreateNewTextFileClicked() { browseFilesPresenter.onCreateNewTextFileClicked() } 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 index 38ed63a3c..337916a27 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/VaultContentActionBottomSheet.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/bottomsheet/VaultContentActionBottomSheet.kt @@ -13,6 +13,7 @@ class VaultContentActionBottomSheet : BaseBottomSheet + + diff --git a/presentation/src/main/res/drawable/ic_folder_upload_gray.xml b/presentation/src/main/res/drawable/ic_folder_upload_gray.xml new file mode 100644 index 000000000..679fb6a2a --- /dev/null +++ b/presentation/src/main/res/drawable/ic_folder_upload_gray.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/layout/dialog_bottom_sheet_vault_action.xml b/presentation/src/main/res/layout/dialog_bottom_sheet_vault_action.xml index 902ba5edc..bb1d66c52 100644 --- a/presentation/src/main/res/layout/dialog_bottom_sheet_vault_action.xml +++ b/presentation/src/main/res/layout/dialog_bottom_sheet_vault_action.xml @@ -36,6 +36,12 @@ android:text="@string/screen_file_browser_action_upload_files" app:drawableStartCompat="@drawable/ic_file_upload_gray" /> + + Create folder Create text file Upload files + Upload folder Files File exported Files exported Nothing to export + Selected folder contains no files Creating download directory failed From dac8dffe5603e6520f60d39056000d04c56808f5 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 16:51:47 +0100 Subject: [PATCH 02/26] Add upload service with foreground execution and resume support --- data/build.gradle | 2 +- .../data/db/UpgradeDatabaseTest.kt | 92 +++++++ .../cryptomator/data/db/DatabaseUpgrades.java | 6 +- .../org/cryptomator/data/db/Upgrade13To14.kt | 39 +++ .../data/db/UploadCheckpointDao.java | 90 +++++++ .../db/entities/UploadCheckpointEntity.java | 140 ++++++++++ .../usecases/cloud/FileUploadedCallback.java | 9 + .../domain/usecases/cloud/UploadFiles.java | 18 +- .../usecases/cloud/UploadFolderFiles.java | 39 ++- .../domain/usecases/cloud/UploadFileTest.kt | 109 ++++++++ .../usecases/cloud/UploadFolderFilesTest.kt | 209 +++++++++++++++ presentation/src/main/AndroidManifest.xml | 6 + .../presentation/CryptomatorApp.kt | 68 +++++ .../di/component/ApplicationComponent.java | 3 + .../presenter/BrowseFilesPresenter.kt | 107 +++----- .../presenter/UploadFolderStructureBuilder.kt | 28 ++ .../presenter/UriBasedDataSource.kt | 2 + .../presenter/VaultListPresenter.kt | 129 +++++++++- .../service/UploadNotification.kt | 139 ++++++++++ .../presentation/service/UploadService.java | 240 ++++++++++++++++++ .../ui/activity/VaultListActivity.kt | 10 + .../ui/dialog/ResumeUploadDialog.kt | 50 ++++ presentation/src/main/res/values/strings.xml | 19 ++ 23 files changed, 1473 insertions(+), 81 deletions(-) create mode 100644 data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt create mode 100644 data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java create mode 100644 data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/presenter/UploadFolderStructureBuilder.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt diff --git a/data/build.gradle b/data/build.gradle index 71c24e195..7bc773222 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -102,7 +102,7 @@ android { } greendao { - schemaVersion 13 + schemaVersion 14 } configurations.all { diff --git a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt index b50776a8d..171788756 100644 --- a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt +++ b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt @@ -56,6 +56,7 @@ class UpgradeDatabaseTest { Upgrade10To11().applyTo(db, 10) Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) Upgrade12To13(context).applyTo(db, 12) + Upgrade13To14().applyTo(db, 13) CloudEntityDao(DaoConfig(db, CloudEntityDao::class.java)).loadAll() VaultEntityDao(DaoConfig(db, VaultEntityDao::class.java)).loadAll() @@ -915,6 +916,97 @@ class UpgradeDatabaseTest { } } + @Test + fun upgrade13To14() { + Upgrade0To1().applyTo(db, 0) + Upgrade1To2().applyTo(db, 1) + Upgrade2To3(context).applyTo(db, 2) + Upgrade3To4().applyTo(db, 3) + Upgrade4To5().applyTo(db, 4) + Upgrade5To6().applyTo(db, 5) + Upgrade6To7().applyTo(db, 6) + Upgrade7To8().applyTo(db, 7) + Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) + Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + Upgrade10To11().applyTo(db, 10) + Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + Upgrade12To13(context).applyTo(db, 12) + + Upgrade13To14().applyTo(db, 13) + + // Insert a checkpoint row + Sql.insertInto("UPLOAD_CHECKPOINT_ENTITY") // + .integer("_id", 1) // + .integer("VAULT_ID", 42) // + .text("TYPE", "folder") // + .text("TARGET_FOLDER_PATH", "/Documents") // + .text("SOURCE_FOLDER_URI", "content://tree/uri") // + .text("SOURCE_FOLDER_NAME", "Photos") // + .text("PENDING_FILE_URIS", null) // + .text("COMPLETED_FILES", "[\"file1.txt\"]") // + .integer("TOTAL_FILE_COUNT", 5) // + .integer("TIMESTAMP", 1700000000) // + .executeOn(db) + + Sql.query("UPLOAD_CHECKPOINT_ENTITY").where("VAULT_ID", Sql.eq(42)).executeOn(db).use { + it.moveToFirst() + Assert.assertThat(it.getInt(it.getColumnIndex("_id")), CoreMatchers.`is`(1)) + Assert.assertThat(it.getInt(it.getColumnIndex("VAULT_ID")), CoreMatchers.`is`(42)) + Assert.assertThat(it.getString(it.getColumnIndex("TYPE")), CoreMatchers.`is`("folder")) + Assert.assertThat(it.getString(it.getColumnIndex("TARGET_FOLDER_PATH")), CoreMatchers.`is`("/Documents")) + Assert.assertThat(it.getString(it.getColumnIndex("SOURCE_FOLDER_URI")), CoreMatchers.`is`("content://tree/uri")) + Assert.assertThat(it.getString(it.getColumnIndex("SOURCE_FOLDER_NAME")), CoreMatchers.`is`("Photos")) + Assert.assertThat(it.getString(it.getColumnIndex("COMPLETED_FILES")), CoreMatchers.`is`("[\"file1.txt\"]")) + Assert.assertThat(it.getInt(it.getColumnIndex("TOTAL_FILE_COUNT")), CoreMatchers.`is`(5)) + Assert.assertThat(it.getLong(it.getColumnIndex("TIMESTAMP")), CoreMatchers.`is`(1700000000L)) + } + } + + @Test + fun upgrade13To14UniqueVaultIdConstraint() { + Upgrade0To1().applyTo(db, 0) + Upgrade1To2().applyTo(db, 1) + Upgrade2To3(context).applyTo(db, 2) + Upgrade3To4().applyTo(db, 3) + Upgrade4To5().applyTo(db, 4) + Upgrade5To6().applyTo(db, 5) + Upgrade6To7().applyTo(db, 6) + Upgrade7To8().applyTo(db, 7) + Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) + Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + Upgrade10To11().applyTo(db, 10) + Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + Upgrade12To13(context).applyTo(db, 12) + + Upgrade13To14().applyTo(db, 13) + + Sql.insertInto("UPLOAD_CHECKPOINT_ENTITY") // + .integer("_id", 1) // + .integer("VAULT_ID", 42) // + .text("TYPE", "files") // + .text("TARGET_FOLDER_PATH", "/path") // + .text("COMPLETED_FILES", "[]") // + .integer("TOTAL_FILE_COUNT", 1) // + .integer("TIMESTAMP", 1000) // + .executeOn(db) + + // Inserting a second row with the same VAULT_ID should fail due to unique index + try { + Sql.insertInto("UPLOAD_CHECKPOINT_ENTITY") // + .integer("_id", 2) // + .integer("VAULT_ID", 42) // + .text("TYPE", "folder") // + .text("TARGET_FOLDER_PATH", "/other") // + .text("COMPLETED_FILES", "[]") // + .integer("TOTAL_FILE_COUNT", 2) // + .integer("TIMESTAMP", 2000) // + .executeOn(db) + Assert.fail("Expected constraint violation for duplicate VAULT_ID") + } catch (e: Exception) { + // Expected: unique constraint violation + } + } + @Test fun upgrade12To13Webdav() { Upgrade0To1().applyTo(db, 0) diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java index 52401e64f..c4fb6b420 100644 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java @@ -31,7 +31,8 @@ public DatabaseUpgrades( // Upgrade9To10 upgrade9To10, // Upgrade10To11 upgrade10To11, // Upgrade11To12 upgrade11To12, // - Upgrade12To13 upgrade12To13 + Upgrade12To13 upgrade12To13, // + Upgrade13To14 upgrade13To14 ) { availableUpgrades = defineUpgrades( // @@ -47,7 +48,8 @@ public DatabaseUpgrades( // upgrade9To10, // upgrade10To11, // upgrade11To12, // - upgrade12To13); + upgrade12To13, // + upgrade13To14); } private Map> defineUpgrades(DatabaseUpgrade... upgrades) { diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt new file mode 100644 index 000000000..5ffab7c31 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt @@ -0,0 +1,39 @@ +package org.cryptomator.data.db + +import org.greenrobot.greendao.database.Database +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Upgrade13To14 @Inject constructor() : DatabaseUpgrade(13, 14) { + + override fun internalApplyTo(db: Database, origin: Int) { + db.beginTransaction() + try { + createUploadCheckpointTable(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun createUploadCheckpointTable(db: Database) { + Sql.createTable("UPLOAD_CHECKPOINT_ENTITY") // + .id() // + .requiredInt("VAULT_ID") // + .requiredText("TYPE") // + .requiredText("TARGET_FOLDER_PATH") // + .optionalText("SOURCE_FOLDER_URI") // + .optionalText("SOURCE_FOLDER_NAME") // + .optionalText("PENDING_FILE_URIS") // + .requiredText("COMPLETED_FILES") // + .requiredInt("TOTAL_FILE_COUNT") // + .requiredInt("TIMESTAMP") // + .executeOn(db) + + Sql.createUniqueIndex("IDX_UPLOAD_CHECKPOINT_ENTITY_VAULT_ID") // + .on("UPLOAD_CHECKPOINT_ENTITY") // + .asc("VAULT_ID") // + .executeOn(db) + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java new file mode 100644 index 000000000..6a77d1fb4 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java @@ -0,0 +1,90 @@ +package org.cryptomator.data.db; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import org.cryptomator.data.db.entities.UploadCheckpointEntity; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class UploadCheckpointDao { + + private static final String TABLE_NAME = "UPLOAD_CHECKPOINT_ENTITY"; + + private final DatabaseFactory databaseFactory; + + @Inject + public UploadCheckpointDao(DatabaseFactory databaseFactory) { + this.databaseFactory = databaseFactory; + } + + private SQLiteDatabase getDb() { + return databaseFactory.getWritableDatabase(); + } + + public long insertOrReplace(UploadCheckpointEntity entity) { + ContentValues values = toContentValues(entity); + UploadCheckpointEntity existing = findByVaultId(entity.getVaultId()); + if (existing != null) { + getDb().update(TABLE_NAME, values, "VAULT_ID = ?", new String[]{existing.getVaultId().toString()}); + return existing.getId(); + } else { + return getDb().insertOrThrow(TABLE_NAME, null, values); + } + } + + public UploadCheckpointEntity findByVaultId(long vaultId) { + Cursor cursor = getDb().query(TABLE_NAME, null, "VAULT_ID = ?", + new String[]{String.valueOf(vaultId)}, null, null, null); + try { + if (cursor.moveToFirst()) { + return fromCursor(cursor); + } + return null; + } finally { + cursor.close(); + } + } + + public void deleteByVaultId(long vaultId) { + getDb().delete(TABLE_NAME, "VAULT_ID = ?", new String[]{String.valueOf(vaultId)}); + } + + public void updateCompletedFiles(long vaultId, String completedFilesJson) { + ContentValues values = new ContentValues(); + values.put("COMPLETED_FILES", completedFilesJson); + getDb().update(TABLE_NAME, values, "VAULT_ID = ?", new String[]{String.valueOf(vaultId)}); + } + + private ContentValues toContentValues(UploadCheckpointEntity entity) { + ContentValues values = new ContentValues(); + values.put("VAULT_ID", entity.getVaultId()); + values.put("TYPE", entity.getType()); + values.put("TARGET_FOLDER_PATH", entity.getTargetFolderPath()); + values.put("SOURCE_FOLDER_URI", entity.getSourceFolderUri()); + values.put("SOURCE_FOLDER_NAME", entity.getSourceFolderName()); + values.put("PENDING_FILE_URIS", entity.getPendingFileUris()); + values.put("COMPLETED_FILES", entity.getCompletedFiles()); + values.put("TOTAL_FILE_COUNT", entity.getTotalFileCount()); + values.put("TIMESTAMP", entity.getTimestamp()); + return values; + } + + private UploadCheckpointEntity fromCursor(Cursor cursor) { + UploadCheckpointEntity entity = new UploadCheckpointEntity(); + entity.setId(cursor.getLong(cursor.getColumnIndexOrThrow("_id"))); + entity.setVaultId(cursor.getLong(cursor.getColumnIndexOrThrow("VAULT_ID"))); + entity.setType(cursor.getString(cursor.getColumnIndexOrThrow("TYPE"))); + entity.setTargetFolderPath(cursor.getString(cursor.getColumnIndexOrThrow("TARGET_FOLDER_PATH"))); + entity.setSourceFolderUri(cursor.getString(cursor.getColumnIndexOrThrow("SOURCE_FOLDER_URI"))); + entity.setSourceFolderName(cursor.getString(cursor.getColumnIndexOrThrow("SOURCE_FOLDER_NAME"))); + entity.setPendingFileUris(cursor.getString(cursor.getColumnIndexOrThrow("PENDING_FILE_URIS"))); + entity.setCompletedFiles(cursor.getString(cursor.getColumnIndexOrThrow("COMPLETED_FILES"))); + entity.setTotalFileCount(cursor.getInt(cursor.getColumnIndexOrThrow("TOTAL_FILE_COUNT"))); + entity.setTimestamp(cursor.getLong(cursor.getColumnIndexOrThrow("TIMESTAMP"))); + return entity; + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java new file mode 100644 index 000000000..34910de34 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java @@ -0,0 +1,140 @@ +package org.cryptomator.data.db.entities; + +import org.greenrobot.greendao.annotation.Entity; +import org.greenrobot.greendao.annotation.Id; +import org.greenrobot.greendao.annotation.Index; +import org.greenrobot.greendao.annotation.NotNull; +import org.greenrobot.greendao.annotation.Generated; + +@Entity(indexes = {@Index(value = "vaultId", unique = true)}) +public class UploadCheckpointEntity extends DatabaseEntity { + + @Id + private Long id; + + @NotNull + private Long vaultId; + + @NotNull + private String type; + + @NotNull + private String targetFolderPath; + + private String sourceFolderUri; + + private String sourceFolderName; + + private String pendingFileUris; + + @NotNull + private String completedFiles; + + @NotNull + private int totalFileCount; + + @NotNull + private long timestamp; + + @Generated(hash = 482695414) + public UploadCheckpointEntity(Long id, @NotNull Long vaultId, + @NotNull String type, @NotNull String targetFolderPath, + String sourceFolderUri, String sourceFolderName, String pendingFileUris, + @NotNull String completedFiles, int totalFileCount, long timestamp) { + this.id = id; + this.vaultId = vaultId; + this.type = type; + this.targetFolderPath = targetFolderPath; + this.sourceFolderUri = sourceFolderUri; + this.sourceFolderName = sourceFolderName; + this.pendingFileUris = pendingFileUris; + this.completedFiles = completedFiles; + this.totalFileCount = totalFileCount; + this.timestamp = timestamp; + } + + @Generated(hash = 1737881290) + public UploadCheckpointEntity() { + } + + @Override + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getVaultId() { + return this.vaultId; + } + + public void setVaultId(Long vaultId) { + this.vaultId = vaultId; + } + + public String getType() { + return this.type; + } + + public void setType(String type) { + this.type = type; + } + + public String getTargetFolderPath() { + return this.targetFolderPath; + } + + public void setTargetFolderPath(String targetFolderPath) { + this.targetFolderPath = targetFolderPath; + } + + public String getSourceFolderUri() { + return this.sourceFolderUri; + } + + public void setSourceFolderUri(String sourceFolderUri) { + this.sourceFolderUri = sourceFolderUri; + } + + public String getSourceFolderName() { + return this.sourceFolderName; + } + + public void setSourceFolderName(String sourceFolderName) { + this.sourceFolderName = sourceFolderName; + } + + public String getPendingFileUris() { + return this.pendingFileUris; + } + + public void setPendingFileUris(String pendingFileUris) { + this.pendingFileUris = pendingFileUris; + } + + public String getCompletedFiles() { + return this.completedFiles; + } + + public void setCompletedFiles(String completedFiles) { + this.completedFiles = completedFiles; + } + + public int getTotalFileCount() { + return this.totalFileCount; + } + + public void setTotalFileCount(int totalFileCount) { + this.totalFileCount = totalFileCount; + } + + public long getTimestamp() { + return this.timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } +} diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java new file mode 100644 index 000000000..bc42e6514 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java @@ -0,0 +1,9 @@ +package org.cryptomator.domain.usecases.cloud; + +public interface FileUploadedCallback { + + void onFileUploaded(String relativePath); + + FileUploadedCallback NO_OP = relativePath -> { + }; +} 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 index ae6fd984b..9b6e253f6 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java @@ -18,17 +18,21 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Set; import static java.io.File.createTempFile; @UseCase -class UploadFiles { +public class UploadFiles { private final Context context; private final CloudContentRepository cloudContentRepository; private final CloudFolder parent; private final List files; + private Set completedFiles = Collections.emptySet(); + private FileUploadedCallback fileUploadedCallback = FileUploadedCallback.NO_OP; private volatile boolean cancelled; private final Flag cancelledFlag = new Flag() { @@ -48,6 +52,14 @@ public UploadFiles(Context context, // this.files = files; } + public void setCompletedFiles(Set completedFiles) { + this.completedFiles = completedFiles; + } + + public void setFileUploadedCallback(FileUploadedCallback fileUploadedCallback) { + this.fileUploadedCallback = fileUploadedCallback; + } + public void onCancel() { cancelled = true; } @@ -68,7 +80,11 @@ public List execute(ProgressAware progressAware) throws private List upload(ProgressAware progressAware) throws BackendException { List uploadedFiles = new ArrayList<>(); for (UploadFile file : files) { + if (completedFiles.contains(file.getFileName())) { + continue; + } uploadedFiles.add(upload(file, progressAware)); + fileUploadedCallback.onFileUploaded(file.getFileName()); } return uploadedFiles; } diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java index aa7fca6f5..51a881915 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java @@ -6,6 +6,7 @@ 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.repository.CloudContentRepository; import org.cryptomator.domain.usecases.ProgressAware; @@ -18,17 +19,21 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Set; import static java.io.File.createTempFile; @UseCase -class UploadFolderFiles { +public class UploadFolderFiles { private final Context context; private final CloudContentRepository cloudContentRepository; private final CloudFolder parent; private final UploadFolderStructure folderStructure; + private Set completedFiles = Collections.emptySet(); + private FileUploadedCallback fileUploadedCallback = FileUploadedCallback.NO_OP; private volatile boolean cancelled; private final Flag cancelledFlag = new Flag() { @@ -48,6 +53,14 @@ public UploadFolderFiles(Context context, // this.folderStructure = folderStructure; } + public void setCompletedFiles(Set completedFiles) { + this.completedFiles = completedFiles; + } + + public void setFileUploadedCallback(FileUploadedCallback fileUploadedCallback) { + this.fileUploadedCallback = fileUploadedCallback; + } + public void onCancel() { cancelled = true; } @@ -66,22 +79,40 @@ public List execute(ProgressAware progressAware) throws } private List uploadFolder(CloudFolder targetParent, UploadFolderStructure structure, ProgressAware progressAware) throws BackendException { - CloudFolder createdFolder = cloudContentRepository.create( // - cloudContentRepository.folder(targetParent, structure.getFolderName())); + return uploadFolder(targetParent, structure, structure.getFolderName(), progressAware); + } + + private List uploadFolder(CloudFolder targetParent, UploadFolderStructure structure, String relativePath, ProgressAware progressAware) throws BackendException { + CloudFolder createdFolder = createFolderSafe(targetParent, structure.getFolderName()); List uploadedFiles = new ArrayList<>(); for (UploadFile file : structure.getFiles()) { + String fileRelativePath = relativePath + "/" + file.getFileName(); + if (completedFiles.contains(fileRelativePath)) { + continue; + } uploadedFiles.add(upload(createdFolder, file, progressAware)); + fileUploadedCallback.onFileUploaded(fileRelativePath); } for (UploadFolderStructure subfolder : structure.getSubfolders()) { - uploadedFiles.addAll(uploadFolder(createdFolder, subfolder, progressAware)); + String subfolderRelativePath = relativePath + "/" + subfolder.getFolderName(); + uploadedFiles.addAll(uploadFolder(createdFolder, subfolder, subfolderRelativePath, progressAware)); } return uploadedFiles; } + private CloudFolder createFolderSafe(CloudFolder parent, String folderName) throws BackendException { + try { + return cloudContentRepository.create( // + cloudContentRepository.folder(parent, folderName)); + } catch (CloudNodeAlreadyExistsException e) { + return cloudContentRepository.folder(parent, folderName); + } + } + private CloudFile upload(CloudFolder folder, UploadFile uploadFile, ProgressAware progressAware) throws BackendException { DataSource dataSource = uploadFile.getDataSource(); if (dataSource.size(context) != null) { diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt index 6db6f4948..6cd40754c 100644 --- a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt @@ -11,12 +11,15 @@ import org.cryptomator.domain.usecases.ProgressAware import org.cryptomator.util.Optional import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.same +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.io.ByteArrayInputStream import java.io.IOException @@ -125,6 +128,112 @@ class UploadFileTest { return data } + @Test + @DisplayName("Completed files are skipped during upload") + @Throws(BackendException::class) + fun testCompletedFilesAreSkipped() { + val fileSize: Long = 1337 + val dataSource1 = dataSourceWithBytes(0, fileSize, fileSize) + val dataSource2 = dataSourceWithBytes(1, fileSize, fileSize) + val targetFile2: CloudFile = mock() + val resultFile2: CloudFile = mock() + + val files = listOf( + UploadFile.Builder() + .withFileName("file1.txt") + .withDataSource(dataSource1) + .thatIsReplacing(false) + .build(), + UploadFile.Builder() + .withFileName("file2.txt") + .withDataSource(dataSource2) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFiles(context, cloudContentRepository, parent, files) + inTest.setCompletedFiles(setOf("file1.txt")) + + whenever(cloudContentRepository.file(parent, "file2.txt", fileSize)).thenReturn(targetFile2) + whenever( + cloudContentRepository.write( + same(targetFile2), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile2) + + val result = inTest.execute(progressAware) + + MatcherAssert.assertThat(result.size, CoreMatchers.`is`(1)) + MatcherAssert.assertThat(result[0], CoreMatchers.`is`(resultFile2)) + } + + @Test + @DisplayName("FileUploadedCallback is called after each file upload") + @Throws(BackendException::class) + fun testFileUploadedCallbackIsCalled() { + val fileSize: Long = 100 + val dataSource = dataSourceWithBytes(0, fileSize, fileSize) + val callback: FileUploadedCallback = mock() + + val files = listOf( + UploadFile.Builder() + .withFileName(fileName) + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFiles(context, cloudContentRepository, parent, files) + inTest.setFileUploadedCallback(callback) + + whenever(cloudContentRepository.file(parent, fileName, fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + inTest.execute(progressAware) + + verify(callback).onFileUploaded(fileName) + } + + @Test + @DisplayName("All completed files skipped returns empty list") + @Throws(BackendException::class) + fun testAllFilesCompletedReturnsEmpty() { + val fileSize: Long = 100 + val dataSource = dataSourceWithBytes(0, fileSize, fileSize) + + val files = listOf( + UploadFile.Builder() + .withFileName("a.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build(), + UploadFile.Builder() + .withFileName("b.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFiles(context, cloudContentRepository, parent, files) + inTest.setCompletedFiles(setOf("a.txt", "b.txt")) + + val result = inTest.execute(progressAware) + + MatcherAssert.assertThat(result.size, CoreMatchers.`is`(0)) + } + private fun testCandidate(dataSource: DataSource, replacing: Boolean): UploadFiles { return UploadFiles( // context, // diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt index ece9cab57..ec8bf048b 100644 --- a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt @@ -6,6 +6,7 @@ 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.repository.CloudContentRepository import org.cryptomator.domain.usecases.ProgressAware import org.cryptomator.util.Optional @@ -17,6 +18,7 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.same import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -213,6 +215,213 @@ class UploadFolderFilesTest { assertThat(deepSub.totalFileCount(), `is`(1)) } + @Test + @DisplayName("Completed files are skipped during folder upload") + @Throws(BackendException::class) + fun testCompletedFilesAreSkipped() { + val fileSize: Long = 100 + val dataSource1 = dataSourceWithBytes(1, fileSize, fileSize) + val dataSource2 = dataSourceWithBytes(2, fileSize, fileSize) + + val structure = UploadFolderStructure("testFolder") + structure.addFile( + UploadFile.Builder() + .withFileName("file1.txt") + .withDataSource(dataSource1) + .thatIsReplacing(false) + .build() + ) + structure.addFile( + UploadFile.Builder() + .withFileName("file2.txt") + .withDataSource(dataSource2) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + inTest.setCompletedFiles(setOf("testFolder/file1.txt")) + + whenever(cloudContentRepository.file(createdFolder, "file2.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + val result = inTest.execute(progressAware) + + assertThat(result.size, `is`(1)) + assertThat(result[0], `is`(resultFile)) + } + + @Test + @DisplayName("FileUploadedCallback is called with relative path after each file upload") + @Throws(BackendException::class) + fun testFileUploadedCallbackCalledWithRelativePath() { + val fileSize: Long = 100 + val dataSource = dataSourceWithBytes(0, fileSize, fileSize) + val callback: FileUploadedCallback = mock() + + val structure = UploadFolderStructure("testFolder") + structure.addFile( + UploadFile.Builder() + .withFileName("file.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + inTest.setFileUploadedCallback(callback) + + whenever(cloudContentRepository.file(createdFolder, "file.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + inTest.execute(progressAware) + + verify(callback).onFileUploaded("testFolder/file.txt") + } + + @Test + @DisplayName("Callback uses correct relative path for files in subfolders") + @Throws(BackendException::class) + fun testCallbackRelativePathInSubfolder() { + val fileSize: Long = 100 + val dataSource = dataSourceWithBytes(0, fileSize, fileSize) + val callback: FileUploadedCallback = mock() + + val subfolder = UploadFolderStructure("sub") + subfolder.addFile( + UploadFile.Builder() + .withFileName("deep.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val structure = UploadFolderStructure("testFolder") + structure.addSubfolder(subfolder) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + inTest.setFileUploadedCallback(callback) + + whenever(cloudContentRepository.folder(createdFolder, "sub")).thenReturn(subfolderToCreate) + whenever(cloudContentRepository.create(subfolderToCreate)).thenReturn(createdSubfolder) + whenever(cloudContentRepository.file(createdSubfolder, "deep.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + inTest.execute(progressAware) + + verify(callback).onFileUploaded("testFolder/sub/deep.txt") + } + + @Test + @DisplayName("createFolderSafe returns existing folder when CloudNodeAlreadyExistsException is thrown") + @Throws(BackendException::class) + fun testCreateFolderSafeHandlesAlreadyExists() { + val fileSize: Long = 100 + val dataSource = dataSourceWithBytes(0, fileSize, fileSize) + + val structure = UploadFolderStructure("testFolder") + structure.addFile( + UploadFile.Builder() + .withFileName("file.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + whenever(cloudContentRepository.create(folderToCreate)).thenThrow(CloudNodeAlreadyExistsException("testFolder")) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + + whenever(cloudContentRepository.file(folderToCreate, "file.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + val result = inTest.execute(progressAware) + + assertThat(result.size, `is`(1)) + assertThat(result[0], `is`(resultFile)) + } + + @Test + @DisplayName("Completed files in subfolder are skipped correctly") + @Throws(BackendException::class) + fun testCompletedFilesInSubfolderAreSkipped() { + val fileSize: Long = 100 + val dataSource1 = dataSourceWithBytes(1, fileSize, fileSize) + val dataSource2 = dataSourceWithBytes(2, fileSize, fileSize) + + val subfolder = UploadFolderStructure("sub") + subfolder.addFile( + UploadFile.Builder() + .withFileName("subfile1.txt") + .withDataSource(dataSource1) + .thatIsReplacing(false) + .build() + ) + subfolder.addFile( + UploadFile.Builder() + .withFileName("subfile2.txt") + .withDataSource(dataSource2) + .thatIsReplacing(false) + .build() + ) + + val structure = UploadFolderStructure("testFolder") + structure.addSubfolder(subfolder) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + inTest.setCompletedFiles(setOf("testFolder/sub/subfile1.txt")) + + whenever(cloudContentRepository.folder(createdFolder, "sub")).thenReturn(subfolderToCreate) + whenever(cloudContentRepository.create(subfolderToCreate)).thenReturn(createdSubfolder) + whenever(cloudContentRepository.file(createdSubfolder, "subfile2.txt", fileSize)).thenReturn(targetFile2) + whenever( + cloudContentRepository.write( + same(targetFile2), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile2) + + val result = inTest.execute(progressAware) + + assertThat(result.size, `is`(1)) + assertThat(result[0], `is`(resultFile2)) + } + private fun dataSourceWithBytes(value: Int, amount: Long, size: Long?): DataSource { check(amount <= Int.MAX_VALUE) { "Can not use values > Integer.MAX_VALUE" } val bytes = bytes(value, amount) diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index cb407cbe2..460636783 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + @@ -211,6 +212,11 @@ android:name=".service.AutoUploadService" android:enabled="true" /> + + @Volatile private var autoUploadServiceBinder: AutoUploadService.Binder? = null + @Volatile + private var uploadServiceBinder: UploadService.Binder? = null + override fun onCreate() { super.onCreate() setupLogging() @@ -86,6 +94,11 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } catch (e: IllegalStateException) { Timber.tag("App").e(e, "Failed to launch auto upload service") } + try { + startUploadService() + } catch (e: IllegalStateException) { + Timber.tag("App").e(e, "Failed to launch upload service") + } } private fun startCryptorsService() { @@ -151,6 +164,61 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } } + private fun startUploadService() { + bindService(Intent(this, UploadService::class.java), object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + Timber.tag("App").i("Upload service connected") + uploadServiceBinder = service as UploadService.Binder + uploadServiceBinder?.init( + applicationComponent.cloudContentRepository(), + applicationComponent.contentResolverUtil(), + applicationComponent.uploadCheckpointDao(), + Companion.applicationContext + ) + } + + override fun onServiceDisconnected(name: ComponentName) { + Timber.tag("App").i("Upload service disconnected") + uploadServiceBinder = null + } + }, BIND_AUTO_CREATE) + } + + fun startFileUpload(cloud: Cloud, targetFolderPath: String, files: List, + completedFiles: Set, vaultId: Long) { + uploadServiceBinder?.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId) + } + + fun startFolderUpload(cloud: Cloud, targetFolderPath: String, folderStructure: UploadFolderStructure, + completedFiles: Set, vaultId: Long) { + uploadServiceBinder?.startFolderUpload(cloud, targetFolderPath, folderStructure, completedFiles, vaultId) + } + + fun createUploadCheckpoint(vaultId: Long, type: String, targetFolderPath: String, + sourceFolderUri: String?, sourceFolderName: String?, pendingFileUris: List, totalFileCount: Int) { + val dao = uploadServiceBinder?.uploadCheckpointDao ?: return + val entity = UploadCheckpointEntity().apply { + this.vaultId = vaultId + this.type = type + this.targetFolderPath = targetFolderPath + this.sourceFolderUri = sourceFolderUri + this.sourceFolderName = sourceFolderName + this.pendingFileUris = toJsonArray(pendingFileUris) + this.completedFiles = "[]" + this.totalFileCount = totalFileCount + this.timestamp = System.currentTimeMillis() + } + dao.insertOrReplace(entity) + } + + fun getUploadCheckpointDao(): UploadCheckpointDao? { + return uploadServiceBinder?.uploadCheckpointDao + } + + private fun toJsonArray(items: List): String { + return items.joinToString(",", "[", "]") { "\"${it.replace("\"", "\\\"")}\"" } + } + private fun checkToStartAutoImageUpload(sharedPreferencesHandler: SharedPreferencesHandler): Boolean { return sharedPreferencesHandler.usePhotoUpload() // && (!sharedPreferencesHandler.autoPhotoUploadOnlyUsingWifi() || applicationComponent.networkConnectionCheck().checkWifiOnAndConnected()) 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 index ca0232028..4a94b5853 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java @@ -4,6 +4,7 @@ import org.cryptomator.data.cloud.crypto.CryptorsModule; import org.cryptomator.data.repository.RepositoryModule; +import org.cryptomator.data.db.UploadCheckpointDao; import org.cryptomator.data.util.NetworkConnectionCheck; import org.cryptomator.domain.executor.PostExecutionThread; import org.cryptomator.domain.executor.ThreadExecutor; @@ -47,4 +48,6 @@ public interface ApplicationComponent { NetworkConnectionCheck networkConnectionCheck(); + UploadCheckpointDao uploadCheckpointDao(); + } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index ceed2923b..811a831c5 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -40,7 +40,6 @@ import org.cryptomator.domain.usecases.cloud.RenameFileUseCase import org.cryptomator.domain.usecases.cloud.RenameFolderUseCase import org.cryptomator.domain.usecases.cloud.UploadFile import org.cryptomator.domain.usecases.cloud.UploadFilesUseCase -import org.cryptomator.domain.usecases.cloud.UploadFolderFilesUseCase import org.cryptomator.domain.usecases.cloud.UploadFolderStructure import org.cryptomator.domain.usecases.cloud.UploadState import org.cryptomator.domain.usecases.vault.AssertUnlockedUseCase @@ -101,7 +100,6 @@ class BrowseFilesPresenter @Inject constructor( // private val downloadFilesUseCase: DownloadFilesUseCase, // private val deleteNodesUseCase: DeleteNodesUseCase, // private val uploadFilesUseCase: UploadFilesUseCase, // - private val uploadFolderFilesUseCase: UploadFolderFilesUseCase, // private val renameFileUseCase: RenameFileUseCase, // private val renameFolderUseCase: RenameFolderUseCase, // private val copyDataUseCase: CopyDataUseCase, // @@ -418,33 +416,22 @@ class BrowseFilesPresenter @Inject constructor( // } 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) - } - } + uploadLocation?.let { location -> + val cloud = location.toCloudNode().cloud ?: return@let + val vault = location.vault() + val vaultId = vault?.toVault()?.id ?: return@let + val targetFolderPath = location.toCloudNode().path - override fun onSuccess(files: List) { - files.forEach { file -> view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(file)) } - onFileUploadCompleted() - } + val cryptomatorApp = activity().application as CryptomatorApp - 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) - } - } - }) + // Create checkpoint + val fileUris = files.map { it.dataSource.toString() } + cryptomatorApp.createUploadCheckpoint( + vaultId, "files", targetFolderPath, null, null, fileUris, files.size + ) + + cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId) + onFileUploadCompleted() } } @@ -1046,7 +1033,7 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadCanceled() { uploadFilesUseCase.cancel() - uploadFolderFilesUseCase.cancel() + context().startService(org.cryptomator.presentation.service.UploadService.cancelUploadIntent(context())) } @Callback @@ -1086,10 +1073,15 @@ class BrowseFilesPresenter @Inject constructor( // } } + @JvmField + @InstanceState + var lastSelectedFolderUri: Uri? = null + @Callback fun selectedFolder(result: ActivityResult) { val treeUri = result.intent().data ?: return context().contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + lastSelectedFolderUri = treeUri val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: return view?.showProgress(ProgressModel.GENERIC) Thread { @@ -1100,55 +1092,27 @@ class BrowseFilesPresenter @Inject constructor( // view?.showMessage(R.string.screen_file_browser_nothing_to_upload) return@runOnUiThread } - uploadFolder(folderStructure) + uploadFolder(folderStructure, treeUri) } }.start() } - private fun buildUploadFolderStructure(documentFile: DocumentFile): UploadFolderStructure { - val structure = UploadFolderStructure(documentFile.name ?: "folder") - documentFile.listFiles().forEach { child -> - when { - child.isDirectory -> { - structure.addSubfolder(buildUploadFolderStructure(child)) - } - child.isFile -> { - child.name?.let { name -> - structure.addFile( - UploadFile.anUploadFile() // - .withFileName(name) // - .withDataSource(UriBasedDataSource.from(child.uri)) // - .thatIsReplacing(false) // - .build() - ) - } - } - } - } - return structure - } - - private fun uploadFolder(folderStructure: UploadFolderStructure) { - uploadLocation?.let { - view?.showUploadDialog(folderStructure.totalFileCount()) - uploadFolderFilesUseCase // - .withParent(it.toCloudNode()) // - .andFolderStructure(folderStructure) // - .run(object : DefaultProgressAwareResultHandler, UploadState>() { - override fun onProgress(progress: Progress) { - view?.showProgress(progressModelMapper.toModel(progress)) - } + private fun uploadFolder(folderStructure: UploadFolderStructure, sourceFolderUri: Uri? = null) { + uploadLocation?.let { location -> + val cloud = location.toCloudNode().cloud ?: return@let + val vault = location.vault() + val vaultId = vault?.toVault()?.id ?: return@let + val targetFolderPath = location.toCloudNode().path - override fun onSuccess(files: List) { - onFileUploadCompleted() - getCloudList(it) - } + val cryptomatorApp = activity().application as CryptomatorApp - override fun onError(e: Throwable) { - onFileUploadError() - super.onError(e) - } - }) + // Create checkpoint + cryptomatorApp.createUploadCheckpoint( + vaultId, "folder", targetFolderPath, sourceFolderUri?.toString(), folderStructure.folderName, emptyList(), folderStructure.totalFileCount() + ) + + cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId) + onFileUploadCompleted() } } @@ -1374,7 +1338,6 @@ class BrowseFilesPresenter @Inject constructor( // downloadFilesUseCase, // deleteNodesUseCase, // uploadFilesUseCase, // - uploadFolderFilesUseCase, // renameFileUseCase, // renameFolderUseCase, // copyDataUseCase, // diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/UploadFolderStructureBuilder.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/UploadFolderStructureBuilder.kt new file mode 100644 index 000000000..2b8cf15d5 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/UploadFolderStructureBuilder.kt @@ -0,0 +1,28 @@ +package org.cryptomator.presentation.presenter + +import androidx.documentfile.provider.DocumentFile +import org.cryptomator.domain.usecases.cloud.UploadFile +import org.cryptomator.domain.usecases.cloud.UploadFolderStructure + +fun buildUploadFolderStructure(documentFile: DocumentFile): UploadFolderStructure { + val structure = UploadFolderStructure(documentFile.name ?: "folder") + documentFile.listFiles().forEach { child -> + when { + child.isDirectory -> { + structure.addSubfolder(buildUploadFolderStructure(child)) + } + child.isFile -> { + child.name?.let { name -> + structure.addFile( + UploadFile.anUploadFile() // + .withFileName(name) // + .withDataSource(UriBasedDataSource.from(child.uri)) // + .thatIsReplacing(false) // + .build() + ) + } + } + } + } + return structure +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt index 79c6defaf..a460aa020 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/UriBasedDataSource.kt @@ -33,6 +33,8 @@ class UriBasedDataSource private constructor(private val uri: Uri) : DataSource return Optional.ofNullable(ContentResolverUtil(context).fileModifiedDate(uri)) } + override fun toString(): String = uri.toString() + companion object { @JvmStatic diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 4e6e2e1d4..6b98a208e 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -66,6 +66,12 @@ import org.cryptomator.presentation.workflow.PermissionsResult import org.cryptomator.presentation.workflow.Workflow import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.crypto.CryptoMode +import androidx.documentfile.provider.DocumentFile +import org.cryptomator.data.db.entities.UploadCheckpointEntity +import org.cryptomator.domain.usecases.cloud.UploadFile +import org.cryptomator.domain.usecases.cloud.UploadFolderStructure +import org.cryptomator.presentation.di.component.ApplicationComponent +import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog import javax.inject.Inject import timber.log.Timber @@ -526,16 +532,137 @@ class VaultListPresenter @Inject constructor( // .run(object : DefaultResultHandler() { override fun onSuccess(vault: Vault) { view?.addOrUpdateVault(VaultModel(vault)) - navigateToVaultContent(vault, folder) view?.showProgress(ProgressModel.COMPLETED) if (checkToStartAutoImageUpload(vault)) { val cryptomatorApp = activity().application as CryptomatorApp cryptomatorApp.startAutoUpload(cryptoCloud) } + checkForInterruptedUpload(vault, folder) } }) } + private fun checkForInterruptedUpload(vault: Vault, folder: CloudFolder) { + val cryptomatorApp = activity().application as CryptomatorApp + val dao = cryptomatorApp.getUploadCheckpointDao() + if (dao == null) { + navigateToVaultContent(vault, folder) + return + } + val checkpoint = dao.findByVaultId(vault.id) + if (checkpoint != null) { + val completedCount = parseJsonArraySize(checkpoint.completedFiles) + ResumeUploadDialog + .withContext(activity()) + .show(vault.id, completedCount, checkpoint.totalFileCount) + pendingNavigationAfterResume = Pair(vault, folder) + } else { + navigateToVaultContent(vault, folder) + } + } + + private var pendingNavigationAfterResume: Pair? = null + + fun onResumeUploadConfirmed(vaultId: Long) { + val cryptomatorApp = activity().application as CryptomatorApp + val dao = cryptomatorApp.getUploadCheckpointDao() ?: return + val checkpoint = dao.findByVaultId(vaultId) ?: return + + val vault = try { + applicationComponent().vaultRepository().load(vaultId) + } catch (e: Exception) { + Timber.tag("VaultListPresenter").e(e, "Failed to load vault for resume") + dao.deleteByVaultId(vaultId) + navigatePendingAfterResume() + return + } + + val cloud = applicationComponent().cloudRepository().decryptedViewOf(vault) + val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() + + if (checkpoint.type == "folder") { + handleFolderResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) + } else { + handleFileResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) + } + + navigatePendingAfterResume() + } + + private fun handleFolderResume(cryptomatorApp: CryptomatorApp, cloud: Cloud, + checkpoint: UploadCheckpointEntity, + completedFiles: Set, vaultId: Long) { + val sourceFolderUri = checkpoint.sourceFolderUri + if (sourceFolderUri == null) { + cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + return + } + val uri = Uri.parse(sourceFolderUri) + try { + val documentFile = DocumentFile.fromTreeUri(context(), uri) + if (documentFile != null && documentFile.canRead()) { + val folderStructure = buildUploadFolderStructure(documentFile) + cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId) + return + } + } catch (e: SecurityException) { + // fall through to show permission lost message + } + view?.showMessage(R.string.dialog_resume_upload_permission_lost) + cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + } + + private fun handleFileResume(cryptomatorApp: CryptomatorApp, cloud: Cloud, + checkpoint: UploadCheckpointEntity, + completedFiles: Set, vaultId: Long) { + val fileUris = parseJsonArray(checkpoint.pendingFileUris) + val contentResolverUtil = applicationComponent().contentResolverUtil() + val uploadFiles = fileUris.mapNotNull { uriString -> + val uri = Uri.parse(uriString) + val fileName = contentResolverUtil.fileName(uri) + if (fileName != null) { + UploadFile.anUploadFile() + .withFileName(fileName) + .withDataSource(UriBasedDataSource.from(uri)) + .thatIsReplacing(false) + .build() + } else null + } + if (uploadFiles.isNotEmpty()) { + cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId) + } else { + cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + } + } + + fun onResumeUploadDeclined(vaultId: Long) { + val cryptomatorApp = activity().application as CryptomatorApp + cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + navigatePendingAfterResume() + } + + private fun navigatePendingAfterResume() { + pendingNavigationAfterResume?.let { (vault, folder) -> + navigateToVaultContent(vault, folder) + pendingNavigationAfterResume = null + } + } + + private fun applicationComponent(): ApplicationComponent { + return (activity().application as CryptomatorApp).component + } + + private fun parseJsonArray(json: String?): List { + if (json.isNullOrEmpty() || json == "[]") return emptyList() + val content = json.removePrefix("[").removeSuffix("]") + if (content.isEmpty()) return emptyList() + return content.split(",").map { it.trim().removeSurrounding("\"") } + } + + private fun parseJsonArraySize(json: String?): Int { + return parseJsonArray(json).size + } + private fun checkToStartAutoImageUpload(vault: Vault): Boolean { return if (sharedPreferencesHandler.usePhotoUpload() && sharedPreferencesHandler.photoUploadVault() == vault.id) { !sharedPreferencesHandler.autoPhotoUploadOnlyUsingWifi() || networkConnectionCheck.checkWifiOnAndConnected() diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt new file mode 100644 index 000000000..82755d860 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt @@ -0,0 +1,139 @@ +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.ACTION_MAIN +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import androidx.core.app.NotificationCompat +import org.cryptomator.presentation.R +import org.cryptomator.presentation.ui.activity.VaultListActivity +import org.cryptomator.presentation.util.ResourceHelper.Companion.getColor +import java.lang.String.format +import timber.log.Timber + +class UploadNotification(private val context: Context, private val totalFiles: Int) { + + private val builder: NotificationCompat.Builder + private var notificationManager: NotificationManager? = null + private var completedFiles = 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_upload_title)) + .setSmallIcon(R.drawable.ic_notification) + .setColor(getColor(R.color.colorPrimary)) + .addAction(cancelAction()) + .setGroup(NOTIFICATION_GROUP_KEY) + .setOngoing(true) + } + + private fun cancelAction(): NotificationCompat.Action { + val intentAction = UploadService.cancelUploadIntent(context) + val cancelIntent = PendingIntent.getService(context, 0, intentAction, FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return NotificationCompat.Action.Builder( + R.drawable.ic_lock, + context.getString(R.string.notification_upload_cancel), + cancelIntent + ).build() + } + + 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 or PendingIntent.FLAG_IMMUTABLE) + } + + fun update(progress: Int) { + builder + .setContentIntent(startTheActivity()) + .setContentText( + format( + context.getString(R.string.notification_upload_message), + completedFiles + 1, + totalFiles + ) + ) + .setProgress(100, progress, false) + show() + } + + fun updateFinishedFile() { + completedFiles += 1 + update(100) + } + + fun showUploadFinished(count: Int) { + builder + .setContentIntent(startTheActivity()) + .setContentTitle(context.getString(R.string.notification_upload_finished_title)) + .setContentText(format(context.getString(R.string.notification_upload_finished_message), count)) + .setProgress(0, 0, false) + .setAutoCancel(true) + .setOngoing(false) + .clearActions() + show() + } + + fun showUploadInterrupted() { + Timber.tag("UploadNotification").i("Show upload interrupted notification") + showErrorWithMessage(context.getString(R.string.notification_upload_interrupted_message)) + } + + fun showVaultLockedDuringUpload() { + Timber.tag("UploadNotification").i("Show vault locked during upload notification") + showErrorWithMessage(context.getString(R.string.notification_upload_failed_vault_locked)) + } + + fun showGeneralErrorDuringUpload() { + Timber.tag("UploadNotification").i("Show general error during upload notification") + showErrorWithMessage(context.getString(R.string.notification_upload_failed_general_error)) + } + + private fun showErrorWithMessage(message: String) { + builder + .setContentIntent(startTheActivity()) + .setContentTitle(context.getString(R.string.notification_upload_failed_title)) + .setContentText(message) + .setProgress(0, 0, false) + .setAutoCancel(true) + .setOngoing(false) + .clearActions() + show() + } + + fun show() { + notificationManager?.notify(NOTIFICATION_ID, builder.build()) + } + + fun hide() { + notificationManager?.cancel(NOTIFICATION_ID) + } + + fun getNotification() = builder.build() + + companion object { + + const val NOTIFICATION_ID = 94875 + private const val NOTIFICATION_CHANNEL_ID = "65479" + private const val NOTIFICATION_CHANNEL_NAME = "Cryptomator Upload" + private const val NOTIFICATION_GROUP_KEY = "CryptomatorUploadGroup" + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java new file mode 100644 index 000000000..211bc6ad4 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -0,0 +1,240 @@ +package org.cryptomator.presentation.service; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +import androidx.annotation.Nullable; + +import org.cryptomator.data.db.UploadCheckpointDao; +import org.cryptomator.domain.Cloud; +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.exception.MissingCryptorException; +import org.cryptomator.domain.repository.CloudContentRepository; +import org.cryptomator.domain.usecases.ProgressAware; +import org.cryptomator.domain.usecases.cloud.FileUploadedCallback; +import org.cryptomator.domain.usecases.cloud.UploadFile; +import org.cryptomator.domain.usecases.cloud.UploadFiles; +import org.cryptomator.domain.usecases.cloud.UploadFolderFiles; +import org.cryptomator.domain.usecases.cloud.UploadFolderStructure; +import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.presentation.util.ContentResolverUtil; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import timber.log.Timber; + +public class UploadService extends Service { + + private static final String ACTION_CANCEL_UPLOAD = "CANCEL_UPLOAD"; + + private UploadNotification notification; + private CloudContentRepository cloudContentRepository; + private ContentResolverUtil contentResolverUtil; + private UploadCheckpointDao uploadCheckpointDao; + private Context appContext; + private Thread worker; + private volatile boolean cancelled; + private volatile Runnable cancelCallback; + private long startTimeNotificationDelay; + private long elapsedTimeNotificationDelay = 0L; + + public static Intent cancelUploadIntent(Context context) { + Intent cancelIntent = new Intent(context, UploadService.class); + cancelIntent.setAction(ACTION_CANCEL_UPLOAD); + return cancelIntent; + } + + public void startFileUpload(Cloud cloud, String targetFolderPath, List files, + Set completedFiles, long vaultId) { + startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), (targetFolder, callback, progressAware) -> { + UploadFiles uploadFiles = new UploadFiles(appContext, cloudContentRepository, targetFolder, files); + uploadFiles.setCompletedFiles(completedFiles); + uploadFiles.setFileUploadedCallback(callback); + cancelCallback = uploadFiles::onCancel; + uploadFiles.execute(progressAware); + }); + } + + public void startFolderUpload(Cloud cloud, String targetFolderPath, UploadFolderStructure folderStructure, + Set completedFiles, long vaultId) { + startUpload(cloud, targetFolderPath, completedFiles, vaultId, folderStructure.totalFileCount(), (targetFolder, callback, progressAware) -> { + UploadFolderFiles uploadFolderFiles = new UploadFolderFiles(appContext, cloudContentRepository, targetFolder, folderStructure); + uploadFolderFiles.setCompletedFiles(completedFiles); + uploadFolderFiles.setFileUploadedCallback(callback); + cancelCallback = uploadFolderFiles::onCancel; + uploadFolderFiles.execute(progressAware); + }); + } + + private void startUpload(Cloud cloud, String targetFolderPath, Set completedFiles, + long vaultId, int totalFiles, UploadTask uploadTask) { + notification = new UploadNotification(appContext, totalFiles); + + startForeground(UploadNotification.NOTIFICATION_ID, notification.getNotification()); + notification.show(); + + cancelled = false; + + worker = new Thread(() -> { + try { + CloudFolder targetFolder = targetFolderPath.isEmpty() + ? cloudContentRepository.root(cloud) + : cloudContentRepository.resolve(cloud, targetFolderPath); + + FileUploadedCallback callback = createCheckpointCallback(vaultId); + ProgressAware progressAware = progress -> updateNotification(progress.asPercentage()); + + uploadTask.execute(targetFolder, callback, progressAware); + + uploadCheckpointDao.deleteByVaultId(vaultId); + notification.showUploadFinished(totalFiles - completedFiles.size()); + Timber.tag("UploadService").i("Upload completed"); + } catch (CancellationException e) { + Timber.tag("UploadService").i("Upload canceled by user"); + } catch (MissingCryptorException e) { + notification.showVaultLockedDuringUpload(); + Timber.tag("UploadService").e(e, "Vault locked during upload"); + } catch (BackendException | FatalBackendException e) { + notification.showGeneralErrorDuringUpload(); + Timber.tag("UploadService").e(e, "Upload failed"); + } finally { + stopForeground(STOP_FOREGROUND_DETACH); + stopSelf(); + } + }); + + worker.start(); + } + + @FunctionalInterface + private interface UploadTask { + void execute(CloudFolder targetFolder, FileUploadedCallback callback, + ProgressAware progressAware) throws BackendException; + } + + private FileUploadedCallback createCheckpointCallback(long vaultId) { + Set uploadedSoFar = new HashSet<>(); + return relativePath -> { + uploadedSoFar.add(relativePath); + try { + String json = toJsonArray(uploadedSoFar); + uploadCheckpointDao.updateCompletedFiles(vaultId, json); + } catch (Exception e) { + Timber.tag("UploadService").w(e, "Failed to update checkpoint"); + } + new Handler(Looper.getMainLooper()).post(() -> { + if (notification != null) { + notification.updateFinishedFile(); + } + }); + }; + } + + private String toJsonArray(Set items) { + return items.stream() + .map(item -> "\"" + item.replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(",", "[", "]")); + } + + private void updateNotification(int asPercentage) { + if (elapsedTimeNotificationDelay > 200 && !cancelled) { + new Handler(Looper.getMainLooper()).post(() -> { + notification.update(asPercentage); + startTimeNotificationDelay = System.currentTimeMillis(); + elapsedTimeNotificationDelay = 0; + }); + } else { + elapsedTimeNotificationDelay = System.currentTimeMillis() - startTimeNotificationDelay; + } + } + + @Override + public void onCreate() { + super.onCreate(); + Timber.tag("UploadService").d("created"); + notification = new UploadNotification(this, 0); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Timber.tag("UploadService").i("started"); + if (isCancelUpload(intent)) { + Timber.tag("UploadService").i("Received cancel upload"); + cancelled = true; + Runnable cancel = cancelCallback; + if (cancel != null) { + cancel.run(); + } + hideNotification(); + } + return START_NOT_STICKY; + } + + private boolean isCancelUpload(Intent intent) { + return intent != null && ACTION_CANCEL_UPLOAD.equals(intent.getAction()); + } + + @Override + public void onDestroy() { + Timber.tag("UploadService").i("onDestroyed"); + if (worker != null) { + worker.interrupt(); + } + hideNotification(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + Timber.tag("UploadService").i("App killed by user"); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return new Binder(); + } + + private void hideNotification() { + if (notification != null) { + notification.hide(); + } + } + + public class Binder extends android.os.Binder { + + Binder() { + } + + public void init(CloudContentRepository cloudContentRepository, ContentResolverUtil contentResolverUtil, + UploadCheckpointDao uploadCheckpointDao, Context context) { + UploadService.this.cloudContentRepository = cloudContentRepository; + UploadService.this.contentResolverUtil = contentResolverUtil; + UploadService.this.uploadCheckpointDao = uploadCheckpointDao; + UploadService.this.appContext = context; + } + + public void startFileUpload(Cloud cloud, String targetFolderPath, List files, + Set completedFiles, long vaultId) { + UploadService.this.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId); + } + + public void startFolderUpload(Cloud cloud, String targetFolderPath, UploadFolderStructure folderStructure, + Set completedFiles, long vaultId) { + UploadService.this.startFolderUpload(cloud, targetFolderPath, folderStructure, completedFiles, vaultId); + } + + public UploadCheckpointDao getUploadCheckpointDao() { + return UploadService.this.uploadCheckpointDao; + } + } +} 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 index 409b867f2..03d815542 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -27,6 +27,7 @@ import org.cryptomator.presentation.ui.callback.VaultListCallback import org.cryptomator.presentation.ui.dialog.AskForLockScreenDialog import org.cryptomator.presentation.ui.dialog.BetaConfirmationDialog import org.cryptomator.presentation.ui.dialog.CBCPasswordVaultsMigrationDialog +import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog import org.cryptomator.presentation.ui.dialog.VaultDeleteConfirmationDialog @@ -45,6 +46,7 @@ class VaultListActivity : BaseActivity(Activi UpdateAppDialog.Callback, // BetaConfirmationDialog.Callback, // CBCPasswordVaultsMigrationDialog.Callback, // + ResumeUploadDialog.Callback, // BiometricAuthenticationMigration.Callback { @Inject @@ -252,4 +254,12 @@ class VaultListActivity : BaseActivity(Activi vaultListPresenter.biometricKeyInvalidated(vaults) } + override fun onResumeUploadConfirmed(vaultId: Long) { + vaultListPresenter.onResumeUploadConfirmed(vaultId) + } + + override fun onResumeUploadDeclined(vaultId: Long) { + vaultListPresenter.onResumeUploadDeclined(vaultId) + } + } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt new file mode 100644 index 000000000..f9aed8bbd --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt @@ -0,0 +1,50 @@ +package org.cryptomator.presentation.ui.dialog + +import android.content.Context +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog +import org.cryptomator.presentation.R + +class ResumeUploadDialog private constructor(private val context: Context) { + + private val callback: Callback + + interface Callback { + + fun onResumeUploadConfirmed(vaultId: Long) + fun onResumeUploadDeclined(vaultId: Long) + } + + fun show(vaultId: Long, completedCount: Int, totalCount: Int) { + AlertDialog.Builder(context) + .setCancelable(false) + .setTitle(R.string.dialog_resume_upload_title) + .setMessage( + String.format( + context.getString(R.string.dialog_resume_upload_message), + completedCount, + totalCount + ) + ) + .setPositiveButton(R.string.dialog_resume_upload_resume) { _: DialogInterface?, _: Int -> + callback.onResumeUploadConfirmed(vaultId) + } + .setNegativeButton(R.string.dialog_resume_upload_discard) { _: DialogInterface?, _: Int -> + callback.onResumeUploadDeclined(vaultId) + } + .create() + .show() + } + + companion object { + + @JvmStatic + fun withContext(context: Context): ResumeUploadDialog { + return ResumeUploadDialog(context) + } + } + + init { + callback = context as Callback + } +} diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 327cc1a9c..e1fb7241e 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -627,6 +627,25 @@ @string/permission_snackbar_auth_auto_upload Tap to refresh authentication. + + Uploading files + Uploading %1$d/%2$d + Cancel upload + Upload finished + %1$d files uploaded to vault + Upload interrupted + Upload was interrupted. Unlock vault to resume. + Upload failed + An error occurred during upload. + Vault locked during upload. + + + Resume upload? + An interrupted upload was found (%1$d of %2$d files completed). Do you want to resume? + Resume + Discard + Cannot resume: access to the source folder has been lost. + @string/dialog_button_cancel Open writable file Vault stays unlocked until finished editing From 9abeabcfee4aaf14110a4af749dfa19d8c6d86c2 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 17:25:51 +0100 Subject: [PATCH 03/26] Fix race conditions, error handling, and plurals in upload service --- .../data/db/UpgradeDatabaseTest.kt | 4 +-- .../usecases/cloud/UploadFolderFiles.java | 8 +++++- presentation/src/main/AndroidManifest.xml | 1 + .../presentation/CryptomatorApp.kt | 26 +++++++++++++------ .../presenter/BrowseFilesPresenter.kt | 21 ++++++++++----- .../presenter/VaultListPresenter.kt | 25 +++++++++++------- .../service/UploadNotification.kt | 5 ++-- .../presentation/service/UploadService.java | 15 +++++++++-- presentation/src/main/res/values/strings.xml | 5 +++- 9 files changed, 77 insertions(+), 33 deletions(-) diff --git a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt index 171788756..07dfe3d22 100644 --- a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt +++ b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt @@ -1002,8 +1002,8 @@ class UpgradeDatabaseTest { .integer("TIMESTAMP", 2000) // .executeOn(db) Assert.fail("Expected constraint violation for duplicate VAULT_ID") - } catch (e: Exception) { - // Expected: unique constraint violation + } catch (e: android.database.sqlite.SQLiteConstraintException) { + // Expected: unique constraint violation on VAULT_ID } } diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java index 51a881915..67ce69093 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java @@ -149,14 +149,20 @@ private CloudFile writeCloudFile(CloudFolder folder, String fileName, CancelAwar private File copyDataToFile(DataSource dataSource) { File dir = context.getCacheDir(); + File target; + try { + target = createTempFile("upload", "tmp", dir); + } catch (IOException e) { + throw new FatalBackendException(e); + } try { - File target = createTempFile("upload", "tmp", dir); InputStream in = CancelAwareDataSource.wrap(dataSource, cancelledFlag).open(context); OutputStream out = new FileOutputStream(target); StreamHelper.copy(in, out); dataSource.modifiedDate(context).ifPresent(value -> target.setLastModified(value.getTime())); return target; } catch (IOException e) { + target.delete(); throw new FatalBackendException(e); } } diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 460636783..6086e408a 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -215,6 +215,7 @@ { @@ -186,12 +186,22 @@ class CryptomatorApp : MultiDexApplication(), HasComponent fun startFileUpload(cloud: Cloud, targetFolderPath: String, files: List, completedFiles: Set, vaultId: Long) { - uploadServiceBinder?.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId) + val binder = uploadServiceBinder + if (binder != null) { + binder.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId) + } else { + Timber.tag("App").e("Upload service not connected, file upload request dropped") + } } fun startFolderUpload(cloud: Cloud, targetFolderPath: String, folderStructure: UploadFolderStructure, completedFiles: Set, vaultId: Long) { - uploadServiceBinder?.startFolderUpload(cloud, targetFolderPath, folderStructure, completedFiles, vaultId) + val binder = uploadServiceBinder + if (binder != null) { + binder.startFolderUpload(cloud, targetFolderPath, folderStructure, completedFiles, vaultId) + } else { + Timber.tag("App").e("Upload service not connected, folder upload request dropped") + } } fun createUploadCheckpoint(vaultId: Long, type: String, targetFolderPath: String, diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 811a831c5..dc88e240e 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -1085,14 +1085,21 @@ class BrowseFilesPresenter @Inject constructor( // val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: return view?.showProgress(ProgressModel.GENERIC) Thread { - val folderStructure = buildUploadFolderStructure(documentFile) - activity().runOnUiThread { - view?.showProgress(ProgressModel.COMPLETED) - if (folderStructure.totalFileCount() == 0) { - view?.showMessage(R.string.screen_file_browser_nothing_to_upload) - return@runOnUiThread + try { + val folderStructure = buildUploadFolderStructure(documentFile) + activity().runOnUiThread { + view?.showProgress(ProgressModel.COMPLETED) + if (folderStructure.totalFileCount() == 0) { + view?.showMessage(R.string.screen_file_browser_nothing_to_upload) + return@runOnUiThread + } + uploadFolder(folderStructure, treeUri) + } + } catch (e: Exception) { + activity().runOnUiThread { + view?.showProgress(ProgressModel.COMPLETED) + showError(e) } - uploadFolder(folderStructure, treeUri) } }.start() } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 6b98a208e..783b35d86 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -9,8 +9,10 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.widget.Toast +import androidx.documentfile.provider.DocumentFile import com.google.common.base.Optional import org.cryptomator.data.cloud.crypto.CryptoCloud +import org.cryptomator.data.db.entities.UploadCheckpointEntity import org.cryptomator.data.util.NetworkConnectionCheck import org.cryptomator.domain.Cloud import org.cryptomator.domain.CloudFolder @@ -26,6 +28,8 @@ import org.cryptomator.domain.usecases.LicenseCheck import org.cryptomator.domain.usecases.NoOpResultHandler import org.cryptomator.domain.usecases.UpdateCheck import org.cryptomator.domain.usecases.cloud.GetRootFolderUseCase +import org.cryptomator.domain.usecases.cloud.UploadFile +import org.cryptomator.domain.usecases.cloud.UploadFolderStructure import org.cryptomator.domain.usecases.vault.DeleteVaultUseCase import org.cryptomator.domain.usecases.vault.GetVaultListUseCase import org.cryptomator.domain.usecases.vault.ListCBCEncryptedPasswordVaultsUseCase @@ -40,6 +44,7 @@ import org.cryptomator.generator.Callback import org.cryptomator.presentation.BuildConfig import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R +import org.cryptomator.presentation.di.component.ApplicationComponent import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.UnlockVaultIntent @@ -54,6 +59,7 @@ import org.cryptomator.presentation.ui.dialog.AppIsObscuredInfoDialog import org.cryptomator.presentation.ui.dialog.AskForLockScreenDialog import org.cryptomator.presentation.ui.dialog.CBCPasswordVaultsMigrationDialog import org.cryptomator.presentation.ui.dialog.EnterPasswordDialog +import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog import org.cryptomator.presentation.ui.dialog.VaultsRemovedDuringMigrationDialog @@ -66,12 +72,8 @@ import org.cryptomator.presentation.workflow.PermissionsResult import org.cryptomator.presentation.workflow.Workflow import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.crypto.CryptoMode -import androidx.documentfile.provider.DocumentFile -import org.cryptomator.data.db.entities.UploadCheckpointEntity -import org.cryptomator.domain.usecases.cloud.UploadFile -import org.cryptomator.domain.usecases.cloud.UploadFolderStructure -import org.cryptomator.presentation.di.component.ApplicationComponent -import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog +import org.json.JSONArray +import org.json.JSONException import javax.inject.Inject import timber.log.Timber @@ -606,7 +608,7 @@ class VaultListPresenter @Inject constructor( // return } } catch (e: SecurityException) { - // fall through to show permission lost message + Timber.tag("VaultListPresenter").w(e, "Permission lost for folder URI during resume") } view?.showMessage(R.string.dialog_resume_upload_permission_lost) cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) @@ -654,9 +656,12 @@ class VaultListPresenter @Inject constructor( // private fun parseJsonArray(json: String?): List { if (json.isNullOrEmpty() || json == "[]") return emptyList() - val content = json.removePrefix("[").removeSuffix("]") - if (content.isEmpty()) return emptyList() - return content.split(",").map { it.trim().removeSurrounding("\"") } + return try { + val jsonArray = JSONArray(json) + (0 until jsonArray.length()).map { jsonArray.getString(it) } + } catch (e: JSONException) { + emptyList() + } } private fun parseJsonArraySize(json: String?): Int { diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt index 82755d860..a03729f22 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt @@ -62,12 +62,13 @@ class UploadNotification(private val context: Context, private val totalFiles: I } fun update(progress: Int) { + val currentFile = (completedFiles + 1).coerceAtMost(totalFiles) builder .setContentIntent(startTheActivity()) .setContentText( format( context.getString(R.string.notification_upload_message), - completedFiles + 1, + currentFile, totalFiles ) ) @@ -84,7 +85,7 @@ class UploadNotification(private val context: Context, private val totalFiles: I builder .setContentIntent(startTheActivity()) .setContentTitle(context.getString(R.string.notification_upload_finished_title)) - .setContentText(format(context.getString(R.string.notification_upload_finished_message), count)) + .setContentText(context.resources.getQuantityString(R.plurals.notification_upload_finished_message, count, count)) .setProgress(0, 0, false) .setAutoCancel(true) .setOngoing(false) diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 211bc6ad4..49cf7d0a6 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -45,8 +45,8 @@ public class UploadService extends Service { private Thread worker; private volatile boolean cancelled; private volatile Runnable cancelCallback; - private long startTimeNotificationDelay; - private long elapsedTimeNotificationDelay = 0L; + private volatile long startTimeNotificationDelay; + private volatile long elapsedTimeNotificationDelay = 0L; public static Intent cancelUploadIntent(Context context) { Intent cancelIntent = new Intent(context, UploadService.class); @@ -61,6 +61,9 @@ public void startFileUpload(Cloud cloud, String targetFolderPath, List completedFiles, long vaultId, int totalFiles, UploadTask uploadTask) { + if (worker != null && worker.isAlive()) { + Timber.tag("UploadService").w("Upload already in progress, ignoring request"); + return; + } + notification = new UploadNotification(appContext, totalFiles); startForeground(UploadNotification.NOTIFICATION_ID, notification.getNotification()); diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index e1fb7241e..8fa532644 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -632,7 +632,10 @@ Uploading %1$d/%2$d Cancel upload Upload finished - %1$d files uploaded to vault + + %1$d file uploaded to vault + %1$d files uploaded to vault + Upload interrupted Upload was interrupted. Unlock vault to resume. Upload failed From 9f36ab909c5e375c2c4f21aad151108c557a83ba Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 17:50:35 +0100 Subject: [PATCH 04/26] Clean up upload service: simplify DAO, remove dead code, fix dialog string --- .../cryptomator/data/db/UploadCheckpointDao.java | 15 +++------------ .../cryptomator/presentation/CryptomatorApp.kt | 1 - .../presentation/service/UploadService.java | 5 +---- .../presentation/ui/dialog/ResumeUploadDialog.kt | 6 +----- presentation/src/main/res/values/strings.xml | 2 +- 5 files changed, 6 insertions(+), 23 deletions(-) diff --git a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java index 6a77d1fb4..7b90dbe7a 100644 --- a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java +++ b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java @@ -27,25 +27,16 @@ private SQLiteDatabase getDb() { public long insertOrReplace(UploadCheckpointEntity entity) { ContentValues values = toContentValues(entity); - UploadCheckpointEntity existing = findByVaultId(entity.getVaultId()); - if (existing != null) { - getDb().update(TABLE_NAME, values, "VAULT_ID = ?", new String[]{existing.getVaultId().toString()}); - return existing.getId(); - } else { - return getDb().insertOrThrow(TABLE_NAME, null, values); - } + return getDb().insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE); } public UploadCheckpointEntity findByVaultId(long vaultId) { - Cursor cursor = getDb().query(TABLE_NAME, null, "VAULT_ID = ?", - new String[]{String.valueOf(vaultId)}, null, null, null); - try { + try (Cursor cursor = getDb().query(TABLE_NAME, null, "VAULT_ID = ?", + new String[]{String.valueOf(vaultId)}, null, null, null)) { if (cursor.moveToFirst()) { return fromCursor(cursor); } return null; - } finally { - cursor.close(); } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index bc2bac6be..593508f42 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -171,7 +171,6 @@ class CryptomatorApp : MultiDexApplication(), HasComponent uploadServiceBinder = service as UploadService.Binder uploadServiceBinder?.init( applicationComponent.cloudContentRepository(), - applicationComponent.contentResolverUtil(), applicationComponent.uploadCheckpointDao(), Companion.applicationContext ) diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 49cf7d0a6..70f62dc5e 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -24,7 +24,6 @@ import org.cryptomator.domain.usecases.cloud.UploadFolderFiles; import org.cryptomator.domain.usecases.cloud.UploadFolderStructure; import org.cryptomator.domain.usecases.cloud.UploadState; -import org.cryptomator.presentation.util.ContentResolverUtil; import java.util.HashSet; import java.util.List; @@ -39,7 +38,6 @@ public class UploadService extends Service { private UploadNotification notification; private CloudContentRepository cloudContentRepository; - private ContentResolverUtil contentResolverUtil; private UploadCheckpointDao uploadCheckpointDao; private Context appContext; private Thread worker; @@ -226,10 +224,9 @@ public class Binder extends android.os.Binder { Binder() { } - public void init(CloudContentRepository cloudContentRepository, ContentResolverUtil contentResolverUtil, + public void init(CloudContentRepository cloudContentRepository, UploadCheckpointDao uploadCheckpointDao, Context context) { UploadService.this.cloudContentRepository = cloudContentRepository; - UploadService.this.contentResolverUtil = contentResolverUtil; UploadService.this.uploadCheckpointDao = uploadCheckpointDao; UploadService.this.appContext = context; } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt index f9aed8bbd..1523723bf 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt @@ -20,11 +20,7 @@ class ResumeUploadDialog private constructor(private val context: Context) { .setCancelable(false) .setTitle(R.string.dialog_resume_upload_title) .setMessage( - String.format( - context.getString(R.string.dialog_resume_upload_message), - completedCount, - totalCount - ) + context.getString(R.string.dialog_resume_upload_message, completedCount, totalCount) ) .setPositiveButton(R.string.dialog_resume_upload_resume) { _: DialogInterface?, _: Int -> callback.onResumeUploadConfirmed(vaultId) diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 8fa532644..ef0a58ebc 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -644,7 +644,7 @@ Resume upload? - An interrupted upload was found (%1$d of %2$d files completed). Do you want to resume? + An interrupted upload was found (%1$d/%2$d files uploaded). Do you want to resume? Resume Discard Cannot resume: access to the source folder has been lost. From 62a50632cf24150ecacc55db310e56828bf477b9 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 19:52:25 +0100 Subject: [PATCH 05/26] Fix multi-resume checkpoint loss, simplify dialog and presenter code --- .../presenter/BrowseFilesPresenter.kt | 4 --- .../presenter/VaultListPresenter.kt | 25 ++++++++----------- .../presentation/service/UploadService.java | 6 ++--- .../ui/dialog/ResumeUploadDialog.kt | 11 +++----- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index dc88e240e..444db6625 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -423,8 +423,6 @@ class BrowseFilesPresenter @Inject constructor( // val targetFolderPath = location.toCloudNode().path val cryptomatorApp = activity().application as CryptomatorApp - - // Create checkpoint val fileUris = files.map { it.dataSource.toString() } cryptomatorApp.createUploadCheckpoint( vaultId, "files", targetFolderPath, null, null, fileUris, files.size @@ -1112,8 +1110,6 @@ class BrowseFilesPresenter @Inject constructor( // val targetFolderPath = location.toCloudNode().path val cryptomatorApp = activity().application as CryptomatorApp - - // Create checkpoint cryptomatorApp.createUploadCheckpoint( vaultId, "folder", targetFolderPath, sourceFolderUri?.toString(), folderStructure.folderName, emptyList(), folderStructure.totalFileCount() ) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 783b35d86..30136bca9 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -12,6 +12,7 @@ import android.widget.Toast import androidx.documentfile.provider.DocumentFile import com.google.common.base.Optional import org.cryptomator.data.cloud.crypto.CryptoCloud +import org.cryptomator.data.db.UploadCheckpointDao import org.cryptomator.data.db.entities.UploadCheckpointEntity import org.cryptomator.data.util.NetworkConnectionCheck import org.cryptomator.domain.Cloud @@ -553,7 +554,7 @@ class VaultListPresenter @Inject constructor( // } val checkpoint = dao.findByVaultId(vault.id) if (checkpoint != null) { - val completedCount = parseJsonArraySize(checkpoint.completedFiles) + val completedCount = parseJsonArray(checkpoint.completedFiles).size ResumeUploadDialog .withContext(activity()) .show(vault.id, completedCount, checkpoint.totalFileCount) @@ -583,20 +584,20 @@ class VaultListPresenter @Inject constructor( // val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() if (checkpoint.type == "folder") { - handleFolderResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) + handleFolderResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) } else { - handleFileResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) + handleFileResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) } navigatePendingAfterResume() } - private fun handleFolderResume(cryptomatorApp: CryptomatorApp, cloud: Cloud, - checkpoint: UploadCheckpointEntity, + private fun handleFolderResume(cryptomatorApp: CryptomatorApp, dao: UploadCheckpointDao, + cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { val sourceFolderUri = checkpoint.sourceFolderUri if (sourceFolderUri == null) { - cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + dao.deleteByVaultId(vaultId) return } val uri = Uri.parse(sourceFolderUri) @@ -611,11 +612,11 @@ class VaultListPresenter @Inject constructor( // Timber.tag("VaultListPresenter").w(e, "Permission lost for folder URI during resume") } view?.showMessage(R.string.dialog_resume_upload_permission_lost) - cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + dao.deleteByVaultId(vaultId) } - private fun handleFileResume(cryptomatorApp: CryptomatorApp, cloud: Cloud, - checkpoint: UploadCheckpointEntity, + private fun handleFileResume(cryptomatorApp: CryptomatorApp, dao: UploadCheckpointDao, + cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { val fileUris = parseJsonArray(checkpoint.pendingFileUris) val contentResolverUtil = applicationComponent().contentResolverUtil() @@ -633,7 +634,7 @@ class VaultListPresenter @Inject constructor( // if (uploadFiles.isNotEmpty()) { cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId) } else { - cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + dao.deleteByVaultId(vaultId) } } @@ -664,10 +665,6 @@ class VaultListPresenter @Inject constructor( // } } - private fun parseJsonArraySize(json: String?): Int { - return parseJsonArray(json).size - } - private fun checkToStartAutoImageUpload(vault: Vault): Boolean { return if (sharedPreferencesHandler.usePhotoUpload() && sharedPreferencesHandler.photoUploadVault() == vault.id) { !sharedPreferencesHandler.autoPhotoUploadOnlyUsingWifi() || networkConnectionCheck.checkWifiOnAndConnected() diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 70f62dc5e..7cd97725a 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -100,7 +100,7 @@ private void startUpload(Cloud cloud, String targetFolderPath, Set compl ? cloudContentRepository.root(cloud) : cloudContentRepository.resolve(cloud, targetFolderPath); - FileUploadedCallback callback = createCheckpointCallback(vaultId); + FileUploadedCallback callback = createCheckpointCallback(vaultId, completedFiles); ProgressAware progressAware = progress -> updateNotification(progress.asPercentage()); uploadTask.execute(targetFolder, callback, progressAware); @@ -131,8 +131,8 @@ void execute(CloudFolder targetFolder, FileUploadedCallback callback, ProgressAware progressAware) throws BackendException; } - private FileUploadedCallback createCheckpointCallback(long vaultId) { - Set uploadedSoFar = new HashSet<>(); + private FileUploadedCallback createCheckpointCallback(long vaultId, Set completedFiles) { + Set uploadedSoFar = new HashSet<>(completedFiles); return relativePath -> { uploadedSoFar.add(relativePath); try { diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt index 1523723bf..063878185 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt @@ -1,13 +1,12 @@ package org.cryptomator.presentation.ui.dialog import android.content.Context -import android.content.DialogInterface import androidx.appcompat.app.AlertDialog import org.cryptomator.presentation.R class ResumeUploadDialog private constructor(private val context: Context) { - private val callback: Callback + private val callback: Callback = context as Callback interface Callback { @@ -22,10 +21,10 @@ class ResumeUploadDialog private constructor(private val context: Context) { .setMessage( context.getString(R.string.dialog_resume_upload_message, completedCount, totalCount) ) - .setPositiveButton(R.string.dialog_resume_upload_resume) { _: DialogInterface?, _: Int -> + .setPositiveButton(R.string.dialog_resume_upload_resume) { _, _ -> callback.onResumeUploadConfirmed(vaultId) } - .setNegativeButton(R.string.dialog_resume_upload_discard) { _: DialogInterface?, _: Int -> + .setNegativeButton(R.string.dialog_resume_upload_discard) { _, _ -> callback.onResumeUploadDeclined(vaultId) } .create() @@ -39,8 +38,4 @@ class ResumeUploadDialog private constructor(private val context: Context) { return ResumeUploadDialog(context) } } - - init { - callback = context as Callback - } } From 414d87879c7bf1a63445490a7e2d8a8171e91165 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 21:36:26 +0100 Subject: [PATCH 06/26] Fix resume navigation and broaden folder resume exception handling --- .../presenter/VaultListPresenter.kt | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 30136bca9..2fef6553d 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -567,29 +567,30 @@ class VaultListPresenter @Inject constructor( // private var pendingNavigationAfterResume: Pair? = null fun onResumeUploadConfirmed(vaultId: Long) { - val cryptomatorApp = activity().application as CryptomatorApp - val dao = cryptomatorApp.getUploadCheckpointDao() ?: return - val checkpoint = dao.findByVaultId(vaultId) ?: return - - val vault = try { - applicationComponent().vaultRepository().load(vaultId) - } catch (e: Exception) { - Timber.tag("VaultListPresenter").e(e, "Failed to load vault for resume") - dao.deleteByVaultId(vaultId) - navigatePendingAfterResume() - return - } + try { + val cryptomatorApp = activity().application as CryptomatorApp + val dao = cryptomatorApp.getUploadCheckpointDao() ?: return + val checkpoint = dao.findByVaultId(vaultId) ?: return + + val vault = try { + applicationComponent().vaultRepository().load(vaultId) + } catch (e: Exception) { + Timber.tag("VaultListPresenter").e(e, "Failed to load vault for resume") + dao.deleteByVaultId(vaultId) + return + } - val cloud = applicationComponent().cloudRepository().decryptedViewOf(vault) - val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() + val cloud = applicationComponent().cloudRepository().decryptedViewOf(vault) + val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() - if (checkpoint.type == "folder") { - handleFolderResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) - } else { - handleFileResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) + if (checkpoint.type == "folder") { + handleFolderResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) + } else { + handleFileResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) + } + } finally { + navigatePendingAfterResume() } - - navigatePendingAfterResume() } private fun handleFolderResume(cryptomatorApp: CryptomatorApp, dao: UploadCheckpointDao, @@ -608,8 +609,8 @@ class VaultListPresenter @Inject constructor( // cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId) return } - } catch (e: SecurityException) { - Timber.tag("VaultListPresenter").w(e, "Permission lost for folder URI during resume") + } catch (e: Exception) { + Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") } view?.showMessage(R.string.dialog_resume_upload_permission_lost) dao.deleteByVaultId(vaultId) From 9c1ca9f6e7b5d5d77f16ba30d64a85541b84d47f Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Thu, 19 Feb 2026 22:13:52 +0100 Subject: [PATCH 07/26] Add tests for UploadFolderStructure and StreamHelper --- .../domain/usecases/cloud/StreamHelperTest.kt | 112 ++++++++++++++++++ .../cloud/UploadFolderStructureTest.kt | 97 +++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt create mode 100644 domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructureTest.kt diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt new file mode 100644 index 000000000..a98644a9e --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt @@ -0,0 +1,112 @@ +package org.cryptomator.domain.usecases.cloud + +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +class StreamHelperTest { + + @Test + @DisplayName("copy transfers all bytes from input to output") + fun copyTransfersAllBytes() { + val data = byteArrayOf(1, 2, 3, 4, 5, 42, 127, -1) + val input = ByteArrayInputStream(data) + val output = ByteArrayOutputStream() + + StreamHelper.copy(input, output) + + assertThat(output.toByteArray(), `is`(data)) + } + + @Test + @DisplayName("copy handles empty stream") + fun copyHandlesEmptyStream() { + val input = ByteArrayInputStream(ByteArray(0)) + val output = ByteArrayOutputStream() + + StreamHelper.copy(input, output) + + assertThat(output.toByteArray(), `is`(ByteArray(0))) + } + + @Test + @DisplayName("copy closes both streams on success") + fun copyClosesStreamsOnSuccess() { + val input: InputStream = mock { + on { read(any()) } doReturn -1 + } + val output: OutputStream = mock() + + StreamHelper.copy(input, output) + + verify(input).close() + verify(output).close() + } + + @Test + @DisplayName("copy closes both streams when read throws IOException") + fun copyClosesStreamsOnReadError() { + val input: InputStream = mock { + on { read(any()) } doThrow IOException("read failed") + } + val output: OutputStream = mock() + + try { + StreamHelper.copy(input, output) + } catch (_: IOException) { + } + + verify(input).close() + verify(output).close() + } + + @Test + @DisplayName("copy closes both streams when write throws IOException") + fun copyClosesStreamsOnWriteError() { + val data = byteArrayOf(1, 2, 3) + val input = ByteArrayInputStream(data) + val output: OutputStream = mock { + on { write(any(), any(), any()) } doThrow IOException("write failed") + } + + try { + StreamHelper.copy(input, output) + } catch (_: IOException) { + } + + verify(output).close() + } + + @Test + @DisplayName("closeQuietly does not throw on null") + fun closeQuietlyHandlesNull() { + assertDoesNotThrow { + StreamHelper.closeQuietly(null) + } + } + + @Test + @DisplayName("closeQuietly swallows IOException from close") + fun closeQuietlySwallowsException() { + val closeable: Closeable = mock { + on { close() } doThrow IOException("close failed") + } + + assertDoesNotThrow { + StreamHelper.closeQuietly(closeable) + } + } +} diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructureTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructureTest.kt new file mode 100644 index 000000000..114952fdf --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructureTest.kt @@ -0,0 +1,97 @@ +package org.cryptomator.domain.usecases.cloud + +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock + +class UploadFolderStructureTest { + + private val dataSource: DataSource = mock() + + @Test + @DisplayName("Empty folder has totalFileCount of 0") + fun totalFileCountWithNoFiles() { + val structure = UploadFolderStructure("empty") + + assertThat(structure.totalFileCount(), `is`(0)) + } + + @Test + @DisplayName("Flat folder counts its own files") + fun totalFileCountWithFilesOnly() { + val structure = UploadFolderStructure("flat") + structure.addFile(uploadFile("a.txt")) + structure.addFile(uploadFile("b.txt")) + structure.addFile(uploadFile("c.txt")) + + assertThat(structure.totalFileCount(), `is`(3)) + } + + @Test + @DisplayName("Nested subfolders sum file counts recursively") + fun totalFileCountWithNestedSubfolders() { + val deep = UploadFolderStructure("deep") + deep.addFile(uploadFile("deep.txt")) + + val mid = UploadFolderStructure("mid") + mid.addFile(uploadFile("mid1.txt")) + mid.addFile(uploadFile("mid2.txt")) + mid.addSubfolder(deep) + + val root = UploadFolderStructure("root") + root.addFile(uploadFile("root.txt")) + root.addSubfolder(mid) + + assertThat(root.totalFileCount(), `is`(4)) + assertThat(mid.totalFileCount(), `is`(3)) + assertThat(deep.totalFileCount(), `is`(1)) + } + + @Test + @DisplayName("Empty subfolders contribute 0 to totalFileCount") + fun totalFileCountWithEmptySubfolders() { + val empty1 = UploadFolderStructure("empty1") + val empty2 = UploadFolderStructure("empty2") + + val root = UploadFolderStructure("root") + root.addFile(uploadFile("file.txt")) + root.addSubfolder(empty1) + root.addSubfolder(empty2) + + assertThat(root.totalFileCount(), `is`(1)) + } + + @Test + @DisplayName("Folder name is preserved from constructor") + fun folderNameIsPreserved() { + val structure = UploadFolderStructure("my-folder") + + assertThat(structure.folderName, `is`("my-folder")) + } + + @Test + @DisplayName("Added files and subfolders are retrievable via getters") + fun addFileAndAddSubfolder() { + val file = uploadFile("file.txt") + val subfolder = UploadFolderStructure("sub") + + val structure = UploadFolderStructure("root") + structure.addFile(file) + structure.addSubfolder(subfolder) + + assertThat(structure.files.size, `is`(1)) + assertThat(structure.files[0], `is`(file)) + assertThat(structure.subfolders.size, `is`(1)) + assertThat(structure.subfolders[0], `is`(subfolder)) + } + + private fun uploadFile(name: String): UploadFile { + return UploadFile.Builder() + .withFileName(name) + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + } +} From 890a78ddd27911952f207a553f96900df73265a3 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Fri, 20 Feb 2026 10:59:46 +0100 Subject: [PATCH 08/26] Suspend vault auto-lock during upload picker and service flows --- .../presenter/BrowseFilesPresenter.kt | 34 ++++++++++++++----- .../presentation/service/UploadService.java | 2 ++ .../ui/activity/BrowseFilesActivity.kt | 2 ++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 444db6625..e3cf696ff 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -131,6 +131,7 @@ class BrowseFilesPresenter @Inject constructor( // ) : Presenter(exceptionMappings) { private val authenticationExceptionHandler: AuthenticationExceptionHandler + private val cryptomatorApp: CryptomatorApp get() = activity().application as CryptomatorApp private lateinit var filesForUpload: MutableMap private lateinit var existingFilesForUpload: MutableMap private lateinit var downloadFiles: MutableList @@ -422,7 +423,6 @@ class BrowseFilesPresenter @Inject constructor( // val vaultId = vault?.toVault()?.id ?: return@let val targetFolderPath = location.toCloudNode().path - val cryptomatorApp = activity().application as CryptomatorApp val fileUris = files.map { it.dataSource.toString() } cryptomatorApp.createUploadCheckpoint( vaultId, "files", targetFolderPath, null, null, fileUris, files.size @@ -541,7 +541,6 @@ class BrowseFilesPresenter @Inject constructor( // if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { openWritableFileNotification = OpenWritableFileNotification(context(), it) openWritableFileNotification?.show() - val cryptomatorApp = activity().application as CryptomatorApp cryptomatorApp.suspendLock() } try { @@ -581,7 +580,6 @@ class BrowseFilesPresenter @Inject constructor( // 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() @@ -809,6 +807,7 @@ class BrowseFilesPresenter @Inject constructor( // private fun uploadFiles(nonReplacing: Map, replacing: Map) { if (nonReplacing.size + replacing.size == 0) { + cryptomatorApp.unSuspendLock() return } view?.showUploadDialog(nonReplacing.size + replacing.size) @@ -1021,6 +1020,7 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadFilesClicked(folder: CloudFolderModel) { uploadLocation = folder + cryptomatorApp.suspendLock() var intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" @@ -1034,8 +1034,12 @@ class BrowseFilesPresenter @Inject constructor( // context().startService(org.cryptomator.presentation.service.UploadService.cancelUploadIntent(context())) } - @Callback + @Callback(dispatchResultOkOnly = false) fun selectedFiles(result: ActivityResult) { + if (!result.isResultOk) { + cryptomatorApp.unSuspendLock() + return + } val fileUris = getFileUrisFromIntent(result.intent()) prepareSelectedFilesForUpload(fileUris) } @@ -1055,11 +1059,13 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadFolderClicked(folder: CloudFolderModel) { uploadLocation = folder try { + cryptomatorApp.suspendLock() requestActivityResult( // ActivityResultCallbacks.selectedFolder(), // Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) ) } catch (exception: ActivityNotFoundException) { + cryptomatorApp.unSuspendLock() Toast // .makeText( // activity().applicationContext, // @@ -1075,12 +1081,23 @@ class BrowseFilesPresenter @Inject constructor( // @InstanceState var lastSelectedFolderUri: Uri? = null - @Callback + @Callback(dispatchResultOkOnly = false) fun selectedFolder(result: ActivityResult) { - val treeUri = result.intent().data ?: return + val treeUri = if (result.isResultOk) { + result.intent().data + } else { + null + } + if (treeUri == null) { + cryptomatorApp.unSuspendLock() + return + } context().contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) lastSelectedFolderUri = treeUri - val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: return + val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: run { + cryptomatorApp.unSuspendLock() + return + } view?.showProgress(ProgressModel.GENERIC) Thread { try { @@ -1088,12 +1105,14 @@ class BrowseFilesPresenter @Inject constructor( // activity().runOnUiThread { view?.showProgress(ProgressModel.COMPLETED) if (folderStructure.totalFileCount() == 0) { + cryptomatorApp.unSuspendLock() view?.showMessage(R.string.screen_file_browser_nothing_to_upload) return@runOnUiThread } uploadFolder(folderStructure, treeUri) } } catch (e: Exception) { + cryptomatorApp.unSuspendLock() activity().runOnUiThread { view?.showProgress(ProgressModel.COMPLETED) showError(e) @@ -1109,7 +1128,6 @@ class BrowseFilesPresenter @Inject constructor( // val vaultId = vault?.toVault()?.id ?: return@let val targetFolderPath = location.toCloudNode().path - val cryptomatorApp = activity().application as CryptomatorApp cryptomatorApp.createUploadCheckpoint( vaultId, "folder", targetFolderPath, sourceFolderUri?.toString(), folderStructure.folderName, emptyList(), folderStructure.totalFileCount() ) diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 7cd97725a..3675abd09 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -11,6 +11,7 @@ import org.cryptomator.data.db.UploadCheckpointDao; import org.cryptomator.domain.Cloud; +import org.cryptomator.presentation.CryptomatorApp; import org.cryptomator.domain.CloudFolder; import org.cryptomator.domain.exception.BackendException; import org.cryptomator.domain.exception.CancellationException; @@ -117,6 +118,7 @@ private void startUpload(Cloud cloud, String targetFolderPath, Set compl notification.showGeneralErrorDuringUpload(); Timber.tag("UploadService").e(e, "Upload failed"); } finally { + ((CryptomatorApp) getApplicationContext()).unSuspendLock(); stopForeground(STOP_FOREGROUND_DETACH); stopSelf(); } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index a5bbdee65..3d0245ef1 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -14,6 +14,7 @@ import org.cryptomator.domain.CloudNode import org.cryptomator.domain.exception.ParentFolderIsNullException import org.cryptomator.generator.Activity import org.cryptomator.generator.InjectIntent +import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.ActivityLayoutBinding import org.cryptomator.presentation.intent.BrowseFilesIntent @@ -356,6 +357,7 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onReplaceCanceled() { + (application as CryptomatorApp).unSuspendLock() showProgress(COMPLETED) } From 44a6a76d8f9ced07d1a2eb806ad5e65109bd9dd4 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sat, 21 Feb 2026 06:17:25 +0100 Subject: [PATCH 09/26] Align upload feature with app architecture patterns --- .../presentation/CryptomatorApp.kt | 27 ------ .../presenter/BrowseFilesPresenter.kt | 90 ++++++++++++------ .../presenter/VaultListPresenter.kt | 91 ++++++++++--------- .../ui/dialog/ResumeUploadDialog.kt | 56 +++++++----- .../main/res/layout/dialog_resume_upload.xml | 5 + presentation/src/main/res/values/strings.xml | 1 + .../presenter/VaultListPresenterTest.java | 12 +++ 7 files changed, 165 insertions(+), 117 deletions(-) create mode 100644 presentation/src/main/res/layout/dialog_resume_upload.xml diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index 593508f42..74585b0ed 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -13,8 +13,6 @@ 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.db.UploadCheckpointDao -import org.cryptomator.data.db.entities.UploadCheckpointEntity import org.cryptomator.data.repository.RepositoryModule import org.cryptomator.domain.Cloud import org.cryptomator.domain.usecases.cloud.UploadFile @@ -203,31 +201,6 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } } - fun createUploadCheckpoint(vaultId: Long, type: String, targetFolderPath: String, - sourceFolderUri: String?, sourceFolderName: String?, pendingFileUris: List, totalFileCount: Int) { - val dao = uploadServiceBinder?.uploadCheckpointDao ?: return - val entity = UploadCheckpointEntity().apply { - this.vaultId = vaultId - this.type = type - this.targetFolderPath = targetFolderPath - this.sourceFolderUri = sourceFolderUri - this.sourceFolderName = sourceFolderName - this.pendingFileUris = toJsonArray(pendingFileUris) - this.completedFiles = "[]" - this.totalFileCount = totalFileCount - this.timestamp = System.currentTimeMillis() - } - dao.insertOrReplace(entity) - } - - fun getUploadCheckpointDao(): UploadCheckpointDao? { - return uploadServiceBinder?.uploadCheckpointDao - } - - private fun toJsonArray(items: List): String { - return items.joinToString(",", "[", "]") { "\"${it.replace("\"", "\\\"")}\"" } - } - private fun checkToStartAutoImageUpload(sharedPreferencesHandler: SharedPreferencesHandler): Boolean { return sharedPreferencesHandler.usePhotoUpload() // && (!sharedPreferencesHandler.autoPhotoUploadOnlyUsingWifi() || applicationComponent.networkConnectionCheck().checkWifiOnAndConnected()) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index e3cf696ff..6c1a62093 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -7,7 +7,13 @@ import android.provider.DocumentsContract import android.widget.Toast import androidx.core.net.toFile import androidx.documentfile.provider.DocumentFile +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers import org.cryptomator.data.cloud.crypto.CryptoFolder +import org.cryptomator.data.db.UploadCheckpointDao +import org.cryptomator.data.db.entities.UploadCheckpointEntity import org.cryptomator.domain.Cloud import org.cryptomator.domain.CloudFile import org.cryptomator.domain.CloudFolder @@ -127,6 +133,7 @@ class BrowseFilesPresenter @Inject constructor( // private val shareFileHelper: ShareFileHelper, // private val downloadFileUtil: DownloadFileUtil, // private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val uploadCheckpointDao: UploadCheckpointDao, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { @@ -137,6 +144,7 @@ class BrowseFilesPresenter @Inject constructor( // private lateinit var downloadFiles: MutableList private var resumedAfterAuthentication = false + private var folderStructureDisposable: Disposable? = null @InjectIntent lateinit var intent: BrowseFilesIntent @@ -174,6 +182,11 @@ class BrowseFilesPresenter @Inject constructor( // setRefreshOnBackPressEnabled(enableRefreshOnBackpressSupplier.setInAction(false)) } + override fun destroyed() { + folderStructureDisposable?.dispose() + folderStructureDisposable = null + } + fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { resumed() @@ -423,13 +436,12 @@ class BrowseFilesPresenter @Inject constructor( // val vaultId = vault?.toVault()?.id ?: return@let val targetFolderPath = location.toCloudNode().path - val fileUris = files.map { it.dataSource.toString() } - cryptomatorApp.createUploadCheckpoint( - vaultId, "files", targetFolderPath, null, null, fileUris, files.size - ) + saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { + pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) + } cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId) - onFileUploadCompleted() + onUploadDispatched() } } @@ -1099,26 +1111,23 @@ class BrowseFilesPresenter @Inject constructor( // return } view?.showProgress(ProgressModel.GENERIC) - Thread { - try { - val folderStructure = buildUploadFolderStructure(documentFile) - activity().runOnUiThread { - view?.showProgress(ProgressModel.COMPLETED) - if (folderStructure.totalFileCount() == 0) { - cryptomatorApp.unSuspendLock() - view?.showMessage(R.string.screen_file_browser_nothing_to_upload) - return@runOnUiThread - } - uploadFolder(folderStructure, treeUri) + folderStructureDisposable?.dispose() + folderStructureDisposable = Single.fromCallable { buildUploadFolderStructure(documentFile) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ folderStructure -> + view?.showProgress(ProgressModel.COMPLETED) + if (folderStructure.totalFileCount() == 0) { + cryptomatorApp.unSuspendLock() + view?.showMessage(R.string.screen_file_browser_nothing_to_upload) + return@subscribe } - } catch (e: Exception) { + uploadFolder(folderStructure, treeUri) + }, { e -> cryptomatorApp.unSuspendLock() - activity().runOnUiThread { - view?.showProgress(ProgressModel.COMPLETED) - showError(e) - } - } - }.start() + view?.showProgress(ProgressModel.COMPLETED) + showError(e) + }) } private fun uploadFolder(folderStructure: UploadFolderStructure, sourceFolderUri: Uri? = null) { @@ -1128,13 +1137,40 @@ class BrowseFilesPresenter @Inject constructor( // val vaultId = vault?.toVault()?.id ?: return@let val targetFolderPath = location.toCloudNode().path - cryptomatorApp.createUploadCheckpoint( - vaultId, "folder", targetFolderPath, sourceFolderUri?.toString(), folderStructure.folderName, emptyList(), folderStructure.totalFileCount() - ) + saveCheckpoint(vaultId, "folder", targetFolderPath, folderStructure.totalFileCount()) { + this.sourceFolderUri = sourceFolderUri?.toString() + this.sourceFolderName = folderStructure.folderName + } cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId) - onFileUploadCompleted() + onUploadDispatched() + } + } + + private fun saveCheckpoint( + vaultId: Long, type: String, targetFolderPath: String, + totalFileCount: Int, configure: UploadCheckpointEntity.() -> Unit = {} + ) { + val entity = UploadCheckpointEntity().apply { + this.vaultId = vaultId + this.type = type + this.targetFolderPath = targetFolderPath + this.completedFiles = "[]" + this.totalFileCount = totalFileCount + this.timestamp = System.currentTimeMillis() + configure() } + uploadCheckpointDao.insertOrReplace(entity) + } + + private fun onUploadDispatched() { + view?.showProgress(ProgressModel.COMPLETED) + view?.showMessage(R.string.notification_upload_started) + uploadLocation = null + } + + private fun toJsonArray(items: List): String { + return items.joinToString(",", "[", "]") { "\"${it.replace("\"", "\\\"")}\"" } } private fun moveIntentFor(parent: CloudFolderModel, sourceNodes: List>): IntentBuilder { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 2fef6553d..26062cb0c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -21,6 +21,8 @@ import org.cryptomator.domain.CloudType import org.cryptomator.domain.Vault import org.cryptomator.domain.di.PerView import org.cryptomator.domain.exception.license.LicenseNotValidException +import org.cryptomator.domain.repository.CloudRepository +import org.cryptomator.domain.repository.VaultRepository import org.cryptomator.domain.usecases.DoLicenseCheckUseCase import org.cryptomator.domain.usecases.DoUpdateCheckUseCase import org.cryptomator.domain.usecases.DoUpdateUseCase @@ -45,7 +47,6 @@ import org.cryptomator.generator.Callback import org.cryptomator.presentation.BuildConfig import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R -import org.cryptomator.presentation.di.component.ApplicationComponent import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.UnlockVaultIntent @@ -64,6 +65,7 @@ import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog import org.cryptomator.presentation.ui.dialog.VaultsRemovedDuringMigrationDialog +import org.cryptomator.presentation.util.ContentResolverUtil import org.cryptomator.presentation.util.FileUtil import org.cryptomator.presentation.workflow.ActivityResult import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow @@ -75,6 +77,10 @@ import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.crypto.CryptoMode import org.json.JSONArray import org.json.JSONException +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers import javax.inject.Inject import timber.log.Timber @@ -102,15 +108,25 @@ class VaultListPresenter @Inject constructor( // private val authenticationExceptionHandler: AuthenticationExceptionHandler, // private val cloudFolderModelMapper: CloudFolderModelMapper, // private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val uploadCheckpointDao: UploadCheckpointDao, // + private val vaultRepository: VaultRepository, // + private val cloudRepository: CloudRepository, // + private val contentResolverUtil: ContentResolverUtil, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { private var vaultAction: VaultAction? = null + private var folderResumeDisposable: Disposable? = null override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } + override fun destroyed() { + folderResumeDisposable?.dispose() + folderResumeDisposable = null + } + fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { loadVaultList() @@ -546,18 +562,10 @@ class VaultListPresenter @Inject constructor( // } private fun checkForInterruptedUpload(vault: Vault, folder: CloudFolder) { - val cryptomatorApp = activity().application as CryptomatorApp - val dao = cryptomatorApp.getUploadCheckpointDao() - if (dao == null) { - navigateToVaultContent(vault, folder) - return - } - val checkpoint = dao.findByVaultId(vault.id) + val checkpoint = uploadCheckpointDao.findByVaultId(vault.id) if (checkpoint != null) { val completedCount = parseJsonArray(checkpoint.completedFiles).size - ResumeUploadDialog - .withContext(activity()) - .show(vault.id, completedCount, checkpoint.totalFileCount) + view?.showDialog(ResumeUploadDialog.newInstance(vault.id, completedCount, checkpoint.totalFileCount)) pendingNavigationAfterResume = Pair(vault, folder) } else { navigateToVaultContent(vault, folder) @@ -567,60 +575,66 @@ class VaultListPresenter @Inject constructor( // private var pendingNavigationAfterResume: Pair? = null fun onResumeUploadConfirmed(vaultId: Long) { + val cryptomatorApp = activity().application as CryptomatorApp try { - val cryptomatorApp = activity().application as CryptomatorApp - val dao = cryptomatorApp.getUploadCheckpointDao() ?: return - val checkpoint = dao.findByVaultId(vaultId) ?: return + val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: return val vault = try { - applicationComponent().vaultRepository().load(vaultId) + vaultRepository.load(vaultId) } catch (e: Exception) { Timber.tag("VaultListPresenter").e(e, "Failed to load vault for resume") - dao.deleteByVaultId(vaultId) + uploadCheckpointDao.deleteByVaultId(vaultId) return } - val cloud = applicationComponent().cloudRepository().decryptedViewOf(vault) + val cloud = cloudRepository.decryptedViewOf(vault) val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() if (checkpoint.type == "folder") { - handleFolderResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) + handleFolderResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) } else { - handleFileResume(cryptomatorApp, dao, cloud, checkpoint, completedFiles, vaultId) + handleFileResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) } } finally { navigatePendingAfterResume() } } - private fun handleFolderResume(cryptomatorApp: CryptomatorApp, dao: UploadCheckpointDao, + private fun handleFolderResume(cryptomatorApp: CryptomatorApp, cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { val sourceFolderUri = checkpoint.sourceFolderUri if (sourceFolderUri == null) { - dao.deleteByVaultId(vaultId) + uploadCheckpointDao.deleteByVaultId(vaultId) return } val uri = Uri.parse(sourceFolderUri) - try { - val documentFile = DocumentFile.fromTreeUri(context(), uri) - if (documentFile != null && documentFile.canRead()) { - val folderStructure = buildUploadFolderStructure(documentFile) - cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId) - return - } - } catch (e: Exception) { - Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") + val documentFile = DocumentFile.fromTreeUri(context(), uri) + if (documentFile == null || !documentFile.canRead()) { + view?.showMessage(R.string.dialog_resume_upload_permission_lost) + uploadCheckpointDao.deleteByVaultId(vaultId) + return } - view?.showMessage(R.string.dialog_resume_upload_permission_lost) - dao.deleteByVaultId(vaultId) + view?.showProgress(ProgressModel.GENERIC) + folderResumeDisposable?.dispose() + folderResumeDisposable = Single.fromCallable { buildUploadFolderStructure(documentFile) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ folderStructure -> + view?.showProgress(ProgressModel.COMPLETED) + cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId) + }, { e -> + view?.showProgress(ProgressModel.COMPLETED) + Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") + view?.showMessage(R.string.dialog_resume_upload_permission_lost) + uploadCheckpointDao.deleteByVaultId(vaultId) + }) } - private fun handleFileResume(cryptomatorApp: CryptomatorApp, dao: UploadCheckpointDao, + private fun handleFileResume(cryptomatorApp: CryptomatorApp, cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { val fileUris = parseJsonArray(checkpoint.pendingFileUris) - val contentResolverUtil = applicationComponent().contentResolverUtil() val uploadFiles = fileUris.mapNotNull { uriString -> val uri = Uri.parse(uriString) val fileName = contentResolverUtil.fileName(uri) @@ -635,13 +649,12 @@ class VaultListPresenter @Inject constructor( // if (uploadFiles.isNotEmpty()) { cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId) } else { - dao.deleteByVaultId(vaultId) + uploadCheckpointDao.deleteByVaultId(vaultId) } } fun onResumeUploadDeclined(vaultId: Long) { - val cryptomatorApp = activity().application as CryptomatorApp - cryptomatorApp.getUploadCheckpointDao()?.deleteByVaultId(vaultId) + uploadCheckpointDao.deleteByVaultId(vaultId) navigatePendingAfterResume() } @@ -652,10 +665,6 @@ class VaultListPresenter @Inject constructor( // } } - private fun applicationComponent(): ApplicationComponent { - return (activity().application as CryptomatorApp).component - } - private fun parseJsonArray(json: String?): List { if (json.isNullOrEmpty() || json == "[]") return emptyList() return try { diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt index 063878185..fa67f24a1 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt @@ -1,12 +1,15 @@ package org.cryptomator.presentation.ui.dialog -import android.content.Context +import android.content.DialogInterface +import android.os.Bundle import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import org.cryptomator.generator.Dialog import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.DialogResumeUploadBinding -class ResumeUploadDialog private constructor(private val context: Context) { - - private val callback: Callback = context as Callback +@Dialog +class ResumeUploadDialog : BaseDialog(DialogResumeUploadBinding::inflate) { interface Callback { @@ -14,28 +17,37 @@ class ResumeUploadDialog private constructor(private val context: Context) { fun onResumeUploadDeclined(vaultId: Long) } - fun show(vaultId: Long, completedCount: Int, totalCount: Int) { - AlertDialog.Builder(context) - .setCancelable(false) - .setTitle(R.string.dialog_resume_upload_title) - .setMessage( - context.getString(R.string.dialog_resume_upload_message, completedCount, totalCount) - ) - .setPositiveButton(R.string.dialog_resume_upload_resume) { _, _ -> - callback.onResumeUploadConfirmed(vaultId) - } - .setNegativeButton(R.string.dialog_resume_upload_discard) { _, _ -> - callback.onResumeUploadDeclined(vaultId) - } - .create() - .show() + public override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { + val vaultId = requireArguments().getLong(ARG_VAULT_ID) + val completedCount = requireArguments().getInt(ARG_COMPLETED_COUNT) + val totalCount = requireArguments().getInt(ARG_TOTAL_COUNT) + builder // + .setCancelable(false) // + .setTitle(R.string.dialog_resume_upload_title) // + .setMessage(getString(R.string.dialog_resume_upload_message, completedCount, totalCount)) // + .setPositiveButton(R.string.dialog_resume_upload_resume) { _: DialogInterface, _: Int -> callback?.onResumeUploadConfirmed(vaultId) } // + .setNegativeButton(R.string.dialog_resume_upload_discard) { _: DialogInterface, _: Int -> callback?.onResumeUploadDeclined(vaultId) } + return builder.create() + } + + public override fun setupView() { + // empty } companion object { - @JvmStatic - fun withContext(context: Context): ResumeUploadDialog { - return ResumeUploadDialog(context) + private const val ARG_VAULT_ID = "vaultId" + private const val ARG_COMPLETED_COUNT = "completedCount" + private const val ARG_TOTAL_COUNT = "totalCount" + + fun newInstance(vaultId: Long, completedCount: Int, totalCount: Int): DialogFragment { + val dialog = ResumeUploadDialog() + val args = Bundle() + args.putLong(ARG_VAULT_ID, vaultId) + args.putInt(ARG_COMPLETED_COUNT, completedCount) + args.putInt(ARG_TOTAL_COUNT, totalCount) + dialog.arguments = args + return dialog } } } diff --git a/presentation/src/main/res/layout/dialog_resume_upload.xml b/presentation/src/main/res/layout/dialog_resume_upload.xml new file mode 100644 index 000000000..c9732b6dd --- /dev/null +++ b/presentation/src/main/res/layout/dialog_resume_upload.xml @@ -0,0 +1,5 @@ + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index ef0a58ebc..00c5e72a5 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -628,6 +628,7 @@ Tap to refresh authentication. + Upload started Uploading files Uploading %1$d/%2$d Cancel upload diff --git a/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java b/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java index fdd20b7ce..323b85ddf 100644 --- a/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java +++ b/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java @@ -2,7 +2,10 @@ import android.app.Activity; +import org.cryptomator.data.db.UploadCheckpointDao; import org.cryptomator.data.util.NetworkConnectionCheck; +import org.cryptomator.domain.repository.CloudRepository; +import org.cryptomator.domain.repository.VaultRepository; import org.cryptomator.domain.Cloud; import org.cryptomator.domain.CloudType; import org.cryptomator.domain.OnedriveCloud; @@ -28,6 +31,7 @@ import org.cryptomator.presentation.model.VaultModel; import org.cryptomator.presentation.model.mappers.CloudFolderModelMapper; import org.cryptomator.presentation.ui.activity.view.VaultListView; +import org.cryptomator.presentation.util.ContentResolverUtil; import org.cryptomator.presentation.util.FileUtil; import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow; import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler; @@ -115,6 +119,10 @@ public class VaultListPresenterTest { private FileUtil fileUtil = Mockito.mock(FileUtil.class); private AuthenticationExceptionHandler authenticationExceptionHandler = Mockito.mock(AuthenticationExceptionHandler.class); private SharedPreferencesHandler sharedPreferencesHandler = Mockito.mock(SharedPreferencesHandler.class); + private UploadCheckpointDao uploadCheckpointDao = Mockito.mock(UploadCheckpointDao.class); + private VaultRepository vaultRepository = Mockito.mock(VaultRepository.class); + private CloudRepository cloudRepository = Mockito.mock(CloudRepository.class); + private ContentResolverUtil contentResolverUtil = Mockito.mock(ContentResolverUtil.class); private ExceptionHandlers exceptionMappings = Mockito.mock(ExceptionHandlers.class); private VaultListPresenter inTest; @@ -142,6 +150,10 @@ public void setup() { authenticationExceptionHandler, // cloudNodeModelMapper, // sharedPreferencesHandler, // + uploadCheckpointDao, // + vaultRepository, // + cloudRepository, // + contentResolverUtil, // exceptionMappings); when(vaultListView.activity()).thenReturn(activity); inTest.setView(vaultListView); From fff3b4e5451a0e13d02f51d06dc627b7caca22cd Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sat, 21 Feb 2026 16:01:15 +0100 Subject: [PATCH 10/26] Migrate changed-file upload to foreground service with temp file cleanup --- .../presentation/CryptomatorApp.kt | 5 +- .../presenter/BrowseFilesPresenter.kt | 61 +++++-------------- .../presentation/service/UploadService.java | 29 +++++++-- 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index 74585b0ed..940ff08b5 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.net.Uri import android.os.Build import android.os.IBinder import android.os.StrictMode @@ -182,10 +183,10 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } fun startFileUpload(cloud: Cloud, targetFolderPath: String, files: List, - completedFiles: Set, vaultId: Long) { + completedFiles: Set, vaultId: Long, cleanupUris: List = emptyList()) { val binder = uploadServiceBinder if (binder != null) { - binder.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId) + binder.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId, cleanupUris) } else { Timber.tag("App").e("Upload service not connected, file upload request dropped") } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 6c1a62093..8d5b16537 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -20,7 +20,6 @@ import org.cryptomator.domain.CloudFolder import org.cryptomator.domain.CloudNode import org.cryptomator.domain.Vault import org.cryptomator.domain.di.PerView -import org.cryptomator.domain.exception.CloudNodeAlreadyExistsException import org.cryptomator.domain.exception.EmptyDirFileException import org.cryptomator.domain.exception.FatalBackendException import org.cryptomator.domain.exception.NoDirFileException @@ -86,7 +85,6 @@ import org.cryptomator.presentation.workflow.AddExistingVaultWorkflow import org.cryptomator.presentation.workflow.AuthenticationExceptionHandler import org.cryptomator.presentation.workflow.CreateNewVaultWorkflow import org.cryptomator.presentation.workflow.Workflow -import org.cryptomator.util.ExceptionUtil import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.file.FileCacheUtils import org.cryptomator.util.file.MimeType @@ -445,15 +443,6 @@ class BrowseFilesPresenter @Inject constructor( // } } - 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()) } @@ -631,37 +620,26 @@ class BrowseFilesPresenter @Inject constructor( // } private fun uploadChangedFile(openFileType: OpenFileType) { - view?.showUploadDialog(1) openedCloudFile?.let { openedCloudFile -> openedCloudFile.parent?.let { openedCloudFilesParent -> uriToOpenedFile?.let { uriToOpenedFile -> - uploadFilesUseCase // - .withParent(openedCloudFilesParent.toCloudNode()) // - .andFiles(listOf(createUploadFile(openedCloudFile.name, uriToOpenedFile, true))) // - .run(object : DefaultProgressAwareResultHandler, UploadState>() { - override fun onProgress(progress: Progress) { - view?.showProgress(progressModelMapper.toModel(progress)) - } + val cloud = openedCloudFilesParent.toCloudNode().cloud ?: return@let + val vaultId = openedCloudFilesParent.vault()?.toVault()?.id ?: return@let + val targetFolderPath = openedCloudFilesParent.toCloudNode().path + val files = listOf(createUploadFile(openedCloudFile.name, uriToOpenedFile, true)) - override fun onSuccess(files: List) { - files.forEach { file -> - view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(file)) - } - deleteFileIfMicrosoftWorkaround(openFileType, uriToOpenedFile) - onFileUploadCompleted() - } + saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { + pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) + } - 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) - } - } - }) + val cleanupUris = if (openFileType == OpenFileType.MICROSOFT_WORKAROUND) { + listOf(uriToOpenedFile) + } else { + emptyList() + } + + cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId, cleanupUris) + onUploadDispatched() } } } @@ -865,15 +843,6 @@ class BrowseFilesPresenter @Inject constructor( // 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) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 3675abd09..47888954f 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -3,6 +3,7 @@ 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; @@ -11,7 +12,6 @@ import org.cryptomator.data.db.UploadCheckpointDao; import org.cryptomator.domain.Cloud; -import org.cryptomator.presentation.CryptomatorApp; import org.cryptomator.domain.CloudFolder; import org.cryptomator.domain.exception.BackendException; import org.cryptomator.domain.exception.CancellationException; @@ -25,7 +25,10 @@ import org.cryptomator.domain.usecases.cloud.UploadFolderFiles; import org.cryptomator.domain.usecases.cloud.UploadFolderStructure; import org.cryptomator.domain.usecases.cloud.UploadState; +import org.cryptomator.presentation.CryptomatorApp; +import java.io.File; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -46,6 +49,7 @@ public class UploadService extends Service { private volatile Runnable cancelCallback; private volatile long startTimeNotificationDelay; private volatile long elapsedTimeNotificationDelay = 0L; + private volatile List cleanupUris = Collections.emptyList(); public static Intent cancelUploadIntent(Context context) { Intent cancelIntent = new Intent(context, UploadService.class); @@ -54,7 +58,8 @@ public static Intent cancelUploadIntent(Context context) { } public void startFileUpload(Cloud cloud, String targetFolderPath, List files, - Set completedFiles, long vaultId) { + Set completedFiles, long vaultId, List cleanupUris) { + this.cleanupUris = cleanupUris; startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), (targetFolder, callback, progressAware) -> { UploadFiles uploadFiles = new UploadFiles(appContext, cloudContentRepository, targetFolder, files); uploadFiles.setCompletedFiles(completedFiles); @@ -118,6 +123,7 @@ private void startUpload(Cloud cloud, String targetFolderPath, Set compl notification.showGeneralErrorDuringUpload(); Timber.tag("UploadService").e(e, "Upload failed"); } finally { + deleteCleanupUris(); ((CryptomatorApp) getApplicationContext()).unSuspendLock(); stopForeground(STOP_FOREGROUND_DETACH); stopSelf(); @@ -157,6 +163,21 @@ private String toJsonArray(Set items) { .collect(Collectors.joining(",", "[", "]")); } + private void deleteCleanupUris() { + List uris = cleanupUris; + cleanupUris = Collections.emptyList(); + for (Uri uri : uris) { + try { + File file = new File(uri.getPath()); + if (file.delete()) { + Timber.tag("UploadService").d("Cleaned up temp file: %s", uri); + } + } catch (Exception e) { + Timber.tag("UploadService").w(e, "Failed to clean up temp file"); + } + } + } + private void updateNotification(int asPercentage) { if (elapsedTimeNotificationDelay > 200 && !cancelled) { new Handler(Looper.getMainLooper()).post(() -> { @@ -234,8 +255,8 @@ public void init(CloudContentRepository cloudContentRepository, } public void startFileUpload(Cloud cloud, String targetFolderPath, List files, - Set completedFiles, long vaultId) { - UploadService.this.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId); + Set completedFiles, long vaultId, List cleanupUris) { + UploadService.this.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId, cleanupUris); } public void startFolderUpload(Cloud cloud, String targetFolderPath, UploadFolderStructure folderStructure, From e1c1ad42929069bc4d8ebf0226080e86ae22df3c Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 06:28:37 +0100 Subject: [PATCH 11/26] Migrate text editor save to foreground upload service --- .../presenter/TextEditorPresenter.kt | 62 +++++++------------ 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt index 7633a138f..29d792307 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt @@ -1,17 +1,13 @@ package org.cryptomator.presentation.presenter -import org.cryptomator.domain.CloudFile import org.cryptomator.domain.di.PerView import org.cryptomator.domain.exception.ParentFolderIsNullException -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.CryptomatorApp 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 @@ -25,10 +21,10 @@ 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 cryptomatorApp: CryptomatorApp get() = activity().application as CryptomatorApp private val textFile = AtomicReference() @JvmField @@ -63,40 +59,30 @@ class TextEditorPresenter @Inject constructor( // if (!hasUnsavedChanges()) { return } - view?.let { - it.showProgress(ProgressModel.GENERIC) + view?.let { v -> + val file = textFile.get() + val parent = file.parent ?: throw ParentFolderIsNullException(file.name) + val parentNode = parent.toCloudNode() + val cloud = parentNode.cloud ?: return + val vaultId = parent.vault()?.toVault()?.id ?: return + val uri = fileCacheUtils.tmpFile() // - .withContent(it.textFileContent) // + .withContent(v.textFileContent) // .create() - uploadFile(textFile.get().name, UriBasedDataSource.from(uri)) - } - } + val files = listOf( + UploadFile.anUploadFile() // + .withFileName(file.name) // + .withDataSource(UriBasedDataSource.from(uri)) // + .thatIsReplacing(true) // + .build() + ) - private fun uploadFile(fileName: String, dataSource: DataSource) { - textFile.get().parent?.let { - uploadFilesUseCase // - .withParent(it.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) - } + cryptomatorApp.startFileUpload(cloud, parentNode.path, files, emptySet(), vaultId, listOf(uri)) - override fun onError(e: Throwable) { - view?.showProgress(ProgressModel.COMPLETED) - showError(e) - } - }) - } ?: throw ParentFolderIsNullException(textFile.get().name) + existingTextFileContent.set(v.textFileContent) + v.showMessage(R.string.notification_upload_started) + v.finish() + } } fun loadFileContent() { @@ -120,8 +106,4 @@ class TextEditorPresenter @Inject constructor( // fun setTextFile(textFile: CloudFileModel) { this.textFile.set(textFile) } - - init { - unsubscribeOnDestroy(uploadFilesUseCase) - } } From ebcef0625313bb9b09686b1d68aa940e28445138 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 08:09:51 +0100 Subject: [PATCH 12/26] Queue concurrent uploads and handle dispatch failures --- .../presentation/CryptomatorApp.kt | 20 +-- .../presenter/BrowseFilesPresenter.kt | 65 +++++--- .../presenter/TextEditorPresenter.kt | 12 +- .../presenter/VaultListPresenter.kt | 28 ++-- .../presentation/service/UploadService.java | 150 +++++++++++++----- presentation/src/main/res/values/strings.xml | 1 + 6 files changed, 187 insertions(+), 89 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index 940ff08b5..c414021e0 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -183,23 +183,23 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } fun startFileUpload(cloud: Cloud, targetFolderPath: String, files: List, - completedFiles: Set, vaultId: Long, cleanupUris: List = emptyList()) { - val binder = uploadServiceBinder - if (binder != null) { - binder.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId, cleanupUris) - } else { + completedFiles: Set, vaultId: Long, cleanupUris: List = emptyList()): Boolean { + val binder = uploadServiceBinder ?: run { Timber.tag("App").e("Upload service not connected, file upload request dropped") + return false } + binder.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId, cleanupUris) + return true } fun startFolderUpload(cloud: Cloud, targetFolderPath: String, folderStructure: UploadFolderStructure, - completedFiles: Set, vaultId: Long) { - val binder = uploadServiceBinder - if (binder != null) { - binder.startFolderUpload(cloud, targetFolderPath, folderStructure, completedFiles, vaultId) - } else { + completedFiles: Set, vaultId: Long): Boolean { + val binder = uploadServiceBinder ?: run { Timber.tag("App").e("Upload service not connected, folder upload request dropped") + return false } + binder.startFolderUpload(cloud, targetFolderPath, folderStructure, completedFiles, vaultId) + return true } private fun checkToStartAutoImageUpload(sharedPreferencesHandler: SharedPreferencesHandler): Boolean { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 8d5b16537..72f6666bd 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -438,8 +438,13 @@ class BrowseFilesPresenter @Inject constructor( // pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) } - cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId) - onUploadDispatched() + if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId)) { + onUploadDispatched() + } else { + uploadCheckpointDao.deleteByVaultId(vaultId) + cryptomatorApp.unSuspendLock() + onUploadDispatchFailed() + } } } @@ -620,28 +625,29 @@ class BrowseFilesPresenter @Inject constructor( // } private fun uploadChangedFile(openFileType: OpenFileType) { - openedCloudFile?.let { openedCloudFile -> - openedCloudFile.parent?.let { openedCloudFilesParent -> - uriToOpenedFile?.let { uriToOpenedFile -> - val cloud = openedCloudFilesParent.toCloudNode().cloud ?: return@let - val vaultId = openedCloudFilesParent.vault()?.toVault()?.id ?: return@let - val targetFolderPath = openedCloudFilesParent.toCloudNode().path - val files = listOf(createUploadFile(openedCloudFile.name, uriToOpenedFile, true)) - - saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { - pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) - } + val file = openedCloudFile ?: return + val parent = file.parent ?: return + val uri = uriToOpenedFile ?: return + val cloud = parent.toCloudNode().cloud ?: return + val vaultId = parent.vault()?.toVault()?.id ?: return + val targetFolderPath = parent.toCloudNode().path + val files = listOf(createUploadFile(file.name, uri, true)) + + saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { + pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) + } - val cleanupUris = if (openFileType == OpenFileType.MICROSOFT_WORKAROUND) { - listOf(uriToOpenedFile) - } else { - emptyList() - } + val cleanupUris = if (openFileType == OpenFileType.MICROSOFT_WORKAROUND) { + listOf(uri) + } else { + emptyList() + } - cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId, cleanupUris) - onUploadDispatched() - } - } + if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId, cleanupUris)) { + onUploadDispatched() + } else { + uploadCheckpointDao.deleteByVaultId(vaultId) + onUploadDispatchFailed() } } @@ -1111,8 +1117,13 @@ class BrowseFilesPresenter @Inject constructor( // this.sourceFolderName = folderStructure.folderName } - cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId) - onUploadDispatched() + if (cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId)) { + onUploadDispatched() + } else { + uploadCheckpointDao.deleteByVaultId(vaultId) + cryptomatorApp.unSuspendLock() + onUploadDispatchFailed() + } } } @@ -1138,6 +1149,12 @@ class BrowseFilesPresenter @Inject constructor( // uploadLocation = null } + private fun onUploadDispatchFailed() { + view?.showProgress(ProgressModel.COMPLETED) + view?.showMessage(R.string.error_upload_service_unavailable) + uploadLocation = null + } + private fun toJsonArray(items: List): String { return items.joinToString(",", "[", "]") { "\"${it.replace("\"", "\\\"")}\"" } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt index 29d792307..463c0a890 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt @@ -77,11 +77,13 @@ class TextEditorPresenter @Inject constructor( // .build() ) - cryptomatorApp.startFileUpload(cloud, parentNode.path, files, emptySet(), vaultId, listOf(uri)) - - existingTextFileContent.set(v.textFileContent) - v.showMessage(R.string.notification_upload_started) - v.finish() + if (cryptomatorApp.startFileUpload(cloud, parentNode.path, files, emptySet(), vaultId, listOf(uri))) { + existingTextFileContent.set(v.textFileContent) + v.showMessage(R.string.notification_upload_started) + v.finish() + } else { + v.showMessage(R.string.error_upload_service_unavailable) + } } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 26062cb0c..2b746fba0 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -115,6 +115,7 @@ class VaultListPresenter @Inject constructor( // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { + private val cryptomatorApp: CryptomatorApp get() = activity().application as CryptomatorApp private var vaultAction: VaultAction? = null private var folderResumeDisposable: Disposable? = null @@ -553,7 +554,6 @@ class VaultListPresenter @Inject constructor( // view?.addOrUpdateVault(VaultModel(vault)) view?.showProgress(ProgressModel.COMPLETED) if (checkToStartAutoImageUpload(vault)) { - val cryptomatorApp = activity().application as CryptomatorApp cryptomatorApp.startAutoUpload(cryptoCloud) } checkForInterruptedUpload(vault, folder) @@ -575,7 +575,6 @@ class VaultListPresenter @Inject constructor( // private var pendingNavigationAfterResume: Pair? = null fun onResumeUploadConfirmed(vaultId: Long) { - val cryptomatorApp = activity().application as CryptomatorApp try { val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: return @@ -591,17 +590,16 @@ class VaultListPresenter @Inject constructor( // val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() if (checkpoint.type == "folder") { - handleFolderResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) + handleFolderResume(cloud, checkpoint, completedFiles, vaultId) } else { - handleFileResume(cryptomatorApp, cloud, checkpoint, completedFiles, vaultId) + handleFileResume(cloud, checkpoint, completedFiles, vaultId) } } finally { navigatePendingAfterResume() } } - private fun handleFolderResume(cryptomatorApp: CryptomatorApp, - cloud: Cloud, checkpoint: UploadCheckpointEntity, + private fun handleFolderResume(cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { val sourceFolderUri = checkpoint.sourceFolderUri if (sourceFolderUri == null) { @@ -622,7 +620,9 @@ class VaultListPresenter @Inject constructor( // .observeOn(AndroidSchedulers.mainThread()) .subscribe({ folderStructure -> view?.showProgress(ProgressModel.COMPLETED) - cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId) + if (!cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId)) { + view?.showMessage(R.string.error_upload_service_unavailable) + } }, { e -> view?.showProgress(ProgressModel.COMPLETED) Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") @@ -631,23 +631,23 @@ class VaultListPresenter @Inject constructor( // }) } - private fun handleFileResume(cryptomatorApp: CryptomatorApp, - cloud: Cloud, checkpoint: UploadCheckpointEntity, + private fun handleFileResume(cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { val fileUris = parseJsonArray(checkpoint.pendingFileUris) val uploadFiles = fileUris.mapNotNull { uriString -> val uri = Uri.parse(uriString) - val fileName = contentResolverUtil.fileName(uri) - if (fileName != null) { + contentResolverUtil.fileName(uri)?.let { fileName -> UploadFile.anUploadFile() .withFileName(fileName) .withDataSource(UriBasedDataSource.from(uri)) - .thatIsReplacing(false) + .thatIsReplacing(true) .build() - } else null + } } if (uploadFiles.isNotEmpty()) { - cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId) + if (!cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId)) { + view?.showMessage(R.string.error_upload_service_unavailable) + } } else { uploadCheckpointDao.deleteByVaultId(vaultId) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 47888954f..16022639d 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -28,9 +28,12 @@ import org.cryptomator.presentation.CryptomatorApp; import java.io.File; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedList; import java.util.List; +import java.util.Queue; import java.util.Set; import java.util.stream.Collectors; @@ -51,6 +54,10 @@ public class UploadService extends Service { private volatile long elapsedTimeNotificationDelay = 0L; private volatile List cleanupUris = Collections.emptyList(); + private final Object queueLock = new Object(); + private final Queue pendingUploads = new LinkedList<>(); + private boolean workerRunning = false; + public static Intent cancelUploadIntent(Context context) { Intent cancelIntent = new Intent(context, UploadService.class); cancelIntent.setAction(ACTION_CANCEL_UPLOAD); @@ -59,8 +66,7 @@ public static Intent cancelUploadIntent(Context context) { public void startFileUpload(Cloud cloud, String targetFolderPath, List files, Set completedFiles, long vaultId, List cleanupUris) { - this.cleanupUris = cleanupUris; - startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), (targetFolder, callback, progressAware) -> { + startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), cleanupUris, (targetFolder, callback, progressAware) -> { UploadFiles uploadFiles = new UploadFiles(appContext, cloudContentRepository, targetFolder, files); uploadFiles.setCompletedFiles(completedFiles); uploadFiles.setFileUploadedCallback(callback); @@ -74,7 +80,7 @@ public void startFileUpload(Cloud cloud, String targetFolderPath, List completedFiles, long vaultId) { - startUpload(cloud, targetFolderPath, completedFiles, vaultId, folderStructure.totalFileCount(), (targetFolder, callback, progressAware) -> { + startUpload(cloud, targetFolderPath, completedFiles, vaultId, folderStructure.totalFileCount(), Collections.emptyList(), (targetFolder, callback, progressAware) -> { UploadFolderFiles uploadFolderFiles = new UploadFolderFiles(appContext, cloudContentRepository, targetFolder, folderStructure); uploadFolderFiles.setCompletedFiles(completedFiles); uploadFolderFiles.setFileUploadedCallback(callback); @@ -87,58 +93,125 @@ public void startFolderUpload(Cloud cloud, String targetFolderPath, UploadFolder } private void startUpload(Cloud cloud, String targetFolderPath, Set completedFiles, - long vaultId, int totalFiles, UploadTask uploadTask) { - if (worker != null && worker.isAlive()) { - Timber.tag("UploadService").w("Upload already in progress, ignoring request"); - return; + long vaultId, int totalFiles, List cleanupUris, UploadTask uploadTask) { + QueuedUpload upload = new QueuedUpload(cloud, targetFolderPath, completedFiles, vaultId, totalFiles, uploadTask, cleanupUris); + synchronized (queueLock) { + if (workerRunning) { + pendingUploads.add(upload); + Timber.tag("UploadService").i("Upload queued (another in progress)"); + return; + } + workerRunning = true; } + startWorker(upload); + } - notification = new UploadNotification(appContext, totalFiles); - - startForeground(UploadNotification.NOTIFICATION_ID, notification.getNotification()); - notification.show(); - - cancelled = false; - + private void startWorker(QueuedUpload initial) { worker = new Thread(() -> { + QueuedUpload current = initial; + boolean isFirst = true; try { - CloudFolder targetFolder = targetFolderPath.isEmpty() - ? cloudContentRepository.root(cloud) - : cloudContentRepository.resolve(cloud, targetFolderPath); - - FileUploadedCallback callback = createCheckpointCallback(vaultId, completedFiles); - ProgressAware progressAware = progress -> updateNotification(progress.asPercentage()); - - uploadTask.execute(targetFolder, callback, progressAware); - - uploadCheckpointDao.deleteByVaultId(vaultId); - notification.showUploadFinished(totalFiles - completedFiles.size()); - Timber.tag("UploadService").i("Upload completed"); - } catch (CancellationException e) { - Timber.tag("UploadService").i("Upload canceled by user"); - } catch (MissingCryptorException e) { - notification.showVaultLockedDuringUpload(); - Timber.tag("UploadService").e(e, "Vault locked during upload"); - } catch (BackendException | FatalBackendException e) { - notification.showGeneralErrorDuringUpload(); - Timber.tag("UploadService").e(e, "Upload failed"); + while (current != null) { + if (!processUpload(current, isFirst)) { + break; + } + isFirst = false; + synchronized (queueLock) { + current = pendingUploads.poll(); + } + } } finally { - deleteCleanupUris(); + drainAndCleanupQueue(); + synchronized (queueLock) { + workerRunning = false; + } ((CryptomatorApp) getApplicationContext()).unSuspendLock(); stopForeground(STOP_FOREGROUND_DETACH); stopSelf(); } }); - worker.start(); } + private boolean processUpload(QueuedUpload upload, boolean isFirst) { + this.cleanupUris = upload.cleanupUris; + + notification = new UploadNotification(appContext, upload.totalFiles); + if (isFirst) { + startForeground(UploadNotification.NOTIFICATION_ID, notification.getNotification()); + } + notification.show(); + + cancelled = false; + + try { + CloudFolder targetFolder = upload.targetFolderPath.isEmpty() + ? cloudContentRepository.root(upload.cloud) + : cloudContentRepository.resolve(upload.cloud, upload.targetFolderPath); + + FileUploadedCallback callback = createCheckpointCallback(upload.vaultId, upload.completedFiles); + ProgressAware progressAware = progress -> updateNotification(progress.asPercentage()); + + upload.uploadTask.execute(targetFolder, callback, progressAware); + + uploadCheckpointDao.deleteByVaultId(upload.vaultId); + notification.showUploadFinished(upload.totalFiles - upload.completedFiles.size()); + Timber.tag("UploadService").i("Upload completed"); + return true; + } catch (CancellationException e) { + Timber.tag("UploadService").i("Upload canceled by user"); + return false; + } catch (MissingCryptorException e) { + notification.showVaultLockedDuringUpload(); + Timber.tag("UploadService").e(e, "Vault locked during upload"); + return false; + } catch (BackendException | FatalBackendException e) { + notification.showGeneralErrorDuringUpload(); + Timber.tag("UploadService").e(e, "Upload failed"); + return false; + } finally { + deleteCleanupUris(); + } + } + + private void drainAndCleanupQueue() { + List abandoned; + synchronized (queueLock) { + abandoned = new ArrayList<>(pendingUploads); + pendingUploads.clear(); + } + for (QueuedUpload upload : abandoned) { + deleteTempFiles(upload.cleanupUris); + } + } + @FunctionalInterface private interface UploadTask { void execute(CloudFolder targetFolder, FileUploadedCallback callback, ProgressAware progressAware) throws BackendException; } + private static class QueuedUpload { + final Cloud cloud; + final String targetFolderPath; + final Set completedFiles; + final long vaultId; + final int totalFiles; + final UploadTask uploadTask; + final List cleanupUris; + + QueuedUpload(Cloud cloud, String targetFolderPath, Set completedFiles, + long vaultId, int totalFiles, UploadTask uploadTask, List cleanupUris) { + this.cloud = cloud; + this.targetFolderPath = targetFolderPath; + this.completedFiles = completedFiles; + this.vaultId = vaultId; + this.totalFiles = totalFiles; + this.uploadTask = uploadTask; + this.cleanupUris = cleanupUris; + } + } + private FileUploadedCallback createCheckpointCallback(long vaultId, Set completedFiles) { Set uploadedSoFar = new HashSet<>(completedFiles); return relativePath -> { @@ -166,6 +239,10 @@ private String toJsonArray(Set items) { private void deleteCleanupUris() { List uris = cleanupUris; cleanupUris = Collections.emptyList(); + deleteTempFiles(uris); + } + + private void deleteTempFiles(List uris) { for (Uri uri : uris) { try { File file = new File(uri.getPath()); @@ -202,6 +279,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { Timber.tag("UploadService").i("started"); if (isCancelUpload(intent)) { Timber.tag("UploadService").i("Received cancel upload"); + drainAndCleanupQueue(); cancelled = true; Runnable cancel = cancelCallback; if (cancel != null) { diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 00c5e72a5..2183aea8f 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -649,6 +649,7 @@ Resume Discard Cannot resume: access to the source folder has been lost. + Could not start upload. Please try again. @string/dialog_button_cancel Open writable file From 5267dcf1b699f8c41d904d5d519267630f842645 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 09:06:56 +0100 Subject: [PATCH 13/26] Fix suspended lock leak, temp file leak, and asymmetric test coverage --- .../domain/usecases/cloud/StreamHelperTest.kt | 6 ++-- .../presenter/BrowseFilesPresenter.kt | 28 +++++++++++++------ .../presenter/TextEditorPresenter.kt | 2 ++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt index a98644a9e..5a32d5055 100644 --- a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt @@ -76,8 +76,9 @@ class StreamHelperTest { @Test @DisplayName("copy closes both streams when write throws IOException") fun copyClosesStreamsOnWriteError() { - val data = byteArrayOf(1, 2, 3) - val input = ByteArrayInputStream(data) + val input: InputStream = mock { + on { read(any()) } doReturn 1 + } val output: OutputStream = mock { on { write(any(), any(), any()) } doThrow IOException("write failed") } @@ -87,6 +88,7 @@ class StreamHelperTest { } catch (_: IOException) { } + verify(input).close() verify(output).close() } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 72f6666bd..f5d96aae2 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -429,10 +429,16 @@ class BrowseFilesPresenter @Inject constructor( // private fun uploadFiles(files: List) { uploadLocation?.let { location -> - val cloud = location.toCloudNode().cloud ?: return@let - val vault = location.vault() - val vaultId = vault?.toVault()?.id ?: return@let - val targetFolderPath = location.toCloudNode().path + val locationNode = location.toCloudNode() + val cloud = locationNode.cloud ?: run { + cryptomatorApp.unSuspendLock() + return@let + } + val vaultId = location.vault()?.toVault()?.id ?: run { + cryptomatorApp.unSuspendLock() + return@let + } + val targetFolderPath = locationNode.path saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) @@ -1107,10 +1113,16 @@ class BrowseFilesPresenter @Inject constructor( // private fun uploadFolder(folderStructure: UploadFolderStructure, sourceFolderUri: Uri? = null) { uploadLocation?.let { location -> - val cloud = location.toCloudNode().cloud ?: return@let - val vault = location.vault() - val vaultId = vault?.toVault()?.id ?: return@let - val targetFolderPath = location.toCloudNode().path + val locationNode = location.toCloudNode() + val cloud = locationNode.cloud ?: run { + cryptomatorApp.unSuspendLock() + return@let + } + val vaultId = location.vault()?.toVault()?.id ?: run { + cryptomatorApp.unSuspendLock() + return@let + } + val targetFolderPath = locationNode.path saveCheckpoint(vaultId, "folder", targetFolderPath, folderStructure.totalFileCount()) { this.sourceFolderUri = sourceFolderUri?.toString() diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt index 463c0a890..87b4d3496 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt @@ -12,6 +12,7 @@ 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.File import java.io.IOException import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -82,6 +83,7 @@ class TextEditorPresenter @Inject constructor( // v.showMessage(R.string.notification_upload_started) v.finish() } else { + uri.path?.let { File(it).delete() } v.showMessage(R.string.error_upload_service_unavailable) } } From 4e6a3c4518929e01bf71bbe3cba8dff634d950f2 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 10:32:26 +0100 Subject: [PATCH 14/26] Fix lock suspension leaks in presenter destroyed() and upload resume paths --- .../presenter/BrowseFilesPresenter.kt | 3 ++ .../presenter/VaultListPresenter.kt | 45 ++++++++++--------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index f5d96aae2..7e287bcd7 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -181,6 +181,9 @@ class BrowseFilesPresenter @Inject constructor( // } override fun destroyed() { + if (folderStructureDisposable?.isDisposed == false) { + cryptomatorApp.unSuspendLock() + } folderStructureDisposable?.dispose() folderStructureDisposable = null } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 2b746fba0..cd4a963bc 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -124,6 +124,9 @@ class VaultListPresenter @Inject constructor( // } override fun destroyed() { + if (folderResumeDisposable?.isDisposed == false) { + cryptomatorApp.unSuspendLock() + } folderResumeDisposable?.dispose() folderResumeDisposable = null } @@ -578,22 +581,20 @@ class VaultListPresenter @Inject constructor( // try { val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: return - val vault = try { - vaultRepository.load(vaultId) - } catch (e: Exception) { - Timber.tag("VaultListPresenter").e(e, "Failed to load vault for resume") - uploadCheckpointDao.deleteByVaultId(vaultId) - return - } - + val vault = vaultRepository.load(vaultId) val cloud = cloudRepository.decryptedViewOf(vault) val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() + cryptomatorApp.suspendLock() if (checkpoint.type == "folder") { handleFolderResume(cloud, checkpoint, completedFiles, vaultId) } else { handleFileResume(cloud, checkpoint, completedFiles, vaultId) } + } catch (e: Exception) { + cryptomatorApp.unSuspendLock() + Timber.tag("VaultListPresenter").e(e, "Failed to resume upload") + uploadCheckpointDao.deleteByVaultId(vaultId) } finally { navigatePendingAfterResume() } @@ -601,16 +602,13 @@ class VaultListPresenter @Inject constructor( // private fun handleFolderResume(cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { - val sourceFolderUri = checkpoint.sourceFolderUri - if (sourceFolderUri == null) { - uploadCheckpointDao.deleteByVaultId(vaultId) - return + val documentFile = checkpoint.sourceFolderUri?.let { uri -> + DocumentFile.fromTreeUri(context(), Uri.parse(uri))?.takeIf { it.canRead() } } - val uri = Uri.parse(sourceFolderUri) - val documentFile = DocumentFile.fromTreeUri(context(), uri) - if (documentFile == null || !documentFile.canRead()) { + if (documentFile == null) { view?.showMessage(R.string.dialog_resume_upload_permission_lost) uploadCheckpointDao.deleteByVaultId(vaultId) + cryptomatorApp.unSuspendLock() return } view?.showProgress(ProgressModel.GENERIC) @@ -621,10 +619,12 @@ class VaultListPresenter @Inject constructor( // .subscribe({ folderStructure -> view?.showProgress(ProgressModel.COMPLETED) if (!cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId)) { + cryptomatorApp.unSuspendLock() view?.showMessage(R.string.error_upload_service_unavailable) } }, { e -> view?.showProgress(ProgressModel.COMPLETED) + cryptomatorApp.unSuspendLock() Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") view?.showMessage(R.string.dialog_resume_upload_permission_lost) uploadCheckpointDao.deleteByVaultId(vaultId) @@ -633,8 +633,7 @@ class VaultListPresenter @Inject constructor( // private fun handleFileResume(cloud: Cloud, checkpoint: UploadCheckpointEntity, completedFiles: Set, vaultId: Long) { - val fileUris = parseJsonArray(checkpoint.pendingFileUris) - val uploadFiles = fileUris.mapNotNull { uriString -> + val uploadFiles = parseJsonArray(checkpoint.pendingFileUris).mapNotNull { uriString -> val uri = Uri.parse(uriString) contentResolverUtil.fileName(uri)?.let { fileName -> UploadFile.anUploadFile() @@ -644,12 +643,14 @@ class VaultListPresenter @Inject constructor( // .build() } } - if (uploadFiles.isNotEmpty()) { - if (!cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId)) { - view?.showMessage(R.string.error_upload_service_unavailable) - } - } else { + if (uploadFiles.isEmpty()) { uploadCheckpointDao.deleteByVaultId(vaultId) + cryptomatorApp.unSuspendLock() + return + } + if (!cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId)) { + cryptomatorApp.unSuspendLock() + view?.showMessage(R.string.error_upload_service_unavailable) } } From 82f5e042cda5899e58a4369b6b22009f9885c54e Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 14:11:49 +0100 Subject: [PATCH 15/26] Replace boolean lock suspension with time-bounded keep-alive leases --- .../presentation/CryptomatorApp.kt | 21 ++- .../presenter/BrowseFilesPresenter.kt | 53 ++++--- .../presenter/VaultListPresenter.kt | 37 +++-- .../presentation/service/CryptorsService.java | 15 +- .../presentation/service/KeepAliveLease.kt | 44 ++++++ .../service/KeepAliveLeaseManager.kt | 36 +++++ .../presentation/service/UploadService.java | 16 +- .../ui/activity/BrowseFilesActivity.kt | 3 +- .../ui/activity/VaultListActivity.kt | 7 +- .../service/KeepAliveLeaseTest.kt | 146 ++++++++++++++++++ 10 files changed, 324 insertions(+), 54 deletions(-) create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLease.kt create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLeaseManager.kt create mode 100644 presentation/src/test/java/org/cryptomator/presentation/service/KeepAliveLeaseTest.kt diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index c414021e0..8de853e84 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -29,6 +29,8 @@ import org.cryptomator.presentation.logging.ReleaseLogger import org.cryptomator.presentation.service.AutoUploadNotification import org.cryptomator.presentation.service.AutoUploadService import org.cryptomator.presentation.service.CryptorsService +import org.cryptomator.presentation.service.KeepAliveLease +import org.cryptomator.presentation.service.LeaseReason import org.cryptomator.presentation.service.UploadService import org.cryptomator.util.NoOpActivityLifecycleCallbacks import org.cryptomator.util.SharedPreferencesHandler @@ -260,14 +262,21 @@ class CryptomatorApp : MultiDexApplication(), HasComponent return appCryptors.isEmpty() } - fun suspendLock() { - val localServiceBinder = cryptoServiceBinder - localServiceBinder?.suspendLock() + fun acquireLease(reason: LeaseReason, ttlMs: Long, hardMaxMs: Long? = null, tag: String): KeepAliveLease? { + return cryptoServiceBinder?.acquireLease(reason, ttlMs, hardMaxMs, tag) } - fun unSuspendLock() { - val localServiceBinder = cryptoServiceBinder - localServiceBinder?.unSuspendLock() + @Volatile + private var editingLease: KeepAliveLease? = null + + fun acquireEditingLease() { + editingLease?.release() + editingLease = acquireLease(LeaseReason.EDITING, 4 * 60 * 60 * 1000L, tag = "editing") + } + + fun releaseEditingLease() { + editingLease?.release() + editingLease = null } companion object { diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 7e287bcd7..9618349ba 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -53,6 +53,8 @@ import org.cryptomator.generator.InjectIntent import org.cryptomator.generator.InstanceState import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R +import org.cryptomator.presentation.service.KeepAliveLease +import org.cryptomator.presentation.service.LeaseReason import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.BrowseFilesIntent import org.cryptomator.presentation.intent.ChooseCloudNodeSettings @@ -143,6 +145,7 @@ class BrowseFilesPresenter @Inject constructor( // private var resumedAfterAuthentication = false private var folderStructureDisposable: Disposable? = null + private var pickerLease: KeepAliveLease? = null @InjectIntent lateinit var intent: BrowseFilesIntent @@ -181,9 +184,8 @@ class BrowseFilesPresenter @Inject constructor( // } override fun destroyed() { - if (folderStructureDisposable?.isDisposed == false) { - cryptomatorApp.unSuspendLock() - } + pickerLease?.release() + pickerLease = null folderStructureDisposable?.dispose() folderStructureDisposable = null } @@ -434,11 +436,11 @@ class BrowseFilesPresenter @Inject constructor( // uploadLocation?.let { location -> val locationNode = location.toCloudNode() val cloud = locationNode.cloud ?: run { - cryptomatorApp.unSuspendLock() + releasePickerLease() return@let } val vaultId = location.vault()?.toVault()?.id ?: run { - cryptomatorApp.unSuspendLock() + releasePickerLease() return@let } val targetFolderPath = locationNode.path @@ -448,10 +450,11 @@ class BrowseFilesPresenter @Inject constructor( // } if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId)) { + releasePickerLease() onUploadDispatched() } else { uploadCheckpointDao.deleteByVaultId(vaultId) - cryptomatorApp.unSuspendLock() + releasePickerLease() onUploadDispatchFailed() } } @@ -556,7 +559,7 @@ class BrowseFilesPresenter @Inject constructor( // if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { openWritableFileNotification = OpenWritableFileNotification(context(), it) openWritableFileNotification?.show() - cryptomatorApp.suspendLock() + cryptomatorApp.acquireEditingLease() } try { requestActivityResult(ActivityResultCallbacks.openFileFinished(openFileType), viewFileIntent) @@ -595,7 +598,7 @@ class BrowseFilesPresenter @Inject constructor( // Timber.tag("BrowseFilesPresenter").e(e, "Failed to sleep after resuming editing, necessary for google office apps") } if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { - cryptomatorApp.unSuspendLock() + cryptomatorApp.releaseEditingLease() } hideWritableNotification() @@ -812,7 +815,7 @@ class BrowseFilesPresenter @Inject constructor( // private fun uploadFiles(nonReplacing: Map, replacing: Map) { if (nonReplacing.size + replacing.size == 0) { - cryptomatorApp.unSuspendLock() + releasePickerLease() return } view?.showUploadDialog(nonReplacing.size + replacing.size) @@ -1016,7 +1019,7 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadFilesClicked(folder: CloudFolderModel) { uploadLocation = folder - cryptomatorApp.suspendLock() + pickerLease = cryptomatorApp.acquireLease(LeaseReason.FILE_PICKER, 2 * 60 * 1000L, tag = "filePicker") var intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" @@ -1033,7 +1036,7 @@ class BrowseFilesPresenter @Inject constructor( // @Callback(dispatchResultOkOnly = false) fun selectedFiles(result: ActivityResult) { if (!result.isResultOk) { - cryptomatorApp.unSuspendLock() + releasePickerLease() return } val fileUris = getFileUrisFromIntent(result.intent()) @@ -1055,13 +1058,13 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadFolderClicked(folder: CloudFolderModel) { uploadLocation = folder try { - cryptomatorApp.suspendLock() + pickerLease = cryptomatorApp.acquireLease(LeaseReason.FOLDER_PICKER, 2 * 60 * 1000L, tag = "folderPicker") requestActivityResult( // ActivityResultCallbacks.selectedFolder(), // Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) ) } catch (exception: ActivityNotFoundException) { - cryptomatorApp.unSuspendLock() + releasePickerLease() Toast // .makeText( // activity().applicationContext, // @@ -1085,13 +1088,13 @@ class BrowseFilesPresenter @Inject constructor( // null } if (treeUri == null) { - cryptomatorApp.unSuspendLock() + releasePickerLease() return } context().contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) lastSelectedFolderUri = treeUri val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: run { - cryptomatorApp.unSuspendLock() + releasePickerLease() return } view?.showProgress(ProgressModel.GENERIC) @@ -1102,13 +1105,13 @@ class BrowseFilesPresenter @Inject constructor( // .subscribe({ folderStructure -> view?.showProgress(ProgressModel.COMPLETED) if (folderStructure.totalFileCount() == 0) { - cryptomatorApp.unSuspendLock() + releasePickerLease() view?.showMessage(R.string.screen_file_browser_nothing_to_upload) return@subscribe } uploadFolder(folderStructure, treeUri) }, { e -> - cryptomatorApp.unSuspendLock() + releasePickerLease() view?.showProgress(ProgressModel.COMPLETED) showError(e) }) @@ -1118,11 +1121,11 @@ class BrowseFilesPresenter @Inject constructor( // uploadLocation?.let { location -> val locationNode = location.toCloudNode() val cloud = locationNode.cloud ?: run { - cryptomatorApp.unSuspendLock() + releasePickerLease() return@let } val vaultId = location.vault()?.toVault()?.id ?: run { - cryptomatorApp.unSuspendLock() + releasePickerLease() return@let } val targetFolderPath = locationNode.path @@ -1133,10 +1136,11 @@ class BrowseFilesPresenter @Inject constructor( // } if (cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId)) { + releasePickerLease() onUploadDispatched() } else { uploadCheckpointDao.deleteByVaultId(vaultId) - cryptomatorApp.unSuspendLock() + releasePickerLease() onUploadDispatchFailed() } } @@ -1158,6 +1162,15 @@ class BrowseFilesPresenter @Inject constructor( // uploadCheckpointDao.insertOrReplace(entity) } + private fun releasePickerLease() { + pickerLease?.release() + pickerLease = null + } + + fun onUploadReplaceCanceled() { + releasePickerLease() + } + private fun onUploadDispatched() { view?.showProgress(ProgressModel.COMPLETED) view?.showMessage(R.string.notification_upload_started) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index cd4a963bc..563ff2739 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -47,6 +47,8 @@ import org.cryptomator.generator.Callback import org.cryptomator.presentation.BuildConfig import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R +import org.cryptomator.presentation.service.KeepAliveLease +import org.cryptomator.presentation.service.LeaseReason import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.UnlockVaultIntent @@ -118,15 +120,15 @@ class VaultListPresenter @Inject constructor( // private val cryptomatorApp: CryptomatorApp get() = activity().application as CryptomatorApp private var vaultAction: VaultAction? = null private var folderResumeDisposable: Disposable? = null + private var resumeLease: KeepAliveLease? = null override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) } override fun destroyed() { - if (folderResumeDisposable?.isDisposed == false) { - cryptomatorApp.unSuspendLock() - } + resumeLease?.release() + resumeLease = null folderResumeDisposable?.dispose() folderResumeDisposable = null } @@ -579,23 +581,26 @@ class VaultListPresenter @Inject constructor( // fun onResumeUploadConfirmed(vaultId: Long) { try { - val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: return + val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: run { + navigatePendingAfterResume() + return + } val vault = vaultRepository.load(vaultId) val cloud = cloudRepository.decryptedViewOf(vault) val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() - cryptomatorApp.suspendLock() + resumeLease = cryptomatorApp.acquireLease(LeaseReason.UPLOAD, 2 * 60 * 1000L, tag = "resume") if (checkpoint.type == "folder") { handleFolderResume(cloud, checkpoint, completedFiles, vaultId) } else { handleFileResume(cloud, checkpoint, completedFiles, vaultId) + navigatePendingAfterResume() } } catch (e: Exception) { - cryptomatorApp.unSuspendLock() + releaseResumeLease() Timber.tag("VaultListPresenter").e(e, "Failed to resume upload") uploadCheckpointDao.deleteByVaultId(vaultId) - } finally { navigatePendingAfterResume() } } @@ -608,7 +613,8 @@ class VaultListPresenter @Inject constructor( // if (documentFile == null) { view?.showMessage(R.string.dialog_resume_upload_permission_lost) uploadCheckpointDao.deleteByVaultId(vaultId) - cryptomatorApp.unSuspendLock() + releaseResumeLease() + navigatePendingAfterResume() return } view?.showProgress(ProgressModel.GENERIC) @@ -619,15 +625,17 @@ class VaultListPresenter @Inject constructor( // .subscribe({ folderStructure -> view?.showProgress(ProgressModel.COMPLETED) if (!cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId)) { - cryptomatorApp.unSuspendLock() view?.showMessage(R.string.error_upload_service_unavailable) } + releaseResumeLease() + navigatePendingAfterResume() }, { e -> view?.showProgress(ProgressModel.COMPLETED) - cryptomatorApp.unSuspendLock() Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") view?.showMessage(R.string.dialog_resume_upload_permission_lost) uploadCheckpointDao.deleteByVaultId(vaultId) + releaseResumeLease() + navigatePendingAfterResume() }) } @@ -645,13 +653,13 @@ class VaultListPresenter @Inject constructor( // } if (uploadFiles.isEmpty()) { uploadCheckpointDao.deleteByVaultId(vaultId) - cryptomatorApp.unSuspendLock() + releaseResumeLease() return } if (!cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId)) { - cryptomatorApp.unSuspendLock() view?.showMessage(R.string.error_upload_service_unavailable) } + releaseResumeLease() } fun onResumeUploadDeclined(vaultId: Long) { @@ -659,6 +667,11 @@ class VaultListPresenter @Inject constructor( // navigatePendingAfterResume() } + private fun releaseResumeLease() { + resumeLease?.release() + resumeLease = null + } + private fun navigatePendingAfterResume() { pendingNavigationAfterResume?.let { (vault, folder) -> navigateToVaultContent(vault, folder) diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java b/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java index d28905f75..9f5ae7655 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/CryptorsService.java @@ -34,14 +34,14 @@ public class CryptorsService extends Service { private UnlockedNotification notification; private final Consumer onLockTimeoutChanged = this::onLockTimeoutChanged; private volatile boolean running = true; - private volatile boolean lockSuspended = false; + private final KeepAliveLeaseManager leaseManager = new KeepAliveLeaseManager(); private final Thread worker = new Thread(new Runnable() { @Override public void run() { while (running) { try { waitUntilVaultsUnlockedAndInBackground(); - if (!lockSuspended) { + if (!leaseManager.hasActiveLease()) { if (autolockTimeout.expired()) { Timber.tag("CryptorsService").i("autolock timeout expired"); destroyCryptorsAndHideNotification(); @@ -188,12 +188,9 @@ public void appInForeground(boolean appInForeground) { onAppInForegroundChanged(appInForeground); } - public void suspendLock() { - lockSuspended = true; - } - - public void unSuspendLock() { - lockSuspended = false; + public KeepAliveLease acquireLease(LeaseReason reason, long ttlMs, + Long hardMaxMs, String tag) { + return leaseManager.acquire(reason, ttlMs, hardMaxMs, tag); } public void setFileUtil(FileUtil mfileUtil) { @@ -207,7 +204,7 @@ class ScreenLockReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { if (intent.getAction().equals(Intent.ACTION_SCREEN_OFF) && // sharedPreferencesHandler.lockOnScreenOff() && // - !lockSuspended) { + !leaseManager.hasActiveLease()) { Timber.tag("CryptorsService").i("ScreenLock received, destroying cryptors and shutting down service"); destroyCryptorsAndHideNotification(); diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLease.kt b/presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLease.kt new file mode 100644 index 000000000..355196d7e --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLease.kt @@ -0,0 +1,44 @@ +package org.cryptomator.presentation.service + +import android.os.SystemClock +import java.io.Closeable +import java.util.concurrent.atomic.AtomicBoolean + +class KeepAliveLease internal constructor( + private val manager: KeepAliveLeaseManager, + val reason: LeaseReason, + private val defaultTtlMs: Long, + private val hardMaxMs: Long?, + val tag: String +) : Closeable { + + private val released = AtomicBoolean(false) + private val createdAt = SystemClock.elapsedRealtime() + + @Volatile + var expiresAt = createdAt + defaultTtlMs + private set + + fun release() { + if (released.compareAndSet(false, true)) { + manager.removeLease(this) + } + } + + override fun close() = release() + + @JvmOverloads + fun renew(ttlMs: Long = defaultTtlMs) { + if (released.get()) return + val now = SystemClock.elapsedRealtime() + val cap = hardMaxMs?.let { createdAt + it } ?: Long.MAX_VALUE + expiresAt = minOf(now + ttlMs, cap) + } + + val isExpired: Boolean + get() = !released.get() && SystemClock.elapsedRealtime() > expiresAt + + val isReleased: Boolean get() = released.get() +} + +enum class LeaseReason { FILE_PICKER, FOLDER_PICKER, UPLOAD, EDITING } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLeaseManager.kt b/presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLeaseManager.kt new file mode 100644 index 000000000..6bb910c65 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/KeepAliveLeaseManager.kt @@ -0,0 +1,36 @@ +package org.cryptomator.presentation.service + +import java.util.concurrent.CopyOnWriteArrayList + +import timber.log.Timber + +class KeepAliveLeaseManager { + + private val leases = CopyOnWriteArrayList() + + fun acquire(reason: LeaseReason, ttlMs: Long, hardMaxMs: Long? = null, tag: String): KeepAliveLease { + val lease = KeepAliveLease(this, reason, ttlMs, hardMaxMs, tag) + leases.add(lease) + Timber.tag("KeepAlive").d("Acquired [%s/%s] ttl=%dms hardMax=%s (active=%d)", + reason, tag, ttlMs, hardMaxMs?.let { "${it}ms" } ?: "none", leases.size) + return lease + } + + internal fun removeLease(lease: KeepAliveLease) { + leases.remove(lease) + Timber.tag("KeepAlive").d("Released [%s/%s] (active=%d)", lease.reason, lease.tag, leases.size) + } + + /** + * Called by CryptorsService worker every 1 second. + * Returns true if lock should be suspended (any non-expired lease exists). + */ + fun hasActiveLease(): Boolean { + val expired = leases.filter { it.isExpired } + expired.forEach { lease -> + Timber.tag("KeepAlive").w("Lease [%s/%s] expired", lease.reason, lease.tag) + lease.release() + } + return leases.isNotEmpty() + } +} diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 16022639d..c40de090a 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -53,6 +53,7 @@ public class UploadService extends Service { private volatile long startTimeNotificationDelay; private volatile long elapsedTimeNotificationDelay = 0L; private volatile List cleanupUris = Collections.emptyList(); + private volatile KeepAliveLease uploadLease; private final Object queueLock = new Object(); private final Queue pendingUploads = new LinkedList<>(); @@ -107,11 +108,17 @@ private void startUpload(Cloud cloud, String targetFolderPath, Set compl } private void startWorker(QueuedUpload initial) { + KeepAliveLease lease = ((CryptomatorApp) getApplicationContext()).acquireLease( + LeaseReason.UPLOAD, 5 * 60 * 1000L, 12 * 60 * 60 * 1000L, "upload"); + uploadLease = lease; worker = new Thread(() -> { QueuedUpload current = initial; boolean isFirst = true; try { while (current != null) { + if (lease != null) { + lease.renew(); + } if (!processUpload(current, isFirst)) { break; } @@ -125,7 +132,10 @@ private void startWorker(QueuedUpload initial) { synchronized (queueLock) { workerRunning = false; } - ((CryptomatorApp) getApplicationContext()).unSuspendLock(); + if (lease != null) { + lease.release(); + } + uploadLease = null; stopForeground(STOP_FOREGROUND_DETACH); stopSelf(); } @@ -256,6 +266,10 @@ private void deleteTempFiles(List uris) { } private void updateNotification(int asPercentage) { + KeepAliveLease lease = uploadLease; + if (lease != null) { + lease.renew(); + } if (elapsedTimeNotificationDelay > 200 && !cancelled) { new Handler(Looper.getMainLooper()).post(() -> { notification.update(asPercentage); diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index 3d0245ef1..c3d72e6eb 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -14,7 +14,6 @@ import org.cryptomator.domain.CloudNode import org.cryptomator.domain.exception.ParentFolderIsNullException import org.cryptomator.generator.Activity import org.cryptomator.generator.InjectIntent -import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.ActivityLayoutBinding import org.cryptomator.presentation.intent.BrowseFilesIntent @@ -357,7 +356,7 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onReplaceCanceled() { - (application as CryptomatorApp).unSuspendLock() + browseFilesPresenter.onUploadReplaceCanceled() showProgress(COMPLETED) } 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 index 03d815542..0c9abae96 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -76,7 +76,7 @@ class VaultListActivity : BaseActivity(Activi if (stopEditFilePressed() && sharedPreferencesHandler.keepUnlockedWhileEditing()) { hideNotification() - unSuspendLock() + releaseEditingLease() } } @@ -88,9 +88,8 @@ class VaultListActivity : BaseActivity(Activi OpenWritableFileNotification(context(), Uri.EMPTY).hide() } - private fun unSuspendLock() { - val cryptomatorApp = activity().application as CryptomatorApp - cryptomatorApp.unSuspendLock() + private fun releaseEditingLease() { + (application as CryptomatorApp).releaseEditingLease() } override fun createFragment(): Fragment = VaultListFragment() diff --git a/presentation/src/test/java/org/cryptomator/presentation/service/KeepAliveLeaseTest.kt b/presentation/src/test/java/org/cryptomator/presentation/service/KeepAliveLeaseTest.kt new file mode 100644 index 000000000..7f639f05d --- /dev/null +++ b/presentation/src/test/java/org/cryptomator/presentation/service/KeepAliveLeaseTest.kt @@ -0,0 +1,146 @@ +package org.cryptomator.presentation.service + +import android.os.SystemClock + +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic + +class KeepAliveLeaseTest { + + private lateinit var manager: KeepAliveLeaseManager + private lateinit var systemClockMock: MockedStatic + private var now = 10_000L + + @BeforeEach + fun setup() { + manager = KeepAliveLeaseManager() + systemClockMock = mockStatic(SystemClock::class.java) + systemClockMock.`when` { SystemClock.elapsedRealtime() }.thenAnswer { now } + } + + @AfterEach + fun tearDown() { + systemClockMock.close() + } + + @Test + fun releaseRemovesLease() { + val lease = manager.acquire(LeaseReason.FILE_PICKER, 60_000L, tag = "test") + assertThat(manager.hasActiveLease(), `is`(true)) + + lease.release() + assertThat(manager.hasActiveLease(), `is`(false)) + } + + @Test + fun doubleReleaseIsNoOp() { + val lease = manager.acquire(LeaseReason.FILE_PICKER, 60_000L, tag = "test") + + lease.release() + lease.release() + + assertThat(lease.isReleased, `is`(true)) + assertThat(manager.hasActiveLease(), `is`(false)) + } + + @Test + fun expiredLeaseDetectedByIsExpired() { + val lease = manager.acquire(LeaseReason.UPLOAD, 5_000L, tag = "test") + assertThat(lease.isExpired, `is`(false)) + + now += 6_000L + assertThat(lease.isExpired, `is`(true)) + } + + @Test + fun hasActiveLeaseAutoExpiresStaleLeases() { + manager.acquire(LeaseReason.UPLOAD, 5_000L, tag = "stale") + assertThat(manager.hasActiveLease(), `is`(true)) + + now += 6_000L + assertThat(manager.hasActiveLease(), `is`(false)) + } + + @Test + fun renewExtendsTtl() { + val lease = manager.acquire(LeaseReason.UPLOAD, 5_000L, tag = "test") + + now += 4_000L + lease.renew() + + now += 4_000L + assertThat(lease.isExpired, `is`(false)) + + now += 2_000L + assertThat(lease.isExpired, `is`(true)) + } + + @Test + fun renewBoundedByHardMax() { + val lease = manager.acquire(LeaseReason.UPLOAD, 5_000L, hardMaxMs = 8_000L, tag = "test") + + now += 4_000L + lease.renew() + + // hard max is createdAt + 8_000 = 18_000; renew would set to now + 5_000 = 19_000 + // should be capped at 18_000 + assertThat(lease.expiresAt, `is`(18_000L)) + } + + @Test + fun renewAfterReleaseIsNoOp() { + val lease = manager.acquire(LeaseReason.UPLOAD, 5_000L, tag = "test") + val originalExpiry = lease.expiresAt + + lease.release() + now += 1_000L + lease.renew() + + assertThat(lease.expiresAt, `is`(originalExpiry)) + } + + @Test + fun multipleLeases() { + val lease1 = manager.acquire(LeaseReason.FILE_PICKER, 3_000L, tag = "picker") + val lease2 = manager.acquire(LeaseReason.UPLOAD, 10_000L, tag = "upload") + + assertThat(manager.hasActiveLease(), `is`(true)) + + lease1.release() + assertThat(manager.hasActiveLease(), `is`(true)) + + lease2.release() + assertThat(manager.hasActiveLease(), `is`(false)) + } + + @Test + fun overlappingLeasesAreIndependent() { + val lease1 = manager.acquire(LeaseReason.EDITING, 10_000L, tag = "editing") + val lease2 = manager.acquire(LeaseReason.UPLOAD, 5_000L, tag = "upload") + + // Upload expires but editing is still active + now += 6_000L + assertThat(manager.hasActiveLease(), `is`(true)) + assertThat(lease2.isReleased, `is`(true)) + assertThat(lease1.isReleased, `is`(false)) + } + + @Test + fun concurrentReleaseFromMultipleThreads() { + val lease = manager.acquire(LeaseReason.FILE_PICKER, 60_000L, tag = "test") + + val threads = (1..10).map { + Thread { lease.release() } + } + threads.forEach { it.start() } + threads.forEach { it.join() } + + assertThat(lease.isReleased, `is`(true)) + assertThat(manager.hasActiveLease(), `is`(false)) + } +} From d871bf8caa6b0188310a9b84d6fa4e53b8482abc Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 14:49:18 +0100 Subject: [PATCH 16/26] Fix picker lease leaks and clean up stale checkpoints for abandoned uploads --- .../presenter/BrowseFilesPresenter.kt | 76 ++++++++----------- .../presentation/service/UploadService.java | 1 + 2 files changed, 31 insertions(+), 46 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 9618349ba..09ac0e003 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -433,30 +433,22 @@ class BrowseFilesPresenter @Inject constructor( // } private fun uploadFiles(files: List) { - uploadLocation?.let { location -> - val locationNode = location.toCloudNode() - val cloud = locationNode.cloud ?: run { - releasePickerLease() - return@let - } - val vaultId = location.vault()?.toVault()?.id ?: run { - releasePickerLease() - return@let - } - val targetFolderPath = locationNode.path + val location = uploadLocation ?: return releasePickerLease() + val locationNode = location.toCloudNode() + val cloud = locationNode.cloud ?: return releasePickerLease() + val vaultId = location.vault()?.toVault()?.id ?: return releasePickerLease() + val targetFolderPath = locationNode.path - saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { - pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) - } + saveCheckpoint(vaultId, "files", targetFolderPath, files.size) { + pendingFileUris = toJsonArray(files.map { it.dataSource.toString() }) + } - if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId)) { - releasePickerLease() - onUploadDispatched() - } else { - uploadCheckpointDao.deleteByVaultId(vaultId) - releasePickerLease() - onUploadDispatchFailed() - } + releasePickerLease() + if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId)) { + onUploadDispatched() + } else { + uploadCheckpointDao.deleteByVaultId(vaultId) + onUploadDispatchFailed() } } @@ -1118,31 +1110,23 @@ class BrowseFilesPresenter @Inject constructor( // } private fun uploadFolder(folderStructure: UploadFolderStructure, sourceFolderUri: Uri? = null) { - uploadLocation?.let { location -> - val locationNode = location.toCloudNode() - val cloud = locationNode.cloud ?: run { - releasePickerLease() - return@let - } - val vaultId = location.vault()?.toVault()?.id ?: run { - releasePickerLease() - return@let - } - val targetFolderPath = locationNode.path - - saveCheckpoint(vaultId, "folder", targetFolderPath, folderStructure.totalFileCount()) { - this.sourceFolderUri = sourceFolderUri?.toString() - this.sourceFolderName = folderStructure.folderName - } + val location = uploadLocation ?: return releasePickerLease() + val locationNode = location.toCloudNode() + val cloud = locationNode.cloud ?: return releasePickerLease() + val vaultId = location.vault()?.toVault()?.id ?: return releasePickerLease() + val targetFolderPath = locationNode.path + + saveCheckpoint(vaultId, "folder", targetFolderPath, folderStructure.totalFileCount()) { + this.sourceFolderUri = sourceFolderUri?.toString() + this.sourceFolderName = folderStructure.folderName + } - if (cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId)) { - releasePickerLease() - onUploadDispatched() - } else { - uploadCheckpointDao.deleteByVaultId(vaultId) - releasePickerLease() - onUploadDispatchFailed() - } + releasePickerLease() + if (cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId)) { + onUploadDispatched() + } else { + uploadCheckpointDao.deleteByVaultId(vaultId) + onUploadDispatchFailed() } } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index c40de090a..1c16c3eb8 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -191,6 +191,7 @@ private void drainAndCleanupQueue() { pendingUploads.clear(); } for (QueuedUpload upload : abandoned) { + uploadCheckpointDao.deleteByVaultId(upload.vaultId); deleteTempFiles(upload.cleanupUris); } } From 552b9019b31dd005358112ff1dd20f84ede51017 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sun, 22 Feb 2026 17:23:45 +0100 Subject: [PATCH 17/26] Add targeted UI updates via UploadUiUpdates event bus during upload --- .../usecases/cloud/FileUploadedCallback.java | 6 +- .../usecases/cloud/FolderCreatedCallback.java | 11 +++ .../domain/usecases/cloud/UploadFiles.java | 12 +-- .../usecases/cloud/UploadFolderFiles.java | 41 ++++----- .../domain/usecases/cloud/UploadFileTest.kt | 2 +- .../usecases/cloud/UploadFolderFilesTest.kt | 4 +- .../presentation/CryptomatorApp.kt | 22 +++-- .../di/component/ApplicationComponent.java | 3 + .../presenter/BrowseFilesPresenter.kt | 66 +++++++++----- .../presentation/service/UploadService.java | 88 ++++++++++++++++--- .../presentation/service/UploadUiUpdates.kt | 54 ++++++++++++ 11 files changed, 231 insertions(+), 78 deletions(-) create mode 100644 domain/src/main/java/org/cryptomator/domain/usecases/cloud/FolderCreatedCallback.java create mode 100644 presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java index bc42e6514..5b4194e26 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java @@ -1,9 +1,11 @@ package org.cryptomator.domain.usecases.cloud; +import org.cryptomator.domain.CloudFile; + public interface FileUploadedCallback { - void onFileUploaded(String relativePath); + void onFileUploaded(String relativePath, CloudFile file); - FileUploadedCallback NO_OP = relativePath -> { + FileUploadedCallback NO_OP = (relativePath, file) -> { }; } diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FolderCreatedCallback.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FolderCreatedCallback.java new file mode 100644 index 000000000..1f26ebc05 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FolderCreatedCallback.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFolder; + +public interface FolderCreatedCallback { + + void onFolderCreated(String relativePath, CloudFolder folder); + + FolderCreatedCallback NO_OP = (relativePath, folder) -> { + }; +} 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 index 9b6e253f6..4f98979ec 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java @@ -35,12 +35,7 @@ public class UploadFiles { private FileUploadedCallback fileUploadedCallback = FileUploadedCallback.NO_OP; private volatile boolean cancelled; - private final Flag cancelledFlag = new Flag() { - @Override - public boolean get() { - return cancelled; - } - }; + private final Flag cancelledFlag = () -> cancelled; public UploadFiles(Context context, // CloudContentRepository cloudContentRepository, // @@ -83,8 +78,9 @@ private List upload(ProgressAware progressAware) throws if (completedFiles.contains(file.getFileName())) { continue; } - uploadedFiles.add(upload(file, progressAware)); - fileUploadedCallback.onFileUploaded(file.getFileName()); + CloudFile uploadedFile = upload(file, progressAware); + uploadedFiles.add(uploadedFile); + fileUploadedCallback.onFileUploaded(file.getFileName(), uploadedFile); } return uploadedFiles; } diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java index 67ce69093..861cc4ce7 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java @@ -34,14 +34,10 @@ public class UploadFolderFiles { private final UploadFolderStructure folderStructure; private Set completedFiles = Collections.emptySet(); private FileUploadedCallback fileUploadedCallback = FileUploadedCallback.NO_OP; + private FolderCreatedCallback folderCreatedCallback = FolderCreatedCallback.NO_OP; private volatile boolean cancelled; - private final Flag cancelledFlag = new Flag() { - @Override - public boolean get() { - return cancelled; - } - }; + private final Flag cancelledFlag = () -> cancelled; public UploadFolderFiles(Context context, // CloudContentRepository cloudContentRepository, // @@ -61,6 +57,10 @@ public void setFileUploadedCallback(FileUploadedCallback fileUploadedCallback) { this.fileUploadedCallback = fileUploadedCallback; } + public void setFolderCreatedCallback(FolderCreatedCallback folderCreatedCallback) { + this.folderCreatedCallback = folderCreatedCallback; + } + public void onCancel() { cancelled = true; } @@ -84,6 +84,7 @@ private List uploadFolder(CloudFolder targetParent, UploadFolderStruc private List uploadFolder(CloudFolder targetParent, UploadFolderStructure structure, String relativePath, ProgressAware progressAware) throws BackendException { CloudFolder createdFolder = createFolderSafe(targetParent, structure.getFolderName()); + folderCreatedCallback.onFolderCreated(relativePath, createdFolder); List uploadedFiles = new ArrayList<>(); @@ -92,8 +93,9 @@ private List uploadFolder(CloudFolder targetParent, UploadFolderStruc if (completedFiles.contains(fileRelativePath)) { continue; } - uploadedFiles.add(upload(createdFolder, file, progressAware)); - fileUploadedCallback.onFileUploaded(fileRelativePath); + CloudFile uploadedFile = upload(createdFolder, file, progressAware); + uploadedFiles.add(uploadedFile); + fileUploadedCallback.onFileUploaded(fileRelativePath, uploadedFile); } for (UploadFolderStructure subfolder : structure.getSubfolders()) { @@ -149,20 +151,19 @@ private CloudFile writeCloudFile(CloudFolder folder, String fileName, CancelAwar private File copyDataToFile(DataSource dataSource) { File dir = context.getCacheDir(); - File target; - try { - target = createTempFile("upload", "tmp", dir); - } catch (IOException e) { - throw new FatalBackendException(e); - } try { - InputStream in = CancelAwareDataSource.wrap(dataSource, cancelledFlag).open(context); - OutputStream out = new FileOutputStream(target); - StreamHelper.copy(in, out); - dataSource.modifiedDate(context).ifPresent(value -> target.setLastModified(value.getTime())); - return target; + File target = createTempFile("upload", "tmp", dir); + try { + InputStream in = CancelAwareDataSource.wrap(dataSource, cancelledFlag).open(context); + OutputStream out = new FileOutputStream(target); + StreamHelper.copy(in, out); + dataSource.modifiedDate(context).ifPresent(value -> target.setLastModified(value.getTime())); + return target; + } catch (IOException e) { + target.delete(); + throw e; + } } catch (IOException e) { - target.delete(); throw new FatalBackendException(e); } } diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt index 6cd40754c..81e5dc9c3 100644 --- a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt @@ -203,7 +203,7 @@ class UploadFileTest { inTest.execute(progressAware) - verify(callback).onFileUploaded(fileName) + verify(callback).onFileUploaded(eq(fileName), same(resultFile)) } @Test diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt index ec8bf048b..39578c165 100644 --- a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt @@ -292,7 +292,7 @@ class UploadFolderFilesTest { inTest.execute(progressAware) - verify(callback).onFileUploaded("testFolder/file.txt") + verify(callback).onFileUploaded(eq("testFolder/file.txt"), same(resultFile)) } @Test @@ -333,7 +333,7 @@ class UploadFolderFilesTest { inTest.execute(progressAware) - verify(callback).onFileUploaded("testFolder/sub/deep.txt") + verify(callback).onFileUploaded(eq("testFolder/sub/deep.txt"), same(resultFile)) } @Test diff --git a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt index 8de853e84..2a55df802 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/CryptomatorApp.kt @@ -85,20 +85,16 @@ class CryptomatorApp : MultiDexApplication(), HasComponent } private fun launchServices() { + launchService("cryptors") { startCryptorsService() } + launchService("auto upload") { startAutoUploadService() } + launchService("upload") { startUploadService() } + } + + private fun launchService(name: String, start: () -> Unit) { 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") - } - try { - startUploadService() + start() } catch (e: IllegalStateException) { - Timber.tag("App").e(e, "Failed to launch upload service") + Timber.tag("App").e(e, "Failed to launch %s service", name) } } @@ -173,6 +169,8 @@ class CryptomatorApp : MultiDexApplication(), HasComponent uploadServiceBinder?.init( applicationComponent.cloudContentRepository(), applicationComponent.uploadCheckpointDao(), + applicationComponent.uploadUiUpdates(), + applicationComponent.fileUtil(), Companion.applicationContext ) } 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 index 4a94b5853..d79a74393 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java +++ b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java @@ -15,6 +15,7 @@ import org.cryptomator.domain.repository.VaultRepository; import org.cryptomator.presentation.di.module.ApplicationModule; import org.cryptomator.presentation.di.module.ThreadModule; +import org.cryptomator.presentation.service.UploadUiUpdates; import org.cryptomator.presentation.util.ContentResolverUtil; import org.cryptomator.presentation.util.FileUtil; @@ -50,4 +51,6 @@ public interface ApplicationComponent { UploadCheckpointDao uploadCheckpointDao(); + UploadUiUpdates uploadUiUpdates(); + } diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 09ac0e003..d3b3c4b86 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -53,8 +53,11 @@ import org.cryptomator.generator.InjectIntent import org.cryptomator.generator.InstanceState import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R +import org.cryptomator.presentation.service.FolderKey import org.cryptomator.presentation.service.KeepAliveLease import org.cryptomator.presentation.service.LeaseReason +import org.cryptomator.presentation.service.UploadUiEvent +import org.cryptomator.presentation.service.UploadUiUpdates import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.BrowseFilesIntent import org.cryptomator.presentation.intent.ChooseCloudNodeSettings @@ -134,6 +137,7 @@ class BrowseFilesPresenter @Inject constructor( // private val downloadFileUtil: DownloadFileUtil, // private val sharedPreferencesHandler: SharedPreferencesHandler, // private val uploadCheckpointDao: UploadCheckpointDao, // + private val uploadUiUpdates: UploadUiUpdates, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { @@ -145,6 +149,7 @@ class BrowseFilesPresenter @Inject constructor( // private var resumedAfterAuthentication = false private var folderStructureDisposable: Disposable? = null + private var uploadEventsDisposable: Disposable? = null private var pickerLease: KeepAliveLease? = null @InjectIntent @@ -188,6 +193,8 @@ class BrowseFilesPresenter @Inject constructor( // pickerLease = null folderStructureDisposable?.dispose() folderStructureDisposable = null + uploadEventsDisposable?.dispose() + uploadEventsDisposable = null } fun onWindowFocusChanged(hasFocus: Boolean) { @@ -221,6 +228,7 @@ class BrowseFilesPresenter @Inject constructor( // } else { showCloudNodesCollectionInView(cloudNodes) } + subscribeToUploadEvents(cloudFolderModel) view?.showLoading(false) } @@ -444,11 +452,8 @@ class BrowseFilesPresenter @Inject constructor( // } releasePickerLease() - if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId)) { - onUploadDispatched() - } else { - uploadCheckpointDao.deleteByVaultId(vaultId) - onUploadDispatchFailed() + dispatchUpload(vaultId) { + cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId) } } @@ -647,11 +652,8 @@ class BrowseFilesPresenter @Inject constructor( // emptyList() } - if (cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId, cleanupUris)) { - onUploadDispatched() - } else { - uploadCheckpointDao.deleteByVaultId(vaultId) - onUploadDispatchFailed() + dispatchUpload(vaultId) { + cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId, cleanupUris) } } @@ -1122,11 +1124,8 @@ class BrowseFilesPresenter @Inject constructor( // } releasePickerLease() - if (cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId)) { - onUploadDispatched() - } else { - uploadCheckpointDao.deleteByVaultId(vaultId) - onUploadDispatchFailed() + dispatchUpload(vaultId) { + cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId) } } @@ -1155,15 +1154,38 @@ class BrowseFilesPresenter @Inject constructor( // releasePickerLease() } - private fun onUploadDispatched() { - view?.showProgress(ProgressModel.COMPLETED) - view?.showMessage(R.string.notification_upload_started) - uploadLocation = null + private fun subscribeToUploadEvents(folder: CloudFolderModel) { + uploadEventsDisposable?.dispose() + val vaultId = folder.vault()?.toVault()?.id ?: return + val folderKey = FolderKey(vaultId, folder.toCloudNode().path) + + // Subscribe first, then apply snapshot to avoid missing events emitted between + // snapshot read and subscribe. Duplicates are harmless (addOrUpdateCloudNode is idempotent). + uploadEventsDisposable = uploadUiUpdates.eventsFor(folderKey) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ event -> + when (event) { + is UploadUiEvent.NodeCreated -> view?.addOrUpdateCloudNode(event.node) + is UploadUiEvent.UploadFinished -> uploadUiUpdates.clear(event.folderKey) + } + }, { e -> + Timber.tag("BrowseFilesPresenter").e(e, "Upload event stream error") + }) + + uploadUiUpdates.snapshot(folderKey).forEach { node -> + view?.addOrUpdateCloudNode(node) + } } - private fun onUploadDispatchFailed() { - view?.showProgress(ProgressModel.COMPLETED) - view?.showMessage(R.string.error_upload_service_unavailable) + private fun dispatchUpload(vaultId: Long, dispatch: () -> Boolean) { + if (dispatch()) { + view?.showProgress(ProgressModel.COMPLETED) + view?.showMessage(R.string.notification_upload_started) + } else { + uploadCheckpointDao.deleteByVaultId(vaultId) + view?.showProgress(ProgressModel.COMPLETED) + view?.showMessage(R.string.error_upload_service_unavailable) + } uploadLocation = null } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 1c16c3eb8..f62e19f80 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -12,6 +12,7 @@ import org.cryptomator.data.db.UploadCheckpointDao; 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; @@ -20,12 +21,17 @@ import org.cryptomator.domain.repository.CloudContentRepository; import org.cryptomator.domain.usecases.ProgressAware; import org.cryptomator.domain.usecases.cloud.FileUploadedCallback; +import org.cryptomator.domain.usecases.cloud.FolderCreatedCallback; import org.cryptomator.domain.usecases.cloud.UploadFile; import org.cryptomator.domain.usecases.cloud.UploadFiles; import org.cryptomator.domain.usecases.cloud.UploadFolderFiles; import org.cryptomator.domain.usecases.cloud.UploadFolderStructure; import org.cryptomator.domain.usecases.cloud.UploadState; import org.cryptomator.presentation.CryptomatorApp; +import org.cryptomator.presentation.model.CloudFileModel; +import org.cryptomator.presentation.model.CloudFolderModel; +import org.cryptomator.presentation.util.FileIcon; +import org.cryptomator.presentation.util.FileUtil; import java.io.File; import java.util.ArrayList; @@ -46,6 +52,8 @@ public class UploadService extends Service { private UploadNotification notification; private CloudContentRepository cloudContentRepository; private UploadCheckpointDao uploadCheckpointDao; + private UploadUiUpdates uploadUiUpdates; + private FileUtil fileUtil; private Context appContext; private Thread worker; private volatile boolean cancelled; @@ -67,10 +75,10 @@ public static Intent cancelUploadIntent(Context context) { public void startFileUpload(Cloud cloud, String targetFolderPath, List files, Set completedFiles, long vaultId, List cleanupUris) { - startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), cleanupUris, (targetFolder, callback, progressAware) -> { + startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), cleanupUris, (targetFolder, fileCallback, folderCallback, progressAware) -> { UploadFiles uploadFiles = new UploadFiles(appContext, cloudContentRepository, targetFolder, files); uploadFiles.setCompletedFiles(completedFiles); - uploadFiles.setFileUploadedCallback(callback); + uploadFiles.setFileUploadedCallback(fileCallback); cancelCallback = uploadFiles::onCancel; if (cancelled) { throw new CancellationException(); @@ -81,10 +89,11 @@ public void startFileUpload(Cloud cloud, String targetFolderPath, List completedFiles, long vaultId) { - startUpload(cloud, targetFolderPath, completedFiles, vaultId, folderStructure.totalFileCount(), Collections.emptyList(), (targetFolder, callback, progressAware) -> { + startUpload(cloud, targetFolderPath, completedFiles, vaultId, folderStructure.totalFileCount(), Collections.emptyList(), (targetFolder, fileCallback, folderCallback, progressAware) -> { UploadFolderFiles uploadFolderFiles = new UploadFolderFiles(appContext, cloudContentRepository, targetFolder, folderStructure); uploadFolderFiles.setCompletedFiles(completedFiles); - uploadFolderFiles.setFileUploadedCallback(callback); + uploadFolderFiles.setFileUploadedCallback(fileCallback); + uploadFolderFiles.setFolderCreatedCallback(folderCallback); cancelCallback = uploadFolderFiles::onCancel; if (cancelled) { throw new CancellationException(); @@ -154,28 +163,35 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { cancelled = false; + FolderKey folderKey = new FolderKey(upload.vaultId, upload.targetFolderPath); + try { CloudFolder targetFolder = upload.targetFolderPath.isEmpty() ? cloudContentRepository.root(upload.cloud) : cloudContentRepository.resolve(upload.cloud, upload.targetFolderPath); - FileUploadedCallback callback = createCheckpointCallback(upload.vaultId, upload.completedFiles); + FileUploadedCallback fileCallback = createCheckpointCallback(upload.vaultId, upload.completedFiles, folderKey); + FolderCreatedCallback folderCallback = createFolderCreatedCallback(folderKey); ProgressAware progressAware = progress -> updateNotification(progress.asPercentage()); - upload.uploadTask.execute(targetFolder, callback, progressAware); + upload.uploadTask.execute(targetFolder, fileCallback, folderCallback, progressAware); uploadCheckpointDao.deleteByVaultId(upload.vaultId); + emitUploadFinished(folderKey); notification.showUploadFinished(upload.totalFiles - upload.completedFiles.size()); Timber.tag("UploadService").i("Upload completed"); return true; } catch (CancellationException e) { + clearSnapshot(folderKey); Timber.tag("UploadService").i("Upload canceled by user"); return false; } catch (MissingCryptorException e) { + clearSnapshot(folderKey); notification.showVaultLockedDuringUpload(); Timber.tag("UploadService").e(e, "Vault locked during upload"); return false; } catch (BackendException | FatalBackendException e) { + clearSnapshot(folderKey); notification.showGeneralErrorDuringUpload(); Timber.tag("UploadService").e(e, "Upload failed"); return false; @@ -196,9 +212,9 @@ private void drainAndCleanupQueue() { } } - @FunctionalInterface private interface UploadTask { - void execute(CloudFolder targetFolder, FileUploadedCallback callback, + void execute(CloudFolder targetFolder, FileUploadedCallback fileCallback, + FolderCreatedCallback folderCallback, ProgressAware progressAware) throws BackendException; } @@ -223,9 +239,9 @@ private static class QueuedUpload { } } - private FileUploadedCallback createCheckpointCallback(long vaultId, Set completedFiles) { + private FileUploadedCallback createCheckpointCallback(long vaultId, Set completedFiles, FolderKey folderKey) { Set uploadedSoFar = new HashSet<>(completedFiles); - return relativePath -> { + return (relativePath, file) -> { uploadedSoFar.add(relativePath); try { String json = toJsonArray(uploadedSoFar); @@ -233,6 +249,12 @@ private FileUploadedCallback createCheckpointCallback(long vaultId, Set } catch (Exception e) { Timber.tag("UploadService").w(e, "Failed to update checkpoint"); } + // Only emit for direct children of the target folder. + // Flat uploads: relativePath is just the filename (no "/"). + // Folder uploads: files inside the folder always have "/" — they're not direct children. + if (!relativePath.contains("/")) { + emitFileCreated(folderKey, file); + } new Handler(Looper.getMainLooper()).post(() -> { if (notification != null) { notification.updateFinishedFile(); @@ -241,6 +263,47 @@ private FileUploadedCallback createCheckpointCallback(long vaultId, Set }; } + private FolderCreatedCallback createFolderCreatedCallback(FolderKey folderKey) { + // Only emit for the root folder (direct child of target). + // The root folder's relativePath has no "/"; subfolders do. + return (relativePath, folder) -> { + if (!relativePath.contains("/")) { + emitFolderCreated(folderKey, folder); + } + }; + } + + private void emitFileCreated(FolderKey folderKey, CloudFile file) { + if (fileUtil != null) { + CloudFileModel model = new CloudFileModel(file, FileIcon.fileIconFor(file.getName(), fileUtil)); + emitEvent(new UploadUiEvent.NodeCreated(folderKey, model)); + } + } + + private void emitFolderCreated(FolderKey folderKey, CloudFolder folder) { + emitEvent(new UploadUiEvent.NodeCreated(folderKey, new CloudFolderModel(folder))); + } + + private void emitUploadFinished(FolderKey folderKey) { + emitEvent(new UploadUiEvent.UploadFinished(folderKey)); + } + + private void emitEvent(UploadUiEvent event) { + if (uploadUiUpdates != null) { + try { + uploadUiUpdates.emit(event); + } catch (Exception e) { + Timber.tag("UploadService").w(e, "Failed to emit upload event"); + } + } + } + + private void clearSnapshot(FolderKey folderKey) { + if (uploadUiUpdates != null) { + uploadUiUpdates.clear(folderKey); + } + } + private String toJsonArray(Set items) { return items.stream() .map(item -> "\"" + item.replace("\"", "\\\"") + "\"") @@ -341,9 +404,12 @@ public class Binder extends android.os.Binder { } public void init(CloudContentRepository cloudContentRepository, - UploadCheckpointDao uploadCheckpointDao, Context context) { + UploadCheckpointDao uploadCheckpointDao, UploadUiUpdates uploadUiUpdates, + FileUtil fileUtil, Context context) { UploadService.this.cloudContentRepository = cloudContentRepository; UploadService.this.uploadCheckpointDao = uploadCheckpointDao; + UploadService.this.uploadUiUpdates = uploadUiUpdates; + UploadService.this.fileUtil = fileUtil; UploadService.this.appContext = context; } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt new file mode 100644 index 000000000..a2e0916c0 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt @@ -0,0 +1,54 @@ +package org.cryptomator.presentation.service + +import io.reactivex.BackpressureOverflowStrategy +import io.reactivex.Flowable +import io.reactivex.processors.PublishProcessor +import org.cryptomator.presentation.model.CloudNodeModel +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +data class FolderKey(val vaultId: Long, val parentPath: String) + +sealed class UploadUiEvent { + abstract val folderKey: FolderKey + + data class NodeCreated(override val folderKey: FolderKey, val node: CloudNodeModel<*>) : UploadUiEvent() + data class UploadFinished(override val folderKey: FolderKey) : UploadUiEvent() +} + +@Singleton +class UploadUiUpdates @Inject constructor() { + + private val processor = PublishProcessor.create().toSerialized() + private val snapshots = ConcurrentHashMap>>() + + fun emit(event: UploadUiEvent) { + when (event) { + is UploadUiEvent.NodeCreated -> { + snapshots.getOrPut(event.folderKey) { ConcurrentHashMap() }[event.node.name] = event.node + } + is UploadUiEvent.UploadFinished -> { + snapshots.remove(event.folderKey) + } + } + processor.onNext(event) + } + + fun eventsFor(folderKey: FolderKey): Flowable { + return processor + .filter { it.folderKey == folderKey } + .onBackpressureBuffer(512, { + Timber.tag("UploadUiUpdates").w("Backpressure buffer overflow, dropping oldest event") + }, BackpressureOverflowStrategy.DROP_OLDEST) + } + + fun snapshot(folderKey: FolderKey): List> { + return snapshots[folderKey]?.values?.toList() ?: emptyList() + } + + fun clear(folderKey: FolderKey) { + snapshots.remove(folderKey) + } +} From 27eaf979cfeaac90241c6a89427fb93f0d8e1f1f Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Mon, 23 Feb 2026 06:05:43 +0100 Subject: [PATCH 18/26] Show replace dialog for folder uploads and persist choice for resume --- .../org/cryptomator/data/db/Upgrade13To14.kt | 1 + .../data/db/UploadCheckpointDao.java | 2 + .../db/entities/UploadCheckpointEntity.java | 20 ++++- .../domain/usecases/cloud/UploadFile.java | 6 +- .../usecases/cloud/UploadFolderFiles.java | 10 ++- .../usecases/cloud/UploadFolderStructure.java | 9 ++ .../usecases/cloud/UploadFolderFilesTest.kt | 89 +++++++++++++++++++ .../presenter/BrowseFilesPresenter.kt | 34 ++++++- .../presenter/VaultListPresenter.kt | 3 + .../ui/activity/BrowseFilesActivity.kt | 4 + .../ui/activity/view/BrowseFilesView.kt | 1 + .../presentation/ui/dialog/ReplaceDialog.kt | 11 +++ presentation/src/main/res/values/strings.xml | 4 + 13 files changed, 186 insertions(+), 8 deletions(-) diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt index 5ffab7c31..df752cb0a 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt @@ -29,6 +29,7 @@ internal class Upgrade13To14 @Inject constructor() : DatabaseUpgrade(13, 14) { .requiredText("COMPLETED_FILES") // .requiredInt("TOTAL_FILE_COUNT") // .requiredInt("TIMESTAMP") // + .optionalInt("REPLACING") // .executeOn(db) Sql.createUniqueIndex("IDX_UPLOAD_CHECKPOINT_ENTITY_VAULT_ID") // diff --git a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java index 7b90dbe7a..2cd6d3002 100644 --- a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java +++ b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java @@ -61,6 +61,7 @@ private ContentValues toContentValues(UploadCheckpointEntity entity) { values.put("COMPLETED_FILES", entity.getCompletedFiles()); values.put("TOTAL_FILE_COUNT", entity.getTotalFileCount()); values.put("TIMESTAMP", entity.getTimestamp()); + values.put("REPLACING", entity.isReplacing() ? 1 : 0); return values; } @@ -76,6 +77,7 @@ private UploadCheckpointEntity fromCursor(Cursor cursor) { entity.setCompletedFiles(cursor.getString(cursor.getColumnIndexOrThrow("COMPLETED_FILES"))); entity.setTotalFileCount(cursor.getInt(cursor.getColumnIndexOrThrow("TOTAL_FILE_COUNT"))); entity.setTimestamp(cursor.getLong(cursor.getColumnIndexOrThrow("TIMESTAMP"))); + entity.setReplacing(cursor.getInt(cursor.getColumnIndexOrThrow("REPLACING")) != 0); return entity; } } diff --git a/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java index 34910de34..29181e116 100644 --- a/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java +++ b/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java @@ -36,11 +36,14 @@ public class UploadCheckpointEntity extends DatabaseEntity { @NotNull private long timestamp; - @Generated(hash = 482695414) + private boolean replacing; + + @Generated(hash = 1792302708) public UploadCheckpointEntity(Long id, @NotNull Long vaultId, @NotNull String type, @NotNull String targetFolderPath, String sourceFolderUri, String sourceFolderName, String pendingFileUris, - @NotNull String completedFiles, int totalFileCount, long timestamp) { + @NotNull String completedFiles, int totalFileCount, long timestamp, + boolean replacing) { this.id = id; this.vaultId = vaultId; this.type = type; @@ -51,6 +54,7 @@ public UploadCheckpointEntity(Long id, @NotNull Long vaultId, this.completedFiles = completedFiles; this.totalFileCount = totalFileCount; this.timestamp = timestamp; + this.replacing = replacing; } @Generated(hash = 1737881290) @@ -137,4 +141,16 @@ public long getTimestamp() { public void setTimestamp(long timestamp) { this.timestamp = timestamp; } + + public boolean isReplacing() { + return this.replacing; + } + + public void setReplacing(boolean replacing) { + this.replacing = replacing; + } + + public boolean getReplacing() { + return this.replacing; + } } 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 index ddf1bd672..040b4bda7 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFile.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFile.java @@ -6,7 +6,7 @@ public class UploadFile { private final DataSource dataSource; - private final Boolean replacing; + private Boolean replacing; private UploadFile(Builder builder) { this.fileName = builder.fileName; @@ -37,6 +37,10 @@ public Boolean getReplacing() { return replacing; } + public void setReplacing(boolean replacing) { + this.replacing = replacing; + } + public static class Builder { private String fileName; diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java index 861cc4ce7..72530a1eb 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java @@ -93,9 +93,13 @@ private List uploadFolder(CloudFolder targetParent, UploadFolderStruc if (completedFiles.contains(fileRelativePath)) { continue; } - CloudFile uploadedFile = upload(createdFolder, file, progressAware); - uploadedFiles.add(uploadedFile); - fileUploadedCallback.onFileUploaded(fileRelativePath, uploadedFile); + try { + CloudFile uploadedFile = upload(createdFolder, file, progressAware); + uploadedFiles.add(uploadedFile); + fileUploadedCallback.onFileUploaded(fileRelativePath, uploadedFile); + } catch (CloudNodeAlreadyExistsException e) { + fileUploadedCallback.onFileUploaded(fileRelativePath, null); + } } for (UploadFolderStructure subfolder : structure.getSubfolders()) { diff --git a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java index 1cf054856..9dc657fc0 100644 --- a/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java @@ -35,6 +35,15 @@ public void addSubfolder(UploadFolderStructure subfolder) { this.subfolders.add(subfolder); } + public void setAllReplacing(boolean replacing) { + for (UploadFile file : files) { + file.setReplacing(replacing); + } + for (UploadFolderStructure subfolder : subfolders) { + subfolder.setAllReplacing(replacing); + } + } + public int totalFileCount() { int count = files.size(); for (UploadFolderStructure subfolder : subfolders) { diff --git a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt index 39578c165..b345cbb5b 100644 --- a/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt @@ -422,6 +422,95 @@ class UploadFolderFilesTest { assertThat(result[0], `is`(resultFile2)) } + @Test + @DisplayName("Per-file CloudNodeAlreadyExistsException is caught and file is skipped") + @Throws(BackendException::class) + fun testPerFileAlreadyExistsIsSkipped() { + val fileSize: Long = 100 + val dataSource1 = dataSourceWithBytes(1, fileSize, fileSize) + val dataSource2 = dataSourceWithBytes(2, fileSize, fileSize) + val callback: FileUploadedCallback = mock() + + val structure = UploadFolderStructure("testFolder") + structure.addFile( + UploadFile.Builder() + .withFileName("existing.txt") + .withDataSource(dataSource1) + .thatIsReplacing(false) + .build() + ) + structure.addFile( + UploadFile.Builder() + .withFileName("new.txt") + .withDataSource(dataSource2) + .thatIsReplacing(false) + .build() + ) + + val inTest = UploadFolderFiles(context, cloudContentRepository, parent, structure) + inTest.setFileUploadedCallback(callback) + + val existingTarget: CloudFile = mock() + whenever(cloudContentRepository.file(createdFolder, "existing.txt", fileSize)).thenReturn(existingTarget) + whenever( + cloudContentRepository.write( + same(existingTarget), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenThrow(CloudNodeAlreadyExistsException("existing.txt")) + + whenever(cloudContentRepository.file(createdFolder, "new.txt", fileSize)).thenReturn(targetFile) + whenever( + cloudContentRepository.write( + same(targetFile), + any(DataSource::class.java), + same(progressAware), + eq(false), + eq(fileSize) + ) + ).thenReturn(resultFile) + + val result = inTest.execute(progressAware) + + assertThat(result.size, `is`(1)) + assertThat(result[0], `is`(resultFile)) + verify(callback).onFileUploaded(eq("testFolder/existing.txt"), eq(null)) + verify(callback).onFileUploaded(eq("testFolder/new.txt"), same(resultFile)) + } + + @Test + @DisplayName("setAllReplacing recursively sets replacing on all files") + fun testSetAllReplacing() { + val dataSource = dataSourceWithBytes(0, 10, 10) + + val subfolder = UploadFolderStructure("sub") + subfolder.addFile( + UploadFile.Builder() + .withFileName("subfile.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + + val root = UploadFolderStructure("root") + root.addFile( + UploadFile.Builder() + .withFileName("rootfile.txt") + .withDataSource(dataSource) + .thatIsReplacing(false) + .build() + ) + root.addSubfolder(subfolder) + + root.setAllReplacing(true) + + assertThat(root.files[0].replacing, `is`(true)) + assertThat(root.subfolders[0].files[0].replacing, `is`(true)) + } + private fun dataSourceWithBytes(value: Int, amount: Long, size: Long?): DataSource { check(amount <= Int.MAX_VALUE) { "Can not use values > Integer.MAX_VALUE" } val bytes = bytes(value, amount) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index d3b3c4b86..924bf88dd 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -151,6 +151,8 @@ class BrowseFilesPresenter @Inject constructor( // private var folderStructureDisposable: Disposable? = null private var uploadEventsDisposable: Disposable? = null private var pickerLease: KeepAliveLease? = null + private var pendingFolderUpload: UploadFolderStructure? = null + private var pendingFolderSourceUri: Uri? = null @InjectIntent lateinit var intent: BrowseFilesIntent @@ -819,6 +821,10 @@ class BrowseFilesPresenter @Inject constructor( // uploadFiles(filesReadyForUpload) } + private fun folderExistsAtLocation(folderName: String): Boolean { + return view?.renderedCloudNodes()?.any { it is CloudFolderModel && it.name == folderName } == true + } + private fun hasUsedFileNamesAtLocation(currentCloudNodes: List>): Boolean { currentCloudNodes .filter { filesForUpload.containsKey(it.name) } @@ -846,11 +852,22 @@ class BrowseFilesPresenter @Inject constructor( // } fun uploadFilesAndReplaceExistingFiles() { + pendingFolderUpload?.let { folder -> + folder.setAllReplacing(true) + uploadFolder(folder, pendingFolderSourceUri, replacing = true) + clearPendingFolderUpload() + return + } differencesOfUploadAndExistingFiles() uploadFiles(filesForUpload, existingFilesForUpload) } fun uploadFilesAndSkipExistingFiles() { + pendingFolderUpload?.let { folder -> + uploadFolder(folder, pendingFolderSourceUri) + clearPendingFolderUpload() + return + } differencesOfUploadAndExistingFiles() uploadFiles(filesForUpload, emptyMap()) } @@ -1103,7 +1120,13 @@ class BrowseFilesPresenter @Inject constructor( // view?.showMessage(R.string.screen_file_browser_nothing_to_upload) return@subscribe } - uploadFolder(folderStructure, treeUri) + if (folderExistsAtLocation(folderStructure.folderName)) { + pendingFolderUpload = folderStructure + pendingFolderSourceUri = treeUri + view?.showReplaceFolderDialog(folderStructure.folderName) + } else { + uploadFolder(folderStructure, treeUri) + } }, { e -> releasePickerLease() view?.showProgress(ProgressModel.COMPLETED) @@ -1111,7 +1134,7 @@ class BrowseFilesPresenter @Inject constructor( // }) } - private fun uploadFolder(folderStructure: UploadFolderStructure, sourceFolderUri: Uri? = null) { + private fun uploadFolder(folderStructure: UploadFolderStructure, sourceFolderUri: Uri? = null, replacing: Boolean = false) { val location = uploadLocation ?: return releasePickerLease() val locationNode = location.toCloudNode() val cloud = locationNode.cloud ?: return releasePickerLease() @@ -1121,6 +1144,7 @@ class BrowseFilesPresenter @Inject constructor( // saveCheckpoint(vaultId, "folder", targetFolderPath, folderStructure.totalFileCount()) { this.sourceFolderUri = sourceFolderUri?.toString() this.sourceFolderName = folderStructure.folderName + this.isReplacing = replacing } releasePickerLease() @@ -1151,9 +1175,15 @@ class BrowseFilesPresenter @Inject constructor( // } fun onUploadReplaceCanceled() { + clearPendingFolderUpload() releasePickerLease() } + private fun clearPendingFolderUpload() { + pendingFolderUpload = null + pendingFolderSourceUri = null + } + private fun subscribeToUploadEvents(folder: CloudFolderModel) { uploadEventsDisposable?.dispose() val vaultId = folder.vault()?.toVault()?.id ?: return diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 563ff2739..c82763c03 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -624,6 +624,9 @@ class VaultListPresenter @Inject constructor( // .observeOn(AndroidSchedulers.mainThread()) .subscribe({ folderStructure -> view?.showProgress(ProgressModel.COMPLETED) + if (checkpoint.isReplacing) { + folderStructure.setAllReplacing(true) + } if (!cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId)) { view?.showMessage(R.string.error_upload_service_unavailable) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index c3d72e6eb..d1f1d93b4 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt @@ -312,6 +312,10 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi ReplaceDialog.withContext(this).show(existingFiles, size) } + override fun showReplaceFolderDialog(folderName: String) { + ReplaceDialog.withContext(this).showForFolder(folderName) + } + override fun showUploadDialog(uploadingFiles: Int) { showDialog(UploadCloudFileDialog.newInstance(uploadingFiles)) } 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 index da9a83af5..752b60dee 100644 --- 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 @@ -21,6 +21,7 @@ interface BrowseFilesView : View { fun hideProgress(nodes: List>) fun showFileTypeNotSupportedDialog(file: CloudFileModel) fun showReplaceDialog(existingFiles: List, size: Int) + fun showReplaceFolderDialog(folderName: String) fun showUploadDialog(uploadingFiles: Int) fun renderedCloudNodes(): List> fun hasExcludedFolder(): Boolean diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ReplaceDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ReplaceDialog.kt index fb744316c..7d43a33ea 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ReplaceDialog.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ReplaceDialog.kt @@ -17,6 +17,17 @@ class ReplaceDialog private constructor(private val context: Context) { fun onReplaceCanceled() } + fun showForFolder(folderName: String) { + AlertDialog.Builder(context) // + .setTitle(ResourceHelper.getString(R.string.dialog_replace_folder_title)) // + .setMessage(String.format(ResourceHelper.getString(R.string.dialog_replace_folder_msg), folderName)) // + .setPositiveButton(ResourceHelper.getString(R.string.dialog_replace_folder_positive_button)) { _: DialogInterface, _: Int -> callback.onReplacePositiveClicked() } // + .setNegativeButton(effectiveReplaceDialogNegativeButton()) { _: DialogInterface, _: Int -> callback.onReplaceNegativeClicked() } // + .setNeutralButton(effectiveReplaceDialogNeutralButton()) { _: DialogInterface, _: Int -> callback.onReplaceCanceled() } // + .setOnCancelListener { callback.onReplaceCanceled() } // + .create().show() + } + fun show(existingFiles: List, uploadingFilesCount: Int) { val existingFilesCount = existingFiles.size val alertDialogBuilder = AlertDialog.Builder(context) // diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 2183aea8f..71ce8d6f8 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -370,6 +370,10 @@ Replace file? Replace files? + Replace folder? + A folder named \'%1$s\' already exists. Do you want to replace its contents? + Replace + Unable to share files You haven\'t set up any vaults. Please create a new vault with the Cryptomator app first. OK From 107cf4cc8ca702fd4de2de6337995af34f6906f4 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Mon, 23 Feb 2026 07:12:36 +0100 Subject: [PATCH 19/26] Fix cancel race, stale checkpoint, and service linger in UploadService --- .../presentation/service/UploadService.java | 12 ++++++++++-- .../presentation/service/UploadUiUpdates.kt | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index f62e19f80..7de37c2da 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -112,6 +112,7 @@ private void startUpload(Cloud cloud, String targetFolderPath, Set compl return; } workerRunning = true; + cancelled = false; } startWorker(upload); } @@ -161,11 +162,12 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { } notification.show(); - cancelled = false; - FolderKey folderKey = new FolderKey(upload.vaultId, upload.targetFolderPath); try { + if (cancelled) { + return false; + } CloudFolder targetFolder = upload.targetFolderPath.isEmpty() ? cloudContentRepository.root(upload.cloud) : cloudContentRepository.resolve(upload.cloud, upload.targetFolderPath); @@ -183,6 +185,7 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { return true; } catch (CancellationException e) { clearSnapshot(folderKey); + uploadCheckpointDao.deleteByVaultId(upload.vaultId); Timber.tag("UploadService").i("Upload canceled by user"); return false; } catch (MissingCryptorException e) { @@ -364,6 +367,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { cancel.run(); } hideNotification(); + synchronized (queueLock) { + if (!workerRunning) { + stopSelf(); + } + } } return START_NOT_STICKY; } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt index a2e0916c0..4637211a9 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt @@ -27,7 +27,7 @@ class UploadUiUpdates @Inject constructor() { fun emit(event: UploadUiEvent) { when (event) { is UploadUiEvent.NodeCreated -> { - snapshots.getOrPut(event.folderKey) { ConcurrentHashMap() }[event.node.name] = event.node + snapshots.computeIfAbsent(event.folderKey) { ConcurrentHashMap() }[event.node.name] = event.node } is UploadUiEvent.UploadFinished -> { snapshots.remove(event.folderKey) From dbf0f20864b937a79cc1df6f487428717e6dee9d Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Mon, 23 Feb 2026 18:28:20 +0100 Subject: [PATCH 20/26] Show upload status indicator in vault list with animated fill-up icon --- .../data/db/UploadCheckpointDao.java | 14 ++++++ .../presenter/VaultListPresenter.kt | 46 ++++++++++++++++++- .../presentation/service/UploadService.java | 32 ++++++++++--- .../presentation/service/UploadUiUpdates.kt | 35 ++++++++++++-- .../ui/activity/VaultListActivity.kt | 9 ++++ .../ui/activity/view/VaultListView.kt | 3 ++ .../presentation/ui/adapter/VaultsAdapter.kt | 45 ++++++++++++++++++ .../ui/fragment/VaultListFragment.kt | 9 ++++ .../src/main/res/animator/upload_fill.xml | 9 ++++ .../res/drawable-night/ic_upload_arrow.xml | 9 ++++ .../drawable-night/ic_upload_arrow_fill.xml | 17 +++++++ .../src/main/res/drawable/ic_upload_arrow.xml | 9 ++++ .../res/drawable/ic_upload_arrow_animated.xml | 7 +++ .../res/drawable/ic_upload_arrow_fill.xml | 17 +++++++ .../src/main/res/layout/item_vault.xml | 14 +++++- .../presenter/VaultListPresenterTest.java | 13 ++++++ 16 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 presentation/src/main/res/animator/upload_fill.xml create mode 100644 presentation/src/main/res/drawable-night/ic_upload_arrow.xml create mode 100644 presentation/src/main/res/drawable-night/ic_upload_arrow_fill.xml create mode 100644 presentation/src/main/res/drawable/ic_upload_arrow.xml create mode 100644 presentation/src/main/res/drawable/ic_upload_arrow_animated.xml create mode 100644 presentation/src/main/res/drawable/ic_upload_arrow_fill.xml diff --git a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java index 2cd6d3002..1e7b582ce 100644 --- a/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java +++ b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java @@ -6,6 +6,9 @@ import org.cryptomator.data.db.entities.UploadCheckpointEntity; +import java.util.HashSet; +import java.util.Set; + import javax.inject.Inject; import javax.inject.Singleton; @@ -44,6 +47,17 @@ public void deleteByVaultId(long vaultId) { getDb().delete(TABLE_NAME, "VAULT_ID = ?", new String[]{String.valueOf(vaultId)}); } + public Set findAllVaultIdsWithCheckpoints() { + Set result = new HashSet<>(); + try (Cursor cursor = getDb().query(TABLE_NAME, new String[]{"VAULT_ID"}, + null, null, null, null, null)) { + while (cursor.moveToNext()) { + result.add(cursor.getLong(0)); + } + } + return result; + } + public void updateCompletedFiles(long vaultId, String completedFilesJson) { ContentValues values = new ContentValues(); values.put("COMPLETED_FILES", completedFilesJson); diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index c82763c03..3253bd00b 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -49,6 +49,9 @@ import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R import org.cryptomator.presentation.service.KeepAliveLease import org.cryptomator.presentation.service.LeaseReason +import org.cryptomator.presentation.service.UploadUiUpdates +import org.cryptomator.presentation.service.VaultUploadEvent +import org.cryptomator.presentation.ui.adapter.VaultUploadState import org.cryptomator.presentation.exception.ExceptionHandlers import org.cryptomator.presentation.intent.Intents import org.cryptomator.presentation.intent.UnlockVaultIntent @@ -111,6 +114,7 @@ class VaultListPresenter @Inject constructor( // private val cloudFolderModelMapper: CloudFolderModelMapper, // private val sharedPreferencesHandler: SharedPreferencesHandler, // private val uploadCheckpointDao: UploadCheckpointDao, // + private val uploadUiUpdates: UploadUiUpdates, // private val vaultRepository: VaultRepository, // private val cloudRepository: CloudRepository, // private val contentResolverUtil: ContentResolverUtil, // @@ -120,7 +124,9 @@ class VaultListPresenter @Inject constructor( // private val cryptomatorApp: CryptomatorApp get() = activity().application as CryptomatorApp private var vaultAction: VaultAction? = null private var folderResumeDisposable: Disposable? = null + private var uploadEventsDisposable: Disposable? = null private var resumeLease: KeepAliveLease? = null + private var pendingNavigationAfterResume: Pair? = null override fun workflows(): Iterable> { return listOf(addExistingVaultWorkflow, createNewVaultWorkflow) @@ -131,6 +137,8 @@ class VaultListPresenter @Inject constructor( // resumeLease = null folderResumeDisposable?.dispose() folderResumeDisposable = null + uploadEventsDisposable?.dispose() + uploadEventsDisposable = null } fun onWindowFocusChanged(hasFocus: Boolean) { @@ -429,10 +437,46 @@ class VaultListPresenter @Inject constructor( // view?.hideVaultCreationHint() } view?.renderVaultList(vaultModels) + refreshUploadStates() + subscribeToVaultUploadEvents() } }) } + private fun refreshUploadStates() { + val checkpointVaultIds = uploadCheckpointDao.findAllVaultIdsWithCheckpoints() + val activeVaultIds = uploadUiUpdates.activeVaultIds() + val stateMap = mutableMapOf() + for (vaultId in activeVaultIds) { + stateMap[vaultId] = VaultUploadState.ACTIVE + } + for (vaultId in checkpointVaultIds) { + if (vaultId !in activeVaultIds) { + stateMap[vaultId] = VaultUploadState.RESUMABLE + } + } + view?.updateUploadStates(stateMap) + } + + private fun subscribeToVaultUploadEvents() { + uploadEventsDisposable?.dispose() + uploadEventsDisposable = uploadUiUpdates.vaultEvents() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ event -> + when (event) { + is VaultUploadEvent.Started -> + view?.updateVaultUploadState(event.vaultId, VaultUploadState.ACTIVE) + is VaultUploadEvent.Finished -> { + val hasCheckpoint = uploadCheckpointDao.findByVaultId(event.vaultId) != null + val state = if (hasCheckpoint) VaultUploadState.RESUMABLE else null + view?.updateVaultUploadState(event.vaultId, state) + } + } + }, { e -> + Timber.tag("VaultListPresenter").w(e, "Vault upload event stream error") + }) + } + private fun navigateToVaultContent(vault: Vault, cloudFolder: CloudFolder) { if (!isPaused) { view?.navigateToVaultContent(VaultModel(vault), cloudFolderModelMapper.toModel(cloudFolder)) @@ -577,8 +621,6 @@ class VaultListPresenter @Inject constructor( // } } - private var pendingNavigationAfterResume: Pair? = null - fun onResumeUploadConfirmed(vaultId: Long) { try { val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: run { diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 7de37c2da..5f4f3a94f 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -105,6 +105,7 @@ public void startFolderUpload(Cloud cloud, String targetFolderPath, UploadFolder private void startUpload(Cloud cloud, String targetFolderPath, Set completedFiles, long vaultId, int totalFiles, List cleanupUris, UploadTask uploadTask) { QueuedUpload upload = new QueuedUpload(cloud, targetFolderPath, completedFiles, vaultId, totalFiles, uploadTask, cleanupUris); + emitVaultActive(vaultId); synchronized (queueLock) { if (workerRunning) { pendingUploads.add(upload); @@ -180,21 +181,25 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { uploadCheckpointDao.deleteByVaultId(upload.vaultId); emitUploadFinished(folderKey); + emitVaultFinished(upload.vaultId); notification.showUploadFinished(upload.totalFiles - upload.completedFiles.size()); Timber.tag("UploadService").i("Upload completed"); return true; } catch (CancellationException e) { clearSnapshot(folderKey); uploadCheckpointDao.deleteByVaultId(upload.vaultId); + emitVaultFinished(upload.vaultId); Timber.tag("UploadService").i("Upload canceled by user"); return false; } catch (MissingCryptorException e) { clearSnapshot(folderKey); + emitVaultFinished(upload.vaultId); notification.showVaultLockedDuringUpload(); Timber.tag("UploadService").e(e, "Vault locked during upload"); return false; } catch (BackendException | FatalBackendException e) { clearSnapshot(folderKey); + emitVaultFinished(upload.vaultId); notification.showGeneralErrorDuringUpload(); Timber.tag("UploadService").e(e, "Upload failed"); return false; @@ -211,6 +216,7 @@ private void drainAndCleanupQueue() { } for (QueuedUpload upload : abandoned) { uploadCheckpointDao.deleteByVaultId(upload.vaultId); + emitVaultFinished(upload.vaultId); deleteTempFiles(upload.cleanupUris); } } @@ -292,13 +298,7 @@ private void emitUploadFinished(FolderKey folderKey) { } private void emitEvent(UploadUiEvent event) { - if (uploadUiUpdates != null) { - try { - uploadUiUpdates.emit(event); - } catch (Exception e) { - Timber.tag("UploadService").w(e, "Failed to emit upload event"); - } - } + safeEmit(() -> uploadUiUpdates.emit(event), "Failed to emit upload event"); } private void clearSnapshot(FolderKey folderKey) { @@ -307,6 +307,24 @@ private void clearSnapshot(FolderKey folderKey) { } } + private void emitVaultActive(long vaultId) { + safeEmit(() -> uploadUiUpdates.markVaultActive(vaultId), "Failed to emit vault active event"); + } + + private void emitVaultFinished(long vaultId) { + safeEmit(() -> uploadUiUpdates.markVaultFinished(vaultId), "Failed to emit vault finished event"); + } + + private void safeEmit(Runnable action, String errorMessage) { + if (uploadUiUpdates != null) { + try { + action.run(); + } catch (Exception e) { + Timber.tag("UploadService").w(e, errorMessage); + } + } + } + private String toJsonArray(Set items) { return items.stream() .map(item -> "\"" + item.replace("\"", "\\\"") + "\"") diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt index 4637211a9..bbf55fb1d 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt @@ -18,20 +18,28 @@ sealed class UploadUiEvent { data class UploadFinished(override val folderKey: FolderKey) : UploadUiEvent() } +sealed class VaultUploadEvent { + abstract val vaultId: Long + + data class Started(override val vaultId: Long) : VaultUploadEvent() + data class Finished(override val vaultId: Long) : VaultUploadEvent() +} + @Singleton class UploadUiUpdates @Inject constructor() { private val processor = PublishProcessor.create().toSerialized() private val snapshots = ConcurrentHashMap>>() + private val vaultProcessor = PublishProcessor.create().toSerialized() + private val activeVaultIds: MutableSet = ConcurrentHashMap.newKeySet() + fun emit(event: UploadUiEvent) { when (event) { - is UploadUiEvent.NodeCreated -> { + is UploadUiEvent.NodeCreated -> snapshots.computeIfAbsent(event.folderKey) { ConcurrentHashMap() }[event.node.name] = event.node - } - is UploadUiEvent.UploadFinished -> { + is UploadUiEvent.UploadFinished -> snapshots.remove(event.folderKey) - } } processor.onNext(event) } @@ -51,4 +59,23 @@ class UploadUiUpdates @Inject constructor() { fun clear(folderKey: FolderKey) { snapshots.remove(folderKey) } + + fun markVaultActive(vaultId: Long) { + activeVaultIds.add(vaultId) + vaultProcessor.onNext(VaultUploadEvent.Started(vaultId)) + } + + fun markVaultFinished(vaultId: Long) { + activeVaultIds.remove(vaultId) + vaultProcessor.onNext(VaultUploadEvent.Finished(vaultId)) + } + + fun activeVaultIds(): Set = HashSet(activeVaultIds) + + fun vaultEvents(): Flowable { + return vaultProcessor + .onBackpressureBuffer(64, { + Timber.tag("UploadUiUpdates").w("Vault event buffer overflow, dropping oldest") + }, BackpressureOverflowStrategy.DROP_OLDEST) + } } 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 index 0c9abae96..77a7f548c 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -32,6 +32,7 @@ import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog import org.cryptomator.presentation.ui.dialog.VaultDeleteConfirmationDialog import org.cryptomator.presentation.ui.dialog.VaultRenameDialog +import org.cryptomator.presentation.ui.adapter.VaultUploadState import org.cryptomator.presentation.ui.fragment.VaultListFragment import org.cryptomator.presentation.ui.layout.ObscuredAwareCoordinatorLayout.Listener import org.cryptomator.presentation.util.BiometricAuthenticationMigration @@ -170,6 +171,14 @@ class VaultListActivity : BaseActivity(Activi vaultListFragment().addOrUpdateVault(vaultModel) } + override fun updateUploadStates(states: Map) { + vaultListFragment().updateUploadStates(states) + } + + override fun updateVaultUploadState(vaultId: Long, state: VaultUploadState?) { + vaultListFragment().updateVaultUploadState(vaultId, state) + } + override fun onAddExistingVault() { vaultListPresenter.onAddExistingVault() } 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 index d4abae251..b7a5d14b2 100644 --- 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 @@ -2,6 +2,7 @@ package org.cryptomator.presentation.ui.activity.view import org.cryptomator.presentation.model.CloudFolderModel import org.cryptomator.presentation.model.VaultModel +import org.cryptomator.presentation.ui.adapter.VaultUploadState interface VaultListView : View { @@ -19,5 +20,7 @@ interface VaultListView : View { fun rowMoved(fromPosition: Int, toPosition: Int) fun vaultMoved(vaults: List) fun migrateCBCEncryptedPasswordVaults(vaults: List) + fun updateUploadStates(states: Map) + fun updateVaultUploadState(vaultId: Long, state: VaultUploadState?) } 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 index 3817099dc..13f81b742 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/VaultsAdapter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/adapter/VaultsAdapter.kt @@ -1,17 +1,26 @@ package org.cryptomator.presentation.ui.adapter +import android.graphics.drawable.AnimatedVectorDrawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.ContextCompat +import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.ItemVaultBinding import org.cryptomator.presentation.model.VaultModel import org.cryptomator.presentation.model.comparator.VaultPositionComparator import org.cryptomator.presentation.ui.adapter.VaultsAdapter.VaultViewHolder import javax.inject.Inject +enum class VaultUploadState { + ACTIVE, RESUMABLE +} + class VaultsAdapter @Inject internal constructor() : RecyclerViewBaseAdapter(VaultPositionComparator()), VaultsMoveListener.Listener { + private val uploadStates = mutableMapOf() + interface OnItemInteractionListener { fun onVaultClicked(vaultModel: VaultModel) @@ -45,6 +54,24 @@ internal constructor() : RecyclerViewBaseAdapter= 0) { + notifyItemChanged(position) + } + } + + fun setUploadStates(states: Map) { + uploadStates.clear() + uploadStates.putAll(states) + notifyDataSetChanged() + } + private fun getVault(vaultId: Long): VaultModel? { return itemCollection.firstOrNull { it.vaultId == vaultId } } @@ -65,6 +92,24 @@ internal constructor() : RecyclerViewBaseAdapter { + binding.uploadIndicator.setImageResource(R.drawable.ic_upload_arrow_animated) + binding.uploadIndicator.clearColorFilter() + binding.uploadIndicator.visibility = View.VISIBLE + (binding.uploadIndicator.drawable as? AnimatedVectorDrawable)?.start() + } + VaultUploadState.RESUMABLE -> { + binding.uploadIndicator.setImageResource(R.drawable.ic_upload_arrow) + binding.uploadIndicator.setColorFilter(ContextCompat.getColor(itemView.context, R.color.textColorLight)) + binding.uploadIndicator.visibility = View.VISIBLE + } + null -> { + binding.uploadIndicator.visibility = View.GONE + } + } + itemView.setOnClickListener { binding.cloudImage.setImageResource(vaultModel.cloudType.vaultSelectedImageResource) callback.onVaultClicked(vaultModel) diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/VaultListFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/VaultListFragment.kt index d66296363..6d5c66245 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/VaultListFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/VaultListFragment.kt @@ -8,6 +8,7 @@ import org.cryptomator.generator.Fragment import org.cryptomator.presentation.databinding.FragmentVaultListBinding import org.cryptomator.presentation.model.VaultModel import org.cryptomator.presentation.presenter.VaultListPresenter +import org.cryptomator.presentation.ui.adapter.VaultUploadState import org.cryptomator.presentation.ui.adapter.VaultsAdapter import org.cryptomator.presentation.ui.adapter.VaultsMoveListener import javax.inject.Inject @@ -105,5 +106,13 @@ class VaultListFragment : BaseFragment(FragmentVaultLi vaultsAdapter.notifyItemMoved(fromPosition, toPosition) } + fun updateUploadStates(states: Map) { + vaultsAdapter.setUploadStates(states) + } + + fun updateVaultUploadState(vaultId: Long, state: VaultUploadState?) { + vaultsAdapter.updateUploadState(vaultId, state) + } + fun rootView(): View = binding.coordinatorLayout } diff --git a/presentation/src/main/res/animator/upload_fill.xml b/presentation/src/main/res/animator/upload_fill.xml new file mode 100644 index 000000000..b09cf64d3 --- /dev/null +++ b/presentation/src/main/res/animator/upload_fill.xml @@ -0,0 +1,9 @@ + + diff --git a/presentation/src/main/res/drawable-night/ic_upload_arrow.xml b/presentation/src/main/res/drawable-night/ic_upload_arrow.xml new file mode 100644 index 000000000..13350872f --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_upload_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/drawable-night/ic_upload_arrow_fill.xml b/presentation/src/main/res/drawable-night/ic_upload_arrow_fill.xml new file mode 100644 index 000000000..3f1e388e9 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_upload_arrow_fill.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_upload_arrow.xml b/presentation/src/main/res/drawable/ic_upload_arrow.xml new file mode 100644 index 000000000..60dfa26c3 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_upload_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_upload_arrow_animated.xml b/presentation/src/main/res/drawable/ic_upload_arrow_animated.xml new file mode 100644 index 000000000..31761e646 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_upload_arrow_animated.xml @@ -0,0 +1,7 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_upload_arrow_fill.xml b/presentation/src/main/res/drawable/ic_upload_arrow_fill.xml new file mode 100644 index 000000000..3d849520e --- /dev/null +++ b/presentation/src/main/res/drawable/ic_upload_arrow_fill.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/presentation/src/main/res/layout/item_vault.xml b/presentation/src/main/res/layout/item_vault.xml index f9a6a4903..dc4b06698 100644 --- a/presentation/src/main/res/layout/item_vault.xml +++ b/presentation/src/main/res/layout/item_vault.xml @@ -47,7 +47,7 @@ android:layout_width="59dp" android:layout_height="59dp" android:layout_centerVertical="true" - android:layout_toStartOf="@id/settings" + android:layout_toStartOf="@id/upload_indicator" android:background="?android:attr/selectableItemBackground" android:paddingStart="14dp" android:paddingTop="10dp" @@ -57,6 +57,18 @@ android:visibility="gone" app:tint="@color/colorPrimary" /> + + Schedulers.trampoline()); + when(uploadUiUpdates.activeVaultIds()).thenReturn(new HashSet<>()); + when(uploadUiUpdates.vaultEvents()).thenReturn(Flowable.never()); + when(uploadCheckpointDao.findAllVaultIdsWithCheckpoints()).thenReturn(new HashSet<>()); inTest = new VaultListPresenter(getVaultListUseCase, // deleteVaultUseCase, // renameVaultUseCase, // @@ -151,6 +163,7 @@ public void setup() { cloudNodeModelMapper, // sharedPreferencesHandler, // uploadCheckpointDao, // + uploadUiUpdates, // vaultRepository, // cloudRepository, // contentResolverUtil, // From 2f0e558ad38f120cb5b1942dd25f1bcce9b39967 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Mon, 23 Feb 2026 18:57:59 +0100 Subject: [PATCH 21/26] Prevent manual vault lock while upload is in progress --- .../presenter/VaultListPresenter.kt | 4 ++++ presentation/src/main/res/values/strings.xml | 1 + .../presenter/VaultListPresenterTest.java | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 3253bd00b..ab49c649e 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -484,6 +484,10 @@ class VaultListPresenter @Inject constructor( // } fun onVaultLockClicked(vault: VaultModel) { + if (uploadUiUpdates.activeVaultIds().contains(vault.vaultId)) { + view?.showMessage(R.string.error_vault_lock_upload_in_progress) + return + } lockVault(vault) } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 71ce8d6f8..4b09d3cb9 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -39,6 +39,7 @@ Version specified in %1$s is different to %2$s %1$s does not match with this %2$s General error while loading the vault config + Cannot lock vault while an upload is in progress Local file isn\'t present anymore after switching back to Cryptomator. Possible changes cannot be propagated back to the cloud. No such bucket Custom Masterkey location not supported yet diff --git a/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java b/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java index 70f2287eb..eec263e9e 100644 --- a/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java +++ b/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java @@ -28,6 +28,7 @@ import org.cryptomator.domain.usecases.vault.UnlockToken; import org.cryptomator.domain.usecases.vault.UpdateVaultParameterIfChangedRemotelyUseCase; import org.cryptomator.presentation.exception.ExceptionHandlers; +import org.cryptomator.presentation.R; import org.cryptomator.presentation.model.VaultModel; import org.cryptomator.presentation.model.mappers.CloudFolderModelMapper; import org.cryptomator.presentation.service.UploadUiUpdates; @@ -46,6 +47,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Set; import io.reactivex.Flowable; import io.reactivex.android.plugins.RxAndroidPlugins; @@ -265,4 +267,26 @@ public void testRenameVaultWithError() { Mockito.any()); } + @Test + public void testOnVaultLockClickedLocksVault() { + when(lockVaultUseCase.withVault(AN_UNLOCKED_VAULT_MODEL.toVault())) // + .thenReturn(lockVaultUseCaseLauncher); + + inTest.onVaultLockClicked(AN_UNLOCKED_VAULT_MODEL); + + verify(lockVaultUseCaseLauncher).run(Mockito.any()); + } + + @Test + public void testOnVaultLockClickedShowsMessageWhenUploadActive() { + Set activeIds = new HashSet<>(); + activeIds.add(AN_UNLOCKED_VAULT.getId()); + when(uploadUiUpdates.activeVaultIds()).thenReturn(activeIds); + + inTest.onVaultLockClicked(AN_UNLOCKED_VAULT_MODEL); + + verify(vaultListView).showMessage(R.string.error_vault_lock_upload_in_progress); + verify(lockVaultUseCase, never()).withVault(Mockito.any()); + } + } From 876ee59664ac5c7ed86ef35f36f3c30bc4bbd920 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Mon, 23 Feb 2026 22:10:14 +0100 Subject: [PATCH 22/26] Replace vault lock toast with Cancel & Lock confirmation dialog --- .../presenter/VaultListPresenter.kt | 9 +++- .../ui/activity/VaultListActivity.kt | 6 +++ .../ui/dialog/CancelUploadAndLockDialog.kt | 46 +++++++++++++++++++ .../layout/dialog_cancel_upload_and_lock.xml | 5 ++ presentation/src/main/res/values/strings.xml | 7 ++- .../presenter/VaultListPresenterTest.java | 19 ++++---- 6 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/dialog/CancelUploadAndLockDialog.kt create mode 100644 presentation/src/main/res/layout/dialog_cancel_upload_and_lock.xml diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index ab49c649e..af0123130 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -49,6 +49,7 @@ import org.cryptomator.presentation.CryptomatorApp import org.cryptomator.presentation.R import org.cryptomator.presentation.service.KeepAliveLease import org.cryptomator.presentation.service.LeaseReason +import org.cryptomator.presentation.service.UploadService import org.cryptomator.presentation.service.UploadUiUpdates import org.cryptomator.presentation.service.VaultUploadEvent import org.cryptomator.presentation.ui.adapter.VaultUploadState @@ -66,6 +67,7 @@ import org.cryptomator.presentation.ui.dialog.AppIsObscuredInfoDialog import org.cryptomator.presentation.ui.dialog.AskForLockScreenDialog import org.cryptomator.presentation.ui.dialog.CBCPasswordVaultsMigrationDialog import org.cryptomator.presentation.ui.dialog.EnterPasswordDialog +import org.cryptomator.presentation.ui.dialog.CancelUploadAndLockDialog import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog @@ -485,12 +487,17 @@ class VaultListPresenter @Inject constructor( // fun onVaultLockClicked(vault: VaultModel) { if (uploadUiUpdates.activeVaultIds().contains(vault.vaultId)) { - view?.showMessage(R.string.error_vault_lock_upload_in_progress) + view?.showDialog(CancelUploadAndLockDialog.newInstance(vault.vaultId)) return } lockVault(vault) } + fun onCancelUploadAndLockConfirmed(vaultId: Long) { + context().startService(UploadService.cancelUploadIntent(context())) + lockVault(VaultModel(vaultRepository.load(vaultId))) + } + fun onVaultClicked(vault: VaultModel) { startVaultAction(vault, VaultAction.UNLOCK) } 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 index 77a7f548c..60a940ea9 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt @@ -27,6 +27,7 @@ import org.cryptomator.presentation.ui.callback.VaultListCallback import org.cryptomator.presentation.ui.dialog.AskForLockScreenDialog import org.cryptomator.presentation.ui.dialog.BetaConfirmationDialog import org.cryptomator.presentation.ui.dialog.CBCPasswordVaultsMigrationDialog +import org.cryptomator.presentation.ui.dialog.CancelUploadAndLockDialog import org.cryptomator.presentation.ui.dialog.ResumeUploadDialog import org.cryptomator.presentation.ui.dialog.UpdateAppAvailableDialog import org.cryptomator.presentation.ui.dialog.UpdateAppDialog @@ -47,6 +48,7 @@ class VaultListActivity : BaseActivity(Activi UpdateAppDialog.Callback, // BetaConfirmationDialog.Callback, // CBCPasswordVaultsMigrationDialog.Callback, // + CancelUploadAndLockDialog.Callback, // ResumeUploadDialog.Callback, // BiometricAuthenticationMigration.Callback { @@ -262,6 +264,10 @@ class VaultListActivity : BaseActivity(Activi vaultListPresenter.biometricKeyInvalidated(vaults) } + override fun onCancelUploadAndLockConfirmed(vaultId: Long) { + vaultListPresenter.onCancelUploadAndLockConfirmed(vaultId) + } + override fun onResumeUploadConfirmed(vaultId: Long) { vaultListPresenter.onResumeUploadConfirmed(vaultId) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/CancelUploadAndLockDialog.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/CancelUploadAndLockDialog.kt new file mode 100644 index 000000000..8158df673 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/CancelUploadAndLockDialog.kt @@ -0,0 +1,46 @@ +package org.cryptomator.presentation.ui.dialog + +import android.content.DialogInterface +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import org.cryptomator.generator.Dialog +import org.cryptomator.presentation.R +import org.cryptomator.presentation.databinding.DialogCancelUploadAndLockBinding + +@Dialog +class CancelUploadAndLockDialog : BaseDialog(DialogCancelUploadAndLockBinding::inflate) { + + interface Callback { + + fun onCancelUploadAndLockConfirmed(vaultId: Long) + } + + public override fun setupDialog(builder: AlertDialog.Builder): android.app.Dialog { + val vaultId = requireArguments().getLong(ARG_VAULT_ID) + builder // + .setCancelable(true) // + .setTitle(R.string.dialog_cancel_upload_lock_title) // + .setMessage(R.string.dialog_cancel_upload_lock_message) // + .setPositiveButton(R.string.dialog_cancel_upload_lock_positive_button) { _: DialogInterface, _: Int -> callback?.onCancelUploadAndLockConfirmed(vaultId) } // + .setNegativeButton(R.string.dialog_cancel_upload_lock_negative_button) { _: DialogInterface, _: Int -> dismiss() } + return builder.create() + } + + public override fun setupView() { + // empty + } + + companion object { + + private const val ARG_VAULT_ID = "vaultId" + + fun newInstance(vaultId: Long): DialogFragment { + val dialog = CancelUploadAndLockDialog() + val args = Bundle() + args.putLong(ARG_VAULT_ID, vaultId) + dialog.arguments = args + return dialog + } + } +} diff --git a/presentation/src/main/res/layout/dialog_cancel_upload_and_lock.xml b/presentation/src/main/res/layout/dialog_cancel_upload_and_lock.xml new file mode 100644 index 000000000..c9732b6dd --- /dev/null +++ b/presentation/src/main/res/layout/dialog_cancel_upload_and_lock.xml @@ -0,0 +1,5 @@ + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 4b09d3cb9..e8dfd1cf4 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -39,7 +39,6 @@ Version specified in %1$s is different to %2$s %1$s does not match with this %2$s General error while loading the vault config - Cannot lock vault while an upload is in progress Local file isn\'t present anymore after switching back to Cryptomator. Possible changes cannot be propagated back to the cloud. No such bucket Custom Masterkey location not supported yet @@ -648,6 +647,12 @@ An error occurred during upload. Vault locked during upload. + + Upload in progress + An upload is currently in progress for this vault. Cancelling will stop the upload. + Cancel & Lock + Keep Uploading + Resume upload? An interrupted upload was found (%1$d/%2$d files uploaded). Do you want to resume? diff --git a/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java b/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java index eec263e9e..7b70c70c2 100644 --- a/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java +++ b/presentation/src/test/java/org/cryptomator/presentation/presenter/VaultListPresenterTest.java @@ -45,9 +45,7 @@ import org.mockito.Mockito; import java.util.Collections; -import java.util.HashSet; import java.util.List; -import java.util.Set; import io.reactivex.Flowable; import io.reactivex.android.plugins.RxAndroidPlugins; @@ -139,9 +137,9 @@ public class VaultListPresenterTest { public void setup() { RxAndroidPlugins.reset(); RxAndroidPlugins.setInitMainThreadSchedulerHandler(scheduler -> Schedulers.trampoline()); - when(uploadUiUpdates.activeVaultIds()).thenReturn(new HashSet<>()); + when(uploadUiUpdates.activeVaultIds()).thenReturn(Collections.emptySet()); when(uploadUiUpdates.vaultEvents()).thenReturn(Flowable.never()); - when(uploadCheckpointDao.findAllVaultIdsWithCheckpoints()).thenReturn(new HashSet<>()); + when(uploadCheckpointDao.findAllVaultIdsWithCheckpoints()).thenReturn(Collections.emptySet()); inTest = new VaultListPresenter(getVaultListUseCase, // deleteVaultUseCase, // renameVaultUseCase, // @@ -278,14 +276,15 @@ public void testOnVaultLockClickedLocksVault() { } @Test - public void testOnVaultLockClickedShowsMessageWhenUploadActive() { - Set activeIds = new HashSet<>(); - activeIds.add(AN_UNLOCKED_VAULT.getId()); - when(uploadUiUpdates.activeVaultIds()).thenReturn(activeIds); + public void testOnVaultLockClickedDoesNotLockWhenUploadActive() { + when(uploadUiUpdates.activeVaultIds()).thenReturn(Collections.singleton(AN_UNLOCKED_VAULT.getId())); - inTest.onVaultLockClicked(AN_UNLOCKED_VAULT_MODEL); + try { + inTest.onVaultLockClicked(AN_UNLOCKED_VAULT_MODEL); + } catch (RuntimeException e) { + // CancelUploadAndLockDialog.newInstance() uses Bundle which is not mocked + } - verify(vaultListView).showMessage(R.string.error_vault_lock_upload_in_progress); verify(lockVaultUseCase, never()).withVault(Mockito.any()); } From 8233d64b25d1f411c0c6f1f7c6eaa2eef0b67207 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Mon, 23 Feb 2026 22:47:46 +0100 Subject: [PATCH 23/26] Add per-vault upload cancellation so locking one vault does not abort others --- .../presenter/VaultListPresenter.kt | 10 +-- .../presentation/service/UploadService.java | 80 +++++++++++++++---- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index af0123130..9a727092a 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -494,7 +494,7 @@ class VaultListPresenter @Inject constructor( // } fun onCancelUploadAndLockConfirmed(vaultId: Long) { - context().startService(UploadService.cancelUploadIntent(context())) + context().startService(UploadService.cancelUploadForVaultIntent(context(), vaultId)) lockVault(VaultModel(vaultRepository.load(vaultId))) } @@ -675,6 +675,10 @@ class VaultListPresenter @Inject constructor( // folderResumeDisposable = Single.fromCallable { buildUploadFolderStructure(documentFile) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) + .doFinally { + releaseResumeLease() + navigatePendingAfterResume() + } .subscribe({ folderStructure -> view?.showProgress(ProgressModel.COMPLETED) if (checkpoint.isReplacing) { @@ -683,15 +687,11 @@ class VaultListPresenter @Inject constructor( // if (!cryptomatorApp.startFolderUpload(cloud, checkpoint.targetFolderPath, folderStructure, completedFiles, vaultId)) { view?.showMessage(R.string.error_upload_service_unavailable) } - releaseResumeLease() - navigatePendingAfterResume() }, { e -> view?.showProgress(ProgressModel.COMPLETED) Timber.tag("VaultListPresenter").w(e, "Failed to read folder during resume") view?.showMessage(R.string.dialog_resume_upload_permission_lost) uploadCheckpointDao.deleteByVaultId(vaultId) - releaseResumeLease() - navigatePendingAfterResume() }) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 5f4f3a94f..51dcb12ed 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -37,10 +37,12 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Queue; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import timber.log.Timber; @@ -48,6 +50,7 @@ public class UploadService extends Service { private static final String ACTION_CANCEL_UPLOAD = "CANCEL_UPLOAD"; + private static final String EXTRA_CANCEL_VAULT_ID = "cancelVaultId"; private UploadNotification notification; private CloudContentRepository cloudContentRepository; @@ -58,6 +61,8 @@ public class UploadService extends Service { private Thread worker; private volatile boolean cancelled; private volatile Runnable cancelCallback; + private volatile long currentVaultId = -1L; + private final Set cancelledVaultIds = ConcurrentHashMap.newKeySet(); private volatile long startTimeNotificationDelay; private volatile long elapsedTimeNotificationDelay = 0L; private volatile List cleanupUris = Collections.emptyList(); @@ -73,6 +78,13 @@ public static Intent cancelUploadIntent(Context context) { return cancelIntent; } + public static Intent cancelUploadForVaultIntent(Context context, long vaultId) { + Intent cancelIntent = new Intent(context, UploadService.class); + cancelIntent.setAction(ACTION_CANCEL_UPLOAD); + cancelIntent.putExtra(EXTRA_CANCEL_VAULT_ID, vaultId); + return cancelIntent; + } + public void startFileUpload(Cloud cloud, String targetFolderPath, List files, Set completedFiles, long vaultId, List cleanupUris) { startUpload(cloud, targetFolderPath, completedFiles, vaultId, files.size(), cleanupUris, (targetFolder, fileCallback, folderCallback, progressAware) -> { @@ -130,7 +142,8 @@ private void startWorker(QueuedUpload initial) { if (lease != null) { lease.renew(); } - if (!processUpload(current, isFirst)) { + boolean success = processUpload(current, isFirst); + if (!success && cancelled) { break; } isFirst = false; @@ -155,6 +168,7 @@ private void startWorker(QueuedUpload initial) { } private boolean processUpload(QueuedUpload upload, boolean isFirst) { + this.currentVaultId = upload.vaultId; this.cleanupUris = upload.cleanupUris; notification = new UploadNotification(appContext, upload.totalFiles); @@ -169,6 +183,10 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { if (cancelled) { return false; } + if (cancelledVaultIds.remove(upload.vaultId)) { + emitVaultFinished(upload.vaultId); + return false; + } CloudFolder targetFolder = upload.targetFolderPath.isEmpty() ? cloudContentRepository.root(upload.cloud) : cloudContentRepository.resolve(upload.cloud, upload.targetFolderPath); @@ -188,6 +206,7 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { } catch (CancellationException e) { clearSnapshot(folderKey); uploadCheckpointDao.deleteByVaultId(upload.vaultId); + cancelledVaultIds.remove(upload.vaultId); emitVaultFinished(upload.vaultId); Timber.tag("UploadService").i("Upload canceled by user"); return false; @@ -214,11 +233,28 @@ private void drainAndCleanupQueue() { abandoned = new ArrayList<>(pendingUploads); pendingUploads.clear(); } - for (QueuedUpload upload : abandoned) { - uploadCheckpointDao.deleteByVaultId(upload.vaultId); - emitVaultFinished(upload.vaultId); - deleteTempFiles(upload.cleanupUris); + abandoned.forEach(this::cleanupAbandonedUpload); + } + + private void drainQueueForVault(long vaultId) { + List removed = new ArrayList<>(); + synchronized (queueLock) { + Iterator it = pendingUploads.iterator(); + while (it.hasNext()) { + QueuedUpload upload = it.next(); + if (upload.vaultId == vaultId) { + removed.add(upload); + it.remove(); + } + } } + removed.forEach(this::cleanupAbandonedUpload); + } + + private void cleanupAbandonedUpload(QueuedUpload upload) { + uploadCheckpointDao.deleteByVaultId(upload.vaultId); + emitVaultFinished(upload.vaultId); + deleteTempFiles(upload.cleanupUris); } private interface UploadTask { @@ -377,17 +413,29 @@ public void onCreate() { public int onStartCommand(Intent intent, int flags, int startId) { Timber.tag("UploadService").i("started"); if (isCancelUpload(intent)) { - Timber.tag("UploadService").i("Received cancel upload"); - drainAndCleanupQueue(); - cancelled = true; - Runnable cancel = cancelCallback; - if (cancel != null) { - cancel.run(); - } - hideNotification(); - synchronized (queueLock) { - if (!workerRunning) { - stopSelf(); + long cancelVaultId = intent.getLongExtra(EXTRA_CANCEL_VAULT_ID, -1L); + if (cancelVaultId != -1L) { + Timber.tag("UploadService").i("Received cancel for vault %d", cancelVaultId); + drainQueueForVault(cancelVaultId); + cancelledVaultIds.add(cancelVaultId); + Runnable cancel = cancelCallback; + long activeVaultId = this.currentVaultId; + if (cancel != null && activeVaultId == cancelVaultId) { + cancel.run(); + } + } else { + Timber.tag("UploadService").i("Received cancel upload"); + drainAndCleanupQueue(); + cancelled = true; + Runnable cancel = cancelCallback; + if (cancel != null) { + cancel.run(); + } + hideNotification(); + synchronized (queueLock) { + if (!workerRunning) { + stopSelf(); + } } } } From 49b50f55c7041f19b4cb49c234d7f0d7082ade28 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Tue, 24 Feb 2026 11:23:54 +0100 Subject: [PATCH 24/26] Fix stale checkpoint on pre-cancel and persist URI permissions for file resume --- buildsystem/dependencies.gradle | 2 +- .../presentation/presenter/BrowseFilesPresenter.kt | 8 ++++++++ .../cryptomator/presentation/service/UploadService.java | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle index 9073c6d6b..cd205a007 100644 --- a/buildsystem/dependencies.gradle +++ b/buildsystem/dependencies.gradle @@ -129,7 +129,7 @@ ext { androidxSwiperefreshVersion = '1.1.0' androidxPreferenceVersion = '1.2.1' androidxRecyclerViewVersion = '1.2.1' - androidxDocumentfileVersion = '1.0.1' + androidxDocumentfileVersion = '1.1.0' androidxBiometricVersion = '1.1.0' androidxTestCoreVersion = '1.4.0' androidxSplashscreenVersion = '1.0.1' diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 924bf88dd..6347c3beb 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -1035,6 +1035,7 @@ class BrowseFilesPresenter @Inject constructor( // intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) intent = Intent.createChooser(intent, context().getString(R.string.screen_file_browser_upload_files_chooser_title)) requestActivityResult(ActivityResultCallbacks.selectedFiles(), intent) } @@ -1051,6 +1052,13 @@ class BrowseFilesPresenter @Inject constructor( // return } val fileUris = getFileUrisFromIntent(result.intent()) + fileUris.forEach { uri -> + try { + context().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + Timber.tag("BrowseFilesPresenter").d("Could not persist URI permission: %s", uri) + } + } prepareSelectedFilesForUpload(fileUris) } diff --git a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java index 51dcb12ed..933094020 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -184,6 +184,7 @@ private boolean processUpload(QueuedUpload upload, boolean isFirst) { return false; } if (cancelledVaultIds.remove(upload.vaultId)) { + uploadCheckpointDao.deleteByVaultId(upload.vaultId); emitVaultFinished(upload.vaultId); return false; } From 320e957ac55c3c94067756aabf090691c06dd1a1 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Tue, 24 Feb 2026 11:26:36 +0100 Subject: [PATCH 25/26] Fix file resume to respect checkpoint replacing flag instead of hardcoding true --- .../cryptomator/presentation/presenter/VaultListPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt index 9a727092a..dc055a164 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/VaultListPresenter.kt @@ -703,7 +703,7 @@ class VaultListPresenter @Inject constructor( // UploadFile.anUploadFile() .withFileName(fileName) .withDataSource(UriBasedDataSource.from(uri)) - .thatIsReplacing(true) + .thatIsReplacing(checkpoint.isReplacing) .build() } } From bf2029461f48f74d0e3573e51c8e0937d8086b76 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Tue, 24 Feb 2026 21:29:40 +0100 Subject: [PATCH 26/26] Include exception in Timber debug log for URI permission failure --- .../cryptomator/presentation/presenter/BrowseFilesPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt index 6347c3beb..3b802a1ec 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -1056,7 +1056,7 @@ class BrowseFilesPresenter @Inject constructor( // try { context().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (e: SecurityException) { - Timber.tag("BrowseFilesPresenter").d("Could not persist URI permission: %s", uri) + Timber.tag("BrowseFilesPresenter").d(e, "Could not persist URI permission: %s", uri) } } prepareSelectedFilesForUpload(fileUris)