Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
28ff7cf
Add folder upload support
tobihagemann Feb 19, 2026
dac8dff
Add upload service with foreground execution and resume support
tobihagemann Feb 19, 2026
9abeabc
Fix race conditions, error handling, and plurals in upload service
tobihagemann Feb 19, 2026
9f36ab9
Clean up upload service: simplify DAO, remove dead code, fix dialog s…
tobihagemann Feb 19, 2026
62a5063
Fix multi-resume checkpoint loss, simplify dialog and presenter code
tobihagemann Feb 19, 2026
414d878
Fix resume navigation and broaden folder resume exception handling
tobihagemann Feb 19, 2026
9c1ca9f
Add tests for UploadFolderStructure and StreamHelper
tobihagemann Feb 19, 2026
890a78d
Suspend vault auto-lock during upload picker and service flows
tobihagemann Feb 20, 2026
44a6a76
Align upload feature with app architecture patterns
tobihagemann Feb 21, 2026
fff3b4e
Migrate changed-file upload to foreground service with temp file cleanup
tobihagemann Feb 21, 2026
e1c1ad4
Migrate text editor save to foreground upload service
tobihagemann Feb 22, 2026
ebcef06
Queue concurrent uploads and handle dispatch failures
tobihagemann Feb 22, 2026
5267dcf
Fix suspended lock leak, temp file leak, and asymmetric test coverage
tobihagemann Feb 22, 2026
4e6a3c4
Fix lock suspension leaks in presenter destroyed() and upload resume …
tobihagemann Feb 22, 2026
82f5e04
Replace boolean lock suspension with time-bounded keep-alive leases
tobihagemann Feb 22, 2026
d871bf8
Fix picker lease leaks and clean up stale checkpoints for abandoned u…
tobihagemann Feb 22, 2026
552b901
Add targeted UI updates via UploadUiUpdates event bus during upload
tobihagemann Feb 22, 2026
27eaf97
Show replace dialog for folder uploads and persist choice for resume
tobihagemann Feb 23, 2026
107cf4c
Fix cancel race, stale checkpoint, and service linger in UploadService
tobihagemann Feb 23, 2026
dbf0f20
Show upload status indicator in vault list with animated fill-up icon
tobihagemann Feb 23, 2026
2f0e558
Prevent manual vault lock while upload is in progress
tobihagemann Feb 23, 2026
876ee59
Replace vault lock toast with Cancel & Lock confirmation dialog
tobihagemann Feb 23, 2026
8233d64
Add per-vault upload cancellation so locking one vault does not abort…
tobihagemann Feb 23, 2026
49b50f5
Fix stale checkpoint on pre-cancel and persist URI permissions for fi…
tobihagemann Feb 24, 2026
320e957
Fix file resume to respect checkpoint replacing flag instead of hardc…
tobihagemann Feb 24, 2026
bf20294
Include exception in Timber debug log for URI permission failure
tobihagemann Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion data/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ android {
}

greendao {
schemaVersion 13
schemaVersion 14
}

configurations.all {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public DatabaseUpgrades( //
Upgrade9To10 upgrade9To10, //
Upgrade10To11 upgrade10To11, //
Upgrade11To12 upgrade11To12, //
Upgrade12To13 upgrade12To13
Upgrade12To13 upgrade12To13, //
Upgrade13To14 upgrade13To14
) {

availableUpgrades = defineUpgrades( //
Expand All @@ -47,7 +48,8 @@ public DatabaseUpgrades( //
upgrade9To10, //
upgrade10To11, //
upgrade11To12, //
upgrade12To13);
upgrade12To13, //
upgrade13To14);
}

private Map<Integer, List<DatabaseUpgrade>> defineUpgrades(DatabaseUpgrade... upgrades) {
Expand Down
39 changes: 39 additions & 0 deletions data/src/main/java/org/cryptomator/data/db/Upgrade13To14.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.cryptomator.data.db

import org.greenrobot.greendao.database.Database
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
internal class Upgrade13To14 @Inject constructor() : DatabaseUpgrade(13, 14) {

override fun internalApplyTo(db: Database, origin: Int) {
db.beginTransaction()
try {
createUploadCheckpointTable(db)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}

private fun createUploadCheckpointTable(db: Database) {
Sql.createTable("UPLOAD_CHECKPOINT_ENTITY") //
.id() //
.requiredInt("VAULT_ID") //
.requiredText("TYPE") //
.requiredText("TARGET_FOLDER_PATH") //
.optionalText("SOURCE_FOLDER_URI") //
.optionalText("SOURCE_FOLDER_NAME") //
.optionalText("PENDING_FILE_URIS") //
.requiredText("COMPLETED_FILES") //
.requiredInt("TOTAL_FILE_COUNT") //
.requiredInt("TIMESTAMP") //
.executeOn(db)

Sql.createUniqueIndex("IDX_UPLOAD_CHECKPOINT_ENTITY_VAULT_ID") //
.on("UPLOAD_CHECKPOINT_ENTITY") //
.asc("VAULT_ID") //
.executeOn(db)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.cryptomator.data.db;

import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

import org.cryptomator.data.db.entities.UploadCheckpointEntity;

import javax.inject.Inject;
import javax.inject.Singleton;

@Singleton
public class UploadCheckpointDao {

private static final String TABLE_NAME = "UPLOAD_CHECKPOINT_ENTITY";

private final DatabaseFactory databaseFactory;

@Inject
public UploadCheckpointDao(DatabaseFactory databaseFactory) {
this.databaseFactory = databaseFactory;
}

private SQLiteDatabase getDb() {
return databaseFactory.getWritableDatabase();
}

public long insertOrReplace(UploadCheckpointEntity entity) {
ContentValues values = toContentValues(entity);
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 void updateCompletedFiles(long vaultId, String completedFilesJson) {
ContentValues values = new ContentValues();
values.put("COMPLETED_FILES", completedFilesJson);
getDb().update(TABLE_NAME, values, "VAULT_ID = ?", new String[]{String.valueOf(vaultId)});
}

private ContentValues toContentValues(UploadCheckpointEntity entity) {
ContentValues values = new ContentValues();
values.put("VAULT_ID", entity.getVaultId());
values.put("TYPE", entity.getType());
values.put("TARGET_FOLDER_PATH", entity.getTargetFolderPath());
values.put("SOURCE_FOLDER_URI", entity.getSourceFolderUri());
values.put("SOURCE_FOLDER_NAME", entity.getSourceFolderName());
values.put("PENDING_FILE_URIS", entity.getPendingFileUris());
values.put("COMPLETED_FILES", entity.getCompletedFiles());
values.put("TOTAL_FILE_COUNT", entity.getTotalFileCount());
values.put("TIMESTAMP", entity.getTimestamp());
return values;
}

private UploadCheckpointEntity fromCursor(Cursor cursor) {
UploadCheckpointEntity entity = new UploadCheckpointEntity();
entity.setId(cursor.getLong(cursor.getColumnIndexOrThrow("_id")));
entity.setVaultId(cursor.getLong(cursor.getColumnIndexOrThrow("VAULT_ID")));
entity.setType(cursor.getString(cursor.getColumnIndexOrThrow("TYPE")));
entity.setTargetFolderPath(cursor.getString(cursor.getColumnIndexOrThrow("TARGET_FOLDER_PATH")));
entity.setSourceFolderUri(cursor.getString(cursor.getColumnIndexOrThrow("SOURCE_FOLDER_URI")));
entity.setSourceFolderName(cursor.getString(cursor.getColumnIndexOrThrow("SOURCE_FOLDER_NAME")));
entity.setPendingFileUris(cursor.getString(cursor.getColumnIndexOrThrow("PENDING_FILE_URIS")));
entity.setCompletedFiles(cursor.getString(cursor.getColumnIndexOrThrow("COMPLETED_FILES")));
entity.setTotalFileCount(cursor.getInt(cursor.getColumnIndexOrThrow("TOTAL_FILE_COUNT")));
entity.setTimestamp(cursor.getLong(cursor.getColumnIndexOrThrow("TIMESTAMP")));
return entity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package org.cryptomator.data.db.entities;

import org.greenrobot.greendao.annotation.Entity;
import org.greenrobot.greendao.annotation.Id;
import org.greenrobot.greendao.annotation.Index;
import org.greenrobot.greendao.annotation.NotNull;
import org.greenrobot.greendao.annotation.Generated;

@Entity(indexes = {@Index(value = "vaultId", unique = true)})
public class UploadCheckpointEntity extends DatabaseEntity {

@Id
private Long id;

@NotNull
private Long vaultId;

@NotNull
private String type;

@NotNull
private String targetFolderPath;

private String sourceFolderUri;

private String sourceFolderName;

private String pendingFileUris;

@NotNull
private String completedFiles;

@NotNull
private int totalFileCount;

@NotNull
private long timestamp;

@Generated(hash = 482695414)
public UploadCheckpointEntity(Long id, @NotNull Long vaultId,
@NotNull String type, @NotNull String targetFolderPath,
String sourceFolderUri, String sourceFolderName, String pendingFileUris,
@NotNull String completedFiles, int totalFileCount, long timestamp) {
this.id = id;
this.vaultId = vaultId;
this.type = type;
this.targetFolderPath = targetFolderPath;
this.sourceFolderUri = sourceFolderUri;
this.sourceFolderName = sourceFolderName;
this.pendingFileUris = pendingFileUris;
this.completedFiles = completedFiles;
this.totalFileCount = totalFileCount;
this.timestamp = timestamp;
}

@Generated(hash = 1737881290)
public UploadCheckpointEntity() {
}

@Override
public Long getId() {
return this.id;
}

public void setId(Long id) {
this.id = id;
}

public Long getVaultId() {
return this.vaultId;
}

public void setVaultId(Long vaultId) {
this.vaultId = vaultId;
}

public String getType() {
return this.type;
}

public void setType(String type) {
this.type = type;
}

public String getTargetFolderPath() {
return this.targetFolderPath;
}

public void setTargetFolderPath(String targetFolderPath) {
this.targetFolderPath = targetFolderPath;
}

public String getSourceFolderUri() {
return this.sourceFolderUri;
}

public void setSourceFolderUri(String sourceFolderUri) {
this.sourceFolderUri = sourceFolderUri;
}

public String getSourceFolderName() {
return this.sourceFolderName;
}

public void setSourceFolderName(String sourceFolderName) {
this.sourceFolderName = sourceFolderName;
}

public String getPendingFileUris() {
return this.pendingFileUris;
}

public void setPendingFileUris(String pendingFileUris) {
this.pendingFileUris = pendingFileUris;
}

public String getCompletedFiles() {
return this.completedFiles;
}

public void setCompletedFiles(String completedFiles) {
this.completedFiles = completedFiles;
}

public int getTotalFileCount() {
return this.totalFileCount;
}

public void setTotalFileCount(int totalFileCount) {
this.totalFileCount = totalFileCount;
}

public long getTimestamp() {
return this.timestamp;
}

public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
}
Loading