Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 buildsystem/dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
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
40 changes: 40 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,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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> findAllVaultIdsWithCheckpoints() {
Set<Long> 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;
}
}
Loading