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/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..07dfe3d22 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: android.database.sqlite.SQLiteConstraintException) { + // Expected: unique constraint violation on VAULT_ID + } + } + @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..df752cb0a --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt @@ -0,0 +1,40 @@ +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") // + .optionalInt("REPLACING") // + .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..1e7b582ce --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/UploadCheckpointDao.java @@ -0,0 +1,97 @@ +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 java.util.HashSet; +import java.util.Set; + +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); + return getDb().insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + + public UploadCheckpointEntity findByVaultId(long vaultId) { + 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; + } + } + + 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); + 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()); + values.put("REPLACING", entity.isReplacing() ? 1 : 0); + 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"))); + 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 new file mode 100644 index 000000000..29181e116 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/UploadCheckpointEntity.java @@ -0,0 +1,156 @@ +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; + + 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, + boolean replacing) { + 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; + this.replacing = replacing; + } + + @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; + } + + 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/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/FileUploadedCallback.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java new file mode 100644 index 000000000..5b4194e26 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/FileUploadedCallback.java @@ -0,0 +1,11 @@ +package org.cryptomator.domain.usecases.cloud; + +import org.cryptomator.domain.CloudFile; + +public interface FileUploadedCallback { + + void onFileUploaded(String relativePath, CloudFile file); + + 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/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/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/UploadFiles.java b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFiles.java index 375d34393..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 @@ -12,34 +12,30 @@ 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; 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 { - - private static final int EOF = -1; +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() { - @Override - public boolean get() { - return cancelled; - } - }; + private final Flag cancelledFlag = () -> cancelled; public UploadFiles(Context context, // CloudContentRepository cloudContentRepository, // @@ -51,6 +47,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; } @@ -71,7 +75,12 @@ public List execute(ProgressAware progressAware) throws private List upload(ProgressAware progressAware) throws BackendException { List uploadedFiles = new ArrayList<>(); for (UploadFile file : files) { - uploadedFiles.add(upload(file, progressAware)); + if (completedFiles.contains(file.getFileName())) { + continue; + } + CloudFile uploadedFile = upload(file, progressAware); + uploadedFiles.add(uploadedFile); + fileUploadedCallback.onFileUploaded(file.getFileName(), uploadedFile); } return uploadedFiles; } @@ -104,7 +113,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 +132,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..72530a1eb --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderFiles.java @@ -0,0 +1,175 @@ +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.CloudNodeAlreadyExistsException; +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.Collections; +import java.util.List; +import java.util.Set; + +import static java.io.File.createTempFile; + +@UseCase +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 FolderCreatedCallback folderCreatedCallback = FolderCreatedCallback.NO_OP; + + private volatile boolean cancelled; + private final Flag cancelledFlag = () -> 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 setCompletedFiles(Set completedFiles) { + this.completedFiles = completedFiles; + } + + public void setFileUploadedCallback(FileUploadedCallback fileUploadedCallback) { + this.fileUploadedCallback = fileUploadedCallback; + } + + public void setFolderCreatedCallback(FolderCreatedCallback folderCreatedCallback) { + this.folderCreatedCallback = folderCreatedCallback; + } + + 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 { + 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()); + folderCreatedCallback.onFolderCreated(relativePath, createdFolder); + + List uploadedFiles = new ArrayList<>(); + + for (UploadFile file : structure.getFiles()) { + String fileRelativePath = relativePath + "/" + file.getFileName(); + if (completedFiles.contains(fileRelativePath)) { + continue; + } + 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()) { + 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) { + 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); + 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) { + 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..9dc657fc0 --- /dev/null +++ b/domain/src/main/java/org/cryptomator/domain/usecases/cloud/UploadFolderStructure.java @@ -0,0 +1,54 @@ +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 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) { + count += subfolder.totalFileCount(); + } + return count; + } +} 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..5a32d5055 --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/StreamHelperTest.kt @@ -0,0 +1,114 @@ +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 input: InputStream = mock { + on { read(any()) } doReturn 1 + } + val output: OutputStream = mock { + on { write(any(), any(), any()) } doThrow IOException("write failed") + } + + try { + StreamHelper.copy(input, output) + } catch (_: IOException) { + } + + verify(input).close() + 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/UploadFileTest.kt b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFileTest.kt index 6db6f4948..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 @@ -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(eq(fileName), same(resultFile)) + } + + @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 new file mode 100644 index 000000000..b345cbb5b --- /dev/null +++ b/domain/src/test/java/org/cryptomator/domain/usecases/cloud/UploadFolderFilesTest.kt @@ -0,0 +1,548 @@ +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.exception.CloudNodeAlreadyExistsException +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.never +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)) + } + + @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(eq("testFolder/file.txt"), same(resultFile)) + } + + @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(eq("testFolder/sub/deep.txt"), same(resultFile)) + } + + @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)) + } + + @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) + 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/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() + } +} 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/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index cb407cbe2..6086e408a 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + @@ -211,6 +212,12 @@ android:name=".service.AutoUploadService" android:enabled="true" /> + + { @@ -42,6 +48,9 @@ class CryptomatorApp : MultiDexApplication(), HasComponent @Volatile private var autoUploadServiceBinder: AutoUploadService.Binder? = null + @Volatile + private var uploadServiceBinder: UploadService.Binder? = null + override fun onCreate() { super.onCreate() setupLogging() @@ -76,15 +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() + start() } catch (e: IllegalStateException) { - Timber.tag("App").e(e, "Failed to launch auto upload service") + Timber.tag("App").e(e, "Failed to launch %s service", name) } } @@ -151,6 +161,47 @@ 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.uploadCheckpointDao(), + applicationComponent.uploadUiUpdates(), + applicationComponent.fileUtil(), + 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, 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): 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 { return sharedPreferencesHandler.usePhotoUpload() // && (!sharedPreferencesHandler.autoPhotoUploadOnlyUsingWifi() || applicationComponent.networkConnectionCheck().checkWifiOnAndConnected()) @@ -209,14 +260,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/di/component/ApplicationComponent.java b/presentation/src/main/java/org/cryptomator/presentation/di/component/ApplicationComponent.java index ca0232028..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 @@ -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; @@ -14,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; @@ -47,4 +49,8 @@ public interface ApplicationComponent { NetworkConnectionCheck networkConnectionCheck(); + 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 20909d5e1..3b802a1ec 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/BrowseFilesPresenter.kt @@ -6,14 +6,20 @@ import android.net.Uri 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 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 @@ -39,6 +45,7 @@ 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.UploadFolderStructure import org.cryptomator.domain.usecases.cloud.UploadState import org.cryptomator.domain.usecases.vault.AssertUnlockedUseCase import org.cryptomator.generator.Callback @@ -46,6 +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 @@ -78,7 +90,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 @@ -125,15 +136,23 @@ class BrowseFilesPresenter @Inject constructor( // private val shareFileHelper: ShareFileHelper, // private val downloadFileUtil: DownloadFileUtil, // private val sharedPreferencesHandler: SharedPreferencesHandler, // + private val uploadCheckpointDao: UploadCheckpointDao, // + private val uploadUiUpdates: UploadUiUpdates, // exceptionMappings: ExceptionHandlers ) : 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 private var resumedAfterAuthentication = false + 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 @@ -171,6 +190,15 @@ class BrowseFilesPresenter @Inject constructor( // setRefreshOnBackPressEnabled(enableRefreshOnBackpressSupplier.setInAction(false)) } + override fun destroyed() { + pickerLease?.release() + pickerLease = null + folderStructureDisposable?.dispose() + folderStructureDisposable = null + uploadEventsDisposable?.dispose() + uploadEventsDisposable = null + } + fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { resumed() @@ -202,6 +230,7 @@ class BrowseFilesPresenter @Inject constructor( // } else { showCloudNodesCollectionInView(cloudNodes) } + subscribeToUploadEvents(cloudFolderModel) view?.showLoading(false) } @@ -414,43 +443,20 @@ 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) - } - } - - override fun onSuccess(files: List) { - files.forEach { file -> view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(file)) } - onFileUploadCompleted() - } - - override fun onError(e: Throwable) { - onFileUploadError() - if (ExceptionUtil.contains(e, CloudNodeAlreadyExistsException::class.java)) { - ExceptionUtil.extract(e, CloudNodeAlreadyExistsException::class.java).get().message - ?.let { message -> onCloudNodeAlreadyExists(message) } - } else { - super.onError(e) - } - } - }) + 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() }) } - } - private fun onUploadFileCompleted(name: String) { - filesForUpload.remove(name) - } - - private fun onCloudNodeAlreadyExists(fileNameAlreadyExists: String) { - addToExistingFiles(fileNameAlreadyExists) - view?.showReplaceDialog(listOf(fileNameAlreadyExists), filesForUpload.size) + releasePickerLease() + dispatchUpload(vaultId) { + cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId) + } } private fun clearCloudList() { @@ -552,8 +558,7 @@ class BrowseFilesPresenter @Inject constructor( // if (sharedPreferencesHandler.keepUnlockedWhileEditing()) { openWritableFileNotification = OpenWritableFileNotification(context(), it) openWritableFileNotification?.show() - val cryptomatorApp = activity().application as CryptomatorApp - cryptomatorApp.suspendLock() + cryptomatorApp.acquireEditingLease() } try { requestActivityResult(ActivityResultCallbacks.openFileFinished(openFileType), viewFileIntent) @@ -592,8 +597,7 @@ 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() + cryptomatorApp.releaseEditingLease() } hideWritableNotification() @@ -632,39 +636,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 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() }) + } - override fun onSuccess(files: List) { - files.forEach { file -> - view?.addOrUpdateCloudNode(cloudFileModelMapper.toModel(file)) - } - deleteFileIfMicrosoftWorkaround(openFileType, uriToOpenedFile) - onFileUploadCompleted() - } + val cleanupUris = if (openFileType == OpenFileType.MICROSOFT_WORKAROUND) { + listOf(uri) + } else { + emptyList() + } - 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) - } - } - }) - } - } + dispatchUpload(vaultId) { + cryptomatorApp.startFileUpload(cloud, targetFolderPath, files, emptySet(), vaultId, cleanupUris) } } @@ -820,6 +811,7 @@ class BrowseFilesPresenter @Inject constructor( // private fun uploadFiles(nonReplacing: Map, replacing: Map) { if (nonReplacing.size + replacing.size == 0) { + releasePickerLease() return } view?.showUploadDialog(nonReplacing.size + replacing.size) @@ -829,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) } @@ -856,24 +852,26 @@ 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()) } - private fun onFileUploadCompleted() { - view?.showProgress(ProgressModel.COMPLETED) - uploadLocation = null - } - - private fun onFileUploadError() { - view?.closeDialog() - } - private fun differencesOfUploadAndExistingFiles() { filesForUpload.keys.removeAll(existingFilesForUpload.keys) } @@ -1032,21 +1030,35 @@ class BrowseFilesPresenter @Inject constructor( // fun onUploadFilesClicked(folder: CloudFolderModel) { uploadLocation = folder + 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 = "*/*" 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) } fun onUploadCanceled() { uploadFilesUseCase.cancel() + context().startService(org.cryptomator.presentation.service.UploadService.cancelUploadIntent(context())) } - @Callback + @Callback(dispatchResultOkOnly = false) fun selectedFiles(result: ActivityResult) { + if (!result.isResultOk) { + releasePickerLease() + 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(e, "Could not persist URI permission: %s", uri) + } + } prepareSelectedFilesForUpload(fileUris) } @@ -1062,6 +1074,163 @@ class BrowseFilesPresenter @Inject constructor( // return fileUris } + fun onUploadFolderClicked(folder: CloudFolderModel) { + uploadLocation = folder + try { + pickerLease = cryptomatorApp.acquireLease(LeaseReason.FOLDER_PICKER, 2 * 60 * 1000L, tag = "folderPicker") + requestActivityResult( // + ActivityResultCallbacks.selectedFolder(), // + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + ) + } catch (exception: ActivityNotFoundException) { + releasePickerLease() + 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") + } + } + + @JvmField + @InstanceState + var lastSelectedFolderUri: Uri? = null + + @Callback(dispatchResultOkOnly = false) + fun selectedFolder(result: ActivityResult) { + val treeUri = if (result.isResultOk) { + result.intent().data + } else { + null + } + if (treeUri == null) { + releasePickerLease() + return + } + context().contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + lastSelectedFolderUri = treeUri + val documentFile = DocumentFile.fromTreeUri(context(), treeUri) ?: run { + releasePickerLease() + return + } + view?.showProgress(ProgressModel.GENERIC) + folderStructureDisposable?.dispose() + folderStructureDisposable = Single.fromCallable { buildUploadFolderStructure(documentFile) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ folderStructure -> + view?.showProgress(ProgressModel.COMPLETED) + if (folderStructure.totalFileCount() == 0) { + releasePickerLease() + view?.showMessage(R.string.screen_file_browser_nothing_to_upload) + return@subscribe + } + if (folderExistsAtLocation(folderStructure.folderName)) { + pendingFolderUpload = folderStructure + pendingFolderSourceUri = treeUri + view?.showReplaceFolderDialog(folderStructure.folderName) + } else { + uploadFolder(folderStructure, treeUri) + } + }, { e -> + releasePickerLease() + view?.showProgress(ProgressModel.COMPLETED) + showError(e) + }) + } + + 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() + 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 + this.isReplacing = replacing + } + + releasePickerLease() + dispatchUpload(vaultId) { + cryptomatorApp.startFolderUpload(cloud, targetFolderPath, folderStructure, emptySet(), vaultId) + } + } + + 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 releasePickerLease() { + pickerLease?.release() + pickerLease = null + } + + 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 + 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 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 + } + + private fun toJsonArray(items: List): String { + return items.joinToString(",", "[", "]") { "\"${it.replace("\"", "\\\"")}\"" } + } + private fun moveIntentFor(parent: CloudFolderModel, sourceNodes: List>): IntentBuilder { val foldersToMove = nodesFor(sourceNodes, CloudFolderModel::class) as List return Intents.browseFilesIntent() // 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..87b4d3496 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/presenter/TextEditorPresenter.kt @@ -1,21 +1,18 @@ 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 import org.cryptomator.util.file.FileCacheUtils +import java.io.File import java.io.IOException import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -25,10 +22,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 +60,33 @@ 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)) - } - } - - 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) - } + val files = listOf( + UploadFile.anUploadFile() // + .withFileName(file.name) // + .withDataSource(UriBasedDataSource.from(uri)) // + .thatIsReplacing(true) // + .build() + ) - override fun onError(e: Throwable) { - view?.showProgress(ProgressModel.COMPLETED) - showError(e) - } - }) - } ?: throw ParentFolderIsNullException(textFile.get().name) + 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 { + uri.path?.let { File(it).delete() } + v.showMessage(R.string.error_upload_service_unavailable) + } + } } fun loadFileContent() { @@ -120,8 +110,4 @@ class TextEditorPresenter @Inject constructor( // fun setTextFile(textFile: CloudFileModel) { this.textFile.set(textFile) } - - init { - unsubscribeOnDestroy(uploadFilesUseCase) - } } 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..dc055a164 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,11 @@ 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.UploadCheckpointDao +import org.cryptomator.data.db.entities.UploadCheckpointEntity import org.cryptomator.data.util.NetworkConnectionCheck import org.cryptomator.domain.Cloud import org.cryptomator.domain.CloudFolder @@ -18,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 @@ -26,6 +31,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 +47,12 @@ 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.service.UploadService +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 @@ -54,9 +67,12 @@ 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 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 @@ -66,6 +82,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 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 @@ -93,15 +115,34 @@ class VaultListPresenter @Inject constructor( // private val authenticationExceptionHandler: AuthenticationExceptionHandler, // 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, // exceptionMappings: ExceptionHandlers ) : Presenter(exceptionMappings) { + 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) } + override fun destroyed() { + resumeLease?.release() + resumeLease = null + folderResumeDisposable?.dispose() + folderResumeDisposable = null + uploadEventsDisposable?.dispose() + uploadEventsDisposable = null + } + fun onWindowFocusChanged(hasFocus: Boolean) { if (hasFocus) { loadVaultList() @@ -398,10 +439,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)) @@ -409,9 +486,18 @@ class VaultListPresenter @Inject constructor( // } fun onVaultLockClicked(vault: VaultModel) { + if (uploadUiUpdates.activeVaultIds().contains(vault.vaultId)) { + view?.showDialog(CancelUploadAndLockDialog.newInstance(vault.vaultId)) + return + } lockVault(vault) } + fun onCancelUploadAndLockConfirmed(vaultId: Long) { + context().startService(UploadService.cancelUploadForVaultIntent(context(), vaultId)) + lockVault(VaultModel(vaultRepository.load(vaultId))) + } + fun onVaultClicked(vault: VaultModel) { startVaultAction(vault, VaultAction.UNLOCK) } @@ -526,16 +612,139 @@ 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 checkpoint = uploadCheckpointDao.findByVaultId(vault.id) + if (checkpoint != null) { + val completedCount = parseJsonArray(checkpoint.completedFiles).size + view?.showDialog(ResumeUploadDialog.newInstance(vault.id, completedCount, checkpoint.totalFileCount)) + pendingNavigationAfterResume = Pair(vault, folder) + } else { + navigateToVaultContent(vault, folder) + } + } + + fun onResumeUploadConfirmed(vaultId: Long) { + try { + val checkpoint = uploadCheckpointDao.findByVaultId(vaultId) ?: run { + navigatePendingAfterResume() + return + } + + val vault = vaultRepository.load(vaultId) + val cloud = cloudRepository.decryptedViewOf(vault) + val completedFiles = parseJsonArray(checkpoint.completedFiles).toHashSet() + + 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) { + releaseResumeLease() + Timber.tag("VaultListPresenter").e(e, "Failed to resume upload") + uploadCheckpointDao.deleteByVaultId(vaultId) + navigatePendingAfterResume() + } + } + + private fun handleFolderResume(cloud: Cloud, checkpoint: UploadCheckpointEntity, + completedFiles: Set, vaultId: Long) { + val documentFile = checkpoint.sourceFolderUri?.let { uri -> + DocumentFile.fromTreeUri(context(), Uri.parse(uri))?.takeIf { it.canRead() } + } + if (documentFile == null) { + view?.showMessage(R.string.dialog_resume_upload_permission_lost) + uploadCheckpointDao.deleteByVaultId(vaultId) + releaseResumeLease() + navigatePendingAfterResume() + return + } + view?.showProgress(ProgressModel.GENERIC) + folderResumeDisposable?.dispose() + folderResumeDisposable = Single.fromCallable { buildUploadFolderStructure(documentFile) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doFinally { + releaseResumeLease() + navigatePendingAfterResume() + } + .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) + } + }, { 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(cloud: Cloud, checkpoint: UploadCheckpointEntity, + completedFiles: Set, vaultId: Long) { + val uploadFiles = parseJsonArray(checkpoint.pendingFileUris).mapNotNull { uriString -> + val uri = Uri.parse(uriString) + contentResolverUtil.fileName(uri)?.let { fileName -> + UploadFile.anUploadFile() + .withFileName(fileName) + .withDataSource(UriBasedDataSource.from(uri)) + .thatIsReplacing(checkpoint.isReplacing) + .build() + } + } + if (uploadFiles.isEmpty()) { + uploadCheckpointDao.deleteByVaultId(vaultId) + releaseResumeLease() + return + } + if (!cryptomatorApp.startFileUpload(cloud, checkpoint.targetFolderPath, uploadFiles, completedFiles, vaultId)) { + view?.showMessage(R.string.error_upload_service_unavailable) + } + releaseResumeLease() + } + + fun onResumeUploadDeclined(vaultId: Long) { + uploadCheckpointDao.deleteByVaultId(vaultId) + navigatePendingAfterResume() + } + + private fun releaseResumeLease() { + resumeLease?.release() + resumeLease = null + } + + private fun navigatePendingAfterResume() { + pendingNavigationAfterResume?.let { (vault, folder) -> + navigateToVaultContent(vault, folder) + pendingNavigationAfterResume = null + } + } + + private fun parseJsonArray(json: String?): List { + if (json.isNullOrEmpty() || json == "[]") return emptyList() + return try { + val jsonArray = JSONArray(json) + (0 until jsonArray.length()).map { jsonArray.getString(it) } + } catch (e: JSONException) { + emptyList() + } + } + 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/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/UploadNotification.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt new file mode 100644 index 000000000..a03729f22 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadNotification.kt @@ -0,0 +1,140 @@ +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) { + val currentFile = (completedFiles + 1).coerceAtMost(totalFiles) + builder + .setContentIntent(startTheActivity()) + .setContentText( + format( + context.getString(R.string.notification_upload_message), + currentFile, + 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(context.resources.getQuantityString(R.plurals.notification_upload_finished_message, count, 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..933094020 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadService.java @@ -0,0 +1,505 @@ +package org.cryptomator.presentation.service; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +import androidx.annotation.Nullable; + +import 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; +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.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; +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; + +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; + private UploadCheckpointDao uploadCheckpointDao; + private UploadUiUpdates uploadUiUpdates; + private FileUtil fileUtil; + private Context appContext; + 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(); + private volatile KeepAliveLease uploadLease; + + 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); + 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) -> { + UploadFiles uploadFiles = new UploadFiles(appContext, cloudContentRepository, targetFolder, files); + uploadFiles.setCompletedFiles(completedFiles); + uploadFiles.setFileUploadedCallback(fileCallback); + cancelCallback = uploadFiles::onCancel; + if (cancelled) { + throw new CancellationException(); + } + uploadFiles.execute(progressAware); + }); + } + + public void startFolderUpload(Cloud cloud, String targetFolderPath, UploadFolderStructure folderStructure, + Set completedFiles, long vaultId) { + 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(fileCallback); + uploadFolderFiles.setFolderCreatedCallback(folderCallback); + cancelCallback = uploadFolderFiles::onCancel; + if (cancelled) { + throw new CancellationException(); + } + uploadFolderFiles.execute(progressAware); + }); + } + + 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); + Timber.tag("UploadService").i("Upload queued (another in progress)"); + return; + } + workerRunning = true; + cancelled = false; + } + startWorker(upload); + } + + 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(); + } + boolean success = processUpload(current, isFirst); + if (!success && cancelled) { + break; + } + isFirst = false; + synchronized (queueLock) { + current = pendingUploads.poll(); + } + } + } finally { + drainAndCleanupQueue(); + synchronized (queueLock) { + workerRunning = false; + } + if (lease != null) { + lease.release(); + } + uploadLease = null; + stopForeground(STOP_FOREGROUND_DETACH); + stopSelf(); + } + }); + worker.start(); + } + + private boolean processUpload(QueuedUpload upload, boolean isFirst) { + this.currentVaultId = upload.vaultId; + this.cleanupUris = upload.cleanupUris; + + notification = new UploadNotification(appContext, upload.totalFiles); + if (isFirst) { + startForeground(UploadNotification.NOTIFICATION_ID, notification.getNotification()); + } + notification.show(); + + FolderKey folderKey = new FolderKey(upload.vaultId, upload.targetFolderPath); + + try { + if (cancelled) { + return false; + } + if (cancelledVaultIds.remove(upload.vaultId)) { + uploadCheckpointDao.deleteByVaultId(upload.vaultId); + emitVaultFinished(upload.vaultId); + return false; + } + CloudFolder targetFolder = upload.targetFolderPath.isEmpty() + ? cloudContentRepository.root(upload.cloud) + : cloudContentRepository.resolve(upload.cloud, upload.targetFolderPath); + + FileUploadedCallback fileCallback = createCheckpointCallback(upload.vaultId, upload.completedFiles, folderKey); + FolderCreatedCallback folderCallback = createFolderCreatedCallback(folderKey); + ProgressAware progressAware = progress -> updateNotification(progress.asPercentage()); + + upload.uploadTask.execute(targetFolder, fileCallback, folderCallback, progressAware); + + 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); + cancelledVaultIds.remove(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; + } finally { + deleteCleanupUris(); + } + } + + private void drainAndCleanupQueue() { + List abandoned; + synchronized (queueLock) { + abandoned = new ArrayList<>(pendingUploads); + pendingUploads.clear(); + } + 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 { + void execute(CloudFolder targetFolder, FileUploadedCallback fileCallback, + FolderCreatedCallback folderCallback, + 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, FolderKey folderKey) { + Set uploadedSoFar = new HashSet<>(completedFiles); + return (relativePath, file) -> { + uploadedSoFar.add(relativePath); + try { + String json = toJsonArray(uploadedSoFar); + uploadCheckpointDao.updateCompletedFiles(vaultId, json); + } 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(); + } + }); + }; + } + + 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) { + safeEmit(() -> uploadUiUpdates.emit(event), "Failed to emit upload event"); + } + + private void clearSnapshot(FolderKey folderKey) { + if (uploadUiUpdates != null) { + uploadUiUpdates.clear(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("\"", "\\\"") + "\"") + .collect(Collectors.joining(",", "[", "]")); + } + + 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()); + 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) { + KeepAliveLease lease = uploadLease; + if (lease != null) { + lease.renew(); + } + 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)) { + 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(); + } + } + } + } + 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, + 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; + } + + public void startFileUpload(Cloud cloud, String targetFolderPath, List files, + Set completedFiles, long vaultId, List cleanupUris) { + UploadService.this.startFileUpload(cloud, targetFolderPath, files, completedFiles, vaultId, cleanupUris); + } + + 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/service/UploadUiUpdates.kt b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt new file mode 100644 index 000000000..bbf55fb1d --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/service/UploadUiUpdates.kt @@ -0,0 +1,81 @@ +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() +} + +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 -> + snapshots.computeIfAbsent(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) + } + + 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/BrowseFilesActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/BrowseFilesActivity.kt index f3d42fa02..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)) } @@ -356,6 +360,7 @@ class BrowseFilesActivity : BaseActivity(ActivityLayoutBi } override fun onReplaceCanceled() { + browseFilesPresenter.onUploadReplaceCanceled() showProgress(COMPLETED) } @@ -470,6 +475,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/activity/VaultListActivity.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/activity/VaultListActivity.kt index 409b867f2..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,10 +27,13 @@ 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 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 @@ -45,6 +48,8 @@ class VaultListActivity : BaseActivity(Activi UpdateAppDialog.Callback, // BetaConfirmationDialog.Callback, // CBCPasswordVaultsMigrationDialog.Callback, // + CancelUploadAndLockDialog.Callback, // + ResumeUploadDialog.Callback, // BiometricAuthenticationMigration.Callback { @Inject @@ -74,7 +79,7 @@ class VaultListActivity : BaseActivity(Activi if (stopEditFilePressed() && sharedPreferencesHandler.keepUnlockedWhileEditing()) { hideNotification() - unSuspendLock() + releaseEditingLease() } } @@ -86,9 +91,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() @@ -169,6 +173,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() } @@ -252,4 +264,16 @@ class VaultListActivity : BaseActivity(Activi vaultListPresenter.biometricKeyInvalidated(vaults) } + override fun onCancelUploadAndLockConfirmed(vaultId: Long) { + vaultListPresenter.onCancelUploadAndLockConfirmed(vaultId) + } + + 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/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/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/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(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/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/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..fa67f24a1 --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/dialog/ResumeUploadDialog.kt @@ -0,0 +1,53 @@ +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.DialogResumeUploadBinding + +@Dialog +class ResumeUploadDialog : BaseDialog(DialogResumeUploadBinding::inflate) { + + interface Callback { + + fun onResumeUploadConfirmed(vaultId: Long) + fun onResumeUploadDeclined(vaultId: Long) + } + + 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 { + + 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/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_folder_upload_gray.xml b/presentation/src/main/res/drawable-night/ic_folder_upload_gray.xml new file mode 100644 index 000000000..85df04f29 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_folder_upload_gray.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_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/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/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" /> + + + 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/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" /> + + 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 @@ -368,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 @@ -625,6 +631,36 @@ @string/permission_snackbar_auth_auto_upload Tap to refresh authentication. + + Upload started + Uploading files + Uploading %1$d/%2$d + Cancel upload + Upload finished + + %1$d file uploaded to vault + %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. + + + 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? + 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 Vault stays unlocked until finished editing 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..7b70c70c2 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; @@ -25,9 +28,12 @@ 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; 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; @@ -41,6 +47,10 @@ import java.util.Collections; import java.util.List; +import io.reactivex.Flowable; +import io.reactivex.android.plugins.RxAndroidPlugins; +import io.reactivex.schedulers.Schedulers; + import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -115,11 +125,21 @@ 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 UploadUiUpdates uploadUiUpdates = Mockito.mock(UploadUiUpdates.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; @BeforeEach public void setup() { + RxAndroidPlugins.reset(); + RxAndroidPlugins.setInitMainThreadSchedulerHandler(scheduler -> Schedulers.trampoline()); + when(uploadUiUpdates.activeVaultIds()).thenReturn(Collections.emptySet()); + when(uploadUiUpdates.vaultEvents()).thenReturn(Flowable.never()); + when(uploadCheckpointDao.findAllVaultIdsWithCheckpoints()).thenReturn(Collections.emptySet()); inTest = new VaultListPresenter(getVaultListUseCase, // deleteVaultUseCase, // renameVaultUseCase, // @@ -142,6 +162,11 @@ public void setup() { authenticationExceptionHandler, // cloudNodeModelMapper, // sharedPreferencesHandler, // + uploadCheckpointDao, // + uploadUiUpdates, // + vaultRepository, // + cloudRepository, // + contentResolverUtil, // exceptionMappings); when(vaultListView.activity()).thenReturn(activity); inTest.setView(vaultListView); @@ -240,4 +265,27 @@ 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 testOnVaultLockClickedDoesNotLockWhenUploadActive() { + when(uploadUiUpdates.activeVaultIds()).thenReturn(Collections.singleton(AN_UNLOCKED_VAULT.getId())); + + try { + inTest.onVaultLockClicked(AN_UNLOCKED_VAULT_MODEL); + } catch (RuntimeException e) { + // CancelUploadAndLockDialog.newInstance() uses Bundle which is not mocked + } + + verify(lockVaultUseCase, never()).withVault(Mockito.any()); + } + } 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)) + } +}