Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.http.HttpStatusCode
import io.ktor.http.Url
import io.ktor.http.isSuccess
import kotlinx.coroutines.coroutineScope
Expand Down Expand Up @@ -202,6 +203,7 @@ class SyncCoordinator(
syncCollectionResponse.hrefs.mapNotNull { (url, eTag) -> eTag?.let { url to it } }.toMap()
)
pushLocalChanges(calendar)
drainPendingRemoteFileDeletions(calendar)
calendarRepository.updateCalendarSyncStatus(
CalendarSyncStatus(CalendarSyncStatusType.SYNCED).serialize(),
syncCollectionResponse.syncToken,
Expand Down Expand Up @@ -306,6 +308,7 @@ class SyncCoordinator(
is MultigetResourceHrefETagResult.Success -> {
applyServerchanges(calendar, multigetResourceHrefsMultiplatformResult.hrefs)
pushLocalChanges(calendar)
drainPendingRemoteFileDeletions(calendar)
calendarRepository.updateCalendarSyncStatus(
CalendarSyncStatus(CalendarSyncStatusType.SYNCED).serialize(),
multigetResourceHrefsMultiplatformResult.syncToken,
Expand Down Expand Up @@ -439,6 +442,22 @@ class SyncCoordinator(
dirtyIcalEntries.forEach { pushSingleLocalChange(it, calendar) }
}

// Deletes attachment blobs that were orphaned by a removed attachment, a hard-deleted entry, or a
// move (source collection). Best-effort: a 2xx or 404 clears the queue entry, anything else is
// left for the next sync to retry.
private suspend fun drainPendingRemoteFileDeletions(calendar: Calendar) {
icalEntryRepository.getPendingRemoteFileDeletions(calendar.id).forEach { pending ->
val status = try {
remote.deleteFile(Url(pending.remoteUrl), credentials)
} catch (e: Exception) {
Napier.d("Remote attachment deletion failed for ${pending.remoteUrl}: ${e.message}")
return@forEach // keep it queued for retry
}
if (status.isSuccess() || status == HttpStatusCode.NotFound)
icalEntryRepository.deletePendingRemoteFileDeletion(pending.id)
}
}

private suspend fun pushSingleLocalChange(dirtyIcalEntry: IcalEntry, calendar: Calendar) {

when (dirtyIcalEntry.syncState) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import app.cash.sqldelight.coroutines.asFlow
import at.techbee.spectacled.db.SpectacledDatabase
import at.techbee.spectacled.screens.core.DatabaseDriverFactory
import at.techbee.spectacled.screens.core.FileManager
import at.techbee.spectacled.screens.core.data.ics.IcsDateTime
import at.techbee.spectacled.screens.core.domain.Attachment
import at.techbee.spectacled.screens.core.domain.AttachmentSyncState
import at.techbee.spectacled.screens.core.domain.IcalEntry
import at.techbee.spectacled.screens.core.domain.PendingRemoteFileDeletion
import at.techbee.spectacled.screens.core.domain.Status
import at.techbee.spectacled.screens.core.domain.SyncState
import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository
Expand All @@ -33,7 +35,8 @@ import kotlinx.coroutines.withContext

@OptIn(ExperimentalCoroutinesApi::class)
class IcalEntryRepositoryImpl(
private val databaseDriverFactory: DatabaseDriverFactory
private val databaseDriverFactory: DatabaseDriverFactory,
private val fileManager: FileManager
) : IcalEntryRepository {

private suspend fun getDatabase() = databaseDriverFactory.provideDatabase(SpectacledDatabase.Schema)
Expand Down Expand Up @@ -269,6 +272,19 @@ class IcalEntryRepositoryImpl(
// Re-read fresh so any attachments downloaded just before the move are picked up.
val entriesToMove = getIcalEntriesWithSubtasks(icalEntryIds)

// 0. Queue the source-collection blobs for deletion BEFORE re-creating the entries: the
// copy's attachment rows reuse the same UNIQUE uid, so inserting them replaces the
// originals' rows (losing their source remoteUrl). Only enqueue blobs that will actually
// be re-uploaded into the target (attachments with a local copy - matching
// remappedForMove's re-upload branch); a remote-only attachment keeps pointing at its
// source blob after the move, so deleting it would lose the file.
entriesToMove.forEach { entry ->
val orphanedSourceUrls = entry.attachments
.filter { it.localPath != null && !it.remoteUrl.isNullOrBlank() }
.map { it.remoteUrl!! }
enqueueRemoteFileDeletions(entry.calendarId, orphanedSourceUrls)
}

// 1. Re-create each entry in the target calendar. Same uid keeps its identity; clearing
// href/etag makes the sync engine PUT it as a new resource into the target collection.
entriesToMove.forEach { entry ->
Expand Down Expand Up @@ -369,9 +385,31 @@ class IcalEntryRepositoryImpl(
}
}

override suspend fun deleteTrashed(cutoffDateTime: IcsDateTime) { // TODO: Test again!
override suspend fun deleteTrashed(cutoffDateTime: IcsDateTime) {
withContext(ioDispatcher) {
getDatabase().icalentry_dtoQueries.deleteTrashed(formatIcsDateTime(cutoffDateTime)?.first)
val db = getDatabase()
val cutoff = formatIcsDateTime(cutoffDateTime)?.first

// Before hard-deleting the rows (which cascades attachments away), reclaim their
// storage: queue each attachment's server blob for remote deletion and remove its local
// file. A moved entry's original has no attachments here - the copy took over its rows
// via the shared uid - so there is nothing to free for those.
val trashed = db.icalentry_dtoQueries.getTrashedBefore(cutoff).awaitAsList()
if (trashed.isNotEmpty()) {
val calendarIdByEntryId = trashed.associate { it.id to it.calendarId }
val attachments = db.attachment_dtoQueries.getAttachmentsForEntries(trashed.map { it.id }).awaitAsList()

attachments.forEach { attachment ->
attachment.remoteUrl?.takeIf { it.isNotBlank() }?.let { remoteUrl ->
calendarIdByEntryId[attachment.icalEntryId]?.let { calendarId ->
enqueueRemoteFileDeletions(calendarId, listOf(remoteUrl))
}
}
attachment.localPath?.let { fileManager.deleteAttachment(it) }
}
}

db.icalentry_dtoQueries.deleteTrashed(cutoff)
}
}

Expand Down Expand Up @@ -414,4 +452,27 @@ class IcalEntryRepositoryImpl(
override suspend fun getAttachmentsForEntry(entryId: Long): List<Attachment> = withContext(ioDispatcher) {
getDatabase().attachment_dtoQueries.getAttachmentsForEntry(entryId).awaitAsList().map { it.toDomain() }
}

override suspend fun enqueueRemoteFileDeletions(calendarId: Long, remoteUrls: List<String>) {
if (remoteUrls.isEmpty()) return
withContext(ioDispatcher) {
val db = getDatabase()
db.transaction {
remoteUrls.filter { it.isNotBlank() }.forEach { remoteUrl ->
db.pending_remote_deletion_dtoQueries.insertPendingRemoteDeletion(calendarId, remoteUrl)
}
}
}
}

override suspend fun getPendingRemoteFileDeletions(calendarId: Long): List<PendingRemoteFileDeletion> = withContext(ioDispatcher) {
getDatabase().pending_remote_deletion_dtoQueries.getPendingRemoteDeletionsForCalendar(calendarId).awaitAsList()
.map { PendingRemoteFileDeletion(id = it.id, calendarId = it.calendarId, remoteUrl = it.remoteUrl) }
}

override suspend fun deletePendingRemoteFileDeletion(id: Long) {
withContext(ioDispatcher) {
getDatabase().pending_remote_deletion_dtoQueries.deletePendingRemoteDeletion(id)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,14 @@ suspend fun downloadFileMultiplatform(
}
return if (response.status.isSuccess()) response.body<ByteArray>() else null // TODO: respond with an actual HttpStatusCode
}

suspend fun deleteFileMultiplatform(
client: HttpClient,
targetUrl: Url,
credentials: Credentials?
): HttpStatusCode {
val response = client.delete(targetUrl) {
credentials?.let { basicAuth(it.username, it.password) }
}
return response.status
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface WebDavRemoteIcalEntryDataSource {
suspend fun deleteResource(calendar: Calendar, icalEntry: IcalEntry, credentials: Credentials?): DeleteResourceResult
suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?): HttpStatusCode
suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?): ByteArray?
suspend fun deleteFile(targetUrl: Url, credentials: Credentials?): HttpStatusCode
}

class DefaultWebDavRemoteIcalEntryDataSource(
Expand Down Expand Up @@ -59,4 +60,7 @@ class DefaultWebDavRemoteIcalEntryDataSource(

override suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?) =
downloadFileMultiplatform(client, sourceUrl, credentials)

override suspend fun deleteFile(targetUrl: Url, credentials: Credentials?) =
deleteFileMultiplatform(client, targetUrl, credentials)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package at.techbee.spectacled.screens.core.domain

/**
* A remote attachment blob that is no longer referenced by any entry and still needs to be deleted
* from its CalDAV collection. Queued locally and drained by the sync engine.
*/
data class PendingRemoteFileDeletion(
val id: Long,
val calendarId: Long,
val remoteUrl: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color
import at.techbee.spectacled.screens.core.data.ics.IcsDateTime
import at.techbee.spectacled.screens.core.domain.Attachment
import at.techbee.spectacled.screens.core.domain.IcalEntry
import at.techbee.spectacled.screens.core.domain.PendingRemoteFileDeletion
import at.techbee.spectacled.screens.core.domain.Status
import at.techbee.spectacled.screens.core.domain.SyncState
import io.ktor.http.Url
Expand Down Expand Up @@ -56,4 +57,10 @@ interface IcalEntryRepository {
suspend fun insertOrUpdateAttachment(attachment: Attachment)
suspend fun deleteAttachment(id: Long)
suspend fun getAttachmentsForEntry(entryId: Long): List<Attachment>

// Remote attachment-blob cleanup queue
/** Queues attachment blob [remoteUrls] (in [calendarId]'s collection) for later deletion by sync. */
suspend fun enqueueRemoteFileDeletions(calendarId: Long, remoteUrls: List<String>)
suspend fun getPendingRemoteFileDeletions(calendarId: Long): List<PendingRemoteFileDeletion>
suspend fun deletePendingRemoteFileDeletion(id: Long)
}
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,13 @@ class DetailsViewModel(
val attachment = _state.value.icalEntry.attachments.find { it.uid == attachmentUid }
if (attachment != null) {
attachment.localPath?.let { fileManager.deleteAttachment(it) }


// If it was already on the server, queue its blob for deletion; re-saving the entry
// only drops the ATTACH reference, it never removes the uploaded file.
attachment.remoteUrl?.takeIf { it.isNotBlank() }?.let { remoteUrl ->
icalEntryRepository.enqueueRemoteFileDeletions(_state.value.icalEntry.calendarId, listOf(remoteUrl))
}

_state.update {
it.copy(
icalEntry = it.icalEntry.copy(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE PendingRemoteDeletionDto (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
calendarId INTEGER NOT NULL,
remoteUrl TEXT NOT NULL UNIQUE
);

CREATE INDEX idx_pending_remote_deletion_calendarId ON PendingRemoteDeletionDto(calendarId);
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ SET
url = :url
WHERE calendarId = :calendarId AND uid = :uid;

getTrashedBefore:
SELECT id, calendarId FROM IcalEntryDto WHERE lastModified < :cutOffTimestamp AND syncState = 'REMOTE_DELETED_LOCAL_TRASHBIN';

deleteTrashed:
DELETE FROM IcalEntryDto WHERE lastModified < :cutOffTimestamp AND syncState = 'REMOTE_DELETED_LOCAL_TRASHBIN';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Queue of remote attachment blobs that are no longer referenced by any entry and must be
-- deleted from their CalDAV collection. Deliberately NOT a child of IcalEntryDto: entry deletes
-- cascade to AttachmentDto, so the blob URL has to survive independently of the entry row.
-- Drained by the sync engine per calendar (best-effort, retried until the server confirms).
CREATE TABLE PendingRemoteDeletionDto (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
calendarId INTEGER NOT NULL,
remoteUrl TEXT NOT NULL UNIQUE
);

CREATE INDEX idx_pending_remote_deletion_calendarId ON PendingRemoteDeletionDto(calendarId);

insertPendingRemoteDeletion:
INSERT OR IGNORE INTO PendingRemoteDeletionDto (calendarId, remoteUrl)
VALUES (?, ?);

getPendingRemoteDeletionsForCalendar:
SELECT * FROM PendingRemoteDeletionDto WHERE calendarId = ?;

deletePendingRemoteDeletion:
DELETE FROM PendingRemoteDeletionDto WHERE id = ?;
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import at.techbee.spectacled.screens.core.domain.CalendarSyncStatus
import at.techbee.spectacled.screens.core.domain.CalendarSyncStatusType
import at.techbee.spectacled.screens.core.domain.HomeCollection
import at.techbee.spectacled.screens.core.domain.IcalEntry
import at.techbee.spectacled.screens.core.domain.PendingRemoteFileDeletion
import at.techbee.spectacled.screens.core.domain.Principal
import at.techbee.spectacled.screens.core.domain.Status
import at.techbee.spectacled.screens.core.domain.SyncState
Expand Down Expand Up @@ -378,6 +379,7 @@ private class FakeRemote(
override suspend fun uploadFile(targetUrl: Url, bytes: ByteArray, mimeType: String?, credentials: Credentials?) = HttpStatusCode.Created
override suspend fun downloadFile(sourceUrl: Url, credentials: Credentials?): ByteArray =
throw AssertionError("downloadFile not expected in a sync test")
override suspend fun deleteFile(targetUrl: Url, credentials: Credentials?) = HttpStatusCode.NoContent
}

private data class SyncMetadataUpdate(val etag: String?, val href: Url?, val syncState: SyncState?, val id: Long)
Expand Down Expand Up @@ -432,6 +434,11 @@ private class FakeIcalEntryRepository(
override suspend fun deleteTrashed(cutoffDateTime: IcsDateTime) = TODO()
override suspend fun deleteAttachment(id: Long) = TODO()
override suspend fun getAttachmentsForEntry(entryId: Long): List<Attachment> = TODO()
override suspend fun enqueueRemoteFileDeletions(calendarId: Long, remoteUrls: List<String>) = TODO()
override suspend fun deletePendingRemoteFileDeletion(id: Long) = TODO()

// Drained on every sync - return nothing so the sync path issues no remote deletions.
override suspend fun getPendingRemoteFileDeletions(calendarId: Long): List<PendingRemoteFileDeletion> = emptyList()
}

private class FakeCalendarRepository : CalendarRepository {
Expand Down
Loading